DatabaseInstaller   C
last analyzed

Complexity

Total Complexity 57

Size/Duplication

Total Lines 484
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 11

Importance

Changes 0
Metric Value
dl 0
loc 484
rs 6.433
c 0
b 0
f 0
wmc 57
lcom 1
cbo 11

15 Methods

Rating   Name   Duplication   Size   Complexity  
A error() 0 4 1
A __construct() 0 12 3
B install() 0 25 5
A errors() 0 8 2
C prepareConfig() 0 34 7
A getConn() 0 21 3
B importTables() 0 23 4
A isDbEmpty() 0 14 2
A writeSetting() 0 21 2
A salt() 0 6 1
C _processFixture() 0 33 7
B _prepareSchema() 0 26 5
B _prepareSchemaProperties() 0 29 4
C _importRecords() 0 36 7
A _getRecords() 0 18 4

How to fix   Complexity   

Complex Class

Complex classes like DatabaseInstaller often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use DatabaseInstaller, and based on these observations, apply Extract Interface, too.

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