Passed
Push — 4 ( 460715...d76dd2 )
by Guy
15:51 queued 09:03
created

DatabaseAdmin::updateLegacyClassNames()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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

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

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
444
            return;
445
        }
446
447
        $numberClasses = count($invalidClasses);
448
        DB::alteration_message(
449
            "Correcting obsolete {$fieldName} values for {$numberClasses} outdated types",
450
            'obsolete'
451
        );
452
453
        // Build case assignment based on all intersected legacy classnames
454
        $cases = [];
455
        $params = [];
456
        foreach ($invalidClasses as $invalidClass) {
457
            $cases[] = "WHEN \"{$fieldName}\" = ? THEN ?";
458
            $params[] = $invalidClass;
459
            $params[] = $mapping[$invalidClass];
460
        }
461
462
        foreach ($this->getClassTables($dataClass) as $table) {
463
            $casesSQL = implode(' ', $cases);
464
            $sql = "UPDATE \"{$table}\" SET \"{$fieldName}\" = CASE {$casesSQL} ELSE \"{$fieldName}\" END";
465
            DB::prepared_query($sql, $params);
466
        }
467
    }
468
469
    /**
470
     * Get tables to update for this class
471
     *
472
     * @param string $dataClass
473
     * @return Generator|string[]
474
     */
475
    protected function getClassTables($dataClass)
476
    {
477
        $schema = DataObject::getSchema();
478
        $table = $schema->tableName($dataClass);
479
480
        // Base table
481
        yield $table;
482
483
        // Remap versioned table class name values as well
484
        /** @var Versioned|DataObject $dataClass */
485
        $dataClass = DataObject::singleton($dataClass);
0 ignored issues
show
Bug introduced by
It seems like $dataClass can also be of type SilverStripe\Versioned\Versioned; however, parameter $class of SilverStripe\View\ViewableData::singleton() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

485
        $dataClass = DataObject::singleton(/** @scrutinizer ignore-type */ $dataClass);
Loading history...
486
        if ($dataClass->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

486
        if ($dataClass->/** @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...
487
            if ($dataClass->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

487
            if ($dataClass->/** @scrutinizer ignore-call */ hasStages()) {
Loading history...
488
                yield "{$table}_Live";
489
            }
490
            yield "{$table}_Versions";
491
        }
492
    }
493
494
    /**
495
     * Find all DBClassName fields on valid subclasses of DataObject that should be remapped. This includes
496
     * `ClassName` fields as well as polymorphic class name fields.
497
     *
498
     * @return array[]
499
     */
500
    protected function getClassNameRemappingFields()
501
    {
502
        $dataClasses = ClassInfo::getValidSubClasses(DataObject::class);
503
        $schema = DataObject::getSchema();
504
        $remapping = [];
505
506
        foreach ($dataClasses as $className) {
507
            $fieldSpecs = $schema->fieldSpecs($className);
508
            foreach ($fieldSpecs as $fieldName => $fieldSpec) {
509
                if (Injector::inst()->create($fieldSpec, 'Dummy') instanceof DBClassName) {
510
                    $remapping[$className][] = $fieldName;
511
                }
512
            }
513
        }
514
515
        return $remapping;
516
    }
517
518
    /**
519
     * Remove invalid records from tables - that is, records that don't have
520
     * corresponding records in their parent class tables.
521
     */
522
    public function cleanup()
523
    {
524
        $baseClasses = [];
525
        foreach (ClassInfo::subclassesFor(DataObject::class) as $class) {
526
            if (get_parent_class($class) == DataObject::class) {
527
                $baseClasses[] = $class;
528
            }
529
        }
530
531
        $schema = DataObject::getSchema();
532
        foreach ($baseClasses as $baseClass) {
533
            // Get data classes
534
            $baseTable = $schema->baseDataTable($baseClass);
535
            $subclasses = ClassInfo::subclassesFor($baseClass);
536
            unset($subclasses[0]);
537
            foreach ($subclasses as $k => $subclass) {
538
                if (!DataObject::getSchema()->classHasTable($subclass)) {
539
                    unset($subclasses[$k]);
540
                }
541
            }
542
543
            if ($subclasses) {
544
                $records = DB::query("SELECT * FROM \"$baseTable\"");
545
546
547
                foreach ($subclasses as $subclass) {
548
                    $subclassTable = $schema->tableName($subclass);
549
                    $recordExists[$subclass] =
550
                        DB::query("SELECT \"ID\" FROM \"$subclassTable\"")->keyedColumn();
551
                }
552
553
                foreach ($records as $record) {
554
                    foreach ($subclasses as $subclass) {
555
                        $subclassTable = $schema->tableName($subclass);
556
                        $id = $record['ID'];
557
                        if (($record['ClassName'] != $subclass)
558
                            && (!is_subclass_of($record['ClassName'], $subclass))
559
                            && isset($recordExists[$subclass][$id])
560
                        ) {
561
                            $sql = "DELETE FROM \"$subclassTable\" WHERE \"ID\" = ?";
562
                            echo "<li>$sql [{$id}]</li>";
563
                            DB::prepared_query($sql, [$id]);
564
                        }
565
                    }
566
                }
567
            }
568
        }
569
    }
570
571
    /**
572
     * Migrate all class names
573
     *
574
     * @todo Migrate to separate build task
575
     */
576
    protected function migrateClassNames()
577
    {
578
        $remappingConfig = $this->config()->get('classname_value_remapping');
579
        $remappingFields = $this->getClassNameRemappingFields();
580
        foreach ($remappingFields as $className => $fieldNames) {
581
            foreach ($fieldNames as $fieldName) {
582
                $this->updateLegacyClassNameField($className, $fieldName, $remappingConfig);
583
            }
584
        }
585
    }
586
}
587