DropUnusedDatabaseObjectsTask   F
last analyzed

Complexity

Total Complexity 73

Size/Duplication

Total Lines 456
Duplicated Lines 0 %

Importance

Changes 4
Bugs 1 Features 0
Metric Value
eloc 235
c 4
b 1
f 0
dl 0
loc 456
rs 2.56
wmc 73

9 Methods

Rating   Name   Duplication   Size   Complexity  
A getClassesWithTables() 0 3 1
A sqlDropColumns() 0 3 1
A run() 0 33 5
A sqlLiteDropColumns() 0 22 4
A removeFromArray() 0 4 2
F removeFields() 0 127 24
C reorderFields() 0 89 17
F removeTables() 0 81 16
A dropColumns() 0 10 3

How to fix   Complexity   

Complex Class

Complex classes like DropUnusedDatabaseObjectsTask 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 DropUnusedDatabaseObjectsTask, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace LeKoala\DevToolkit\Tasks;
4
5
use Exception;
6
use SilverStripe\ORM\DB;
7
use SilverStripe\Dev\BuildTask;
8
use SilverStripe\Core\ClassInfo;
9
use SilverStripe\ORM\DataObject;
10
use SilverStripe\Versioned\Versioned;
0 ignored issues
show
Bug introduced by
The type SilverStripe\Versioned\Versioned was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
11
use LeKoala\DevToolkit\BuildTaskTools;
12
use SilverStripe\Core\Environment;
13
use SilverStripe\Control\HTTPRequest;
14
15
/**
16
 * SilverStripe never delete your tables or fields. Be careful if your database has other tables than SilverStripe!
17
 *
18
 * @author lekoala
19
 */
