backups(); * #pw-order-groups actions,reporting,initialization,advanced * #pw-body = * This class intentionally does not have any external dependencies (other than PDO) * so that it can be included by outside tools for restoring/exporting, with the main * example of that being the ProcessWire installer. * * The recommended way to access these backup methods is via the `$database` API variable * method `$database->backups()`, which returns a `WireDatabaseBackup` instance, however * you can also initialize the class manually if you prefer, like this: * ~~~~~ * // determine where backups will go (should NOT be web accessible) * $backupPath = $config->paths->assets . 'backups/'; * * // create a new WireDatabaseBackup instance * $backup = new WireDatabaseBackup($backupPath); * * // Option 1: set the already-connected DB connection * $backup->setDatabase($this->database); * * // Option 2: OR provide a Config object that contains the DB connection info * $backup->setDatabaseConfig($this->config); * * ~~~~~ * ### Backup the database * ~~~~~ * $file = $backup->backup(); * if($file) { * echo "Backed up to: $file"; * } else { * echo "Backup failed: " . implode("
", $backup->errors()); * } * ~~~~~ * * ### Restore a database * ~~~~~ * $success = $backup->restore($file); * if($success) { * echo "Restored database from file: $file"; * } else { * echo "Restore failed: " . implode("
", $backup->errors()); * } * ~~~~~ * #pw-body * * ProcessWire 3.x, Copyright 2023 by Ryan Cramer * https://processwire.com * * */ class WireDatabaseBackup { const fileHeader = '--- WireDatabaseBackup'; const fileFooter = '--- /WireDatabaseBackup'; /** * ProcessWire instance, when applicable * * @var ProcessWire * */ protected $wire = null; /** * Options available for the $options argument to backup() method * * @var array * */ protected $backupOptions = array( // filename for backup: default is to make a dated filename, but this can also be used (basename only, no path) 'filename' => '', // optional description of this backup 'description' => '', // if specified, export will only include these tables 'tables' => array(), // username to associate with the backup file (string), optional 'user' => '', // exclude creating or inserting into these tables 'excludeTables' => array(), // exclude creating these tables, but still export data (not supported by mysqldump) 'excludeCreateTables' => array(), // exclude exporting data, but still create tables (not supported by mysqldump) 'excludeExportTables' => array(), // SQL conditions for export of individual tables (table => array(SQL conditions)) // The 'table' portion (index) may also be a full PCRE regexp, must start with '/' to be recognized as regex 'whereSQL' => array(), // max number of seconds allowed for execution 'maxSeconds' => 1200, // use DROP TABLES statements before CREATE TABLE statements? 'allowDrop' => true, // use UPDATE ON DUPLICATE KEY so that INSERT statements can UPDATE when rows already present (all tables) 'allowUpdate' => false, // table names that will use UPDATE ON DUPLICATE KEY (does NOT require allowUpdate=true) 'allowUpdateTables' => array(), // find and replace in row data during backup (not supported by exec/mysql method) 'findReplace' => array( // Example: 'databass' => 'database' ), // find and replace in create table statements (not supported by exec/mysqldump) 'findReplaceCreateTable' => array( // Example: 'DEFAULT CHARSET=latin1;' => 'DEFAULT CHARSET=utf8;', ), // additional SQL queries to append at the bottom 'extraSQL' => array( // Example: UPDATE pages SET CREATED=NOW ), // EXEC MODE IS CURRRENTLY EXPERIMENTAL AND NOT RECOMMEND FOR USE YET // if true, we will try to use mysqldump (exec) first. if false, we won't attempt mysqldump. 'exec' => false, // exec command to use for mysqldump (when in use) 'execCommand' => '[dbPath]mysqldump --complete-insert=TRUE --add-locks=FALSE --disable-keys=FALSE --extended-insert=FALSE --default-character-set=utf8 --comments=FALSE --compact --skip-disable-keys --skip-add-locks --add-drop-table=TRUE --result-file=[dbFile] --port=[dbPort] -u[dbUser] -p[dbPass] -h[dbHost] [dbName] [tables]' ); /** * Options available for the $options argument to restore() method * * @var array * */ protected $restoreOptions = array( // table names to restore (empty=all) 'tables' => array(), // allow DROP TABLE statements? 'allowDrop' => true, // DROP ALL tables before restore? (requires that 'allowDrop' must also be true) 'dropAll' => false, // halt execution when an error occurs? 'haltOnError' => false, // max number of seconds allowed for execution 'maxSeconds' => 1200, // find and replace in row data (not supported by exec/mysql method) 'findReplace' => array( // Example: 'databass' => 'database' ), // find and replace in create table statements (not supported by exec/mysql) 'findReplaceCreateTable' => array( // Example: 'DEFAULT CHARSET=latin1;' => 'DEFAULT CHARSET=utf8;', ), // EXEC MODE IS CURRRENTLY EXPERIMENTAL AND NOT RECOMMEND FOR USE YET // if true, we will try to use mysql via exec first (faster). if false, we won't attempt that. 'exec' => false, // command to use for mysql exec 'execCommand' => '[dbPath]mysql --port=[dbPort] -u[dbUser] -p[dbPass] -h[dbHost] [dbName] < [dbFile]', ); /** * @var null|\PDO * */ protected $database = null; /** * @var array * */ protected $databaseConfig = array( 'dbUser' => '', 'dbPass' => '', // optional (if password is blank) 'dbHost' => '', 'dbPort' => '', 'dbName' => '', 'dbPath' => '', // optional mysql/mysqldump path on file system 'dbSocket' => '', 'dbCharset' => 'utf8', ); /** * Array of text indicating details about what methods were used (primarily for debugging) * * @var array * */ protected $notes = array(); /** * Array of text error messages * * @var array * */ protected $errors = array(); /** * Database files path * * @var string|null * */ protected $path = null; /** * Cache for getAllTables() * * @var array * */ protected $tables = array(); /** * Cache for getAllTables() * * @var array * */ protected $counts = array(); /** * Construct * * You should follow-up the construct call with one or both of the following: * * - $backups->setDatabase(PDO|WireDatabasePDO); * - $backups->setDatabaseConfig(array|object); * * #pw-group-initialization * * @param string $path Path where database files are stored * @throws \Exception * */ public function __construct($path = '') { if(strlen($path)) $this->setPath($path); } /** * Set the current ProcessWire instance * * #pw-internal * * @param ProcessWire $wire * */ public function setWire($wire) { if(is_object($wire) && $wire->className() == 'ProcessWire') $this->wire = $wire; } /** * Set the database configuration information * * #pw-group-initialization * * @param array|Config|object $config Containing these properties: * - dbUser * - dbHost * - dbPort * - dbName * - dbPass * - dbPath (optional) * - dbCharset (optional) * @return $this * @throws \Exception if missing required config settings * */ public function setDatabaseConfig($config) { foreach($this->databaseConfig as $key => $_value) { if(is_object($config) && isset($config->$key)) $value = $config->$key; else if(is_array($config) && isset($config[$key])) $value = $config[$key]; else $value = ''; if(empty($value) && !empty($_value)) $value = $_value; // i.e. dbCharset if($key == 'dbPath' && $value) { $value = rtrim($value, '/') . '/'; if(!is_dir($value)) $value = ''; } $this->databaseConfig[$key] = $value; } $missing = array(); $optional = array('dbPass', 'dbPath', 'dbSocket', 'dbPort'); foreach($this->databaseConfig as $key => $value) { if(empty($value) && !in_array($key, $optional)) $missing[] = $key; } if(count($missing)) { throw new \Exception("Missing required config for: " . implode(', ', $missing)); } // $charset = $this->databaseConfig['dbCharset']; // $this->backupOptions['findReplaceCreateTable']['DEFAULT CHARSET=latin1;'] = "DEFAULT CHARSET=$charset;"; return $this; } /** * Set the PDO database connection * * #pw-group-initialization * * @param \PDO|WireDatabasePDO $database * @throws \PDOException on invalid connection * */ public function setDatabase($database) { $query = $database->prepare('SELECT DATABASE()'); $query->execute(); list($dbName) = $query->fetch(\PDO::FETCH_NUM); if($dbName) $this->databaseConfig['dbName'] = $dbName; $this->database = $database; } /** * Get current database connection, initiating the connection if not yet active * * #pw-advanced * * @return \PDO * @throws \Exception * */ public function getDatabase() { if($this->database) return $this->database; $config = $this->databaseConfig; if(empty($config['dbUser'])) throw new \Exception("Please call setDatabaseConfig(config) to supply config information so we can connect."); if($config['dbSocket']) { $dsn = "mysql:unix_socket=$config[dbSocket];dbname=$config[dbName];"; } else { $dsn = "mysql:dbname=$config[dbName];host=$config[dbHost]"; if($config['dbPort']) $dsn .= ";port=$config[dbPort]"; } $options = array( \PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES '$config[dbCharset]'", \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION ); $database = new \PDO($dsn, $config['dbUser'], $config['dbPass'], $options); $this->setDatabase($database); return $database; } /** * Add an error and return last error * * #pw-group-reporting * * @param string $str If omitted, no error is added * @return string * */ public function error($str = '') { if(strlen($str)) $this->errors[] = $str; // append error message return count($this->errors) ? end($this->errors) : ''; // return last error } /** * Return all error messages that occurred * * #pw-group-reporting * * @param bool $reset Specify true to clear out existing errors or omit just to return error messages * @return array * */ public function errors($reset = false) { $errors = $this->errors; if($reset) $this->errors = array(); return $errors; } /** * Record a note * * #pw-group-reporting * * @param $key * @param $value * */ protected function note($key, $value) { if(!empty($this->notes[$key])) $this->notes[$key] .= ", $value"; else $this->notes[$key] = $value; } /** * Get all notes * * #pw-group-reporting * * @param bool $reset * @return array * */ public function notes($reset = false) { $notes = $this->notes; if($reset) $this->notes = array(); return $notes; } /** * Set path where database files are stored * * #pw-group-initialization * * @param string $path * @return $this * @throws \Exception if path has a problem * */ public function setPath($path) { $path = $this->sanitizePath($path); if(!is_dir($path)) throw new \Exception("Path doesn't exist: $path"); if(!is_writable($path)) throw new \Exception("Path isn't writable: $path"); $this->path = $path; return $this; } /** * Get path where database files are stored * * #pw-group-reporting * * @return string * */ public function getPath() { return $this->path; } /** * Return array of all backup files * * To get additional info on any of them, call getFileInfo($basename) method * * #pw-group-reporting * * @param bool $getObjects Get SplFileInfo objects rather than basenames? (3.0.214+) * @return array|\SplFileInfo[] Array of strings (basenames), or array of SplFileInfo objects (when requested) * */ public function getFiles($getObjects = false) { $dir = new \DirectoryIterator($this->path); $files = array(); foreach($dir as $file) { if($file->isDot() || $file->isDir()) continue; $key = $file->getMTime(); while(isset($files[$key])) $key++; $files[$key] = ($getObjects ? $file : $file->getBasename()); } krsort($files); // sort by date, newest to oldest return array_values($files); } /** * Get information about a backup file * * #pw-group-reporting * * @param string $filename * @return array Returns associative array of information on success, empty array on failure * */ public function getFileInfo($filename) { // all possible info (null values become integers when populated) $info = array( 'description' => '', 'valid' => false, 'time' => '', // ISO-8601 'mtime' => null, // timestamp 'user' => '', 'size' => null, 'basename' => '', 'pathname' => '', 'dbName' => '', 'tables' => array(), 'excludeTables' => array(), 'excludeCreateTables' => array(), 'excludeExportTables' => array(), 'numTables' => null, 'numCreateTables' => null, 'numInserts' => null, 'numSeconds' => null, ); $filename = $this->sanitizeFilename($filename); if(!file_exists($filename)) return array(); $fp = fopen($filename, 'r'); if($fp === false) { $this->error('Unable to open file for reading: ' . basename($filename)); return array(); } $line = fgets($fp); if(strpos($line, self::fileHeader) === 0 || strpos($line, "# " . self::fileHeader) === 0) { $pos = strpos($line, '{'); if($pos !== false) { $json = substr($line, $pos); $info2 = json_decode($json, true); if(!$info2) $info2 = array(); foreach($info2 as $key => $value) $info[$key] = $value; } } $bytes = strlen(self::fileFooter) + 255; // some extra bytes in case something gets added at the end fseek($fp, $bytes * -1, SEEK_END); $foot = fread($fp, $bytes); $info['valid'] = strpos($foot, self::fileFooter) !== false; fclose($fp); // footer summary $pos = strpos($foot, self::fileFooter); if($pos !== false) $pos += strlen(self::fileFooter); if($info['valid'] && $pos !== false) { $json = substr($foot, $pos); $summary = json_decode($json, true); if(is_array($summary)) $info = array_merge($info, $summary); } $info['size'] = filesize($filename); $info['mtime'] = filemtime($filename); $info['pathname'] = $filename; $info['basename'] = basename($filename); return $info; } /** * Get array of all table names * * #pw-group-reporting * * @param bool $count If true, returns array indexed by name with count of records as value * @param bool $cache Allow use of cache? * @return array * */ public function getAllTables($count = false, $cache = true) { if($cache) { if($count && count($this->counts)) return $this->counts; if(count($this->tables)) return $this->tables; } else { $this->tables = array(); $this->counts = array(); } $query = $this->database->prepare('SHOW TABLES'); $query->execute(); /** @noinspection PhpAssignmentInConditionInspection */ while($row = $query->fetch(\PDO::FETCH_NUM)) $this->tables[$row[0]] = $row[0]; $query->closeCursor(); if($count) { foreach($this->tables as $table) { $query = $this->database->prepare("SELECT COUNT(*) FROM `$table`"); $query->execute(); $row = $query->fetch(\PDO::FETCH_NUM); $this->counts[$table] = (int) $row[0]; } $query->closeCursor(); return $this->counts; } else { return $this->tables; } } //////////////////////////////////////////////////////////////////////////////////////////////////////////////// /** * Perform a database export/dump * * #pw-group-actions * * @param array $options Options to modify default behavior: * - `filename` (string): filename for backup: default is to make a dated filename, but this can also be used (basename only, no path) * - `description` (string): optional description of this backup * - `tables` (array): if specified, export will only include these tables * - `user` (string): username to associate with the backup file (string), optional * - `excludeTables` (array): exclude creating or inserting into these tables * - `excludeCreateTables` (array): exclude creating these tables, but still export data * - `excludeExportTables` (array): exclude exporting data, but still create tables * - `whereSQL` (array): SQL conditions for export of individual tables [table => [SQL conditions]]. The `table` portion (index) may also be a full PCRE regexp, must start with `/` to be recognized as regex. * - `maxSeconds` (int): max number of seconds allowed for execution (default=1200) * - `allowDrop` (bool): use DROP TABLES statements before CREATE TABLE statements? (default=true) * - `allowUpdate` (bool): use UPDATE ON DUPLICATE KEY so that INSERT statements can UPDATE when rows already present (all tables). (default=false) * - `allowUpdateTables` (array): table names that will use UPDATE ON DUPLICATE KEY (does NOT require allowUpdate=true) * - `findReplace` (array): find and replace in row data during backup. Example: ['databass' => 'database'] * - `findReplaceCreateTable` (array): find and replace in create table statements * Example: ['DEFAULT CHARSET=latin1;' => 'DEFAULT CHARSET=utf8;'] * - `extraSQL` (array): additional SQL queries to append at the bottom. Example: ['UPDATE pages SET created=NOW()'] * @return string Full path and filename of database export file, or false on failure. * @throws \Exception on fatal error * @see WireDatabaseBackup::restore() * */ public function backup(array $options = array()) { if(!$this->path) throw new \Exception("Please call setPath('/backup/files/path/') first"); $this->errors(true); $options = array_merge($this->backupOptions, $options); if(empty($options['filename'])) { // generate unique filename $tail = ((count($options['tables']) || count($options['excludeTables']) || count($options['excludeExportTables'])) ? '-part' : ''); $n = 0; do { $options['filename'] = $this->databaseConfig['dbName'] . '_' . date('Y-m-d_H-i-s') . $tail . ($n ? "-$n" : "") . ".sql"; $n++; } while(file_exists($this->path . $options['filename'])); } else { $options['filename'] = basename($options['filename']); } set_time_limit($options['maxSeconds']); $file = false; if($this->supportsExec($options)) { $file = $this->backupExec($this->path . $options['filename'], $options); $this->note('method', 'exec_mysqldump'); } if(!$file) { $file = $this->backupPDO($this->path . $options['filename'], $options); $this->note('method', 'pdo'); } $success = false; if($file && file_exists($file)) { if(!filesize($file)) { $this->unlink($file); } else { $success = true; } } return $success ? $file : false; } /** * Unlink file using PW if available or PHP if not * * @param string $file * @return bool * @throws WireException * */ protected function unlink($file) { if(!is_file($file)) return false; if($this->wire) { return $this->wire->files->unlink($file, true); } else { return unlink($file); } } /** * Set backup options * * #pw-internal * * @param array $options * @return $this * */ public function setBackupOptions(array $options) { $this->backupOptions = array_merge($this->backupOptions, $options); return $this; } /** * Start a new backup file, adding our info header to the top * * @param string $file * @param array $options * @return bool * */ protected function backupStartFile($file, array $options) { $fp = fopen($file, 'w+'); if(!$fp) { $this->error("Unable to write header to file: $file"); return false; } $info = array( 'time' => date('Y-m-d H:i:s'), 'user' => $options['user'], 'dbName' => $this->databaseConfig['dbName'], 'description' => $options['description'], 'tables' => $options['tables'], 'excludeTables' => $options['excludeTables'], 'excludeCreateTables' => $options['excludeCreateTables'], 'excludeExportTables' => $options['excludeExportTables'], ); $json = json_encode($info); $json = str_replace(array("\r", "\n"), " ", $json); fwrite($fp, "# " . self::fileHeader . " $json\n"); fclose($fp); if($this->wire) $this->wire->files->chmod($file); return true; } /** * End a new backup file, adding our footer to the bottom * * @param string|resource $file * @param array $summary * @param array $options * @return bool * */ protected function backupEndFile($file, array $summary = array(), array $options = array()) { $fp = is_resource($file) ? $file : fopen($file, 'a+'); if(!$fp) { $this->error("Unable to write footer to file: $file"); return false; } foreach($options['extraSQL'] as $sql) { fwrite($fp, "\n" . rtrim($sql, '; ') . ";\n"); } $footer = "# " . self::fileFooter; if(count($summary)) { $json = json_encode($summary); $json = str_replace(array("\r", "\n"), " ", $json); $footer .= " $json"; } fwrite($fp, "\n$footer"); fclose($fp); return true; } /** * Create a mysql dump file using PDO * * @param string $file Path + filename to create * @param array $options * @return string|bool Returns the created file on success or false on error * */ protected function backupPDO($file, array $options = array()) { $database = $this->getDatabase(); $options = array_merge($this->backupOptions, $options); if(!$this->backupStartFile($file, $options)) return false; $startTime = time(); $fp = fopen($file, "a+"); $tables = $this->getAllTables(); $numCreateTables = 0; $numTables = 0; $numInserts = 0; $hasReplace = count($options['findReplace']); foreach($tables as $table) { if(in_array($table, $options['excludeTables'])) continue; if(count($options['tables']) && !in_array($table, $options['tables'])) continue; if(in_array($table, $options['excludeCreateTables'])) { // skip } else { if($options['allowDrop']) fwrite($fp, "\nDROP TABLE IF EXISTS `$table`;"); $query = $database->prepare("SHOW CREATE TABLE `$table`"); $query->execute(); $row = $query->fetch(\PDO::FETCH_NUM); $createTable = $row[1]; foreach($options['findReplaceCreateTable'] as $find => $replace) { $createTable = str_replace($find, $replace, $createTable); } $numCreateTables++; fwrite($fp, "\n$createTable;\n"); } if(in_array($table, $options['excludeExportTables'])) continue; $numTables++; $columns = array(); $query = $database->prepare("SHOW COLUMNS FROM `$table`"); $query->execute(); /** @noinspection PhpAssignmentInConditionInspection */ while($row = $query->fetch(\PDO::FETCH_NUM)) $columns[] = $row[0]; $query->closeCursor(); $columnsStr = '`' . implode('`, `', $columns) . '`'; $sql = "SELECT $columnsStr FROM `$table` "; $conditions = array(); foreach($options['whereSQL'] as $_table => $_conditions) { if($_table === $table || ($_table[0] == '/' && preg_match($_table, $table))) $conditions = array_merge($conditions, $_conditions); } if(count($conditions)) { $sql .= "WHERE "; foreach(array_values($conditions) as $n => $condition) { if($n) $sql .= "AND "; $sql .= "($condition) "; } } $query = $database->prepare($sql); $this->executeQuery($query); /** @noinspection PhpAssignmentInConditionInspection */ while($row = $query->fetch(\PDO::FETCH_NUM)) { $numInserts++; $out = "\nINSERT INTO `$table` ($columnsStr) VALUES("; foreach($row as $value) { if(is_null($value)) { $value = 'NULL'; } else { if($hasReplace) foreach($options['findReplace'] as $find => $replace) { if(strpos($value, $find)) $value = str_replace($find, $replace, $value); } $value = $database->quote($value); } $out .= "$value, "; } $out = rtrim($out, ", ") . ") "; if($options['allowUpdate']) { $out .= "ON DUPLICATE KEY UPDATE "; foreach($columns as $c) $out .= "`$c`=VALUES(`$c`), "; } $out = rtrim($out, ", ") . ";"; fwrite($fp, $out); } $query->closeCursor(); fwrite($fp, "\n"); } $summary = array( 'numTables' => $numTables, 'numCreateTables' => $numCreateTables, 'numInserts' => $numInserts, 'numSeconds' => time() - $startTime, ); $this->backupEndFile($fp, $summary, $options); // this does the fclose return file_exists($file) ? $file : false; } /** * Create a mysql dump file using exec(mysqldump) * * @param string $file Path + filename to create * @param array $options * @return string|bool Returns the created file on success or false on error * * @todo add backupStartFile/backupEndFile support * */ protected function backupExec($file, array $options) { $cmd = $options['execCommand']; $cmd = str_replace(array("\n", "\t"), ' ', $cmd); $cmd = str_replace('[tables]', implode(' ', $options['tables']), $cmd); foreach($options['excludeTables'] as $table) { $cmd .= " --ignore-table=$table"; } if(strpos($cmd, '[dbFile]')) { $cmd = str_replace('[dbFile]', $file, $cmd); } else { $cmd .= " > $file"; } foreach($this->databaseConfig as $key => $value) { $cmd = str_replace("[$key]", $value, $cmd); } exec($cmd); if(file_exists($file)) { if(filesize($file) > 0) return $file; $this->unlink($file); } return false; } //////////////////////////////////////////////////////////////////////////////////////////////////////////////// /** * Restore/import a MySQL database dump file * * This method is designed to restore dump files created by the backup() method of this * class, however it *may* also work with dump files created from other sources like * mysqldump or PhpMyAdmin. * * #pw-group-actions * * @param string $filename Filename to restore, optionally including path (if no path, then path set to construct is assumed) * @param array $options Options to modify default behavior: * - `tables` (array): table names to restore (empty=all) * - `allowDrop` (bool): allow DROP TABLE statements (default=true) * - `dropAll` (bool): DROP ALL tables before restore? The allowDrop optional must also be true. (default=false) * - `haltOnError` (bool): halt execution when an error occurs? (default=false) * - `maxSeconds` (int): max number of seconds allowed for execution (default=1200) * - `findReplace` (array): find and replace in row data. Example: ['databass' => 'database'] * - `findReplaceCreateTable` (array): find and replace in create table statements. * Example: ['DEFAULT CHARSET=utf8;' => 'DEFAULT CHARSET=utf8mb4;'] * @return true on success, false on failure. Call the errors() method to retrieve errors. * @throws \Exception on fatal error * @see WireDatabaseBackup::backup() * */ public function restore($filename, array $options = array()) { $filename = $this->sanitizeFilename($filename); if(!file_exists($filename)) throw new \Exception("Restore file does not exist: $filename"); $options = array_merge($this->restoreOptions, $options); set_time_limit($options['maxSeconds']); $success = false; $this->errors(true); $this->notes(true); if($this->supportsExec($options)) { $this->note('method', 'exec_mysql'); $success = $this->restoreExec($filename, $options); if(!$success) $this->error("Exec mysql failed, attempting PDO..."); } if(!$success) { $this->note('method', 'pdo'); $success = $this->restorePDO($filename, $options); } return $success; } /** * Set restore options * * #pw-internal * * @param array $options * @return $this * */ public function setRestoreOptions(array $options) { $this->restoreOptions = array_merge($this->restoreOptions, $options); return $this; } /** * Import a database SQL file using PDO * * @param string $filename Filename to restore (must be SQL file exported by this class) * @param array $options See $restoreOptions * @return bool true on success, false on failure. Call the errors() method to retrieve errors. * */ protected function restorePDO($filename, array $options = array()) { $fp = fopen($filename, "rb"); if($fp === false) { $this->error("Unable to open: $filename"); return false; } $numInserts = 0; $numTables = 0; $numQueries = 0; if($options['allowDrop'] === true && $options['dropAll'] === true) { $this->dropAllTables(); } $tables = array(); // selective tables to restore, optional foreach($options['tables'] as $table) $tables[$table] = $table; if(!count($tables)) $tables = null; while(!feof($fp)) { $line = trim(fgets($fp)); if(!$this->restoreUseLine($line)) continue; if(preg_match('/^(INSERT|CREATE|DROP)\s+(?:INTO|TABLE IF EXISTS|TABLE IF NOT EXISTS|TABLE)\s+`?([^\s`]+)/i', $line, $matches)) { $command = strtoupper($matches[1]); $table = $matches[2]; } else { $command = ''; $table = ''; } if($command === 'CREATE') { if(!$options['allowDrop'] && stripos($line, 'CREATE TABLE IF NOT EXISTS') === false) { $line = str_ireplace('CREATE TABLE', 'CREATE TABLE IF NOT EXISTS', $line); } } else if($command === 'DROP') { if(!$options['allowDrop']) continue; } else if($command === 'INSERT' && $tables) { if(!isset($tables[$table])) continue; // skip tables not selected for import } while(substr($line, -1) != ';' && !feof($fp)) { // get the rest of the lines in the query (if multi-line) $_line = trim(fgets($fp)); if($this->restoreUseLine($_line)) $line .= $_line; } $replacements = $command === 'CREATE' ? $options['findReplaceCreateTable'] : $options['findReplace']; if(count($replacements)) foreach($replacements as $find => $replace) { if(strpos($line, $find) === false) continue; $line = str_replace($find, $replace, $line); } try { $this->executeQuery($line, $options); if($command === 'INSERT') $numInserts++; if($command === 'CREATE') $numTables++; $numQueries++; } catch(\Exception $e) { $this->error($e->getMessage()); if($options['haltOnError']) break; } } fclose($fp); $this->note('queries', $numQueries); $this->note('inserts', $numInserts); $this->note('tables', $numTables); if(count($this->errors) > 0) { $this->error(count($this->errors) . " queries generated errors ($numQueries queries and $numInserts inserts for $numTables were successful)"); return false; } else { return $numQueries > 0; } } /** * Import a database SQL file using exec(mysql) * * @param string $filename Filename to restore (must be SQL file exported by this class) * @param array $options See $restoreOptions * @return bool True on success, false on failure. Call the errors() method to retrieve errors. * */ protected function restoreExec($filename, array $options = array()) { $cmd = $options['execCommand']; $cmd = str_replace(array("\n", "\t"), ' ', $cmd); $cmd = str_replace('[dbFile]', $filename, $cmd); foreach($this->databaseConfig as $key => $value) { $cmd = str_replace("[$key]", $value, $cmd); } $o = array(); $r = 0; exec($cmd, $o, $r); if($r > 0) { // 0=success, 1=warning, 2=not found $this->error("mysql reported error code $r"); foreach($o as $e) $this->error($e); return false; } return true; } /** * Returns true or false if a line should be used for restore * * @param $line * @return bool * */ protected function restoreUseLine($line) { if(empty($line) || substr($line, 0, 2) == '--' || substr($line, 0, 1) == '#') return false; return true; } /** * Restore from 2 SQL files while resolving table differences (think of it as array_merge for a DB restore) * * The CREATE TABLE and INSERT statements in filename2 take precedence of those in filename1. * INSERT statements from both will be executed, with filename2 INSERTs updating those of filename1. * CREATE TABLE statements in filename1 won't be executed if they also exist in filename2. * * This method assumes both files follow the SQL dump format created by this class. * * #pw-advanced * * @param string $filename1 Original filename * @param string $filename2 Filename that may have statements that will update/override those in filename1 * @param array $options * @return bool True on success, false on fail. * @throws \Exception|WireException if $options['haltOnErrors'] == true. * */ public function restoreMerge($filename1, $filename2, $options) { $options = array_merge($this->restoreOptions, $options); $creates1 = $this->findCreateTables($filename1, $options); $creates2 = $this->findCreateTables($filename2, $options); $creates = array_merge($creates1, $creates2); // CREATE TABLE statements in filename2 override those in filename1 $numErrors = 0; foreach($creates as $table => $create) { if($options['allowDrop']) { if(!$this->executeQuery("DROP TABLE IF EXISTS `$table`", $options)) $numErrors++; } if(!$this->executeQuery($create, $options)) $numErrors++; } $inserts = $this->findInserts($filename1); foreach($inserts as /*$table =>*/ $tableInserts) { foreach($tableInserts as $insert) { if(!$this->executeQuery($insert, $options)) $numErrors++; } } // Convert line 1 to line 2: // 1. INSERT INTO `field_process` (pages_id, data) VALUES('6', '17'); // 2. INSERT INTO `field_process` (pages_id, data) VALUES('6', '17') ON DUPLICATE KEY UPDATE pages_id=VALUES(pages_id), data=VALUES(data); $inserts = $this->findInserts($filename2); foreach($inserts as $table => $tableInserts) { foreach($tableInserts as $insert) { // check if table existed in both dump files, and has no duplicate update statement $regex = '/\s+ON\s+DUPLICATE\s+KEY\s+UPDATE\s+[^\'";]+;$/i'; if(isset($creates1[$table]) && !preg_match($regex, $insert)) { // line doesn't already contain an ON DUPLICATE section, so we need to add it $pos1 = strpos($insert, '(') + 1; $pos2 = strpos($insert, ')') - $pos1; $fields = substr($insert, $pos1, $pos2); $insert = rtrim($insert, '; ') . " ON DUPLICATE KEY UPDATE "; foreach(explode(',', $fields) as $name) { $name = trim($name); $insert .= "$name=VALUES($name), "; } $insert = rtrim($insert, ", ") . ";"; } if(!$this->executeQuery($insert, $options)) $numErrors++; } } return $numErrors === 0; } /** * Drop all tables from database * * @return int Quantity of tables dropped * @throws \Exception * @since 3.0.130 * */ public function dropAllTables() { $database = $this->getDatabase(); $tables = $this->getAllTables(false, false); $qty = 0; $database->exec("SET FOREIGN_KEY_CHECKS=0"); foreach($tables as $table) { if($database->exec("DROP TABLE IF EXISTS `$table`")) $qty++; } $database->exec("SET FOREIGN_KEY_CHECKS=1"); return $qty; } //////////////////////////////////////////////////////////////////////////////////////////////////////////////// /** * Returns array of all create table statements, indexed by table name * * @param string $filename to extract all CREATE TABLE statements from * @param string $regex Regex (PCRE) to match for statement to be returned, must stuff table name into first match * @param bool $multi Whether there can be multiple matches per table * @return array of statements, indexed by table name. If $multi is true, it will be array of arrays. * @throws \Exception if unable to open specified file * */ protected function findStatements($filename, $regex, $multi = true) { $filename = $this->sanitizeFilename($filename); $fp = fopen($filename, 'r'); if(!$fp) throw new \Exception("Unable to open: $filename"); $statements = array(); while(!feof($fp)) { $line = trim(fgets($fp)); if(!preg_match($regex, $line, $matches)) continue; if(empty($matches[1])) continue; $table = $matches[1]; while(substr($line, -1) != ';' && !feof($fp)) $line .= " " . rtrim(fgets($fp)); if($multi) { if(!isset($statements[$table])) $statements[$table] = array(); $statements[$table][] = $line; } else { $statements[$table] = $line; } } fclose($fp); return $statements; } /** * Returns array of all create table statements, indexed by table name * * #pw-internal * * @param string $filename to extract all CREATE TABLE statements from * @param array $options * @return array of CREATE TABLE statements, associative: indexed by table name * @throws \Exception if unable to open specified file * */ public function findCreateTables($filename, array $options) { $regex = '/^CREATE\s+TABLE\s+`?([^`\s]+)/i'; $statements = $this->findStatements($filename, $regex, false); if(!empty($options['findReplaceCreateTable'])) { foreach($options['findReplaceCreateTable'] as $find => $replace) { foreach($statements as $key => $line) { if(strpos($line, $find) === false) continue; $line = str_replace($find, $replace, $line); $statements[$key] = $line; } } } return $statements; } /** * Returns array of all INSERT statements in given filename, indexed by table name * * #pw-internal * * @param string $filename to extract all CREATE TABLE statements from * @return array of arrays of INSERT statements. Base array is associative indexed by table name. * Inside arrays are numerically indexed by order of appearance. * */ public function findInserts($filename) { $regex = '/^INSERT\s+INTO\s+`?([^`\s]+)/i'; return $this->findStatements($filename, $regex, true); } /** * Execute an SQL query, either a string or PDOStatement * * @param string|\PDOStatement $query * @param bool|array $options May be boolean (for haltOnError), or array containing the property (i.e. $options array) * - `haltOnError` (bool): Halt execution when error occurs? (default=false) * @return bool Query result * @throws \Exception if haltOnError, otherwise it populates $this->errors * */ protected function executeQuery($query, $options = array()) { $defaults = array( 'haltOnError' => false ); if(is_bool($options)) { $defaults['haltOnError'] = $options; $options = array(); } $options = array_merge($defaults, $options); $result = false; try { if(is_string($query)) { $result = $this->getDatabase()->exec($query); } else if($query instanceof \PDOStatement) { $result = $query->execute(); } } catch(\Exception $e) { if(empty($options['haltOnError'])) { $this->error($e->getMessage()); } else { throw $e; } } return $result === false ? false : true; } /** * For path: Normalizes slashes and ensures it ends with a slash * * @param $path * @return string * */ protected function sanitizePath($path) { if(DIRECTORY_SEPARATOR != '/') $path = str_replace(DIRECTORY_SEPARATOR, '/', $path); $path = rtrim($path, '/') . '/'; // ensure it ends with trailing slash return $path; } /** * For filename: Normalizes slashes and ensures it starts with a path * * @param $filename * @return string * @throws \Exception if path has not yet been set * */ protected function sanitizeFilename($filename) { if(DIRECTORY_SEPARATOR != '/') $filename = str_replace(DIRECTORY_SEPARATOR, '/', $filename); if(strpos($filename, '/') === false) { $filename = $this->path . $filename; } if(strpos($filename, '/') === false) { $path = $this->getPath(); if(!strlen($path)) throw new \Exception("Please supply full path to file, or call setPath('/backup/files/path/') first"); $filename = $path . $filename; } return $filename; } //////////////////////////////////////////////////////////////////////////////////////////////////////////////// /** * Determine if exec is available for the given command * * Note that WireDatabaseBackup does not currently use exec() mode so this is here for future use. * * @param array $options * @return bool * @throws \Exception on unknown exec type * */ protected function supportsExec(array $options = array()) { if(!$options['exec']) return false; if(empty($this->databaseConfig['dbUser'])) return false; // no db config options provided if(preg_match('{^(?:\[dbPath\])?([_a-zA-Z0-9]+)\s}', $options['execCommand'], $matches)) { $type = $matches[1]; } else { throw new \Exception("Unable to determine command for exec"); } if($type == 'mysqldump') { // these options are not supported by mysqldump via exec if( !empty($options['excludeCreateTables']) || !empty($options['excludeExportTables']) || !empty($options['findReplace']) || !empty($options['findReplaceCreateTable']) || !empty($options['allowUpdateTables']) || !empty($options['allowUpdate'])) { return false; } } else if($type == 'mysql') { // these options are not supported by mysql via exec if( !empty($options['tables']) || !empty($options['allowDrop']) || !empty($options['findReplace']) || !empty($options['findReplaceCreateTable'])) { return false; } } else { throw new \Exception("Unrecognized exec command: $type"); } // first check if exec is available (http://stackoverflow.com/questions/3938120/check-if-exec-is-disabled) if(ini_get('safe_mode')) return false; $d = ini_get('disable_functions'); $s = ini_get('suhosin.executor.func.blacklist'); if("$d$s") { $a = preg_split('/,\s*/', "$d,$s"); if(in_array('exec', $a)) return false; } // now check if mysqldump is available $o = array(); $r = 0; $path = $this->databaseConfig['dbPath']; exec("{$path}$type --version", $o, $r); if(!$r && count($o) && stripos($o[0], $type) !== false && stripos($o[0], 'Ver') !== false) { // i.e. mysqldump Ver 10.13 Distrib 5.5.34, for osx10.6 (i386) return true; } return false; } }