PageRenderTime 69ms CodeModel.GetById 25ms RepoModel.GetById 0ms app.codeStats 1ms

/Pdx.php

https://github.com/ibidem/mjolnir-database
PHP | 1654 lines | 1126 code | 199 blank | 329 comment | 141 complexity | b6f13b0d5f5878a33598a4c53de3d7ad MD5 | raw file
Possible License(s): BSD-3-Clause
  1. <?php namespace mjolnir\database;
  2. /**
  3. * This class contains utilities to be used with the Paradox migration system.
  4. *
  5. * The object interface exposes the main commands (reset, uninstall, etc).
  6. * The static interface exposes helpers to be used when writing the migrations.
  7. *
  8. * @package mjolnir
  9. * @category Database
  10. * @author Ibidem Team
  11. * @copyright (c) 2013, Ibidem Team
  12. * @license https://github.com/ibidem/ibidem/blob/master/LICENSE.md
  13. */
  14. class Pdx /* "Paradox" */ extends \app\Instantiatable implements \mjolnir\types\Versioned
  15. {
  16. use \app\Trait_Versioned
  17. {
  18. coreversion as private trait_coreversion;
  19. }
  20. // version of the class and associated features
  21. const VERSION = '1.0.1'; # this version updates ONLY on breaking changes
  22. /**
  23. * @var string version table base name
  24. */
  25. protected static $table = 'mjolnir__timeline';
  26. /**
  27. * Generate [t]emporary [t]able name based on given model class name.
  28. *
  29. * Only use this method when a table is DROPed in the latest migration.
  30. *
  31. * Usage: using your favorite editor find/replace
  32. *
  33. * \app\Model_YourClass::table()
  34. *
  35. * With:
  36. *
  37. * \app\Pdx::t('Model_YourClass')
  38. *
  39. * You may also use:
  40. *
  41. * \app\Pdx::t('\mynamespace\something\Model_YourClass')
  42. *
  43. * The method will use the original class if still available, ie. cases
  44. * where the table method in the original class was overwritten and is
  45. * non-standard (simply keep the class in thoses cases in a legacy module
  46. * specific to your application).
  47. *
  48. * The method generates tables in the form:
  49. *
  50. * '[prefix]__obsoletetable_[model]'
  51. *
  52. * [!!] If you see these temporary tables outside a intermediate reset
  53. * you've done something wrong in your migrations. The tables should only
  54. * exist to allow for a reset to go though older deprecated states of the
  55. * database when being freshly installed.
  56. *
  57. * [!!] If you do not have control over all instances of the application
  58. * and haven't ensured all instances have been updated to a version where
  59. * the obsolete table has been DROPed, you SHOULD use the legacy
  60. * method or provide the second parameter if the model did not overwrite
  61. * the table method. The method will still try to use the class table
  62. * method (in case of overwrites by 3rd parties, to the table method or
  63. * configuration values that the table method depends on).
  64. *
  65. * @return string table name
  66. */
  67. static function t($model, $tempname = null)
  68. {
  69. $table_prefix = \app\CFS::config('mjolnir/database')['table_prefix'];
  70. // normalize name
  71. if (\strpos($model, '\\'))
  72. {
  73. $modelclass = "\app\\{$model}";
  74. }
  75. else # fully qualified name
  76. {
  77. $modelclass = $model;
  78. // remove namespace
  79. $model = \preg_replace('#\\.*\\#', '', $model);
  80. }
  81. // does the model class still exist?
  82. if (\class_exists($modelclass))
  83. {
  84. // use the original table name on the migration
  85. return $modelclass::table();
  86. }
  87. else # model class has been deleted
  88. {
  89. if ($tempname !== null)
  90. {
  91. return $table_prefix.$tempname;
  92. }
  93. else # create a temporary name
  94. {
  95. // create temporary table
  96. return $table_prefix.'__obsoletetable_'.\strtolower($model);
  97. }
  98. }
  99. }
  100. /**
  101. * @return string version table
  102. */
  103. static function table()
  104. {
  105. return \app\CFS::config('mjolnir/database')['table_prefix'].static::$table;
  106. }
  107. /**
  108. * @return string database used for version table
  109. */
  110. static function database()
  111. {
  112. return 'default';
  113. }
  114. /**
  115. * @return array
  116. */
  117. static function coreversion()
  118. {
  119. return static::trait_coreversion() + \app\PdxVersionMatcher::coreversion();
  120. }
  121. // Migration Utilities & Helpers
  122. // ------------------------------------------------------------------------
  123. /**
  124. * Loads a paradox file from the path config/paradox/$filepath and merges
  125. * require array into it before outputting. The default EXT will be used.
  126. *
  127. * This function is meant to be used inside the main pradox files to keep
  128. * everything readable; in particular to keep require statements readable.
  129. *
  130. * Please do not add functionality to the method, simply create your own
  131. * version that's called by another name; this is why the method not named
  132. * load and so forth.
  133. *
  134. * @return array configuration
  135. */
  136. static function gate($filepath, $require = null, $ext = EXT)
  137. {
  138. $require != null or $require = [];
  139. return \app\Arr::merge(\app\CFS::config("timeline/$filepath", $ext), ['require' => $require]);
  140. }
  141. /**
  142. * When converting from one database structure to another it is often
  143. * required to translate one structure to another, which involves going
  144. * though all the entries in a central table; this method abstracts the
  145. * procedure for you.
  146. *
  147. * Batch reads with batch commits for changes is generally the fastest way
  148. * to perform the operations.
  149. */
  150. static function processor($table, $count, $callback, $reads = 1000, \mjolnir\types\SQLDatabase $db = null)
  151. {
  152. $db !== null or $db = \app\SQLDatabase::instance();
  153. $pages = ((int) ($count / $reads)) + 1;
  154. for ($page = 1; $page <= $pages; ++$page)
  155. {
  156. $db->begin();
  157. $entries = $db->prepare
  158. (
  159. __METHOD__.':read_entries',
  160. '
  161. SELECT *
  162. FROM `'.$table.'`
  163. LIMIT :limit OFFSET :offset
  164. '
  165. )
  166. ->page($page, $reads)
  167. ->run()
  168. ->fetch_all();
  169. foreach ($entries as $entry)
  170. {
  171. $callback($db, $entry);
  172. }
  173. $db->commit();
  174. }
  175. }
  176. /**
  177. * Performs safe select of entries.
  178. *
  179. * @return array entries
  180. */
  181. static function select(\mjolnir\types\SQLDatabase $db, $table, array $constraints = null)
  182. {
  183. $sqlcontraints = \app\SQL::parseconstraints($constraints);
  184. empty($sqlcontraints) or $sqlcontraints = 'WHERE '.$sqlcontraints;
  185. return $db->prepare
  186. (
  187. __METHOD__,
  188. '
  189. SELECT *
  190. FROM `'.$table.'`
  191. '.$sqlcontraints.'
  192. '
  193. )
  194. ->run()
  195. ->fetch_all();
  196. }
  197. /**
  198. * Performs safe insert into table given values and keys. This is a very
  199. * primitive function, which gurantees the integrity of the operation
  200. * inside the migration.
  201. *
  202. * Do not use api powered insertion commands since they will break as the
  203. * source code changes. Since the migration gurantees the integrity of the
  204. * api commands, the migration can not rely on them, since that would cause
  205. * a circular dependency chain.
  206. *
  207. * Fortunately since insert operations in migrations are unlikely to pull
  208. * any user data hardcoding them like this is very straight forward and
  209. * safe.
  210. *
  211. * @return int ID
  212. */
  213. static function insert($key, \mjolnir\types\SQLDatabase $db, $table, array $values, $map = null)
  214. {
  215. $map !== null or $map = [];
  216. isset($map['nums']) or $map['nums'] = [];
  217. isset($map['bools']) or $map['bools'] = [];
  218. isset($map['dates']) or $map['dates'] = [];
  219. $rawkeys = \array_keys($values);
  220. $keys = \app\Arr::implode(', ', $rawkeys, function ($i, $key) {
  221. return "`$key`";
  222. });
  223. $refs = \app\Arr::implode(', ', $rawkeys, function ($i, $key) {
  224. return ":$key";
  225. });
  226. $statement = $db->prepare
  227. (
  228. $key,
  229. '
  230. INSERT INTO `'.$table.'` ('.$keys.') VALUES ('.$refs.')
  231. '
  232. );
  233. // populate statement
  234. foreach ($values as $key => $value)
  235. {
  236. if (\in_array($key, $map['nums']))
  237. {
  238. $statement->num(":$key", $value);
  239. }
  240. else if (\in_array($key, $map['bools']))
  241. {
  242. $statement->bool(":$key", $value);
  243. }
  244. else if (\in_array($key, $map['dates']))
  245. {
  246. $statement->date(":$key", $value);
  247. }
  248. else # assume string
  249. {
  250. $statement->str(":$key", $value);
  251. }
  252. }
  253. $statement->run();
  254. return $db->last_inserted_id();
  255. }
  256. /**
  257. * Same as insert only values is assumed to be array of arrays.
  258. */
  259. static function massinsert($key, \mjolnir\types\SQLDatabase $db, $table, array $values, $map = null)
  260. {
  261. $db->begin();
  262. try
  263. {
  264. foreach ($values as $value)
  265. {
  266. static::insert($key, $db, $table, $value, $map);
  267. }
  268. $db->commit();
  269. }
  270. catch (\Exception $e)
  271. {
  272. $db->rollback();
  273. throw $e;
  274. }
  275. }
  276. /**
  277. * ...
  278. */
  279. static function create_table(\mjolnir\types\Writer $writer, \mjolnir\types\SQLDatabase $db, $table, $definition, $engine, $charset)
  280. {
  281. $shorthands = \app\CFS::config('mjolnir/paradox-sql-definitions');
  282. $shorthands = $shorthands + [':engine' => $engine, ':default_charset' => $charset];
  283. try
  284. {
  285. $db->prepare
  286. (
  287. __METHOD__,
  288. \strtr
  289. (
  290. '
  291. CREATE TABLE `'.$table.'`
  292. (
  293. '.$definition.'
  294. )
  295. ENGINE=:engine DEFAULT CHARSET=:default_charset
  296. ',
  297. $shorthands
  298. ),
  299. 'mysql'
  300. )
  301. ->run();
  302. }
  303. catch (\Exception $e)
  304. {
  305. if (\php_sapi_name() === 'cli')
  306. {
  307. $writer->eol()->eol();
  308. $writer->writef(' SQL: ')->eol();
  309. $writer->writef
  310. (
  311. \strtr
  312. (
  313. \app\Text::baseindent($definition),
  314. $shorthands
  315. )
  316. );
  317. $writer->eol()->eol();
  318. }
  319. throw $e;
  320. }
  321. }
  322. /**
  323. * Remove specified bindings.
  324. */
  325. static function remove_bindings(\mjolnir\types\Writer $writer, \mjolnir\types\SQLDatabase $db, $table, array $bindings)
  326. {
  327. foreach ($bindings as $key)
  328. {
  329. $db->prepare
  330. (
  331. __METHOD__,
  332. '
  333. ALTER TABLE `'.$table.'`
  334. DROP FOREIGN KEY `'.$key.'`
  335. '
  336. )
  337. ->run();
  338. }
  339. }
  340. // ------------------------------------------------------------------------
  341. // Migration Operations
  342. /**
  343. * Performs any necesary migration configuration.
  344. */
  345. protected static function migration_configure(\mjolnir\types\SQLDatabase $db, array $handlers, array & $state)
  346. {
  347. if ( ! isset($handlers['configure']))
  348. {
  349. return;
  350. }
  351. if (\is_array($handlers['configure']))
  352. {
  353. if (isset($handlers['configure']['tables']))
  354. {
  355. foreach ($handlers['configure']['tables'] as $table)
  356. {
  357. if ( ! \in_array($table, $state['tables']))
  358. {
  359. $state['tables'][] = $table;
  360. }
  361. }
  362. }
  363. }
  364. else if (\is_callable($handlers['configure']))
  365. {
  366. $handlers['configure']($db, $state);
  367. }
  368. // else: unsuported format
  369. }
  370. /**
  371. * Perform removal operations.
  372. */
  373. protected static function migration_cleanup(\mjolnir\types\SQLDatabase $db, array $handlers, array & $state)
  374. {
  375. if ( ! isset($handlers['cleanup']))
  376. {
  377. return;
  378. }
  379. if (\is_array($handlers['cleanup']))
  380. {
  381. if (isset($handlers['cleanup']['bindings']))
  382. {
  383. foreach ($handlers['cleanup']['bindings'] as $table => $constraints)
  384. {
  385. static::remove_bindings($state['writer'], $db, $table, $constraints);
  386. }
  387. }
  388. }
  389. else if (\is_callable($handlers['cleanup']))
  390. {
  391. $handlers['cleanup']($db, $state);
  392. }
  393. // else: unsuported format
  394. }
  395. /**
  396. * Table creation operations
  397. */
  398. protected static function migration_tables(\mjolnir\types\SQLDatabase $db, array $handlers, array & $state)
  399. {
  400. if ( ! isset($handlers['tables']))
  401. {
  402. return;
  403. }
  404. if (\is_array($handlers['tables']))
  405. {
  406. $total_tables = \count($handlers['tables']);
  407. $done_tables = 0;
  408. $state['progress.writer']($done_tables, $total_tables);
  409. foreach ($handlers['tables'] as $table => $def)
  410. {
  411. try
  412. {
  413. if (\is_string($def))
  414. {
  415. static::create_table($state['writer'], $db, $table, $def, $state['sql']['default']['engine'], $state['sql']['default']['charset']);
  416. }
  417. else if (\is_array($def))
  418. {
  419. static::create_table($state['writer'], $db, $table, $def['definition'], $def['engine'], $def['charset']);
  420. }
  421. else if (\is_callable($def))
  422. {
  423. $def($state);
  424. }
  425. }
  426. catch (\Exception $e)
  427. {
  428. /** @var \mjolnir\types\Writer $writer */
  429. $writer = $state['writer'];
  430. $writer->eol();
  431. $writer->writef("Exception while running [tables] migration operation for [{$table}].")->eol();
  432. $writer->eol();
  433. $writer->writef("Definition:\n\n%s\n\n", \app\Text::baseindent($def));
  434. throw $e;
  435. }
  436. $done_tables += 1;
  437. $state['progress.writer']($done_tables, $total_tables);
  438. }
  439. }
  440. else if (\is_callable($handlers['tables']))
  441. {
  442. $handlers['tables']($db, $state);
  443. }
  444. // else: unsuported format
  445. }
  446. /**
  447. * Alterations to current structure.
  448. */
  449. protected static function migration_modify(\mjolnir\types\SQLDatabase $db, array $handlers, array & $state)
  450. {
  451. if ( ! isset($handlers['modify']))
  452. {
  453. return;
  454. }
  455. if (\is_array($handlers['modify']))
  456. {
  457. $total_tables = \count($handlers['modify']);
  458. $done_tables = 0;
  459. $state['progress.writer']($done_tables, $total_tables);
  460. $definitions = \app\CFS::config('mjolnir/paradox-sql-definitions');
  461. foreach ($handlers['modify'] as $table => $def)
  462. {
  463. try
  464. {
  465. $db->prepare
  466. (
  467. __METHOD__,
  468. \strtr
  469. (
  470. '
  471. ALTER TABLE `'.$table.'`
  472. '.$def.'
  473. ',
  474. $definitions
  475. )
  476. )
  477. ->run();
  478. }
  479. catch (\Exception $e)
  480. {
  481. /** @var \mjolnir\types\Writer $writer */
  482. $writer = $state['writer'];
  483. $writer->eol();
  484. $writer->writef("Exception while running [modify] migration operation for [{$table}].")->eol();
  485. $writer->eol();
  486. $writer->writef("Definition:\n\n%s\n\n", \app\Text::baseindent($def));
  487. throw $e;
  488. }
  489. $done_tables += 1;
  490. $state['progress.writer']($done_tables, $total_tables);
  491. }
  492. }
  493. else if (\is_callable($handlers['modify']))
  494. {
  495. $handlers['modify']($db, $state);
  496. }
  497. // else: unsuported format
  498. }
  499. /**
  500. * Bindings.
  501. */
  502. protected static function migration_bindings(\mjolnir\types\SQLDatabase $db, array $handlers, array & $state)
  503. {
  504. if ( ! isset($handlers['bindings']))
  505. {
  506. return;
  507. }
  508. if (\is_array($handlers['bindings']))
  509. {
  510. $total_tables = \count($handlers['bindings']);
  511. $done_tables = 0;
  512. $state['progress.writer']($done_tables, $total_tables);
  513. foreach ($handlers['bindings'] as $table => $constraints)
  514. {
  515. $query = "ALTER TABLE `".$table."` ";
  516. $idx = 0;
  517. $count = \count($constraints);
  518. foreach ($constraints as $key => $constraint)
  519. {
  520. ++$idx;
  521. if ( ! isset($constraint[3]))
  522. {
  523. $constraint_key = $key;
  524. }
  525. else # constraint key set
  526. {
  527. $constraint_key = $constraint[3];
  528. }
  529. // keys must be unique over the whole database
  530. $constraint_key = $table.'_'.$constraint_key;
  531. $query .=
  532. '
  533. ADD CONSTRAINT `'.$constraint_key.'`
  534. FOREIGN KEY (`'.$key.'`)
  535. REFERENCES `'.$constraint[0].'` (`id`)
  536. ON DELETE '.$constraint[1].'
  537. ON UPDATE '.$constraint[2].'
  538. ';
  539. if ($idx < $count)
  540. {
  541. $query .= ', ';
  542. }
  543. else # last element
  544. {
  545. $query .= ';';
  546. }
  547. }
  548. try
  549. {
  550. $db->prepare(__METHOD__, $query)->run();
  551. }
  552. catch (\Exception $e)
  553. {
  554. if (\php_sapi_name() === 'cli')
  555. {
  556. $writer = $state['writer'];
  557. $writer->eol()->eol();
  558. $writer->writef(' Query: ')->eol()->eol();
  559. $writer->writef(\app\Text::baseindent($query));
  560. $writer->eol()->eol();
  561. }
  562. throw $e;
  563. }
  564. $done_tables += 1;
  565. $state['progress.writer']($done_tables, $total_tables);
  566. }
  567. }
  568. else if (\is_callable($handlers['bindings']))
  569. {
  570. $handlers['bindings']($db, $state);
  571. }
  572. // else: unsuported format
  573. }
  574. /**
  575. * Post-binding cleanup.
  576. */
  577. protected static function migration_normalize(\mjolnir\types\SQLDatabase $db, array $handlers, array & $state)
  578. {
  579. if ( ! isset($handlers['normalize']))
  580. {
  581. return;
  582. }
  583. if (\is_callable($handlers['normalize']))
  584. {
  585. $handlers['normalize']($db, $state);
  586. }
  587. // else: unsuported format
  588. }
  589. /**
  590. * Post-binding cleanup.
  591. */
  592. protected static function migration_fixes(\mjolnir\types\SQLDatabase $db, array $handlers, array & $state)
  593. {
  594. if ( ! isset($handlers['fixes']))
  595. {
  596. return;
  597. }
  598. if (\is_callable($handlers['fixes']))
  599. {
  600. $handlers['fixes']($db, $state);
  601. }
  602. // else: unsuported format
  603. }
  604. /**
  605. * populate tables with pre-required data.
  606. */
  607. protected static function migration_populate(\mjolnir\types\SQLDatabase $db, array $handlers, array & $state)
  608. {
  609. if ( ! isset($handlers['populate']))
  610. {
  611. return;
  612. }
  613. if (\is_callable($handlers['populate']))
  614. {
  615. $handlers['populate']($db, $state);
  616. }
  617. // else: unsuported format
  618. }
  619. // ------------------------------------------------------------------------
  620. // Migration Command Interface
  621. /**
  622. * Formatting for step information in verbose output.
  623. *
  624. * @var string
  625. */
  626. protected static $lingo = ' %10s | %6s %s %s';
  627. /**
  628. * @var \mjolnir\types\Writer
  629. */
  630. protected $writer = null;
  631. /**
  632. * Show debug messages?
  633. *
  634. * @var boolean
  635. */
  636. protected $verbose = false;
  637. /**
  638. * @return static
  639. */
  640. static function instance(\mjolnir\types\Writer $writer = null, $verbose = null)
  641. {
  642. /** @var Pdx $i */
  643. $i = parent::instance();
  644. $verbose !== null or $verbose = false;
  645. $i->verbose = $verbose;
  646. if ($writer === null)
  647. {
  648. $i->writer = \app\SilentWriter::instance();
  649. }
  650. else # writer != null
  651. {
  652. $i->writer = $writer;
  653. }
  654. return $i;
  655. }
  656. /**
  657. * Loads tables from configuration
  658. */
  659. protected static function uninstall_load_tables(array & $config, array $handlers)
  660. {
  661. if (isset($handlers['configure']))
  662. {
  663. $conf = $handlers['configure'];
  664. if (\is_array($conf))
  665. {
  666. if (isset($conf['tables']))
  667. {
  668. foreach ($conf['tables'] as $table)
  669. {
  670. $config['tables'][] = $table;
  671. }
  672. }
  673. }
  674. else # callback
  675. {
  676. $config = $conf($config);
  677. }
  678. }
  679. }
  680. /**
  681. * Removes all tables. Will not work if database is not set to
  682. *
  683. * @return boolean true if successful, false if not permitted
  684. */
  685. function uninstall($harduninstall = false)
  686. {
  687. $locked = \app\CFS::config('mjolnir/base')['db:lock'];
  688. if ($locked)
  689. {
  690. return false;
  691. }
  692. else # database is not locked
  693. {
  694. $channels = $this->channels();
  695. $config = [ 'tables' => [] ];
  696. if ( ! $harduninstall)
  697. {
  698. $history = $this->history();
  699. // generate table list based on history
  700. foreach ($history as $i)
  701. {
  702. if ($i['hotfix'] === null)
  703. {
  704. $handlers = $channels[$i['channel']]['versions'][$i['version']];
  705. }
  706. else # hotfix
  707. {
  708. $handlers = $channels[$i['channel']]['versions'][$i['version']]['hotfixes'][$i['hotfix']];
  709. }
  710. static::uninstall_load_tables($config, $handlers);
  711. }
  712. }
  713. else # hard uninstall
  714. {
  715. foreach ($channels as $channelname => $chaninfo)
  716. {
  717. foreach ($chaninfo['versions'] as $version => $handlers)
  718. {
  719. static::uninstall_load_tables($config, $handlers);
  720. if (isset($handlers['hotfixes']))
  721. {
  722. foreach ($handlers['hotfixes'] as $hotfix => $fixhandlers)
  723. {
  724. static::uninstall_load_tables($config, $fixhandlers);
  725. }
  726. }
  727. }
  728. }
  729. }
  730. $config['tables'][] = static::table();
  731. if ( ! empty($config['tables']))
  732. {
  733. $db = \app\SQLDatabase::instance(static::database());
  734. $db->prepare
  735. (
  736. __METHOD__.':fk_keys_off',
  737. 'SET foreign_key_checks = FALSE'
  738. )
  739. ->run();
  740. foreach ($config['tables'] as $table)
  741. {
  742. $this->writer->writef(' Removing '.$table)->eol();
  743. $db->prepare
  744. (
  745. __METHOD__.':drop_table',
  746. 'DROP TABLE IF EXISTS `'.$table.'`'
  747. )
  748. ->run();
  749. }
  750. $db->prepare
  751. (
  752. __METHOD__.':fk_keys_on',
  753. 'SET foreign_key_checks = TRUE'
  754. )
  755. ->run();
  756. }
  757. }
  758. return true;
  759. }
  760. /**
  761. * Reset the database.
  762. */
  763. function reset($pivot = null, $version = null, $dryrun = false)
  764. {
  765. $locked = \app\CFS::config('mjolnir/base')['db:lock'];
  766. $exists = $this->has_history_table();
  767. if ($locked && $exists && ! $dryrun)
  768. {
  769. // operation is destructive and database is locked
  770. return false;
  771. }
  772. else # database is not locked
  773. {
  774. $channels = $this->channels();
  775. $status = array
  776. (
  777. // ordered list of versions in processing order
  778. 'history' => [],
  779. // current version for each channel
  780. 'state' => [],
  781. // active channels
  782. 'active' => [],
  783. // checklist of version requirements
  784. 'checklist' => $this->generate_checklist($channels)
  785. );
  786. if ( ! $dryrun)
  787. {
  788. if ($exists)
  789. {
  790. $this->uninstall();
  791. }
  792. else # no history table available
  793. {
  794. $this->writer->writef(' Skipped uninstall. Database is clean.')->eol();
  795. }
  796. }
  797. if ($pivot === null)
  798. {
  799. // generate version history for full reset
  800. foreach ($channels as $channel => & $timeline)
  801. {
  802. if (\count($timeline['versions']) > 0)
  803. {
  804. \end($timeline['versions']);
  805. $last_version = \key($timeline['versions']);
  806. $this->processhistory($channel, $last_version, $status, $channels);
  807. }
  808. }
  809. }
  810. else # pivot !== null
  811. {
  812. // @todo pivot based reset
  813. }
  814. // dry run?
  815. if ($dryrun)
  816. {
  817. // just return the step history
  818. return $status['history'];
  819. }
  820. // execute the history
  821. foreach ($status['history'] as $entry)
  822. {
  823. // execute migration
  824. $this->processmigration($channels, $entry['channel'], $entry['version'], $entry['hotfix']);
  825. }
  826. // operation complete
  827. return true;
  828. }
  829. }
  830. /**
  831. * Reset the database.
  832. */
  833. function upgrade($dryrun = false)
  834. {
  835. $channels = $this->channels();
  836. $status = array
  837. (
  838. // ordered list of versions in processing order
  839. 'history' => [],
  840. // current version for each channel
  841. 'state' => [],
  842. // active channels
  843. 'active' => [],
  844. // checklist of version requirements
  845. 'checklist' => $this->generate_checklist($channels)
  846. );
  847. // inject current history
  848. $history = $this->history();
  849. foreach ($history as $entry)
  850. {
  851. if ($entry['hotfix'] === null)
  852. {
  853. $status['state'][$entry['channel']] = $this->binversion($entry['channel'], $entry['version']);
  854. }
  855. }
  856. // generate version history for upgrade
  857. foreach ($channels as $channel => & $timeline)
  858. {
  859. if (\count($timeline['versions']) > 0)
  860. {
  861. \end($timeline['versions']);
  862. $last_version = \key($timeline['versions']);
  863. $this->processhistory($channel, $last_version, $status, $channels);
  864. }
  865. }
  866. // dry run?
  867. if ($dryrun)
  868. {
  869. // just return the step history
  870. return $status['history'];
  871. }
  872. if ( ! empty($status['history']))
  873. {
  874. // execute the history
  875. foreach ($status['history'] as $entry)
  876. {
  877. // execute migration
  878. $this->processmigration($channels, $entry['channel'], $entry['version'], $entry['hotfix']);
  879. }
  880. }
  881. else # no history
  882. {
  883. $this->writer->writef(' No changes required.');
  884. }
  885. // operation complete
  886. return true;
  887. }
  888. /**
  889. * @return array history table
  890. */
  891. function history()
  892. {
  893. if ($this->has_history_table())
  894. {
  895. $db = \app\SQLDatabase::instance(static::database());
  896. return $db->prepare
  897. (
  898. __METHOD__,
  899. '
  900. SELECT entry.*
  901. FROM `'.static::table().'` entry
  902. '
  903. )
  904. ->run()
  905. ->fetch_all();
  906. }
  907. else # no database
  908. {
  909. return [];
  910. }
  911. }
  912. /**
  913. * @return array
  914. */
  915. function status()
  916. {
  917. $versions = [];
  918. $history = $this->history();
  919. foreach ($history as $entry)
  920. {
  921. if ($entry['hotfix'] === null)
  922. {
  923. $versions[$entry['channel']] = $entry['version'];
  924. }
  925. }
  926. return $versions;
  927. }
  928. // ------------------------------------------------------------------------
  929. // Helpers
  930. /**
  931. * Step information for verbose output
  932. */
  933. protected function shout($op, $channel, $version, $note = null)
  934. {
  935. ! $this->verbose or $this->writer->writef(static::$lingo, $op, $version, $channel, $note)->eol();
  936. }
  937. /**
  938. * @return array
  939. */
  940. protected function generate_checklist($channels)
  941. {
  942. $checklist = [];
  943. foreach ($channels as $channelname => $channelinfo)
  944. {
  945. foreach ($channelinfo['versions'] as $version => $handlers)
  946. {
  947. if (isset($handlers['require']))
  948. {
  949. foreach ($handlers['require'] as $reqchan => $reqver)
  950. {
  951. isset($checklist[$reqchan]) or $checklist[$reqchan] = [];
  952. isset($checklist[$reqchan][$reqver]) or $checklist[$reqchan][$reqver] = [];
  953. // save a copy of what channels and versions depend on
  954. // the specific required version so we can reference it
  955. // back easily in processing and satisfy those
  956. // requirements to avoid process order induced loops
  957. $checklist[$reqchan][$reqver][] = array
  958. (
  959. 'channel' => $channelname,
  960. 'version' => $version,
  961. );
  962. }
  963. }
  964. }
  965. }
  966. return $checklist;
  967. }
  968. /**
  969. * @return int binary version
  970. */
  971. protected function binversion($channel, $version)
  972. {
  973. // split version
  974. $v = \explode('.', $version);
  975. if (\count($v) !== 3)
  976. {
  977. throw new \app\Exception('Invalid version: '.$channel.' '.$version);
  978. }
  979. // 2 digits for patch versions, 3 digits for fixes
  980. $binversion = \intval($v[0]) * 100000 + \intval($v[1]) * 1000 + \intval($v[2]);
  981. if ($binversion == 0)
  982. {
  983. throw new \app\Exception('The version of 0 is reserved.');
  984. }
  985. return $binversion;
  986. }
  987. /**
  988. * @return array
  989. */
  990. protected function processhistory($channel, $target_version, array & $status, array & $channels)
  991. {
  992. $this->shout('fulfilling', $channel, $target_version);
  993. if ( ! isset($channels[$channel]))
  994. {
  995. throw new \app\Exception('Required channel ['.$channel.'] not available.');
  996. }
  997. if ( ! isset($channels[$channel]['versions'][$target_version]))
  998. {
  999. throw new \app\Exception('Required version ['.$target_version.'] in channel ['.$channel.'] not available.');
  1000. }
  1001. // recursion detection
  1002. if (\in_array($channel, \app\Arr::gather($status['active'], 'channel')))
  1003. {
  1004. // provide feedback on loop
  1005. ! $this->verbose or $this->writer->eol();
  1006. $this->writer->writef(' Loop backtrace:')->eol();
  1007. foreach ($status['active'] as $activeinfo)
  1008. {
  1009. $this->writer->writef(' - '.$activeinfo['channel'].' '.$activeinfo['version'])->eol();
  1010. }
  1011. $this->writer->eol();
  1012. throw new \app\Exception('Recursive dependency detected on '.$channel.' '.$target_version);
  1013. }
  1014. $timeline = $channels[$channel];
  1015. if ( ! isset($status['state'][$channel]))
  1016. {
  1017. $status['state'][$channel] = 0;
  1018. }
  1019. $status['active'][] = [ 'channel' => $channel, 'version' => $target_version ];
  1020. $targetver = $this->binversion($channel, $target_version);
  1021. // verify state
  1022. if ($targetver < $status['state'][$channel])
  1023. {
  1024. return; // version already satisfied in the timeline; skipping...
  1025. }
  1026. // process versions
  1027. foreach ($timeline['versions'] as $litversion => $version)
  1028. {
  1029. if ($version['binversion'] <= $status['state'][$channel])
  1030. {
  1031. continue; // version already processed; skipping...
  1032. }
  1033. if (isset($version['require']) && ! empty($version['require']))
  1034. {
  1035. foreach ($version['require'] as $required_channel => $required_version)
  1036. {
  1037. if (isset($status['state'][$required_channel]))
  1038. {
  1039. // check if version is satisfied
  1040. $versionbin = $this->binversion($required_channel, $required_version);
  1041. if ($status['state'][$required_channel] == $versionbin)
  1042. {
  1043. continue; // dependency satisfied
  1044. }
  1045. else if ($status['state'][$required_channel] > $versionbin)
  1046. {
  1047. // the required version has been passed; since the
  1048. // state of the channel may change from even the
  1049. // smallest of changes; versions being passed is
  1050. // not acceptable
  1051. $this->dependency_race_error
  1052. (
  1053. // the scene
  1054. $status,
  1055. // the victim
  1056. $channel,
  1057. $target_version
  1058. );
  1059. }
  1060. // else: version is lower, pass through
  1061. }
  1062. $this->shout('require', $required_channel, $required_version, '>> '.$channel.' '.$litversion);
  1063. $this->processhistory($required_channel, $required_version, $status, $channels);
  1064. }
  1065. }
  1066. // requirements have been met
  1067. $status['history'][] = array
  1068. (
  1069. 'hotfix' => null,
  1070. 'channel' => $channel,
  1071. 'version' => $litversion,
  1072. );
  1073. // update state
  1074. $status['state'][$channel] = $version['binversion'];
  1075. $this->shout('completed', $channel, $litversion);
  1076. // the channel is at a new version, but before continuing to the
  1077. // next version we need to check if any channel requirements have
  1078. // been satisfied in the process, if they have that channel needs
  1079. // to be bumped to this version; else we enter an unnecesary loop
  1080. // generated by processing order--we use the checklist generated
  1081. // at the start of the process for this purpose
  1082. if (isset($status['checklist'][$channel]) && isset($status['checklist'][$channel][$litversion]))
  1083. {
  1084. foreach ($status['checklist'][$channel][$litversion] as $checkpoint)
  1085. {
  1086. // we skip over actively processed requirements
  1087. $skip = false;
  1088. foreach ($status['active'] as $active)
  1089. {
  1090. $active_version = $this->binversion($active['channel'], $active['version']);
  1091. $checkpoint_version = $this->binversion($checkpoint['channel'], $checkpoint['version']);
  1092. // we test with >= on the version because we know that
  1093. // if a channel did require that specific version then
  1094. // they would have initiated the process, thereby
  1095. // rendering it impossible to cause conflict, ie.
  1096. // requirement should have been satisfied already
  1097. if ($active['channel'] == $checkpoint['channel'] && $active_version >= $checkpoint_version)
  1098. {
  1099. $skip = true;
  1100. break;
  1101. }
  1102. }
  1103. if ($skip)
  1104. {
  1105. $this->shout('pass:point', $checkpoint['channel'], $checkpoint['version'], '-- '.$channel.' '.$litversion);
  1106. continue; // requested version already being processed
  1107. }
  1108. // are all requirements of given checkpoint complete? if
  1109. // the checkpoint starts resolving requirements of it's own
  1110. // it's possible for it to indirectly loop back
  1111. $cp = $channels[$checkpoint['channel']]['versions'][$checkpoint['version']];
  1112. $skip_checkpoint = false;
  1113. if (isset($cp['require']) && ! empty($cp['require']))
  1114. {
  1115. foreach ($cp['require'] as $required_channel => $required_version)
  1116. {
  1117. if (isset($status['state'][$required_channel]))
  1118. {
  1119. // check if version is satisfied
  1120. $versionbin = $this->binversion($required_channel, $required_version);
  1121. if ($status['state'][$required_channel] == $versionbin)
  1122. {
  1123. continue; // dependency satisfied
  1124. }
  1125. else if ($status['state'][$required_channel] > $versionbin)
  1126. {
  1127. // the required version has been passed; since the state
  1128. // of the channel may change from even the smallest of
  1129. // changes; versions being passed is not acceptable
  1130. $this->dependency_race_error
  1131. (
  1132. // the scene
  1133. $status,
  1134. // the victim
  1135. $checkpoint['channel'],
  1136. $checkpoint['version']
  1137. );
  1138. }
  1139. // else: version is lower, pass through
  1140. }
  1141. $skip_checkpoint = true;
  1142. }
  1143. }
  1144. if ($skip_checkpoint)
  1145. {
  1146. $this->shout('hold:point', $checkpoint['channel'], $checkpoint['version'], '-- '.$channel.' '.$litversion);
  1147. continue; // checkpoint still has unfilled requirements
  1148. }
  1149. $this->shout('checklist', $checkpoint['channel'], $checkpoint['version'], '<< '.$channel.' '.$litversion);
  1150. $this->processhistory($checkpoint['channel'], $checkpoint['version'], $status, $channels);
  1151. }
  1152. }
  1153. // has target version been satisfied?
  1154. if ($targetver === $version['binversion'])
  1155. {
  1156. break; // completed required version
  1157. }
  1158. }
  1159. // remove channel from active information
  1160. $new_active = [];
  1161. foreach ($status['active'] as $active)
  1162. {
  1163. if ($active['channel'] !== $channel)
  1164. {
  1165. $new_active[] = $active;
  1166. }
  1167. }
  1168. $status['active'] = $new_active;
  1169. }
  1170. /**
  1171. * Error report for situation where dependencies race against each other
  1172. * and a channels fall behind another in the requirement war.
  1173. */
  1174. protected function dependency_race_error(array $status, $channel, $version)
  1175. {
  1176. // provide feedback on loop
  1177. ! $this->verbose or $this->writer->eol();
  1178. $this->writer->writef(' Race backtrace:')->eol();
  1179. foreach ($status['active'] as $activeinfo)
  1180. {
  1181. $this->writer->writef(' - '.$activeinfo['channel'].' '.$activeinfo['version'])->eol();
  1182. }
  1183. $this->writer->eol();
  1184. throw new \app\Exception('Target version breached by race condition on '.$channel.' '.$version);
  1185. }
  1186. /**
  1187. * @return array
  1188. */
  1189. protected function channels()
  1190. {
  1191. // load configuration
  1192. $pdx = \app\CFS::config('mjolnir/paradox');
  1193. // configure channels
  1194. $channels = [];
  1195. foreach ($pdx as $channelname => $channel)
  1196. {
  1197. if (isset($channel['database']))
  1198. {
  1199. $db = \app\SQLDatabase::instance($channel['database']);
  1200. unset($channel['database']);
  1201. }
  1202. else # default database
  1203. {
  1204. $db = \app\SQLDatabase::instance();
  1205. }
  1206. foreach ($channel as $version => & $handler)
  1207. {
  1208. $handler['binversion'] = $this->binversion($channelname, $version);
  1209. }
  1210. \uksort
  1211. (
  1212. $channel,
  1213. function ($a, $b) use ($channel)
  1214. {
  1215. // split version
  1216. $version1 = \explode('.', $a);
  1217. $version2 = \explode('.', $b);
  1218. if (\count($version1) !== 3)
  1219. {
  1220. throw new \app\Exception('Invalid version: '.$channel.' '.$a);
  1221. }
  1222. if (\count($version2) !== 3)
  1223. {
  1224. throw new \app\Exception('Invalid version: '.$channel.' '.$b);
  1225. }
  1226. if (\intval($version1[0]) - \intval($version2[0]) == 0)
  1227. {
  1228. if (\intval($version1[1]) - \intval($version2[1]) == 0)
  1229. {
  1230. return \intval($version1[2]) - \intval($version2[2]);
  1231. }
  1232. else # un-equal
  1233. {
  1234. return \intval($version1[1]) - \intval($version2[1]);
  1235. }
  1236. }
  1237. else # un-equal
  1238. {
  1239. return \intval($version1[0]) - \intval($version2[0]);
  1240. }
  1241. }
  1242. );
  1243. // generate normalized version of channel info
  1244. $channels[$channelname] = array
  1245. (
  1246. 'current' => null,
  1247. 'db' => $db,
  1248. 'versions' => $channel,
  1249. );
  1250. }
  1251. return $channels;
  1252. }
  1253. /**
  1254. * @return boolean
  1255. */
  1256. protected function has_history_table()
  1257. {
  1258. $db = \app\SQLDatabase::instance(static::database());
  1259. $tables = $db->prepare
  1260. (
  1261. __METHOD__,
  1262. '
  1263. SHOW TABLES LIKE :table
  1264. '
  1265. )
  1266. ->str(':table', static::table())
  1267. ->run()
  1268. ->fetch_all();
  1269. return ! empty($tables);
  1270. }
  1271. /**
  1272. * Hook.
  1273. *
  1274. * @return array state
  1275. */
  1276. protected function initialize_migration_state(array & $channelinfo, $channel, $version, $hotfix)
  1277. {
  1278. return array
  1279. (
  1280. 'writer' => $this->writer,
  1281. 'channelinfo' => & $channelinfo,
  1282. 'tables' => [],
  1283. 'identity' => array
  1284. (
  1285. 'channel' => $channel,
  1286. 'version' => $version,
  1287. 'hotfix' => $hotfix,
  1288. ),
  1289. 'sql' => array
  1290. (
  1291. 'default' => array
  1292. (
  1293. 'engine' => static::default_db_engine(),
  1294. 'charset' => static::default_db_charset(),
  1295. ),
  1296. ),
  1297. );
  1298. }
  1299. /**
  1300. * Performs migration steps and creates entry in timeline.
  1301. *
  1302. * To add steps add them under the configuration mjolnir/paradox-steps and
  1303. * overwrite this class accordingly. See: [migration_configure] for an
  1304. * example.
  1305. */
  1306. protected function processmigration(array $channels, $channel, $version, $hotfix)
  1307. {
  1308. $stepformat = ' %15s %-9s %s%s';
  1309. $this->writer->eol();
  1310. $steps = \app\CFS::config('mjolnir/paradox-steps');
  1311. \asort($steps);
  1312. $chaninfo = $channels[$channel];
  1313. $state = $this->initialize_migration_state($chaninfo, $channel, $version, $hotfix);
  1314. // We save to the history first. If an error happens at least the
  1315. // database history will show which step it happend on for future
  1316. // reference; it also enabled us to do a clean install after an
  1317. // exception instead of forcing a hard uninstall.
  1318. $this->pushhistory($channel, $version, $hotfix, $chaninfo['versions'][$version]['description']);
  1319. foreach ($steps as $step => $priority)
  1320. {
  1321. $this->writer->writef
  1322. (
  1323. $stepformat,
  1324. $step,
  1325. $version,
  1326. $channel,
  1327. empty($hotfix) ? '' : ' / '.$hotfix
  1328. );
  1329. $stepmethod = "migration_$step";
  1330. $writer = $this->writer;
  1331. $state['progress.writer'] = function ($done, $total) use ($writer, $stepformat, $step, $version, $channel)
  1332. {
  1333. if (\php_sapi_name() === 'cli')
  1334. {
  1335. $this->writer->writef("\r");
  1336. $this->writer->writef(\str_repeat(' ', 80));
  1337. $this->writer->writef("\r");
  1338. $writer->writef
  1339. (
  1340. $stepformat,
  1341. $step,
  1342. $version,
  1343. \trim($channel),
  1344. (empty($hotfix) ? '' : ' / '.$hotfix).' - '.(\number_format(\round($done * 100 / $total, 2), 2)).'%'
  1345. );
  1346. }
  1347. else # non-CLI context
  1348. {
  1349. // do nothing
  1350. }
  1351. };
  1352. static::{$stepmethod}($chaninfo['db'], $chaninfo['versions'][$version], $state);
  1353. if (\php_sapi_name() === 'cli')
  1354. {
  1355. $this->writer->writef("\r");
  1356. $this->writer->writef(\str_repeat(' ', 80));
  1357. $this->writer->writef("\r");
  1358. }
  1359. else # standard end of line
  1360. {
  1361. $this->writer->eol();
  1362. }
  1363. }
  1364. if ( ! isset($chaninfo['versions'][$version]['description']))
  1365. {
  1366. throw new \app\Exception('Missing description for '.$channel.' '.$version);
  1367. }
  1368. if (\php_sapi_name() === 'cli')
  1369. {
  1370. $this->writer->writef("\r");
  1371. $this->writer->writef(\str_repeat(' ', 80));
  1372. $this->writer->writef("\r");
  1373. }
  1374. $this->writer->writef
  1375. (
  1376. $stepformat,
  1377. '- complete -',
  1378. $version,
  1379. $channel,
  1380. empty($hotfix) ? '' : '/ '.$hotfix
  1381. );
  1382. if (\php_sapi_name() !== 'cli')
  1383. {
  1384. $this->writer->eol();
  1385. }
  1386. }
  1387. /**
  1388. * ...
  1389. */
  1390. function pushhistory($channel, $version, $hotfix, $description)
  1391. {
  1392. $this->ensurehistorytable();
  1393. $db = \app\SQLDatabase::instance(static::database());
  1394. // compute system version
  1395. $versioninfo = $this->versioninfo();
  1396. $system = \app\Arr::implode(', ', $versioninfo, function ($component, $version) {
  1397. return $component.' '.$version;
  1398. });
  1399. static::insert
  1400. (
  1401. __METHOD__,
  1402. $db, static::table(),
  1403. [
  1404. 'channel' => $channel,
  1405. 'version' => $version,
  1406. 'hotfix' => $hotfix,
  1407. 'system' => $system,
  1408. 'description' => $description,
  1409. ]
  1410. );
  1411. }
  1412. /**
  1413. * @return string
  1414. */
  1415. protected static function default_db_engine()
  1416. {
  1417. return 'InnoDB';
  1418. }
  1419. /**
  1420. * @return string
  1421. */
  1422. protected static function default_db_charset()
  1423. {
  1424. return 'utf8';
  1425. }
  1426. /**
  1427. * ...
  1428. */
  1429. protected function ensurehistorytable()
  1430. {
  1431. if ( ! $this->has_history_table())
  1432. {
  1433. $db = \app\SQLDatabase::instance(static::database());
  1434. // create history table
  1435. static::create_table
  1436. (
  1437. $this->writer,
  1438. $db, static::table(),
  1439. '
  1440. `id` :key_primary,
  1441. `channel` :title,
  1442. `version` :title,
  1443. `hotfix` :title DEFAULT NULL,
  1444. `timestamp` :timestamp,
  1445. `system` :block,
  1446. `description` :block,
  1447. PRIMARY KEY(`id`)
  1448. ',
  1449. static::default_db_engine(),
  1450. static::default_db_charset()
  1451. );
  1452. }
  1453. }
  1454. } # class