. * @author niel * @copyright 2014 nZEDb */ namespace nntmux\db; use nntmux\ColorCLI; use nntmux\utility\Git; use App\Models\Settings; use nntmux\utility\Utility; class DbUpdate { public $backedup; /** * @var DB Instance variable for DB object. */ public $pdo; /** * @var Git instance */ public $git; /** * @var object Instance variable for logging object. Currently only ColorCLI supported, * but expanding for full logging with agnostic API planned. */ public $log; /** * @var object Instance object for sites/settings class. */ public $settings; protected $_DbSystem; /** * @var bool Has the Db been backed up? */ private $backedUp = false; /** * @var bool Should we perform a backup? */ private $backup; public function __construct(array $options = []) { $options += [ 'backup' => true, 'db' => null, 'git' => new Git(), 'logger' => new ColorCLI(), ]; $this->backup = $options['backup']; $this->git = $options['git']; $this->log = $options['logger']; // Must be DB not Settings because the Settings table may not exist yet. $this->pdo = (($options['db'] instanceof DB) ? $options['db'] : new DB()); $this->_DbSystem = strtolower($this->pdo->DbSystem()); } /** * @param array $options */ public function loadTables(array $options = []): void { $defaults = [ 'enclosedby' => null, 'ext' => 'tsv', 'files' => [], 'path' => NN_RES.'db'.DS.'schema'.DS.'data', 'regex' => '#^'.Utility::PATH_REGEX.'(?P\d+)-(?P\w+)\.tsv$#', ]; $options += $defaults; $show = (Utility::isCLI() || NN_DEBUG); $files = empty($options['files']) ? Utility::getDirFiles($options) : $options['files']; natsort($files); $local = $this->pdo->isLocalDb() ? '' : 'LOCAL '; $enclosedby = empty($options['enclosedby']) ? '' : 'OPTIONALLY ENCLOSED BY "'. $options['enclosedby'].'"'; $sql = 'LOAD DATA '. $local.'INFILE "%s" IGNORE INTO TABLE `%s` FIELDS TERMINATED BY "\t" '.$enclosedby.'LINES TERMINATED BY "\n" IGNORE 1 LINES (%s)'; foreach ($files as $file) { if ($show === true) { echo "File: $file\n"; } if (is_readable($file)) { if (preg_match($options['regex'], $file, $matches)) { $table = $matches['table']; // Get the first line of the file which holds the columns used. $handle = @fopen($file, 'rb'); if (is_resource($handle)) { $line = fgets($handle); fclose($handle); if ($line === false) { echo "FAILED reading first line of '$file'\n"; continue; } $fields = trim($line); if ($show === true) { ColorCLI::doEcho(ColorCLI::info('Inserting data into table: '.$table)); } if (Utility::isWin()) { $file = str_replace('\\', '\/', $file); } $this->pdo->exec("SET @@session.sql_mode = CASE WHEN @@session.sql_mode NOT LIKE '%NO_AUTO_VALUE_ON_ZERO%' THEN CASE WHEN LENGTH(@@session.sql_mode)>0 THEN CONCAT_WS(',',@@session.sql_mode,'NO_AUTO_VALUE_ON_ZERO') ELSE 'NO_AUTO_VALUE_ON_ZERO' END ELSE @@session.sql_mode END;"); $this->pdo->queryExec(sprintf($sql, $file, $table, $fields)); if ($table !== 'settings') { $success = $this->pdo->query(sprintf('SELECT COUNT(id) AS num FROM %s', $table)); if (empty($success[0]['num'])) { ColorCLI::doEcho(ColorCLI::error('Failed to insert data into table: '.$table)); } else { ColorCLI::doEcho(ColorCLI::notice('Successfully inserted data into '.$table.' table')); } } } else { exit("Failed to open file: '$file'\n"); } } else { echo "Incorrectly formatted filename '$file' (should match ". str_replace('#', '', $options['regex'])."\n"; } } else { echo $this->log->error(" Unable to read file: '$file'"); } } } /** * Takes new files in the correct format from the patches directory and turns them into proper patches. * * The files should be name as '+x~
.sql' where x is a number starting at 1 for your first * patch.
should be the name of the primary table affected. If you have to modify more * than one table, consider splitting into multiple patches using different patch modifier * numbers to order them. i.e. +1~settings.sql, +2~predb.sql, etc. * * @param array $options * * @throws \Exception */ public function newPatches(array $options = []): void { $defaults = [ 'data' => NN_RES.'db'.DS.'schema'.DS.'data'.DS, 'ext' => 'sql', 'path' => NN_RES.'db'.DS.'patches'.DS.$this->_DbSystem, 'regex' => '#^'.Utility::PATH_REGEX.'\+(?P\d+)~(?P
\w+)\.sql$#', 'safe' => true, ]; $options += $defaults; $this->processPatches(['safe' => $options['safe']]); // Make sure we are completely up to date! echo $this->log->primaryOver('Looking for new patches...'); $files = Utility::getDirFiles($options); $count = count($files); echo $this->log->header(" $count found"); if ($count > 0) { echo $this->log->header('Processing...'); natsort($files); $local = $this->pdo->isLocalDb() ? '' : 'LOCAL '; foreach ($files as $file) { if (! preg_match($options['regex'], $file, $matches)) { $this->log->error("$file does not match the pattern {$options['regex']}\nPlease fix this before continuing"); } else { echo $this->log->header('Processing patch file: '.$file); $this->splitSQL($file, ['local' => $local, 'data' => $options['data']]); $current = Settings::settingValue('..sqlpatch'); $current++; Settings::query()->where('setting', '=', 'sqlpatch')->update(['value' => $current]); $newName = $matches['drive'].$matches['path']. str_pad($current, 4, '0', STR_PAD_LEFT).'~'. $matches['table'].'.sql'; rename($matches[0], $newName); $this->git->add($newName); if ($this->git->isCommited($this->git->getBranch().':'.str_replace(NN_ROOT, '', $matches[0]))) { $this->git->add(" -u {$matches[0]}"); // remove old filename from the index. } } } } } /** * @param array $options * * @return int * @throws \RuntimeException */ public function processPatches(array $options = []): int { $patched = 0; $defaults = [ 'data' => NN_RES.'db'.DS.'schema'.DS.'data'.DS, 'ext' => 'sql', 'path' => NN_RES.'db'.DS.'patches'.DS.$this->_DbSystem, 'regex' => '#^'.Utility::PATH_REGEX.'(?P\d{4})~(?P
\w+)\.sql$#', 'safe' => true, ]; $options += $defaults; $currentVersion = Settings::settingValue('..sqlpatch'); if (! is_numeric($currentVersion)) { exit("Bad sqlpatch value: '$currentVersion'\n"); } $files = empty($options['files']) ? Utility::getDirFiles($options) : $options['files']; if (count($files)) { natsort($files); $local = $this->pdo->isLocalDb() ? '' : 'LOCAL '; $data = $options['data']; echo $this->log->primary('Looking for unprocessed patches...'); foreach ($files as $file) { $setPatch = false; $fp = fopen($file, 'r'); $patch = fread($fp, filesize($file)); if (preg_match($options['regex'], str_replace('\\', '/', $file), $matches)) { $patch = (int) $matches['patch']; $setPatch = true; } elseif (preg_match( '/UPDATE `?site`? SET `?value`? = \'?(?P\d+)\'? WHERE `?setting`? = \'sqlpatch\'/i', $patch, $matches ) ) { $patch = (int) $matches['patch']; } else { throw new \RuntimeException('No patch information available, stopping!!'); } if ($patch > $currentVersion) { echo $this->log->header('Processing patch file: '.$file); if (! $this->backedUp && $options['safe']) { $this->_backupDb(); } $this->splitSQL($file, ['local' => $local, 'data' => $data]); if ($setPatch) { Settings::query()->where('setting', '=', 'sqlpatch')->update(['value' => $patch]); } $patched++; } } } else { exit($this->log->error("\nHave you changed the path to the patches folder, or do you have the right permissions?\n")); } if ($patched === 0) { echo $this->log->info("Nothing to patch, you are already on version $currentVersion"); } return $patched; } /** * @param array $options */ public function processSQLFile(array $options = []): void { $defaults = [ 'filepath' => NN_RES.'db'.DS.'schema'.DS.$this->_DbSystem.'-ddl.sql', ]; $options += $defaults; $sql = file_get_contents($options['filepath']); $sql = str_replace(['DELIMITER $$', 'DELIMITER ;', '$$'], '', $sql); $this->pdo->exec($sql); } /** * @param $file * @param array $options */ public function splitSQL($file, array $options = []): void { $defaults = [ 'data' => null, 'delimiter' => ';', 'local' => null, ]; $options += $defaults; if (! empty($options['vars'])) { extract($options['vars'], 'EXTR_OVERWRITE'); } set_time_limit(0); if (is_file($file)) { $file = fopen($file, 'r, b'); if (is_resource($file)) { $query = []; $delimiter = $options['delimiter']; while (! feof($file)) { $line = fgets($file); if ($line === false) { continue; } // Skip comments. if (preg_match('!^\s*(#|--|//)\s*(.+?)\s*$!', $line, $matches)) { echo ColorCLI::info('COMMENT: '.$matches[2]); continue; } // Check for non default delimiters ($$ for example). if (preg_match('#^\s*DELIMITER\s+(?P.+)\s*$#i', $line, $matches)) { $delimiter = $matches['delimiter']; if (NN_DEBUG) { echo ColorCLI::debug("DEBUG: Delimiter switched to $delimiter"); } if ($delimiter !== $options['delimiter']) { continue; } } // Check if the line has delimiter that is non default ($$ for example). if ($delimiter !== $options['delimiter'] && preg_match('#^(.+?)'.preg_quote($delimiter).'\s*$#', $line, $matches)) { // Check if the line has also the default delimiter (;), remove it. if (preg_match('#^(.+?)'.preg_quote($options['delimiter']).'\s*$#', $matches[1], $matches2)) { $matches[1] = $matches2[1]; } // Change the non default delimiter ($$) to the default one(;). $line = $matches[1].$options['delimiter']; } $query[] = $line; if (preg_match('~'.preg_quote($delimiter, '~').'\s*$~iS', $line) === 1) { $query = trim(implode('', $query)); if ($options['local'] !== null) { $query = str_replace('{:local:}', $options['local'], $query); } if (! empty($options['data'])) { $query = str_replace('{:data:}', $options['data'], $query); } try { $qry = $this->pdo->Prepare($query); $qry->execute(); echo $this->log->alternateOver('SUCCESS: ').$this->log->primary($query); } catch (\PDOException $e) { // Log the problem and the query. file_put_contents( NN_LOGS.'patcherrors.log', '['.date('r').'] [ERROR] ['. trim(preg_replace('/\s+/', ' ', $e->getMessage())).']'.PHP_EOL. '['.date('r').'] [QUERY] ['. trim(preg_replace('/\s+/', ' ', $query)).']'.PHP_EOL, FILE_APPEND ); if ( in_array($e->errorInfo[1], [1091, 1060, 1061, 1071, 1146], false) || in_array($e->errorInfo[0], [23505, 42701, 42703, '42P07', '42P16'], false) ) { if ($e->errorInfo[1] === 1060) { echo $this->log->warning( "$query The column already exists - No need to worry \{". $e->errorInfo[1]."}.\n" ); } else { echo $this->log->warning( "$query Skipped - No need to worry \{". $e->errorInfo[1]."}.\n" ); } } else { if (preg_match('/ALTER IGNORE/i', $query)) { $this->pdo->queryExec('SET SESSION old_alter_table = 1'); try { $this->pdo->exec($query); echo $this->log->alternateOver('SUCCESS: ').$this->log->primary($query); } catch (\PDOException $e) { exit($this->log->error("$query Failed \{".$e->errorInfo[1]."}\n\t".$e->errorInfo[2])); } } else { exit($this->log->error("$query Failed \{".$e->errorInfo[1]."}\n\t".$e->errorInfo[2])); } } } while (ob_get_level() > 0) { ob_end_flush(); } flush(); } if (is_string($query) === true) { $query = []; } } } } } /** * @param array $options */ public function updateSchemaData(array $options = []): void { $changed = false; $default = [ 'file' => '10-settings.tsv', 'path' => 'resources'.DS.'db'.DS.'schema'.DS.'data'.DS, 'regex' => '#^(?P
.*)\t(?P.*)\t(?P.*)\t(?P.*)\t(?P.*)\t(?P.*)$#', 'value' => function (array $matches) { return "{$matches['section']}\t{$matches['subsection']}\t{$matches['name']}\t{$matches['value']}\t{$matches['hint']}\t{$matches['setting']}"; }, // WARNING: leaving this empty will blank not remove lines. ]; $options += $default; $file = []; $filespec = Utility::trailingSlash($options['path']).$options['path']; if (file_exists($filespec) && ($file = file($filespec, FILE_IGNORE_NEW_LINES))) { $count = count($file); $index = 0; while ($index < $count) { if (preg_match($options['regex'], $file[$index], $matches)) { if (VERBOSE) { echo $this->log->primary('Matched: '.$file[$index]); } $index++; if (is_callable($options['value'])) { $file[$index] = $options['value']($matches); } else { $file[$index] = $options['value']; } $changed = true; } } } if ($changed) { if (file_put_contents($filespec, implode("\n", $file)) === false) { echo $this->log->error('Error writing file to disc!!'); } } } protected function _backupDb(): void { $PHP = 'php'; system("$PHP ".NN_MISC.'testing'.DS.'DB'.DS.$this->_DbSystem. 'dump_tables.php db dump'); $this->backedup = true; } }