PageRenderTime 63ms CodeModel.GetById 19ms RepoModel.GetById 1ms app.codeStats 0ms

/plugins/Installer/src/Utility/DatabaseInstaller.php

http://github.com/QuickAppsCMS/QuickApps-CMS
PHP | 510 lines | 297 code | 69 blank | 144 comment | 31 complexity | d86bb7412891802b8f06e239c55c9597 MD5 | raw file
Possible License(s): LGPL-2.1, MPL-2.0-no-copyleft-exception, GPL-3.0
  1. <?php
  2. /**
  3. * Licensed under The GPL-3.0 License
  4. * For full copyright and license information, please see the LICENSE.txt
  5. * Redistributions of files must retain the above copyright notice.
  6. *
  7. * @since 2.0.0
  8. * @author Christopher Castro <chris@quickapps.es>
  9. * @link http://www.quickappscms.org
  10. * @license http://opensource.org/licenses/gpl-3.0.html GPL-3.0 License
  11. */
  12. namespace Installer\Utility;
  13. use Cake\Core\InstanceConfigTrait;
  14. use Cake\Database\Connection;
  15. use Cake\Database\Schema\Table as TableSchema;
  16. use Cake\Datasource\ConnectionManager;
  17. use Cake\Filesystem\File;
  18. use Cake\Filesystem\Folder;
  19. use Cake\Utility\Hash;
  20. use Cake\Utility\Inflector;
  21. /**
  22. * Handles database initialization for QuickAppsCMS's first installations.
  23. *
  24. */
  25. class DatabaseInstaller
  26. {
  27. use InstanceConfigTrait;
  28. /**
  29. * Error messages list.
  30. *
  31. * @var array
  32. */
  33. protected $_errors = [];
  34. /**
  35. * Whether the install() method was invoked or not.
  36. *
  37. * @var bool
  38. */
  39. protected $_installed = false;
  40. /**
  41. * Default configuration for this class.
  42. *
  43. * - settingsPath: Full path to the "settings.php" file where store connection
  44. * information used by QuickAppsCMS. This should NEVER be changed, use with
  45. * caution.
  46. *
  47. * - schemaPath: Path to directory containing all tables information to be
  48. * imported (fixtures).
  49. *
  50. * - maxExecutionTime: Time in seconds for PHP's "max_execution_time" directive.
  51. * Defaults to 480 (8 minutes).
  52. *
  53. * @var array
  54. */
  55. protected $_defaultConfig = [
  56. 'settingsPath' => null,
  57. 'schemaPath' => null,
  58. 'maxExecutionTime' => 480,
  59. ];
  60. /**
  61. * Default database connection config.
  62. *
  63. * @var array
  64. */
  65. protected $_defaultConnection = [
  66. 'className' => 'Cake\Database\Connection',
  67. 'driver' => '',
  68. 'database' => '',
  69. 'username' => '',
  70. 'password' => '',
  71. 'host' => '',
  72. 'prefix' => '',
  73. 'encoding' => 'utf8',
  74. 'timezone' => 'UTC',
  75. 'log' => false,
  76. 'cacheMetadata' => true,
  77. ];
  78. /**
  79. * Constructor.
  80. *
  81. * @param array $config Configuration options
  82. */
  83. public function __construct($config = [])
  84. {
  85. $this->_defaultConfig['settingsPath'] = ROOT . '/config/settings.php';
  86. $this->_defaultConfig['schemaPath'] = dirname(dirname(__DIR__)) . '/config/fixture/';
  87. $this->config($config);
  88. if (function_exists('ini_set')) {
  89. ini_set('max_execution_time', (int)$this->config('maxExecutionTime'));
  90. } elseif (function_exists('set_time_limit')) {
  91. set_time_limit((int)$this->config('maxExecutionTime'));
  92. }
  93. }
  94. /**
  95. * Starts the process.
  96. *
  97. * @param array $dbConfig Database connection information
  98. * @return bool True on success, false otherwise
  99. */
  100. public function install($dbConfig = [])
  101. {
  102. $this->_installed = true;
  103. if (!$this->prepareConfig($dbConfig)) {
  104. return false;
  105. }
  106. $conn = $this->getConn();
  107. if ($conn === false) {
  108. return false;
  109. }
  110. if (!$this->isDbEmpty($conn)) {
  111. return false;
  112. }
  113. if (!$this->importTables($conn)) {
  114. return false;
  115. }
  116. $this->writeSetting();
  117. return true;
  118. }
  119. /**
  120. * Registers an error message.
  121. *
  122. * @param string $message The error message
  123. * @return void
  124. */
  125. public function error($message)
  126. {
  127. $this->_errors[] = $message;
  128. }
  129. /**
  130. * Get all error messages.
  131. *
  132. * @return array
  133. */
  134. public function errors()
  135. {
  136. if (!$this->_installed) {
  137. $this->error(__d('installer', 'Nothing installed'));
  138. }
  139. return $this->_errors;
  140. }
  141. /**
  142. * Prepares database configuration attributes.
  143. *
  144. * If the file "ROOT/config/settings.php.tmp" exists, and has declared a
  145. * connection named "default" it will be used.
  146. *
  147. * @param array $dbConfig Database connection info coming from POST
  148. * @return bool True on success, false otherwise
  149. */
  150. public function prepareConfig($dbConfig = [])
  151. {
  152. if ($this->config('connection')) {
  153. return true;
  154. }
  155. if (is_readable(ROOT . '/config/settings.php.tmp')) {
  156. $dbConfig = include ROOT . '/config/settings.php.tmp';
  157. if (empty($dbConfig['Datasources']['default'])) {
  158. $this->error(__d('installer', 'Invalid database information in file "{0}"', ROOT . '/config/settings.php.tmp'));
  159. return false;
  160. }
  161. $dbConfig = $dbConfig['Datasources']['default'];
  162. } else {
  163. if (empty($dbConfig['driver'])) {
  164. $dbConfig['driver'] = '__INVALID__';
  165. }
  166. if (strpos($dbConfig['driver'], "\\") === false) {
  167. $dbConfig['driver'] = "Cake\\Database\\Driver\\{$dbConfig['driver']}";
  168. }
  169. }
  170. list(, $driverClass) = namespaceSplit($dbConfig['driver']);
  171. if (!in_array($driverClass, ['Mysql', 'Postgres', 'Sqlite', 'Sqlserver'])) {
  172. $this->error(__d('installer', 'Invalid database type ({0}).', $driverClass));
  173. return false;
  174. }
  175. $this->config('connection', Hash::merge($this->_defaultConnection, $dbConfig));
  176. return true;
  177. }
  178. /**
  179. * Generates a new connection to DB.
  180. *
  181. * @return \Cake\Database\Connection|bool A connection object, or false on
  182. * failure. On failure error messages are automatically set
  183. */
  184. public function getConn()
  185. {
  186. if (!$this->config('connection.className')) {
  187. $this->error(__d('installer', 'Database engine cannot be empty.'));
  188. return false;
  189. }
  190. try {
  191. ConnectionManager::drop('installation');
  192. ConnectionManager::config('installation', $this->config('connection'));
  193. $conn = ConnectionManager::get('installation');
  194. $conn->connect();
  195. return $conn;
  196. } catch (\Exception $ex) {
  197. $this->error(__d('installer', 'Unable to connect to database, please check your information. Details: {0}', '<p>' . $ex->getMessage() . '</p>'));
  198. return false;
  199. }
  200. }
  201. /**
  202. * Imports tables schema and populates them.
  203. *
  204. * @param \Cake\Database\Connection $conn Database connection to use
  205. * @return bool True on success, false otherwise. On failure error messages
  206. * are automatically set
  207. */
  208. public function importTables($conn)
  209. {
  210. $Folder = new Folder($this->config('schemaPath'));
  211. $fixtures = $Folder->read(false, false, true)[1];
  212. try {
  213. return (bool)$conn->transactional(function ($connection) use ($fixtures) {
  214. foreach ($fixtures as $fixture) {
  215. $result = $this->_processFixture($fixture, $connection);
  216. if (!$result) {
  217. $this->error(__d('installer', 'Error importing "{0}".', $fixture));
  218. return false;
  219. }
  220. }
  221. return true;
  222. });
  223. } catch (\Exception $ex) {
  224. $this->error(__d('installer', 'Unable to import database information. Details: {0}', '<p>' . $ex->getMessage() . '</p>'));
  225. return false;
  226. }
  227. }
  228. /**
  229. * Checks whether connected database is empty or not.
  230. *
  231. * @param \Cake\Database\Connection $conn Database connection to use
  232. * @return bool True if database if empty and tables can be imported, false if
  233. * there are some existing tables
  234. */
  235. public function isDbEmpty($conn)
  236. {
  237. $Folder = new Folder($this->config('schemaPath'));
  238. $existingSchemas = $conn->schemaCollection()->listTables();
  239. $newSchemas = array_map(function ($item) {
  240. return Inflector::underscore(str_replace('Schema.php', '', $item));
  241. }, $Folder->read()[1]);
  242. $result = !array_intersect($existingSchemas, $newSchemas);
  243. if (!$result) {
  244. $this->error(__d('installer', 'A previous installation of QuickAppsCMS already exists, please drop your database tables before continue.'));
  245. }
  246. return $result;
  247. }
  248. /**
  249. * Creates site's "settings.php" file.
  250. *
  251. * @return bool True on success
  252. */
  253. public function writeSetting()
  254. {
  255. $config = [
  256. 'Datasources' => [
  257. 'default' => $this->config('connection'),
  258. ],
  259. 'Security' => [
  260. 'salt' => $this->salt()
  261. ],
  262. 'debug' => false,
  263. ];
  264. $filePath = $this->config('settingsPath');
  265. if (!str_ends_with(strtolower($filePath), '.tmp')) {
  266. $filePath .= '.tmp';
  267. }
  268. $settingsFile = new File($filePath, true);
  269. return $settingsFile->write("<?php\n return " . var_export($config, true) . ";\n");
  270. }
  271. /**
  272. * Generates a random string suitable for security's salt.
  273. *
  274. * @return string
  275. */
  276. public function salt()
  277. {
  278. $space = '$%&()=!#@~0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
  279. return substr(str_shuffle($space), 0, rand(40, 60));
  280. }
  281. /**
  282. * Process the given fixture class, creates its schema and imports its records.
  283. *
  284. * @param string $path Full path to schema class file
  285. * @param \Cake\Database\Connection $connection Database connection to use
  286. * @return bool True on success
  287. */
  288. protected function _processFixture($path, Connection $connection)
  289. {
  290. if (!is_readable($path)) {
  291. return false;
  292. }
  293. require $path;
  294. $fixtureClass = str_replace('.php', '', basename($path));
  295. $schema = $this->_prepareSchema($fixtureClass);
  296. $sql = $schema->createSql($connection);
  297. $tableCreated = true;
  298. foreach ($sql as $stmt) {
  299. try {
  300. if (!$connection->execute($stmt)) {
  301. $tableCreated = false;
  302. }
  303. } catch (\Exception $ex) {
  304. $this->error(__d('installer', 'Unable to create table "{0}". Details: {1}', $schema->name(), $ex->getMessage()));
  305. $tableCreated = false;
  306. }
  307. }
  308. if (!$tableCreated) {
  309. return false;
  310. }
  311. if (!$this->_importRecords($fixtureClass, $schema, $connection)) {
  312. return false;
  313. }
  314. return true;
  315. }
  316. /**
  317. * Gets an schema instance for the given fixture class.
  318. *
  319. * @param string $fixtureClassName The fixture to be "converted"
  320. * @return \Cake\Database\Schema\Table Schema instance
  321. */
  322. protected function _prepareSchema($fixtureClassName)
  323. {
  324. $fixture = new $fixtureClassName;
  325. if (!empty($fixture->table)) {
  326. $tableName = $fixture->table;
  327. } else {
  328. $tableName = (string)Inflector::underscore(str_replace_last('Fixture', '', $fixtureClassName));
  329. }
  330. list($fields, $constraints, $indexes, $options) = $this->_prepareSchemaProperties($fixture);
  331. $schema = new TableSchema($tableName, $fields);
  332. foreach ($constraints as $name => $attrs) {
  333. $schema->addConstraint($name, $attrs);
  334. }
  335. foreach ($indexes as $name => $attrs) {
  336. $schema->addIndex($name, $attrs);
  337. }
  338. if (!empty($options)) {
  339. $schema->options($options);
  340. }
  341. return $schema;
  342. }
  343. /**
  344. * Extracts some properties from the given fixture instance to properly
  345. * build a new table schema instance (constrains, indexes, etc).
  346. *
  347. * @param object $fixture Fixture instance from which extract schema
  348. * properties
  349. * @return array Where with keys 0 => $fields, 1 => $constraints, 2 =>
  350. * $indexes and 3 => $options
  351. */
  352. protected function _prepareSchemaProperties($fixture)
  353. {
  354. $fields = (array)$fixture->fields;
  355. $constraints = [];
  356. $indexes = [];
  357. $options = [];
  358. if (isset($fields['_constraints'])) {
  359. $constraints = $fields['_constraints'];
  360. unset($fields['_constraints']);
  361. }
  362. if (isset($fields['_indexes'])) {
  363. $indexes = $fields['_indexes'];
  364. unset($fields['_indexes']);
  365. }
  366. if (isset($fields['_options'])) {
  367. $options = $fields['_options'];
  368. unset($fields['_options']);
  369. }
  370. return [
  371. $fields,
  372. $constraints,
  373. $indexes,
  374. $options,
  375. ];
  376. }
  377. /**
  378. * Imports all records of the given fixture.
  379. *
  380. * @param string $fixtureClassName Fixture class name
  381. * @param \Cake\Database\Schema\Table $schema Table schema for which records
  382. * will be imported
  383. * @param \Cake\Database\Connection $connection Database connection to use
  384. * @return bool True on success
  385. */
  386. protected function _importRecords($fixtureClassName, TableSchema $schema, Connection $connection)
  387. {
  388. $fixture = new $fixtureClassName;
  389. if (!isset($fixture->records) || empty($fixture->records)) {
  390. return true;
  391. }
  392. $fixture->records = (array)$fixture->records;
  393. if (count($fixture->records) > 100) {
  394. $chunk = array_chunk($fixture->records, 100);
  395. } else {
  396. $chunk = [0 => $fixture->records];
  397. }
  398. foreach ($chunk as $records) {
  399. list($fields, $values, $types) = $this->_getRecords($records, $schema);
  400. $query = $connection->newQuery()
  401. ->insert($fields, $types)
  402. ->into($schema->name());
  403. foreach ($values as $row) {
  404. $query->values($row);
  405. }
  406. try {
  407. $statement = $query->execute();
  408. $statement->closeCursor();
  409. } catch (\Exception $ex) {
  410. $this->error(__d('installer', 'Error while importing data for table "{0}". Details: {1}', $schema->name(), $ex->getMessage()));
  411. return false;
  412. }
  413. }
  414. return true;
  415. }
  416. /**
  417. * Converts the given array of records into data used to generate a query.
  418. *
  419. * @param array $records Records to be imported
  420. * @param \Cake\Database\Schema\Table $schema Table schema for which records will
  421. * be imported
  422. * @return array
  423. */
  424. protected function _getRecords(array $records, TableSchema $schema)
  425. {
  426. $fields = $values = $types = [];
  427. $columns = $schema->columns();
  428. foreach ($records as $record) {
  429. $fields = array_merge($fields, array_intersect(array_keys($record), $columns));
  430. }
  431. $fields = array_values(array_unique($fields));
  432. foreach ($fields as $field) {
  433. $types[$field] = $schema->column($field)['type'];
  434. }
  435. $default = array_fill_keys($fields, null);
  436. foreach ($records as $record) {
  437. $values[] = array_merge($default, $record);
  438. }
  439. return [$fields, $values, $types];
  440. }
  441. }