Passed
Push — develop ( e2f3f2...e6dbd9 )
by Портнов
05:54
created

UpdateDatabase::isTableStructureNotEqual()   B

Complexity

Conditions 9
Paths 7

Size

Total Lines 49
Code Lines 30

Duplication

Lines 0
Ratio 0 %

Importance

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