UpdateDatabase   C
last analyzed

Complexity

Total Complexity 54

Size/Duplication

Total Lines 421
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 54
eloc 216
dl 0
loc 421
rs 6.4799
c 0
b 0
f 0

6 Methods

Rating   Name   Duplication   Size   Complexity  
B updateIndexes() 0 38 7
B isTableStructureNotEqual() 0 53 9
A updateDbStructureByModelsAnnotations() 0 17 3
A updatePermitCustomModules() 0 12 1
F createUpdateDbTableByAnnotations() 0 239 32
A updateDatabaseStructure() 0 8 2

How to fix   Complexity   

Complex Class

Complex classes like UpdateDatabase 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.

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 UpdateDatabase, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
/*
4
 * MikoPBX - free phone system for small business
5
 * Copyright © 2017-2023 Alexey Portnov and Nikolay Beketov
6
 *
7
 * This program is free software: you can redistribute it and/or modify
8
 * it under the terms of the GNU General Public License as published by
9
 * the Free Software Foundation; either version 3 of the License, or
10
 * (at your option) any later version.
11
 *
12
 * This program is distributed in the hope that it will be useful,
13
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15
 * GNU General Public License for more details.
16
 *
17
 * You should have received a copy of the GNU General Public License along with this program.
18
 * If not, see <https://www.gnu.org/licenses/>.
19
 */
20
21
namespace MikoPBX\Core\System\Upgrade;
22
23
use MikoPBX\Common\Models\PbxSettings;
24
use MikoPBX\Common\Providers\MainDatabaseProvider;
25
use MikoPBX\Common\Providers\ModelsAnnotationsProvider;
26
use MikoPBX\Common\Providers\ModelsMetadataProvider;
27
use MikoPBX\Core\System\Processes;
28
use MikoPBX\Core\System\SystemMessages;
29
use MikoPBX\Core\System\Util;
30
use Phalcon\Db\Column;
31
use Phalcon\Db\Index;
32
use Phalcon\Di\Injectable;
33
use ReflectionClass;
34
use Throwable;
35
36
use function MikoPBX\Common\Config\appPath;
37
38
/**
39
 * Class UpdateDatabase
40
 *
41
 *
42
 * @property \Phalcon\Config\Config config
43
 *
44
 *  @package MikoPBX\Core\System\Upgrade
45
 */
