Completed
Push — 4.0 ( 3178fb...dd3379 )
by Maxime
47s queued 18s
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
    protected function init()
54
    {
55
        parent::init();
56
57
        // We allow access to this controller regardless of live-status or ADMIN permission only
58
        // if on CLI or with the database not ready. The latter makes it less errorprone to do an
59
        // initial schema build without requiring a default-admin login.
60
        // Access to this controller is always allowed in "dev-mode", or of the user is ADMIN.
61
        $allowAllCLI = DevelopmentAdmin::config()->get('allow_all_cli');
62
        $canAccess = (
63
            Director::isDev()
64
            || !Security::database_is_ready()
65
            // We need to ensure that DevelopmentAdminTest can simulate permission failures when running
66
            // "dev/tests" from CLI.
67
            || (Director::is_cli() && $allowAllCLI)
68
            || Permission::check("ADMIN")
69
        );
70
        if (!$canAccess) {
71
            Security::permissionFailure(
72
                $this,
73
                "This page is secured and you need administrator rights to access it. " .
74
                "Enter your credentials below and we will send you right along."
75
            );
76
        }
77
    }
78
79
    /**
80
     * Get the data classes, grouped by their root class
81
     *
82
     * @return array Array of data classes, grouped by their root class
83
     */
84
    public function groupedDataClasses()
85
    {
86
        // Get all root data objects
87
        $allClasses = get_declared_classes();
88
        $rootClasses = [];
89
        foreach ($allClasses as $class) {
90
            if (get_parent_class($class) == DataObject::class) {
91
                $rootClasses[$class] = array();
92
            }
93
        }
94
95
        // Assign every other data object one of those
96
        foreach ($allClasses as $class) {
97
            if (!isset($rootClasses[$class]) && is_subclass_of($class, DataObject::class)) {
98
                foreach ($rootClasses as $rootClass => $dummy) {
99
                    if (is_subclass_of($class, $rootClass)) {
100
                        $rootClasses[$rootClass][] = $class;
101
                        break;
102
                    }
103
                }
104
            }
105
        }
106
        return $rootClasses;
107
    }
108
109
110
    /**
111
     * When we're called as /dev/build, that's actually the index. Do the same
112
     * as /dev/build/build.
113
     */
114
    public function index()
115
    {
116
        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...
117
    }
118
119
    /**
120
     * Updates the database schema, creating tables & fields as necessary.
121
     */
122
    public function build()
123
    {
124
        // The default time limit of 30 seconds is normally not enough
125
        Environment::increaseTimeLimitTo(600);
126
127
        // If this code is being run outside of a dev/build or without a ?flush query string param,
128
        // the class manifest hasn't been flushed, so do it here
129
        $request = $this->getRequest();
130
        if (!array_key_exists('flush', $request->getVars()) && strpos($request->getURL(), 'dev/build') !== 0) {
131
            ClassLoader::inst()->getManifest()->regenerate(false);
132
        }
133
134
        $url = $this->getReturnURL();
135
        if ($url) {
136
            echo "<p>Setting up the database; you will be returned to your site shortly....</p>";
137
            $this->doBuild(true);
138
            echo "<p>Done!</p>";
139
            $this->redirect($url);
140
        } else {
141
            $quiet = $this->request->requestVar('quiet') !== null;
142
            $fromInstaller = $this->request->requestVar('from_installer') !== null;
143
            $populate = $this->request->requestVar('dont_populate') === null;
144
            $this->doBuild($quiet || $fromInstaller, $populate);
145
        }
146
    }
147
148
    /**
149
     * Gets the url to return to after build
150
     *
151
     * @return string|null
152
     */
153
    protected function getReturnURL()
154
    {
155
        $url = $this->request->getVar('returnURL');
156
157
        // Check that this url is a site url
158
        if (empty($url) || !Director::is_site_url($url)) {
159
            return null;
160
        }
161
162
        // Convert to absolute URL
163
        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

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

411
        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...
412
            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

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