Issues (2882)

src/ORM/DatabaseAdmin.php (7 issues)

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\Connect\DatabaseException;
15
use SilverStripe\ORM\FieldType\DBClassName;
16
use SilverStripe\Security\Permission;
17
use SilverStripe\Security\Security;
18
use SilverStripe\Versioned\Versioned;
19
20
/**
21
 * DatabaseAdmin class
22
 *
23
 * Utility functions for administrating the database. These can be accessed
24
 * via URL, e.g. http://www.yourdomain.com/db/build.
25
 */
26
class DatabaseAdmin extends Controller
27
{
28
29
    /// SECURITY ///
30
    private static $allowed_actions = array(
0 ignored issues
show
The private property $allowed_actions is not used, and could be removed.
Loading history...
31
        'index',
32
        'build',
33
        'cleanup',
34
        'import'
35
    );
36
37
    /**
38
     * Obsolete classname values that should be remapped in dev/build
39
     */
40
    private static $classname_value_remapping = [
0 ignored issues
show
The private property $classname_value_remapping is not used, and could be removed.
Loading history...
41
        'File' => 'SilverStripe\\Assets\\File',
42
        'Image' => 'SilverStripe\\Assets\\Image',
43
        'Folder' => 'SilverStripe\\Assets\\Folder',
44
        'Group' => 'SilverStripe\\Security\\Group',
45
        'LoginAttempt' => 'SilverStripe\\Security\\LoginAttempt',
46
        'Member' => 'SilverStripe\\Security\\Member',
47
        'MemberPassword' => 'SilverStripe\\Security\\MemberPassword',
48
        'Permission' => 'SilverStripe\\Security\\Permission',
49
        'PermissionRole' => 'SilverStripe\\Security\\PermissionRole',
50
        'PermissionRoleCode' => 'SilverStripe\\Security\\PermissionRoleCode',
51
        'RememberLoginHash' => 'SilverStripe\\Security\\RememberLoginHash',
52
    ];
53
54
    /**
55
     * Config setting to enabled/disable the display of record counts on the dev/build output
56
     */
57
    private static $show_record_counts = true;
58
59
    protected function init()
60
    {
61
        parent::init();
62
63
        // We allow access to this controller regardless of live-status or ADMIN permission only
64
        // if on CLI or with the database not ready. The latter makes it less errorprone to do an
65
        // initial schema build without requiring a default-admin login.
66
        // Access to this controller is always allowed in "dev-mode", or of the user is ADMIN.
67
        $allowAllCLI = DevelopmentAdmin::config()->get('allow_all_cli');
68
        $canAccess = (
69
            Director::isDev()
70
            || !Security::database_is_ready()
71
            // We need to ensure that DevelopmentAdminTest can simulate permission failures when running
72
            // "dev/tests" from CLI.
73
            || (Director::is_cli() && $allowAllCLI)
74
            || Permission::check("ADMIN")
75
        );
76
        if (!$canAccess) {
77
            Security::permissionFailure(
78
                $this,
79
                "This page is secured and you need administrator rights to access it. " .
80
                "Enter your credentials below and we will send you right along."
81
            );
82
        }
83
    }
84
85
    /**
86
     * Get the data classes, grouped by their root class
87
     *
88
     * @return array Array of data classes, grouped by their root class
89
     */
90
    public function groupedDataClasses()
91
    {
92
        // Get all root data objects
93
        $allClasses = get_declared_classes();
94
        $rootClasses = [];
95
        foreach ($allClasses as $class) {
96
            if (get_parent_class($class) == DataObject::class) {
97
                $rootClasses[$class] = array();
98
            }
99
        }
100
101
        // Assign every other data object one of those
102
        foreach ($allClasses as $class) {
103
            if (!isset($rootClasses[$class]) && is_subclass_of($class, DataObject::class)) {
104
                foreach ($rootClasses as $rootClass => $dummy) {
105
                    if (is_subclass_of($class, $rootClass)) {
106
                        $rootClasses[$rootClass][] = $class;
107
                        break;
108
                    }
109
                }
110
            }
111
        }
112
        return $rootClasses;
113
    }
114
115
116
    /**
117
     * When we're called as /dev/build, that's actually the index. Do the same
118
     * as /dev/build/build.
119
     */
120
    public function index()
121
    {
122
        return $this->build();
0 ignored issues
show
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...
123
    }
124
125
    /**
126
     * Updates the database schema, creating tables & fields as necessary.
127
     */
128
    public function build()
129
    {
130
        // The default time limit of 30 seconds is normally not enough
131
        Environment::increaseTimeLimitTo(600);
132
133
        // If this code is being run outside of a dev/build or without a ?flush query string param,
134
        // the class manifest hasn't been flushed, so do it here
135
        $request = $this->getRequest();
136
        if (!array_key_exists('flush', $request->getVars()) && strpos($request->getURL(), 'dev/build') !== 0) {
137
            ClassLoader::inst()->getManifest()->regenerate(false);
138
        }
139
140
        $url = $this->getReturnURL();
141
        if ($url) {
142
            echo "<p>Setting up the database; you will be returned to your site shortly....</p>";
143
            $this->doBuild(true);
144
            echo "<p>Done!</p>";
145
            $this->redirect($url);
146
        } else {
147
            $quiet = $this->request->requestVar('quiet') !== null;
148
            $fromInstaller = $this->request->requestVar('from_installer') !== null;
149
            $populate = $this->request->requestVar('dont_populate') === null;
150
            $this->doBuild($quiet || $fromInstaller, $populate);
151
        }
152
    }
153
154
    /**
155
     * Gets the url to return to after build
156
     *
157
     * @return string|null
158
     */
159
    protected function getReturnURL()
160
    {
161
        $url = $this->request->getVar('returnURL');
162
163
        // Check that this url is a site url
164
        if (empty($url) || !Director::is_site_url($url)) {
165
            return null;
166
        }
167
168
        // Convert to absolute URL
169
        return Director::absoluteURL($url, true);
0 ignored issues
show
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

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

446
        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...
447
            if ($class->hasStages()) {
0 ignored issues
show
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

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