46
class UpdateDatabase extends Injectable
47
{
48
    /**
49
     * Updates database structure according to models annotations
50
     */
51
    public function updateDatabaseStructure(): void
52
    {
53
        try {
54
            MainDatabaseProvider::recreateDBConnections(); // after storage remount
55
            $this->updateDbStructureByModelsAnnotations();
56
            MainDatabaseProvider::recreateDBConnections(); // if we change anything in structure
57
        } catch (Throwable $e) {
58
            SystemMessages::echoWithSyslog('Errors within database upgrade process ' . $e->getMessage());
59
        }
60
    }
61
62
    /**
63
     *
64
     * Step by step goes by models annotations and apply structure changes
65
     *
66
     * @return void
67
     */
68
    private function updateDbStructureByModelsAnnotations(): void
69
    {
70
        $modelsDir = appPath('src/Common/Models');
71
        $results   = glob("$modelsDir/*.php", GLOB_NOSORT);
72
        foreach ($results as $file) {
73
            $className        = pathinfo($file)['filename'];
74
            $moduleModelClass = "MikoPBX\\Common\\Models\\$className";
75
            try {
76
                $this->createUpdateDbTableByAnnotations($moduleModelClass);
77
            } catch (Throwable $exception) {
78
                // Log errors encountered during table update
79
                SystemMessages::echoWithSyslog('Errors within update table ' . $className . ' ' . $exception->getMessage());
80
            }
81
        }
82
83
        // Update permissions for custom modules
84
        $this->updatePermitCustomModules();
85
    }
86
87
    /**
88
     * Update the permissions for custom modules.
89
     * https://github.com/mikopbx/Core/issues/173
90
     *
91
     * @return void
92
     */
93
    private function updatePermitCustomModules(): void
94
    {
95
        $modulesDir = $this->config->path('core.modulesDir');
96
        $findPath  = Util::which('find');
97
        $chownPath = Util::which('chown');
98
        $chmodPath = Util::which('chmod');
99
100
        // Set execute permissions for files in the modules' binary directories
101
        Processes::mwExec("$findPath $modulesDir/*/*bin/ -type f -exec $chmodPath +x {} \;");
102
103
        // Set ownership of the modules directory to www:www
104
        Processes::mwExec("$chownPath -R www:www $modulesDir/*");
105
    }
106
107
    /**
108
     * Create, update DB structure by code description
109
     *
110
     * @param $modelClassName string class name with namespace
111
     *                        i.e. MikoPBX\Common\Models\Extensions or Modules\ModuleSmartIVR\Models\Settings
112
     *
113
     * @return bool
114
     */
115
    public function createUpdateDbTableByAnnotations(string $modelClassName): bool
116
    {
117
        $result = true;
118
119
        // Check if the model class exists and has properties
120
        if (
121
            ! class_exists($modelClassName)
122
            || count(get_class_vars($modelClassName)) === 0
123
        ) {
124
            return true;
125
        }
126
127
128
        // Test if the model class is abstract
129
        try {
130
            $reflection = new ReflectionClass($modelClassName);
131
            if ($reflection->isAbstract()) {
132
                return true;
133
            }
134
        } catch (Throwable $exception) {
135
            return false;
136
        }
137
138
        // Get the model instance
139
        $model                 = new $modelClassName();
140
141
        // Get the connection service name
142
        $connectionServiceName = $model->getReadConnectionService();
143
144
        // Check if the connection service name is empty
145
        if (empty($connectionServiceName)) {
146
            return false;
147
        }
148
149
        // Get the connection service and metadata provider
150
        $connectionService = $this->di->getShared($connectionServiceName);
151
        $metaData          = $this->di->get(ModelsMetadataProvider::SERVICE_NAME);
152
        $metaData->reset();
153
154
        // Get the model annotations
155
        //https://docs.phalcon.io/4.0/ru-ru/annotations
156
        $modelAnnotation = $this->di->get(ModelsAnnotationsProvider::SERVICE_NAME)->get($model);
157
158
        // Initialize table name, structure and indexes
159
        $tableName       = $model->getSource();
160
        $table_structure = [];
161
        $indexes         = [];
162
163
        // Create columns list by code annotations
164
        $newColNames       = $metaData->getAttributes($model);
165
        $previousAttribute = '';
166
        foreach ($newColNames as $attribute) {
167
            $table_structure[$attribute] = [
168
                'type'      => Column::TYPE_VARCHAR,
169
                'after'     => $previousAttribute,
170
                'notNull'   => false,
171
                'isNumeric' => false,
172
                'primary'   => false,
173
            ];
174
            $previousAttribute           = $attribute;
175
        }
176
177
        // Set data types
178
        $propertiesAnnotations = $modelAnnotation->getPropertiesAnnotations();
179
        if ($propertiesAnnotations !== false) {
180
            $attributeTypes = $metaData->getDataTypes($model);
181
            foreach ($attributeTypes as $attribute => $type) {
182
                $table_structure[$attribute]['type'] = $type;
183
                // Try to find size of field
184
                if (array_key_exists($attribute, $propertiesAnnotations)) {
185
                    $propertyDescription = $propertiesAnnotations[$attribute];
186
                    if (
187
                        $propertyDescription->has('Column')
188
                        && $propertyDescription->get('Column')->hasArgument('length')
189
                    ) {
190
                        $table_structure[$attribute]['size'] = $propertyDescription->get('Column')->getArgument(
191
                            'length'
192
                        );
193
                    }
194
                }
195
            }
196
        }
197
198
        // Change type for numeric columns
199
        $numericAttributes = $metaData->getDataTypesNumeric($model);
200
        foreach ($numericAttributes as $attribute => $value) {
201
            $table_structure[$attribute]['type']      = Column::TYPE_INTEGER;
202
            $table_structure[$attribute]['isNumeric'] = true;
203
        }
204
205
        // Set not null for columns
206
        $notNull = $metaData->getNotNullAttributes($model);
207
        foreach ($notNull as $attribute) {
208
            $table_structure[$attribute]['notNull'] = true;
209
        }
210
211
        // Set default values for initial save, later it fill at Models\ModelBase\beforeValidationOnCreate
212
        $defaultValues = $metaData->getDefaultValues($model);
213
        foreach ($defaultValues as $key => $value) {
214
            if ($value !== null) {
215
                $table_structure[$key]['default'] = $value;
216
            }
217
        }
218
219
        // Set primary keys
220
        // $primaryKeys = $metaData->getPrimaryKeyAttributes($model);
221
        // foreach ($primaryKeys as $attribute) {
222
        //     $indexes[$attribute] = new Index($attribute, [$attribute], 'UNIQUE');
223
        // }
224
225
        // Set bind types
226
        $bindTypes = $metaData->getBindTypes($model);
227
        foreach ($bindTypes as $attribute => $value) {
228
            $table_structure[$attribute]['bindType'] = $value;
229
        }
230
231
        // Find auto incremental column, usually it is ID column
232
        $keyFiled = $metaData->getIdentityField($model);
233
        if ($keyFiled) {
234
            unset($indexes[$keyFiled]);
235
            $table_structure[$keyFiled] = [
236
                'type'          => Column::TYPE_INTEGER,
237
                'notNull'       => true,
238
                'autoIncrement' => true,
239
                'primary'       => true,
240
                'isNumeric'     => true,
241
                'first'         => true,
242
            ];
243
        }
244
245
        // Some exceptions
246
        if ($modelClassName === PbxSettings::class) {
247
            $keyFiled = 'key';
248
            unset($indexes[$keyFiled]);
249
            $table_structure[$keyFiled] = [
250
                'type'          => Column::TYPE_VARCHAR,
251
                'notNull'       => true,
252
                'autoIncrement' => false,
253
                'primary'       => true,
254
                'isNumeric'     => false,
255
                'first'         => true,
256
            ];
257
        }
258
259
        // Create additional indexes
260
        $modelClassAnnotation = $modelAnnotation->getClassAnnotations();
261
        if (
262
            $modelClassAnnotation !== null
263
            && $modelClassAnnotation->has('Indexes')
264
        ) {
265
            $additionalIndexes = $modelClassAnnotation->get('Indexes')->getArguments();
266
            foreach ($additionalIndexes as $index) {
267
                $indexName           = "i_{$tableName}_{$index['name']}";
268
                $indexes[$indexName] = new Index($indexName, $index['columns'], $index['type']);
269
            }
270
        }
271
272
        // Create new table structure
273
        $columns = [];
274
        foreach ($table_structure as $colName => $colType) {
275
            $columns[] = new Column($colName, $colType);
276
        }
277
278
        $columnsNew = [
279
            'columns' => $columns,
280
            'indexes' => $indexes,
281
        ];
282
283
        // Let's describe the directory for storing temporary tables and data
284
        $tempDir = $this->di->getShared('config')->path('core.tempDir');
285
        $sqliteTempStore = $connectionService->fetchColumn('PRAGMA temp_store');
286
        $sqliteTempDir   = $connectionService->fetchColumn('PRAGMA temp_store_directory');
287
        $connectionService->execute('PRAGMA temp_store = FILE;');
288
        $connectionService->execute("PRAGMA temp_store_directory = '$tempDir';");
289
290
        // Starting the transaction
291
        $connectionService->begin();
292
293
        if (! $connectionService->tableExists($tableName)) {
294
            $msg = ' - UpdateDatabase: Create new table: ' . $tableName . ' ';
295
            SystemMessages::echoWithSyslog($msg);
296
            $result = $connectionService->createTable($tableName, '', $columnsNew);
297
            SystemMessages::echoResult($msg);
298
        } else {
299
            // Table exists, we have to check/upgrade its structure
300
            $currentColumnsArr = $connectionService->describeColumns($tableName, '');
301
302
            if ($this->isTableStructureNotEqual($currentColumnsArr, $columns)) {
303
                $msg = ' - UpdateDatabase: Upgrade table: ' . $tableName . ' ';
304
                SystemMessages::echoWithSyslog($msg);
305
                // Create new table and copy all data
306
                $currentStateColumnList = [];
307
                $oldColNames            = []; // Old columns names
308
                $countColumnsTemp       = count($currentColumnsArr);
309
                for ($k = 0; $k < $countColumnsTemp; $k++) {
310
                    $currentStateColumnList[$k] = $currentColumnsArr[$k]->getName();
311
                    $oldColNames[]              = $currentColumnsArr[$k]->getName();
312
                }
313
314
                // Create temporary clone on current table with all columns and date
315
                // Delete original table
316
                $gluedColumns = implode(',', $currentStateColumnList);
317
                $query        = "CREATE TEMPORARY TABLE {$tableName}_backup($gluedColumns); 
318
INSERT INTO {$tableName}_backup SELECT $gluedColumns FROM $tableName; 
319
DROP TABLE  $tableName";
320
                $result       = $result && $connectionService->execute($query);
321
322
                // Create new table with new columns structure
323
                $result = $result && $connectionService->createTable($tableName, '', $columnsNew);
324
325
                // Copy data from temporary table to newly created
326
                $newColumnNames  = array_intersect($newColNames, $oldColNames);
327
                $gluedNewColumns = implode(',', $newColumnNames);
328
                $result          = $result && $connectionService->execute(
329
                    "INSERT INTO $tableName ( $gluedNewColumns) SELECT $gluedNewColumns  FROM {$tableName}_backup;"
330
                );
331
332
                // Drop temporary table
333
                $result = $result && $connectionService->execute("DROP TABLE {$tableName}_backup;");
334
                SystemMessages::echoResult($msg);
335
            }
336
        }
337
338
339
        if ($result) {
340
            $result = $this->updateIndexes($tableName, $connectionService, $indexes);
341
        }
342
343
        if ($result) {
344
            $result = $connectionService->commit();
345
        } else {
346
            SystemMessages::sysLogMsg('createUpdateDbTableByAnnotations', "Error: Failed on create/update table {$tableName}", LOG_ERR);
347
            $connectionService->rollback();
348
        }
349
350
        // Restoring PRAGMA values
351
        $connectionService->execute("PRAGMA temp_store = $sqliteTempStore;");
352
        $connectionService->execute("PRAGMA temp_store_directory = '$sqliteTempDir';");
353
        return $result;
354
    }
355
356
    /**
357
     * Compares database structure with metadata info
358
     *
359
     * @param $currentTableStructure
360
     * @param $newTableStructure
361
     *
362
     * @return bool
363
     */
364
    private function isTableStructureNotEqual($currentTableStructure, $newTableStructure): bool
365
    {
366
        // 1. Check fields count
367
        if (count($currentTableStructure) !== count($newTableStructure)) {
368
            return true;
369
        }
370
371
        $comparedSettings = [
372
            'getName',
373
            'getType',
374
            'getTypeReference',
375
            'getTypeValues',
376
            'getSize',
377
            'getScale',
378
            'isUnsigned',
379
            'isNotNull',
380
            'isPrimary',
381
            'isAutoIncrement',
382
            'isNumeric',
383
            'isFirst',
384
            'getAfterPosition',
385
            //'getBindType',
386
            'getDefault',
387
            'hasDefault',
388
        ];
389
390
        // 2. Check fields types
391
        foreach ($newTableStructure as $index => $newField) {
392
            $oldField = $currentTableStructure[$index];
393
            foreach ($comparedSettings as $compared_setting) {
394
                if ($oldField->$compared_setting() !== $newField->$compared_setting()) {
395
                    // Sqlite transform "1" to ""1"" in default settings, but it is normal
396
                    if (
397
                        $compared_setting === 'getDefault'
398
                        && $oldField->$compared_setting() === '"' . $newField->$compared_setting() . '"'
399
                    ) {
400
                        continue;
401
                    }
402
403
                    // Description for "length" is integer, but table structure store it as string
404
                    if (
405
                        $compared_setting === 'getSize'
406
                        && (string)$oldField->$compared_setting() === (string)$newField->$compared_setting()
407
                    ) {
408
                        continue;
409
                    }
410
411
                    return true; // find different columns
412
                }
413
            }
414
        }
415
416
        return false;
417
    }
418
419
420
    /**
421
     * Updates indexes on database
422
     *
423
     * @param string $tableName
424
     * @param mixed  $connectionService DependencyInjection connection service used to read data
425
     * @param array  $indexes
426
     *
427
     * @return bool
428
     */
429
    private function updateIndexes(string $tableName, mixed $connectionService, array $indexes): bool
430
    {
431
        $result         = true;
432
        $currentIndexes = $connectionService->describeIndexes($tableName);
433
434
        // Drop not exist indexes
435
        foreach ($currentIndexes as $indexName => $currentIndex) {
436
            if (
437
                stripos($indexName, 'sqlite_autoindex') === false
438
                && ! array_key_exists($indexName, $indexes)
439
            ) {
440
                $msg = " - UpdateDatabase: Delete index: $indexName ";
441
                SystemMessages::echoWithSyslog($msg);
442
                $result += $connectionService->dropIndex($tableName, '', $indexName);
443
                SystemMessages::echoResult($msg);
444
            }
445
        }
446
447
        // Add/update exist indexes
448
        foreach ($indexes as $indexName => $describedIndex) {
449
            if (array_key_exists($indexName, $currentIndexes)) {
450
                $currentIndex = $currentIndexes[$indexName];
451
                if ($describedIndex->getColumns() !== $currentIndex->getColumns()) {
452
                    $msg = " - UpdateDatabase: Update index: $indexName ";
453
                    SystemMessages::echoWithSyslog($msg);
454
                    $result += $connectionService->dropIndex($tableName, '', $indexName);
455
                    $result += $connectionService->addIndex($tableName, '', $describedIndex);
456
                    SystemMessages::echoResult($msg);
457
                }
458
            } else {
459
                $msg = " - UpdateDatabase: Add new index: $indexName ";
460
                SystemMessages::echoWithSyslog($msg);
461
                $result += $connectionService->addIndex($tableName, '', $describedIndex);
462
                SystemMessages::echoResult($msg);
463
            }
464
        }
465
466
        return $result;
467
    }
468
}
469