├── .gitignore ├── README.md ├── assess.php ├── bin └── mysql-sync ├── composer.json ├── conf └── database.sample.json ├── phpstan.neon └── sync.php /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/* 2 | test.php 3 | test.json 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MySQL Synchroniser 2 | 3 | [![Minimum PHP Version](https://img.shields.io/badge/php-%3E%3D%207.3-8892BF.svg)](https://php.net/) 4 | [![License](https://sqonk.com/opensource/license.svg)](license.txt) 5 | 6 | The MySQL Synchroniser is a simple script written in PHP that can assist and automate the synchronisation of differences in table structures between two database servers. 7 | 8 | Synchronisation is performed between a source database and a destination. 9 | 10 | 11 | ## Install 12 | 13 | Via Composer 14 | 15 | ``` bash 16 | $ composer require sqonk/mysql-sync 17 | ``` 18 | 19 | ## Disclaimer - (Common Sense) 20 | 21 | Always backup the destination database prior to making any changes, this should go without saying. 22 | 23 | ## Usage 24 | 25 | ### Method 1: Using a JSON config file 26 | 27 | First duplicate the sample json sync file provided the conf folder, call it something meanginful and enter the database details for both the source and destination databases. 28 | 29 | ``` json 30 | { 31 | "source" : { 32 | "host" : "", 33 | "user" : "", 34 | "password" : "", 35 | "database" : "", 36 | "port" : "3306" 37 | }, 38 | "dest" : { 39 | "host" : "", 40 | "user" : "", 41 | "password" : "", 42 | "database" : "", 43 | "port" : "3306" 44 | }, 45 | "ignoreColumnWidths" : false 46 | } 47 | ``` 48 | 49 | Then from your terminal run the following the command: 50 | 51 | ``` bash 52 | vendor/bin/mysql-sync path/to/my/config-file.json 53 | ``` 54 | 55 | ### Method 2: Using in-memory PHP array 56 | 57 | Create a new PHP script, load the composer includes and pass your config array accordingly. 58 | 59 | ``` php 60 | require 'vendor/autoload.php' 61 | 62 | mysql_sync([ 63 | "source" => [ 64 | "host" => "", 65 | "user" => "", 66 | "password" => "", 67 | "database" => "", 68 | "port" : "3306" 69 | ], 70 | "dest" => [ 71 | "host" => "", 72 | "user" => "", 73 | "password" => "", 74 | "database" => "", 75 | "port" : "3306" 76 | ], 77 | "ignoreColumnWidths" : false 78 | ]); 79 | ``` 80 | 81 | #### Ignoring Column Widths 82 | 83 | If you are in a situation in which the configuration of the destination database differs from that of the source environment in such a way that column widths do not match up then you can set the option `ignoreColumnWidths` to true in the sync configuration. 84 | 85 | This will adjust the comparison to ignore column width/length. 86 | 87 | ### Process 88 | 89 | A dry-run will first be performed and any differences will be displayed, including: 90 | 91 | * New tables to create in the destination. 92 | * Old tables to drop no longer present on the source. 93 | * Tables present in both but with differing columns (including new, old and modified) 94 | 95 | Once done, you will be prompted if you wish to apply the changes for real. 96 | 97 | ## Credits 98 | 99 | * Theo Howell 100 | * Oliver Jacobs 101 | 102 | ## License 103 | 104 | The MIT License (MIT). Please see [License File](license.txt) for more information. 105 | 106 | -------------------------------------------------------------------------------- /assess.php: -------------------------------------------------------------------------------- 1 | $config 30 | */ 31 | function mysql_sync(object|array $config): void 32 | { 33 | if (is_array($config)) { 34 | $config = json_decode(json_encode($config)); 35 | } 36 | 37 | # --- Connect to both databases. 38 | println("\n--Source: {$config->source->user}@{$config->source->host}/{$config->source->database}"); 39 | 40 | $source = new mysqli($config->source->host, $config->source->user, $config->source->password, $config->source->database, $config->source->port); 41 | if (! $source || $source->connect_error) { // @phpstan-ignore-line 42 | println("Unable to connect to the source MySQL database."); 43 | if ($err = $source->connect_error) { // @phpstan-ignore-line 44 | println($err); 45 | } 46 | exit; 47 | } 48 | 49 | println("\n--Dest: {$config->dest->user}@{$config->dest->host}/{$config->dest->database}\n"); 50 | 51 | $dest = new mysqli($config->dest->host, $config->dest->user, $config->dest->password, $config->dest->database, $config->dest->port); 52 | if (!$source || $source->connect_error) { // @phpstan-ignore-line 53 | println("Unable to connect to the source MySQL database."); 54 | if ($err = $source->connect_error) { // @phpstan-ignore-line 55 | println($err); 56 | } 57 | exit; 58 | } 59 | 60 | $ignoreColumnWidths = (bool)$config->ignoreColumnWidths; 61 | 62 | # ---- Run the sync process, first as a dry run to see what has changed and then ask the user if they want to do it for real. 63 | println("Displaying differences.."); 64 | 65 | $statements = sync($source, $dest, true, $ignoreColumnWidths); 66 | if (getCounts($statements) > 0) { 67 | do { 68 | $r = ask("Do you want to deploy the changes to the destination, dump the SQL modification commands to a file or cancel? [D]eploy to destination, [s]ave to file, [c]ancel"); 69 | } while (! arrays::contains(['D', 's', 'c'], $r)); 70 | 71 | if ($r == 'D') { 72 | context::mysql_transaction($dest)->do(function ($dest) use ($source, $ignoreColumnWidths) { 73 | sync($source, $dest, false, $ignoreColumnWidths); 74 | }); 75 | } elseif ($r == 's') { 76 | $out = implode("\n\n", array_map(fn($set) => implode("\n", $set), $statements)); 77 | $now = date('Y-m-d-h-i'); 78 | file_put_contents(getcwd() . "/database_diff-$now.sql", trim($out)); 79 | } 80 | } 81 | } 82 | 83 | /** 84 | * @return array 85 | */ 86 | function describe(mysqli $db, bool $ignoreColumnWidths): array 87 | { 88 | $tables = []; 89 | 90 | foreach ($db->query("SHOW TABLES") as $row) { 91 | $tableName = arrays::first($row) ?? ''; 92 | if (! $tableName) { 93 | continue; 94 | } 95 | 96 | if ($r = $db->query("DESCRIBE `$tableName`")) { 97 | $table = []; 98 | foreach ($r as $info) { 99 | $info['Null'] = $info['Null'] == 'YES' ? '' : 'NOT NULL'; 100 | if (isset($info['Default'])) { 101 | $info['Default'] = 'DEFAULT ' . $info['Default']; 102 | } 103 | 104 | $type = $info['Type'] ?? ''; 105 | if ($ignoreColumnWidths && $type && str_contains(haystack: $type, needle: '(')) { 106 | $info['Type'] = preg_replace("/(\(.+?\))/", "", $type); 107 | } 108 | 109 | $name = $info['Field']; 110 | $info['FieldQ'] = "`$name`"; 111 | 112 | $table[$name] = arrays::implode_only(' ', $info, 'FieldQ', 'Type', 'Null', 'Default', 'Extra'); 113 | } 114 | $tables[$tableName] = $table; 115 | } 116 | } 117 | return $tables; 118 | } 119 | 120 | /** 121 | * @param array $statements 122 | */ 123 | function getCounts(array $statements): int 124 | { 125 | return array_sum(array_map(fn($arr) => count($arr), $statements)); 126 | } 127 | 128 | /** 129 | * @return array{list, list, list} 130 | */ 131 | function sync(mysqli $source, mysqli $dest, bool $dryRun, bool $ignoreColumnWidths): array 132 | { 133 | $source_tables = describe($source, $ignoreColumnWidths); 134 | $dest_tables = describe($dest, $ignoreColumnWidths); 135 | 136 | $new = array_diff_key($source_tables, $dest_tables); 137 | $dropped = array_diff_key($dest_tables, $source_tables); 138 | $existing = array_diff_key($source_tables, $new); 139 | 140 | 141 | $newStatements = []; 142 | $dropStatements = []; 143 | $alterStatements = []; 144 | 145 | println("\n====== NEW TABLES"); 146 | foreach ($new as $tblName => $cols) { 147 | $create = $source->query("SHOW CREATE TABLE `$tblName`"); 148 | if ($create && $r = $create->fetch_assoc()) { 149 | $create = $r["Create Table"]; 150 | $newStatements[] = $create; 151 | if (! $dryRun) { 152 | println("creating $tblName"); 153 | $dest->query($create); 154 | } else { 155 | println("\n$create"); 156 | } 157 | } 158 | } 159 | if (count($newStatements) == 0) { 160 | println('There are no new tables.'); 161 | } 162 | 163 | println("\n===== TABLES TO REMOVE"); 164 | if (count($dropped) > 0) { 165 | foreach ($dropped as $tblName => $cols) { 166 | $drop = "DROP TABLE `$tblName`"; 167 | $dropStatements[] = $drop; 168 | if (! $dryRun) { 169 | println("dropping $tblName"); 170 | $dest->query($drop); 171 | } else { 172 | println("\n$drop"); 173 | } 174 | } 175 | } else { 176 | println('There are no tables to drop'); 177 | } 178 | 179 | println("\n===== EXISTING TABLES"); 180 | foreach ($existing as $tblName => $master_cols) { 181 | $slave_cols = $dest_tables[$tblName]; 182 | // compare columns 183 | $newCols = array_diff_key($master_cols, $slave_cols); 184 | $droppedCols = array_diff_key($slave_cols, $master_cols); 185 | $existingM = array_diff($master_cols, $newCols); 186 | $existingS = array_diff($slave_cols, $droppedCols); 187 | $alter = []; 188 | 189 | foreach ($newCols as $cmd) { 190 | $st = "ADD COLUMN $cmd"; 191 | if ($dryRun) { 192 | $st .= ','; 193 | } 194 | $alter[] = $st; 195 | } 196 | 197 | $previousDesc = []; 198 | foreach ($existingM as $fn => $descrption) { 199 | $slaveDescription = $existingS[$fn] ?? ''; 200 | if ($descrption != $slaveDescription) { 201 | $m = "MODIFY COLUMN $descrption"; 202 | if ($dryRun) { 203 | $m .= ','; 204 | } 205 | $alter[] = $m; 206 | $previousDesc[$m] = $slaveDescription; 207 | } 208 | } 209 | 210 | foreach ($droppedCols as $colName => $cmd) { 211 | $st = "DROP COLUMN $colName"; 212 | if ($dryRun) { 213 | $st .= ','; 214 | } 215 | $alter[] = $st; 216 | } 217 | 218 | $alterCount = count($alter); 219 | if ($alterCount > 0) { 220 | $last = $alter[$alterCount - 1]; 221 | if (str_ends_with(haystack: $last, needle: ',')) { 222 | $alter[$alterCount - 1] = substr($last, 0, -1); 223 | } 224 | if ($dryRun) { 225 | $alterT = "ALTER TABLE `$tblName`"; 226 | println($alterT); 227 | $alterStatements[] = $alterT; 228 | foreach ($alter as $cmd) { 229 | $alterStatements[] = $cmd; 230 | println(trim($cmd)); 231 | $d = $previousDesc[$cmd] ?? ''; 232 | if ($d) { 233 | println("\twas: [$d]"); 234 | } 235 | } 236 | println(); 237 | 238 | println('-------------'); 239 | } else { 240 | println("adjusting $tblName\n"); 241 | foreach ($alter as $modify) { 242 | $cmd = "ALTER TABLE `$tblName` $modify"; 243 | $alterStatements[] = $cmd; 244 | try { 245 | $dest->query($cmd); 246 | } catch (Exception $error) { 247 | println("Statement failed: [$cmd]", $error->getMessage()); 248 | } 249 | } 250 | } 251 | } 252 | } 253 | 254 | if (count($alterStatements) == 0) { 255 | println('There are no changes between existing tables.'); 256 | } 257 | 258 | println(); 259 | return [$newStatements, $dropStatements, $alterStatements]; 260 | } 261 | --------------------------------------------------------------------------------