simple-db-migrator.php 9.8 KB

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