Passed
Push — master ( 1ece45...bad1ac )
by Robbie
27:22 queued 18:04
created

DatabaseAdmin::getClassNameRemappingFields()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 16
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 9
nc 4
nop 0
dl 0
loc 16
rs 9.9666
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\ORM;
4
5
use BadMethodCallException;
6
use SilverStripe\Control\Controller;
7
use SilverStripe\Control\Director;
8
use SilverStripe\Core\ClassInfo;
9
use SilverStripe\Core\Environment;
10
use SilverStripe\Core\Injector\Injector;
11
use SilverStripe\Core\Manifest\ClassLoader;
12
use SilverStripe\Dev\DevelopmentAdmin;
13
use SilverStripe\Dev\TestOnly;
14
use SilverStripe\ORM\FieldType\DBClassName;
15
use SilverStripe\Security\Permission;
16
use SilverStripe\Security\Security;
17
use SilverStripe\Versioned\Versioned;
18
19
/**
20
 * DatabaseAdmin class
21
 *
22
 * Utility functions for administrating the database. These can be accessed
23
 * via URL, e.g. http://www.yourdomain.com/db/build.
24
 */
25
class DatabaseAdmin extends Controller
26
{
27
28
    /// SECURITY ///
29
    private static $allowed_actions = array(
0 ignored issues
show
introduced by
The private property $allowed_actions is not used, and could be removed.
Loading history...
30
        'index',
31
        'build',
32
        'cleanup',
33
        'import'
34
    );
35
36
    /**
37
     * Obsolete classname values that should be remapped in dev/build
38
     */
39
    private static $classname_value_remapping = [
0 ignored issues
show
introduced by
The private property $classname_value_remapping is not used, and could be removed.
Loading history...
40
        'File' => 'SilverStripe\\Assets\\File',
41
        'Image' => 'SilverStripe\\Assets\\Image',
42
        'Folder' => 'SilverStripe\\Assets\\Folder',
43
        'Group' => 'SilverStripe\\Security\\Group',
44
        'LoginAttempt' => 'SilverStripe\\Security\\LoginAttempt',
45
        'Member' => 'SilverStripe\\Security\\Member',
46
        'MemberPassword' => 'SilverStripe\\Security\\MemberPassword',
47
        'Permission' => 'SilverStripe\\Security\\Permission',
48
        'PermissionRole' => 'SilverStripe\\Security\\PermissionRole',
49
        'PermissionRoleCode' => 'SilverStripe\\Security\\PermissionRoleCode',
50
        'RememberLoginHash' => 'SilverStripe\\Security\\RememberLoginHash',
51
    ];
52
53
    /**
54
     * Config setting to enabled/disable the display of record counts on the dev/build output
55
     */
56
    private static $show_record_counts = true;
57
58
    protected function init()
59
    {
60
        parent::init();
61
62
        // We allow access to this controller regardless of live-status or ADMIN permission only
63
        // if on CLI or with the database not ready. The latter makes it less errorprone to do an
64
        // initial schema build without requiring a default-admin login.
65
        // Access to this controller is always allowed in "dev-mode", or of the user is ADMIN.
66
        $allowAllCLI = DevelopmentAdmin::config()->get('allow_all_cli');
67
        $canAccess = (
68
            Director::isDev()
69
            || !Security::database_is_ready()
70
            // We need to ensure that DevelopmentAdminTest can simulate permission failures when running
71
            // "dev/tests" from CLI.
72
            || (Director::is_cli() && $allowAllCLI)
73
            || Permission::check("ADMIN")
74
        );
75
        if (!$canAccess) {
76
            Security::permissionFailure(
77
                $this,
78
                "This page is secured and you need administrator rights to access it. " .
79
                "Enter your credentials below and we will send you right along."
80
            );
81
        }
82
    }
83
84
    /**
85
     * Get the data classes, grouped by their root class
86
     *
87
     * @return array Array of data classes, grouped by their root class
88
     */
89
    public function groupedDataClasses()
90
    {
91
        // Get all root data objects
92
        $allClasses = get_declared_classes();
93
        $rootClasses = [];
94
        foreach ($allClasses as $class) {
95
            if (get_parent_class($class) == DataObject::class) {
96
                $rootClasses[$class] = array();
97
            }
98
        }
99
100
        // Assign every other data object one of those
101
        foreach ($allClasses as $class) {
102
            if (!isset($rootClasses[$class]) && is_subclass_of($class, DataObject::class)) {
103
                foreach ($rootClasses as $rootClass => $dummy) {
104
                    if (is_subclass_of($class, $rootClass)) {
105
                        $rootClasses[$rootClass][] = $class;
106
                        break;
107
                    }
108
                }
109
            }
110
        }
111
        return $rootClasses;
112
    }
113
114
115
    /**
116
     * When we're called as /dev/build, that's actually the index. Do the same
117
     * as /dev/build/build.
118
     */
119
    public function index()
120
    {
121
        return $this->build();
0 ignored issues
show
Bug introduced by
Are you sure the usage of $this->build() targeting SilverStripe\ORM\DatabaseAdmin::build() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
122
    }
123
124
    /**
125
     * Updates the database schema, creating tables & fields as necessary.
126
     */
127
    public function build()
128
    {
129
        // The default time limit of 30 seconds is normally not enough
130
        Environment::increaseTimeLimitTo(600);
131
132
        // If this code is being run outside of a dev/build or without a ?flush query string param,
133
        // the class manifest hasn't been flushed, so do it here
134
        $request = $this->getRequest();
135
        if (!array_key_exists('flush', $request->getVars()) && strpos($request->getURL(), 'dev/build') !== 0) {
136
            ClassLoader::inst()->getManifest()->regenerate(false);
137
        }
138
139
        $url = $this->getReturnURL();
140
        if ($url) {
141
            echo "<p>Setting up the database; you will be returned to your site shortly....</p>";
142
            $this->doBuild(true);
143
            echo "<p>Done!</p>";
144
            $this->redirect($url);
145
        } else {
146
            $quiet = $this->request->requestVar('quiet') !== null;
147
            $fromInstaller = $this->request->requestVar('from_installer') !== null;
148
            $populate = $this->request->requestVar('dont_populate') === null;
149
            $this->doBuild($quiet || $fromInstaller, $populate);
150
        }
151
    }
152
153
    /**
154
     * Gets the url to return to after build
155
     *
156
     * @return string|null
157
     */
158
    protected function getReturnURL()
159
    {
160
        $url = $this->request->getVar('returnURL');
161
162
        // Check that this url is a site url
163
        if (empty($url) || !Director::is_site_url($url)) {
164
            return null;
165
        }
166
167
        // Convert to absolute URL
168
        return Director::absoluteURL($url, true);
0 ignored issues
show
Bug introduced by
true of type true is incompatible with the type string expected by parameter $relativeParent of SilverStripe\Control\Director::absoluteURL(). ( Ignorable by Annotation )

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

168
        return Director::absoluteURL($url, /** @scrutinizer ignore-type */ true);
Loading history...
169
    }
170
171
    /**
172
     * Build the default data, calling requireDefaultRecords on all
173
     * DataObject classes
174
     */
175
    public function buildDefaults()
176
    {
177
        $dataClasses = ClassInfo::subclassesFor(DataObject::class);
178
        array_shift($dataClasses);
179
180
        if (!Director::is_cli()) {
181
            echo "<ul>";
182
        }
183
184
        foreach ($dataClasses as $dataClass) {
185
            singleton($dataClass)->requireDefaultRecords();
186
            if (Director::is_cli()) {
187
                echo "Defaults loaded for $dataClass\n";
188
            } else {
189
                echo "<li>Defaults loaded for $dataClass</li>\n";
190
            }
191
        }
192
193
        if (!Director::is_cli()) {
194
            echo "</ul>";
195
        }
196
    }
197
198
    /**
199
     * Returns the timestamp of the time that the database was last built
200
     *
201
     * @return string Returns the timestamp of the time that the database was
202
     *                last built
203
     */
204
    public static function lastBuilt()
205
    {
206
        $file = TEMP_PATH
207
            . DIRECTORY_SEPARATOR
208
            . 'database-last-generated-'
209
            . str_replace(array('\\','/',':'), '.', Director::baseFolder());
210
211
        if (file_exists($file)) {
212
            return filemtime($file);
213
        }
214
        return null;
215
    }
216
217
218
    /**
219
     * Updates the database schema, creating tables & fields as necessary.
220
     *
221
     * @param boolean $quiet Don't show messages
222
     * @param boolean $populate Populate the database, as well as setting up its schema
223
     * @param bool $testMode
224
     */
225
    public function doBuild($quiet = false, $populate = true, $testMode = false)
226
    {
227
        if ($quiet) {
228
            DB::quiet();
229
        } else {
230
            $conn = DB::get_conn();
231
            // Assumes database class is like "MySQLDatabase" or "MSSQLDatabase" (suffixed with "Database")
232
            $dbType = substr(get_class($conn), 0, -8);
233
            $dbVersion = $conn->getVersion();
234
            $databaseName = $conn->getSelectedDatabase();
235
236
            if (Director::is_cli()) {
237
                echo sprintf("\n\nBuilding database %s using %s %s\n\n", $databaseName, $dbType, $dbVersion);
238
            } else {
239
                echo sprintf("<h2>Building database %s using %s %s</h2>", $databaseName, $dbType, $dbVersion);
240
            }
241
        }
242
243
        // Set up the initial database
244
        if (!DB::is_active()) {
245
            if (!$quiet) {
246
                echo '<p><b>Creating database</b></p>';
247
            }
248
249
            // Load parameters from existing configuration
250
            $databaseConfig = DB::getConfig();
251
            if (empty($databaseConfig) && empty($_REQUEST['db'])) {
252
                throw new BadMethodCallException("No database configuration available");
253
            }
254
            $parameters = (!empty($databaseConfig)) ? $databaseConfig : $_REQUEST['db'];
255
256
            // Check database name is given
257
            if (empty($parameters['database'])) {
258
                throw new BadMethodCallException(
259
                    "No database name given; please give a value for SS_DATABASE_NAME or set SS_DATABASE_CHOOSE_NAME"
260
                );
261
            }
262
            $database = $parameters['database'];
263
264
            // Establish connection and create database in two steps
265
            unset($parameters['database']);
266
            DB::connect($parameters);
267
            DB::create_database($database);
268
        }
269
270
        // Build the database.  Most of the hard work is handled by DataObject
271
        $dataClasses = ClassInfo::subclassesFor(DataObject::class);
272
        array_shift($dataClasses);
273
274
        if (!$quiet) {
275
            if (Director::is_cli()) {
276
                echo "\nCREATING DATABASE TABLES\n\n";
277
            } else {
278
                echo "\n<p><b>Creating database tables</b></p><ul>\n\n";
279
            }
280
        }
281
282
        $showRecordCounts = (boolean)$this->config()->show_record_counts;
0 ignored issues
show
Bug Best Practice introduced by
The property show_record_counts does not exist on SilverStripe\Core\Config\Config_ForClass. Since you implemented __get, consider adding a @property annotation.
Loading history...
283
284
        // Initiate schema update
285
        $dbSchema = DB::get_schema();
286
        $dbSchema->schemaUpdate(function () use ($dataClasses, $testMode, $quiet, $showRecordCounts) {
287
            $dataObjectSchema = DataObject::getSchema();
288
289
            foreach ($dataClasses as $dataClass) {
290
                // Check if class exists before trying to instantiate - this sidesteps any manifest weirdness
291
                if (!class_exists($dataClass)) {
292
                    continue;
293
                }
294
295
                // Check if this class should be excluded as per testing conventions
296
                $SNG = singleton($dataClass);
297
                if (!$testMode && $SNG instanceof TestOnly) {
298
                    continue;
299
                }
300
                $tableName = $dataObjectSchema->tableName($dataClass);
301
302
                // Log data
303
                if (!$quiet) {
304
                    if ($showRecordCounts && DB::get_schema()->hasTable($tableName)) {
305
                        try {
306
                            $count = DB::query("SELECT COUNT(*) FROM \"$tableName\"")->value();
307
                            $countSuffix = " ($count records)";
308
                        } catch (Exception $e) {
0 ignored issues
show
Bug introduced by
The type SilverStripe\ORM\Exception was not found. Did you mean Exception? If so, make sure to prefix the type with \.
Loading history...
309
                            $countSuffix = " (error getting record count)";
310
                        }
311
                    } else {
312
                        $countSuffix = "";
313
                    }
314
315
                    if (Director::is_cli()) {
316
                        echo " * $tableName$countSuffix\n";
317
                    } else {
318
                        echo "<li>$tableName$countSuffix</li>\n";
319
                    }
320
                }
321
322
                // Instruct the class to apply its schema to the database
323
                $SNG->requireTable();
324
            }
325
        });
326
        ClassInfo::reset_db_cache();
327
328
        if (!$quiet && !Director::is_cli()) {
329
            echo "</ul>";
330
        }
331
332
        if ($populate) {
333
            if (!$quiet) {
334
                if (Director::is_cli()) {
335
                    echo "\nCREATING DATABASE RECORDS\n\n";
336
                } else {
337
                    echo "\n<p><b>Creating database records</b></p><ul>\n\n";
338
                }
339
            }
340
341
            // Remap obsolete class names
342
            $remappingConfig = $this->config()->get('classname_value_remapping');
343
            $remappingFields = $this->getClassNameRemappingFields();
344
            foreach ($remappingFields as $className => $fieldNames) {
345
                foreach ($fieldNames as $fieldName) {
346
                    foreach ($remappingConfig as $oldClassName => $newClassName) {
347
                        $this->updateLegacyClassNames($className, $fieldName, $oldClassName, $newClassName);
348
                    }
349
                }
350
            }
351
352
            // Require all default records
353
            foreach ($dataClasses as $dataClass) {
354
                // Check if class exists before trying to instantiate - this sidesteps any manifest weirdness
355
                // Test_ indicates that it's the data class is part of testing system
356
                if (strpos($dataClass, 'Test_') === false && class_exists($dataClass)) {
357
                    if (!$quiet) {
358
                        if (Director::is_cli()) {
359
                            echo " * $dataClass\n";
360
                        } else {
361
                            echo "<li>$dataClass</li>\n";
362
                        }
363
                    }
364
365
                    DataObject::singleton($dataClass)->requireDefaultRecords();
366
                }
367
            }
368
369
            if (!$quiet && !Director::is_cli()) {
370
                echo "</ul>";
371
            }
372
        }
373
374
        touch(TEMP_PATH
375
            . DIRECTORY_SEPARATOR
376
            . 'database-last-generated-'
377
            . str_replace(array('\\', '/', ':'), '.', Director::baseFolder()));
378
379
        if (isset($_REQUEST['from_installer'])) {
380
            echo "OK";
381
        }
382
383
        if (!$quiet) {
384
            echo (Director::is_cli()) ? "\n Database build completed!\n\n" :"<p>Database build completed!</p>";
385
        }
386
387
        ClassInfo::reset_db_cache();
388
    }
389
390
    /**
391
     * Given a base data class, a field name and an old and new class name (value), look for obsolete ($oldClassName)
392
     * values in the $dataClass's $fieldName column and replace it with $newClassName.
393
     *
394
     * @param string $dataClass    The data class to look up
395
     * @param string $fieldName    The field name to look in for obsolete class names
396
     * @param string $oldClassName The old class name
397
     * @param string $newClassName The new class name
398
     */
399
    protected function updateLegacyClassNames($dataClass, $fieldName, $oldClassName, $newClassName)
400
    {
401
        $schema = DataObject::getSchema();
402
        // Check first to ensure that the class has the specified field to update
403
        if (!$schema->databaseField($dataClass, $fieldName, false)) {
404
            return;
405
        }
406
407
        // Load a list of any records that have obsolete class names
408
        $badRecordCount = DataObject::get($dataClass)
409
            ->filter([$fieldName => $oldClassName])
410
            ->count();
411
412
        if (!$badRecordCount) {
413
            return;
414
        }
415
416
        if (Director::is_cli()) {
417
            echo " * Correcting {$badRecordCount} obsolete {$fieldName} values for {$newClassName}\n";
418
        } else {
419
            echo "<li>Correcting {$badRecordCount} obsolete {$fieldName} values for {$newClassName}</li>\n";
420
        }
421
        $table = $schema->tableName($dataClass);
422
423
        $updateQuery = "UPDATE \"{$table}%s\" SET \"{$fieldName}\" = ? WHERE \"{$fieldName}\" = ?";
424
        $updateQueries = [sprintf($updateQuery, '')];
425
426
        // Remap versioned table class name values as well
427
        /** @var Versioned|DataObject $class */
428
        $class = DataObject::singleton($dataClass);
429
        if ($class->hasExtension(Versioned::class)) {
0 ignored issues
show
Bug introduced by
The method hasExtension() does not exist on SilverStripe\Versioned\Versioned. ( Ignorable by Annotation )

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

429
        if ($class->/** @scrutinizer ignore-call */ hasExtension(Versioned::class)) {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
430
            if ($class->hasStages()) {
0 ignored issues
show
Bug introduced by
The method hasStages() does not exist on SilverStripe\ORM\DataObject. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

430
            if ($class->/** @scrutinizer ignore-call */ hasStages()) {
Loading history...
431
                $updateQueries[] = sprintf($updateQuery, '_Live');
432
            }
433
            $updateQueries[] = sprintf($updateQuery, '_Versions');
434
        }
435
436
        foreach ($updateQueries as $query) {
437
            DB::prepared_query($query, [$newClassName, $oldClassName]);
438
        }
439
    }
440
441
    /**
442
     * Find all DBClassName fields on valid subclasses of DataObject that should be remapped. This includes
443
     * `ClassName` fields as well as polymorphic class name fields.
444
     *
445
     * @return array[]
446
     */
447
    protected function getClassNameRemappingFields()
448
    {
449
        $dataClasses = ClassInfo::getValidSubClasses(DataObject::class);
450
        $schema = DataObject::getSchema();
451
        $remapping = [];
452
453
        foreach ($dataClasses as $className) {
454
            $fieldSpecs = $schema->fieldSpecs($className);
455
            foreach ($fieldSpecs as $fieldName => $fieldSpec) {
456
                if (Injector::inst()->create($fieldSpec, 'Dummy') instanceof DBClassName) {
457
                    $remapping[$className][] = $fieldName;
458
                }
459
            }
460
        }
461
462
        return $remapping;
463
    }
464
465
    /**
466
     * Remove invalid records from tables - that is, records that don't have
467
     * corresponding records in their parent class tables.
468
     */
469
    public function cleanup()
470
    {
471
        $baseClasses = [];
472
        foreach (ClassInfo::subclassesFor(DataObject::class) as $class) {
473
            if (get_parent_class($class) == DataObject::class) {
474
                $baseClasses[] = $class;
475
            }
476
        }
477
478
        $schema = DataObject::getSchema();
479
        foreach ($baseClasses as $baseClass) {
480
            // Get data classes
481
            $baseTable = $schema->baseDataTable($baseClass);
482
            $subclasses = ClassInfo::subclassesFor($baseClass);
483
            unset($subclasses[0]);
484
            foreach ($subclasses as $k => $subclass) {
485
                if (!DataObject::getSchema()->classHasTable($subclass)) {
486
                    unset($subclasses[$k]);
487
                }
488
            }
489
490
            if ($subclasses) {
491
                $records = DB::query("SELECT * FROM \"$baseTable\"");
492
493
494
                foreach ($subclasses as $subclass) {
495
                    $subclassTable = $schema->tableName($subclass);
496
                    $recordExists[$subclass] =
497
                        DB::query("SELECT \"ID\" FROM \"$subclassTable\"")->keyedColumn();
498
                }
499
500
                foreach ($records as $record) {
501
                    foreach ($subclasses as $subclass) {
502
                        $subclassTable = $schema->tableName($subclass);
503
                        $id = $record['ID'];
504
                        if (($record['ClassName'] != $subclass)
505
                            && (!is_subclass_of($record['ClassName'], $subclass))
506
                            && isset($recordExists[$subclass][$id])
507
                        ) {
508
                            $sql = "DELETE FROM \"$subclassTable\" WHERE \"ID\" = ?";
509
                            echo "<li>$sql [{$id}]</li>";
510
                            DB::prepared_query($sql, [$id]);
511
                        }
512
                    }
513
                }
514
            }
515
        }
516
    }
517
}
518