20
class DropUnusedDatabaseObjectsTask extends BuildTask
21
{
22
    use BuildTaskTools;
23
24
    /**
25
     * @var string
26
     */
27
    protected $title = "Drop unused database objects";
28
29
    /**
30
     * @var string
31
     */
32
    protected $description = 'Drop unused tables and fields from your db by comparing current database tables with your dataobjects.';
33
34
    /**
35
     * @var string
36
     */
37
    private static $segment = 'DropUnusedDatabaseObjectsTask';
0 ignored issues
show
introduced by
The private property $segment is not used, and could be removed.
Loading history...
38
39
    /**
40
     * @param HTTPRequest $request
41
     * @return void
42
     */
43
    public function run($request)
44
    {
45
        // This can be very long
46
        Environment::setTimeLimitMax(0);
47
48
        $this->request = $request;
49
50
        $this->addOption("tables", "Clean unused tables", true);
51
        $this->addOption("fields", "Clean unused fields", true);
52
        $this->addOption("reorder", "Reorder fields", false);
53
        $this->addOption("go", "Tick this to proceed", false);
54
55
        $options = $this->askOptions();
56
57
        $tables = $options['tables'];
58
        $fields = $options['fields'];
59
        $reorder = $options['reorder'];
60
        $go = $options['go'];
61
62
        if (!$go) {
63
            echo ('Previewing what this task is about to do.');
64
        } else {
65
            echo ("Let's clean this up!");
66
        }
67
        echo ('<hr/>');
68
        if ($tables) {
69
            $this->removeTables($request, $go);
70
        }
71
        if ($fields) {
72
            $this->removeFields($request, $go);
73
        }
74
        if ($reorder) {
75
            $this->reorderFields($request, $go);
76
        }
77
    }
78
79
    /**
80
     * @param HTTPRequest $request
81
     * @param bool $go
82
     * @return void
83
     */
84
    protected function reorderFields($request, $go = false)
0 ignored issues
show
Unused Code introduced by
The parameter $request is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

84
    protected function reorderFields(/** @scrutinizer ignore-unused */ $request, $go = false)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
85
    {
86
        $conn = DB::get_conn();
87
        $schema = DB::get_schema();
88
        $dataObjectSchema = DataObject::getSchema();
89
        $classes = $this->getClassesWithTables();
90
        $tableList = $schema->tableList();
91
92
        $this->message('<h2>Fields order</h2>');
93
94
        foreach ($classes as $class) {
95
            /** @var \SilverStripe\ORM\DataObject $singl */
96
            $singl = $class::singleton();
97
            $baseClass = $singl->baseClass();
98
            $table = $dataObjectSchema->tableName($class);
99
            $lcTable = strtolower($table);
100
101
            // It does not exist in the list, no need to worry about
102
            if (!isset($tableList[$lcTable])) {
103
                continue;
104
            }
105
106
            $fields = $dataObjectSchema->databaseFields($class);
107
            $baseFields = $dataObjectSchema->databaseFields($baseClass);
108
109
            $realFields = $fields;
110
            if ($baseClass != $class) {
111
                foreach ($baseFields as $k => $v) {
112
                    if ($k == "ID") {
113
                        continue;
114
                    }
115
                    unset($realFields[$k]);
116
                }
117
118
                // When extending multiple classes it's a mess to track, eg SubsitesVirtualPage
119
                if (isset($realFields['VersionID'])) {
120
                    unset($realFields['VersionID']);
121
                }
122
            }
123
124
            // We must pass the regular table name
125
            $list = $schema->fieldList($table);
126
127
            $fields_keys = array_keys($realFields);
128
            $list_keys = array_keys($list);
129
130
            if (json_encode($fields_keys) == json_encode($list_keys)) {
131
                continue;
132
            }
133
134
            $fieldsThatNeedToMove = [];
135
            foreach ($fields_keys as $k => $v) {
136
                if (!isset($list_keys[$k])) {
137
                    continue; // not sure why
138
                }
139
                if ($list_keys[$k] != $v) {
140
                    $fieldsThatNeedToMove[] = $v;
141
                }
142
            }
143
144
            if ($go) {
145
                $this->message("$table: moving " . implode(", ", $fieldsThatNeedToMove));
146
147
                // $conn->transactionStart();
148
                // fields contains the right order (the one from the codebase)
149
                $after = "first";
150
                foreach ($fields_keys as $k => $v) {
151
                    if (isset($list_keys[$k]) && $list_keys[$k] != $v) {
152
                        $col = $v;
153
                        $def = $list[$v] ?? null;
154
                        if (!$def) {
155
                            // This happens when extending another model
156
                            $this->message("Ignore $v that has no definition", "error");
157
                            continue;
158
                        }
159
                        // you CANNOT combine multiple columns reordering in a single ALTER TABLE statement.
160
                        $sql = "ALTER TABLE `$table` MODIFY `$col` $def $after";
161
                        $this->message($sql);
162
                        try {
163
                            $conn->query($sql);
164
                        } catch (Exception $e) {
165
                            $this->message($e->getMessage(), "error");
166
                        }
167
                    }
168
                    $after = "after $v";
169
                }
170
                // $conn->transactionEnd();
171
            } else {
172
                $this->message("$table: would move " . implode(", ", $fieldsThatNeedToMove));
173
            }
174
        }
175
    }
176
177
    /**
178
     * @param HTTPRequest $request
179
     * @param bool $go
180
     * @return void
181
     */
182
    protected function removeFields($request, $go = false)
0 ignored issues
show
Unused Code introduced by
The parameter $request is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

182
    protected function removeFields(/** @scrutinizer ignore-unused */ $request, $go = false)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
183
    {
184
        $conn = DB::get_conn();
0 ignored issues
show
Unused Code introduced by
The assignment to $conn is dead and can be removed.
Loading history...
185
        $schema = DB::get_schema();
186
        $dataObjectSchema = DataObject::getSchema();
187
        $classes = $this->getClassesWithTables();
188
        $tableList = $schema->tableList();
189
190
        $this->message('<h2>Fields</h2>');
191
192
        $empty = true;
193
194
        $processedTables = [];
195
        foreach ($classes as $class) {
196
            /** @var \SilverStripe\ORM\DataObject $singl */
197
            $singl = $class::singleton();
198
            $baseClass = $singl->baseClass();
199
            $table = $dataObjectSchema->tableName($baseClass);
200
            $lcTable = strtolower($table);
201
202
            if (in_array($table, $processedTables)) {
203
                continue;
204
            }
205
            // It does not exist in the list, no need to worry about
206
            if (!isset($tableList[$lcTable])) {
207
                continue;
208
            }
209
            $processedTables[] = $table;
210
            $toDrop = [];
211
212
            $fields = $dataObjectSchema->databaseFields($class);
213
            // We must pass the regular table name
214
            $list = $schema->fieldList($table);
215
            // We can compare DataObject schema with actual schema
216
            foreach ($list as $fieldName => $type) {
217
                /// Never drop ID
218
                if ($fieldName == 'ID') {
219
                    continue;
220
                }
221
                if (!isset($fields[$fieldName])) {
222
                    $toDrop[] = $fieldName;
223
                }
224
            }
225
226
            if (!empty($toDrop)) {
227
                $empty = false;
228
                if ($go) {
229
                    $this->dropColumns($table, $toDrop);
230
                    $this->message("Dropped " . implode(',', $toDrop) . " for $table", "obsolete");
231
                } else {
232
                    $this->message("Would drop " . implode(',', $toDrop) . " for $table", "obsolete");
233
                }
234
            }
235
236
            // Many many support if has own base table
237
            $many_many = $singl::config()->many_many;
238
            foreach ($many_many as $manyName => $manyClass) {
239
                $toDrop = [];
240
241
                // No polymorphism support
242
                if (is_array($manyClass)) {
243
                    continue;
244
                }
245
246
                // This is very naive and only works in basic cases
247
                $manyTable = $table . '_' . $manyName;
248
                if (!$schema->hasTable($manyTable)) {
249
                    continue;
250
                }
251
                $baseManyTable = $dataObjectSchema->tableName($manyClass);
252
                $list = $schema->fieldList($manyTable);
253
                $props = $singl::config()->many_many_extraFields[$manyName] ?? [];
254
                if (empty($props)) {
255
                    continue;
256
                }
257
258
                // We might miss some!
259
                $validNames = array_merge([
260
                    'ID', $baseManyTable . 'ID', $table . 'ID', $table . 'ID', 'ChildID', 'SubsiteID',
261
                ], array_keys($props));
262
                foreach ($list as $fieldName => $fieldDef) {
263
                    if (!in_array($fieldName, $validNames)) {
264
                        $toDrop[] = $fieldName;
265
                    }
266
                }
267
268
                if (!empty($toDrop)) {
269
                    $empty = false;
270
                    if ($go) {
271
                        $this->dropColumns($manyTable, $toDrop);
272
                        $this->message("Dropped " . implode(',', $toDrop) . " for $manyTable ($table)", "obsolete");
273
                    } else {
274
                        $this->message("Would drop " . implode(',', $toDrop) . " for $manyTable ($table)", "obsolete");
275
                    }
276
                }
277
            }
278
279
            // Localised fields support
280
            if ($singl->hasExtension("\\TractorCow\\Fluent\\Extension\\FluentExtension")) {
281
                $toDrop = [];
282
                $localeTable = $table . '_Localised';
283
                //@phpstan-ignore-next-line
284
                $localeFields = $singl->getLocalisedFields($baseClass);
285
                $localeList = $schema->fieldList($localeTable);
286
                foreach ($localeList as $fieldName => $type) {
287
                    /// Never drop locale fields
288
                    if (in_array($fieldName, ['ID', 'RecordID', 'Locale'])) {
289
                        continue;
290
                    }
291
                    if (!isset($localeFields[$fieldName])) {
292
                        $toDrop[] = $fieldName;
293
                    }
294
                }
295
                if (!empty($toDrop)) {
296
                    $empty = false;
297
                    if ($go) {
298
                        $this->dropColumns($localeTable, $toDrop);
299
                        $this->message("Dropped " . implode(',', $toDrop) . " for $localeTable", "obsolete");
300
                    } else {
301
                        $this->message("Would drop " . implode(',', $toDrop) . " for $localeTable", "obsolete");
302
                    }
303
                }
304
            }
305
        }
306
307
        if ($empty) {
308
            $this->message("No fields to remove", "repaired");
309
        }
310
    }
311
312
    /**
313
     * @param HTTPRequest $request
314
     * @param bool $go
315
     * @return void
316
     */
317
    protected function removeTables($request, $go = false)
0 ignored issues
show
Unused Code introduced by
The parameter $request is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

317
    protected function removeTables(/** @scrutinizer ignore-unused */ $request, $go = false)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
318
    {
319
        $conn = DB::get_conn();
0 ignored issues
show
Unused Code introduced by
The assignment to $conn is dead and can be removed.
Loading history...
320
        $schema = DB::get_schema();
321
        $dataObjectSchema = DataObject::getSchema();
322
        $classes = $this->getClassesWithTables();
323
        $allDataObjects = array_values($this->getValidDataObjects());
324
        $tableList = $schema->tableList();
325
        $tablesToRemove = $tableList;
326
327
        $this->message('<h2>Tables</h2>');
328
329
        foreach ($classes as $class) {
330
            /** @var \SilverStripe\ORM\DataObject $singl */
331
            $singl = $class::singleton();
332
            $table = $dataObjectSchema->tableName($class);
333
            $lcTable = strtolower($table);
334
335
            // It does not exist in the list, keep to remove later
336
            if (!isset($tableList[$lcTable])) {
337
                continue;
338
            }
339
340
            self::removeFromArray($lcTable, $tablesToRemove);
341
            // Remove from the list versioned tables
342
            if ($singl->hasExtension(Versioned::class)) {
343
                self::removeFromArray($lcTable . '_live', $tablesToRemove);
344
                self::removeFromArray($lcTable . '_versions', $tablesToRemove);
345
            }
346
            // Remove from the list fluent tables
347
            if ($singl->hasExtension("\\TractorCow\\Fluent\\Extension\\FluentExtension")) {
348
                self::removeFromArray($lcTable . '_localised', $tablesToRemove);
349
                self::removeFromArray($lcTable . '_localised_live', $tablesToRemove);
350
                self::removeFromArray($lcTable . '_localised_versions', $tablesToRemove);
351
            }
352
353
            // Relations
354
            $hasMany = $class::config()->has_many;
355
            if (!empty($hasMany)) {
356
                foreach ($hasMany as $rel => $obj) {
357
                    self::removeFromArray($lcTable . '_' . strtolower($rel), $tablesToRemove);
358
                }
359
            }
360
            // We catch relations without own classes later on
361
            $manyMany = $class::config()->many_many;
362
            if (!empty($manyMany)) {
363
                foreach ($manyMany as $rel => $obj) {
364
                    self::removeFromArray($lcTable . '_' . strtolower($rel), $tablesToRemove);
365
                }
366
            }
367
        }
368
369
        //at this point, we should only have orphans table in dbTables var
370
        foreach ($tablesToRemove as $lcTable => $table) {
371
            // Remove many_many tables without own base table
372
            if (strpos($table, '_') !== false) {
373
                $parts = explode('_', $table);
374
                $potentialClass = $parts[0];
375
                $potentialRelation = $parts[1];
376
                foreach ($allDataObjects as $dataObjectClass) {
377
                    $classParts = explode('\\', $dataObjectClass);
378
                    $tableClass = end($classParts);
379
                    if ($tableClass == $potentialClass) {
380
                        $manyManyRelations = $dataObjectClass::config()->many_many;
381
                        if (isset($manyManyRelations[$potentialRelation])) {
382
                            unset($tablesToRemove[$lcTable]);
383
                            continue 2;
384
                        }
385
                    }
386
                }
387
            }
388
            if ($go) {
389
                DB::query('DROP TABLE `' . $table . '`');
390
                $this->message("Dropped $table", 'obsolete');
391
            } else {
392
                $this->message("Would drop $table", 'obsolete');
393
            }
394
        }
395
396
        if (empty($tablesToRemove)) {
397
            $this->message("No table to remove", "repaired");
398
        }
399
    }
400
401
    /**
402
     * @return array<string>
403
     */
404
    protected function getClassesWithTables()
405
    {
406
        return ClassInfo::dataClassesFor(DataObject::class);
407
    }
408
409
    /**
410
     * @param mixed $val
411
     * @param array<mixed> $arr
412
     * @return void
413
     */
414
    public static function removeFromArray($val, &$arr)
415
    {
416
        if (isset($arr[$val])) {
417
            unset($arr[$val]);
418
        }
419
    }
420
421
    /**
422
     * @param string $table
423
     * @param array<string> $columns
424
     * @return void
425
     */
426
    public function dropColumns($table, $columns)
427
    {
428
        switch (get_class(DB::get_conn())) {
429
            case \SilverStripe\SQLite\SQLite3Database::class:
0 ignored issues
show
Bug introduced by
The type SilverStripe\SQLite\SQLite3Database was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
430
            case 'SQLite3Database':
431
                $this->sqlLiteDropColumns($table, $columns);
432
                break;
433
            default:
434
                $this->sqlDropColumns($table, $columns);
435
                break;
436
        }
437
    }
438
439
    /**
440
     * @param string $table
441
     * @param array<string> $columns
442
     * @return void
443
     */
444
    public function sqlDropColumns($table, $columns)
445
    {
446
        DB::query("ALTER TABLE \"$table\" DROP \"" . implode('", DROP "', $columns) . "\"");
447
    }
448
449
    /**
450
     * @param string $table
451
     * @param array<string> $columns
452
     * @return void
453
     */
454
    public function sqlLiteDropColumns($table, $columns)
455
    {
456
        $newColsSpec = $newCols = [];
457
        foreach (DataObject::getSchema()->databaseFields($table) as $name => $spec) {
458
            if (in_array($name, $columns)) {
459
                continue;
460
            }
461
            $newColsSpec[] = "\"$name\" $spec";
462
            $newCols[] = "\"$name\"";
463
        }
464
465
        $queries = [
466
            "BEGIN TRANSACTION",
467
            "CREATE TABLE \"{$table}_cleanup\" (" . implode(',', $newColsSpec) . ")",
468
            "INSERT INTO \"{$table}_cleanup\" SELECT " . implode(',', $newCols) . " FROM \"$table\"",
469
            "DROP TABLE \"$table\"",
470
            "ALTER TABLE \"{$table}_cleanup\" RENAME TO \"{$table}\"",
471
            "COMMIT"
472
        ];
473
474
        foreach ($queries as $query) {
475
            DB::query($query . ';');
476
        }
477
    }
478
}
479