simple-db-migrator.php 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. <?php
  2. /*
  3. * ॐ Om Brahmarppanam ॐ
  4. *
  5. * simple-db-migrator.php
  6. * Created at: Thu Jul 20 2022 19:34:40 GMT+0530 (GMT+05:30)
  7. *
  8. * Copyright 2022 Harish Karumuthil <harish2704@gmail.com>
  9. *
  10. * Use of this source code is governed by an MIT-style
  11. * license that can be found in the LICENSE file or at
  12. * https://opensource.org/licenses/MIT.
  13. */
  14. require __DIR__ . '/../vendor/autoload.php';
  15. // Carga variables de entorno desde el archivo ".env".
  16. \App\Utils\Env::loadDotEnv();
  17. // Carga opciones de configuración de la aplicación.
  18. \App\Utils\Config::getFromFilename('app');
  19. $DB = new \App\Utils\DB();
  20. chdir(__DIR__ . '/../public');
  21. // Obtiene las opciones de configuración de la base de datos.
  22. $dbConf = [
  23. 'dsn' => $DB->getConnection()->getDsn(),
  24. 'user' => $DB->getConfig()->getUsername(),
  25. 'password' => $DB->getConfig()->getPassword()
  26. ];
  27. // $dbConf = [ "dsn" => "mysql:host=172.20.1.3;dbname=migrationtest", "user" => "root", "password" => "xxxxxx", ];
  28. // $dbConf = [ "dsn" => "pgsql:host=172.17.0.2;dbname=migrationtest", "user" => "postgres", "password" => "xxxxxx", ];
  29. $MIGRAION_ROOT = __DIR__ . '/../migrations';
  30. $L;
  31. class Logger
  32. {
  33. private $levels = ['error', 'warning', 'info', 'log', 'debug'];
  34. public function __construct($level)
  35. {
  36. $this->level = $level;
  37. }
  38. private function _log($level, $args)
  39. {
  40. echo date('D M d, Y G:i') . " [$level] : " . implode(', ', $args) . "\n";
  41. }
  42. public function __call($name, $arguments)
  43. {
  44. $targetLevel = array_search($name, $this->levels);
  45. if ($targetLevel !== false && $targetLevel <= $this->level) {
  46. $this->_log($name, $arguments);
  47. }
  48. }
  49. }
  50. class MigrationItem
  51. {
  52. /**
  53. * @param $v {int} Version number
  54. */
  55. public function __construct($v)
  56. {
  57. global $MIGRAION_ROOT;
  58. $this->v = $v;
  59. $this->upFile = sprintf('%s/up/%03d.sql', $MIGRAION_ROOT, $v);
  60. $this->downFile = sprintf('%s/down/%03d.sql', $MIGRAION_ROOT, $v);
  61. }
  62. public function getSQL($fname)
  63. {
  64. return file_get_contents($fname);
  65. }
  66. public function getUpSQL()
  67. {
  68. return $this->getSQL($this->upFile);
  69. }
  70. public function getDownSql()
  71. {
  72. return $this->getSQL($this->downFile);
  73. }
  74. }
  75. class Migrator
  76. {
  77. public function __construct()
  78. {
  79. global $dbConf, $L;
  80. $this->db = new PDO($dbConf['dsn'], $dbConf['user'], $dbConf['password']);
  81. $this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
  82. $this->L = $L;
  83. }
  84. private function runSQLTransaction($sql)
  85. {
  86. $this->L->debug('Runing SQL');
  87. $this->L->debug($sql);
  88. $res = $this->db->exec("BEGIN;\n" . $sql . "\nCOMMIT;");
  89. }
  90. /*
  91. * Get array of pending version numbers
  92. * @return int[]
  93. */
  94. public function getPendingMigrations()
  95. {
  96. $lastRanMigration = $this->getLastRanMigration();
  97. $availableMigrations = $this->getAvailableMigrations();
  98. if ($lastRanMigration == 0) {
  99. return $availableMigrations;
  100. }
  101. $lastMigrationIdx = array_search($lastRanMigration, $availableMigrations);
  102. if ($lastMigrationIdx === null) {
  103. throw new Exception(
  104. 'Inconsistent state: Last migration is missing in filesystem'
  105. );
  106. }
  107. return array_slice($availableMigrations, $lastMigrationIdx + 1);
  108. }
  109. /*
  110. * Get array of available verion numbers
  111. * @return int[]
  112. */
  113. private function getAvailableMigrations()
  114. {
  115. global $MIGRAION_ROOT;
  116. $files = scandir("$MIGRAION_ROOT/up");
  117. $out = [];
  118. foreach ($files as $fname) {
  119. $match = [];
  120. $matches = preg_match('/^([0-9]*).sql$/', $fname, $match);
  121. if ($matches > 0) {
  122. $out[] = (int) $match[1];
  123. }
  124. }
  125. sort($out);
  126. return $out;
  127. }
  128. /*
  129. * @return int
  130. */
  131. private function getLastRanMigration()
  132. {
  133. try {
  134. $result = $this
  135. ->db
  136. ->query(
  137. 'SELECT version from db_migrations order by version desc limit 1',
  138. PDO::FETCH_ASSOC
  139. )
  140. ->fetchAll();
  141. } catch (Exception $e) {
  142. $this->L->error("db_migrations table doesn't exists. Please run setup");
  143. throw $e;
  144. }
  145. if ($result) {
  146. return $result[0]['version'];
  147. }
  148. return 0;
  149. }
  150. public function runUp()
  151. {
  152. $this->L->warning('Running up');
  153. $pendingMigrations = $this->getPendingMigrations();
  154. $this->L->warning('Pending migrations ' . json_encode($pendingMigrations));
  155. foreach ($pendingMigrations as $migrationV) {
  156. $this->L->warning('Running migration ' . $migrationV);
  157. $migrationItem = new MigrationItem($migrationV);
  158. $sql = $migrationItem->getUpSQL();
  159. $this->runSQLTransaction($sql);
  160. $this
  161. ->db
  162. ->prepare(
  163. 'INSERT INTO db_migrations
  164. (version, created_at, up_sql, down_sql) VALUES (?, ?, ?, ?)'
  165. )
  166. ->execute([
  167. $migrationV,
  168. date('Y-m-d H:i:s'),
  169. $sql,
  170. $migrationItem->getDownSql(),
  171. ]);
  172. }
  173. $this->L->warning('executed all pending migrations');
  174. }
  175. public function setup()
  176. {
  177. try {
  178. $result = $this
  179. ->db
  180. ->query(
  181. 'SELECT version from db_migrations order by version desc limit 1',
  182. PDO::FETCH_ASSOC
  183. )
  184. ->fetchAll();
  185. } catch (Exception $e) {
  186. $this->L->warning("db_migrations table doesn't exists.");
  187. $this->L->info('Creating db_migrations table ...');
  188. return $this->db->query('
  189. CREATE TABLE db_migrations (
  190. version int NOT NULL,
  191. created_at VARCHAR(20) DEFAULT NULL,
  192. up_sql text DEFAULT NULL,
  193. down_sql text DEFAULT NULL,
  194. PRIMARY KEY (version)
  195. )');
  196. }
  197. $this->L->warning('db_migrations table already exists. Skipping setup');
  198. }
  199. /*
  200. * @return string
  201. */
  202. private function getDownSqlFromDb($v)
  203. {
  204. $res = $this
  205. ->db
  206. ->query(
  207. "select down_sql from db_migrations where version = $v",
  208. PDO::FETCH_ASSOC
  209. )
  210. ->fetchAll();
  211. return $res[0]['down_sql'];
  212. }
  213. public function runDown()
  214. {
  215. $this->L->warning('Rolling back last migration ...');
  216. $lastRanMigration = $this->getLastRanMigration();
  217. if (!$lastRanMigration) {
  218. throw new Exception('There is no migration to rollback');
  219. }
  220. $this->L->warning("last migration is $lastRanMigration");
  221. $migrationItem = new MigrationItem($lastRanMigration);
  222. $downSqlFromDisk = $migrationItem->getDownSql();
  223. $downSqlFromDb = $this->getDownSqlFromDb($lastRanMigration);
  224. if ($downSqlFromDisk != $downSqlFromDb) {
  225. $this->L->error(
  226. 'rollback sql stored in db does not match with the sql in filesystem'
  227. );
  228. $this->L->error('SQL from db');
  229. $this->L->error($downSqlFromDb);
  230. $this->L->error('SQL from filesystem');
  231. $this->L->error($downSqlFromDisk);
  232. $this->L->error('Please manually fix this error and run again');
  233. throw new Exception(
  234. 'rollback sql stored in db does not match with the sql in filesystem'
  235. );
  236. }
  237. $this->runSQLTransaction($downSqlFromDisk);
  238. $this
  239. ->db
  240. ->prepare('DELETE FROM db_migrations WHERE version = ?')
  241. ->execute([$lastRanMigration]);
  242. $this->L->warning('Rollback completed');
  243. }
  244. function currentStatus()
  245. {
  246. $lastRanMigration = $this->getLastRanMigration();
  247. $this->L->warning("last migration is $lastRanMigration");
  248. $pendingMigrations = $this->getPendingMigrations();
  249. $this->L->warning('Pending migrations ' . json_encode($pendingMigrations));
  250. }
  251. }
  252. class Application
  253. {
  254. public function __construct()
  255. {
  256. $this->opts = getopt('ldsvh', ['list', 'down', 'setup', 'verbose', 'help']);
  257. $this->logLevel = 1;
  258. $verbose = $this->getOption('verbose');
  259. if ($verbose) {
  260. if ($verbose === true) {
  261. $verbose = [$verbose];
  262. }
  263. $this->logLevel += count($verbose);
  264. }
  265. }
  266. private function getOption($name)
  267. {
  268. foreach ([$name[0], $name] as $k) {
  269. if (isset($this->opts[$k])) {
  270. return $this->opts[$k] === false ? true : $this->opts[$k];
  271. }
  272. }
  273. }
  274. function help()
  275. {
  276. echo 'Description:
  277. A simple database migration tool
  278. Usage:
  279. php simple-db-migrator.php [options]
  280. Options:
  281. -l, --list Show the current status of applied migrations
  282. -s, --setup Create db_migrations table in db and run all pending migrations
  283. -d, --down Roll back last migration
  284. -h, --help Display help for the given command. When no command is given display help for the db:migrate command
  285. -v|vv|vvv, --verbose Increase the verbosity of messages: 1-3 => info,log,debug
  286. ';
  287. }
  288. function execute()
  289. {
  290. global $L;
  291. if ($this->getOption('help')) {
  292. return $this->help();
  293. }
  294. $L = new Logger($this->logLevel);
  295. $L->info('Starting migrator');
  296. $migrator = new Migrator();
  297. if ($this->getOption('list')) {
  298. $migrator->currentStatus();
  299. return;
  300. }
  301. if ($this->getOption('setup')) {
  302. $migrator->setup();
  303. }
  304. if ($this->getOption('down')) {
  305. $migrator->runDown();
  306. } else {
  307. $migrator->runUp();
  308. }
  309. return 0;
  310. }
  311. }
  312. (new Application())->execute();