Passed
Pull Request — 4 (#9735)
by Steve
08:57
created

StandardRelatedDataService   F

Complexity

Total Complexity 74

Size/Duplication

Total Lines 479
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 170
c 1
b 0
f 0
dl 0
loc 479
rs 2.48
wmc 74

16 Methods

Rating   Name   Duplication   Size   Complexity  
A addRelatedReverseManyManys() 0 16 4
B addResultsToClassIDs() 0 19 10
B fetchManyManyResults() 0 49 11
A addRelatedManyManys() 0 17 4
A findTableNameContainingComponentIDField() 0 13 3
A addRelatedReverseHasOnes() 0 8 3
A deriveJoinTableName() 0 11 4
A deriveList() 0 14 4
A removeClasses() 0 5 4
A initClassToTableName() 0 11 3
A updateComponentClass() 0 10 2
A fetchClassObjs() 0 11 3
A addRelatedHasOnes() 0 39 6
B findAll() 0 37 6
A prepareClassNameLiteral() 0 10 2
A fetchReverseHasOneResults() 0 31 5

How to fix   Complexity   

Complex Class

Complex classes like StandardRelatedDataService often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use StandardRelatedDataService, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace SilverStripe\ORM\RelatedData;
4
5
use ReflectionException;
6
use SilverStripe\Core\ClassInfo;
7
use SilverStripe\Core\Config\Config;
8
use SilverStripe\ORM\DB;
9
use SilverStripe\ORM\SS_List;
10
use SilverStripe\ORM\ArrayList;
11
use SilverStripe\ORM\Connect\Query;
12
use SilverStripe\ORM\DataObject;
13
use SilverStripe\ORM\DataObjectSchema;
14
use SilverStripe\ORM\Queries\SQLSelect;
15
16
/**
17
 * Service class used to find all other DataObject instances that are related to a DataObject instance
18
 * in the database
19
 *
20
 * Example demonstrating what '$component' and '$componentClassName' variables refer to:
21
 * PHP model: private static $has_one = [ 'MyFile' => File::class ]
22
 * - $component: 'MyFile'
23
 * - $componentClassName: SilverStripe\Assets\File::class
24
 *
25
 * @internal
26
 */
27
class StandardRelatedDataService implements RelatedDataService
28
{
29
30
    /**
31
     * Used to prevent duplicate database queries
32
     *
33
     * @var array
34
     */
35
    private $queryIdens = [];
36
37
    /**
38
     * @var array
39
     */
40
    private $config;
41
42
    /**
43
     * @var DataObjectSchema
44
     */
45
    private $dataObjectSchema;
46
47
    /**
48
     * @var array
49
     */
50
    private $classToTableName;
51
52
    /**
53
     * Find all DataObject instances that have a linked relationship with $record
54
     *
55
     * @param DataObject $record
56
     * @param string[] $excludedClasses
57
     * @return SS_List
58
     */
59
    public function findAll(DataObject $record, array $excludedClasses = []): SS_List
60
    {
61
        // Do not query unsaved DataObjects
62
        if (!$record->exists()) {
63
            return ArrayList::create();
64
        }
65
66
        $this->config = Config::inst()->getAll();
67
        $this->dataObjectSchema = DataObjectSchema::create();
68
        $this->initClassToTableName();
69
        $classIDs = [];
70
        $throughClasses = [];
71
72
        // "regular" relations i.e. point from $record to different DataObject
73
        $this->addRelatedHasOnes($classIDs, $record);
74
        $this->addRelatedManyManys($classIDs, $record, $throughClasses);
75
76
        // Loop config data to find "reverse" relationships pointing back to $record
77
        foreach (array_keys($this->config) as $lowercaseClassName) {
78
            if (!class_exists($lowercaseClassName)) {
79
                continue;
80
            }
81
            // Example of $class: My\App\MyPage (extends SiteTree)
82
            try {
83
                $class = ClassInfo::class_name($lowercaseClassName);
84
            } catch (ReflectionException $e) {
85
                continue;
86
            }
87
            if (!is_subclass_of($class, DataObject::class)) {
88
                continue;
89
            }
90
            $this->addRelatedReverseHasOnes($classIDs, $record, $class);
91
            $this->addRelatedReverseManyManys($classIDs, $record, $class, $throughClasses);
92
        }
93
        $this->removeClasses($classIDs, $excludedClasses, $throughClasses);
94
        $classObjs = $this->fetchClassObjs($classIDs);
95
        return $this->deriveList($classIDs, $classObjs);
96
    }
97
98
    /**
99
     * Loop has_one relationships on the DataObject we're getting usage for
100
     * e.g. File.has_one = Page, Page.has_many = File
101
     *
102
     * @param array $classIDs
103
     * @param DataObject $record
104
     */
105
    private function addRelatedHasOnes(array &$classIDs, DataObject $record): void
106
    {
107
        $class = get_class($record);
108
        foreach ($record->hasOne() as $component => $componentClass) {
109
            $componentIDField = "{$component}ID";
110
            $tableName = $this->findTableNameContainingComponentIDField($class, $componentIDField);
111
            if ($tableName === '') {
112
                continue;
113
            }
114
115
            $select = sprintf('"%s"', $componentIDField);
116
            $where = sprintf('"ID" = %u AND "%s" > 0', $record->ID, $componentIDField);
117
118
            // Polymorphic
119
            // $record->ParentClass will return null if the column doesn't exist
120
            if ($componentIDField === 'ParentID' && $record->ParentClass) {
0 ignored issues
show
Bug Best Practice introduced by
The property ParentClass does not exist on SilverStripe\ORM\DataObject. Since you implemented __get, consider adding a @property annotation.
Loading history...
121
                $select .= ', "ParentClass"';
122
            }
123
124
            // Prevent duplicate counting of self-referential relations
125
            // The relation will still be fetched by $this::fetchReverseHasOneResults()
126
            if ($record instanceof $componentClass) {
127
                $where .= sprintf(' AND "%s" != %u', $componentIDField, $record->ID);
128
            }
129
130
            // Example SQL:
131
            // Normal:
132
            //   SELECT "MyPageID" FROM "MyFile" WHERE "ID" = 789 AND "MyPageID" > 0;
133
            // Prevent self-referential e.g. File querying File:
134
            //   SELECT "MyFileSubClassID" FROM "MyFile" WHERE "ID" = 456
135
            //      AND "MyFileSubClassID" > 0 AND MyFileSubClassID != 456;
136
            // Polymorphic:
137
            //   SELECT "ParentID", "ParentClass" FROM "MyFile" WHERE "ID" = 789 AND "ParentID" > 0;
138
            $results = SQLSelect::create(
139
                $select,
140
                sprintf('"%s"', $tableName),
141
                $where
142
            )->execute();
143
            $this->addResultsToClassIDs($classIDs, $results, $componentClass);
144
        }
145
    }
146
147
    /**
148
     * Find the table that contains $componentIDField - this is relevant for subclassed DataObjects
149
     * that live in the database as two tables that are joined together
150
     *
151
     * @param string $class
152
     * @param string $componentIDField
153
     * @return string
154
     */
155
    private function findTableNameContainingComponentIDField(string $class, string $componentIDField): string
156
    {
157
        $tableName = '';
158
        $candidateClass = $class;
159
        while ($candidateClass) {
160
            $dbFields = $this->dataObjectSchema->databaseFields($candidateClass, false);
161
            if (array_key_exists($componentIDField, $dbFields)) {
162
                $tableName = $this->dataObjectSchema->tableName($candidateClass);
163
                break;
164
            }
165
            $candidateClass = get_parent_class($class);
166
        }
167
        return $tableName;
168
    }
169
170
    /**
171
     * Loop many_many relationships on the DataObject we're getting usage for
172
     *
173
     * @param array $classIDs
174
     * @param DataObject $record
175
     * @param string[] $throughClasses
176
     */
177
    private function addRelatedManyManys(array &$classIDs, DataObject $record, array &$throughClasses): void
178
    {
179
        $class = get_class($record);
180
        foreach ($record->manyMany() as $component => $componentClass) {
181
            $componentClass = $this->updateComponentClass($componentClass, $throughClasses);
182
            if (
183
                // Ignore belongs_many_many_through with dot syntax
184
                strpos($componentClass, '.') !== false ||
185
                // Prevent duplicate counting of self-referential relations e.g.
186
                // MyFile::$many_many = [ 'MyFile' => MyFile::class ]
187
                // This relation will still be counted in $this::addRelatedReverseManyManys()
188
                $record instanceof $componentClass
189
            ) {
190
                continue;
191
            }
192
            $results = $this->fetchManyManyResults($record, $class, $component, false);
193
            $this->addResultsToClassIDs($classIDs, $results, $componentClass);
194
        }
195
    }
196
197
    /**
198
     * Query the database to retrieve many-many results
199
     *
200
     * @param DataObject $record - The DataObject whose usage data is being retrieved, usually a File
201
     * @param string $class - example: My\App\SomePageType
202
     * @param string $component - example: 'SomeFiles' - My\App\SomePageType::SomeFiles()
203
     * @param bool $reverse - true: SomePage::SomeFiles(), false: SomeFile::SomePages()
204
     * @return Query|null
205
     */
206
    private function fetchManyManyResults(
207
        DataObject $record,
208
        string $class,
209
        string $component,
210
        bool $reverse
211
    ): ?Query {
212
        // Example php file: class MyPage ... private static $many_many = [ 'MyFile' => File::class ]
213
        $data = $this->dataObjectSchema->manyManyComponent($class, $component);
214
        if (!$data || !($data['join'] ?? false)) {
215
            return null;
216
        }
217
        $joinTableName = $this->deriveJoinTableName($data);
218
        if (!ClassInfo::hasTable($joinTableName)) {
219
            return null;
220
        }
221
        $usesThroughTable = $data['join'] != $joinTableName;
222
223
        $parentField = preg_replace('#ID$#', '', $data['parentField']) . 'ID';
224
        $childField = preg_replace('#ID$#', '', $data['childField']) . 'ID';
225
        $selectField = !$reverse ? $childField : $parentField;
226
        $selectFields = [$selectField];
227
        $whereField = !$reverse ? $parentField : $childField;
228
229
        // Support for polymorphic through objects such FileLink that allow for multiple class types on one side e.g.
230
        // ParentID: int, ParentClass: enum('File::class, SiteTree::class, ElementContent::class, ...')
231
        if ($usesThroughTable) {
232
            $dbFields = $this->dataObjectSchema->databaseFields($data['join']);
233
            if ($parentField === 'ParentID' && isset($dbFields['ParentClass'])) {
234
                $selectFields[] = 'ParentClass';
235
                if (!$reverse) {
236
                    return null;
237
                }
238
            }
239
        }
240
241
        // Prevent duplicate queries which can happen when an Image is inserted on a Page subclass via TinyMCE
242
        // and FileLink will make the same query multiple times for all the different page subclasses because
243
        // the FileLink is associated with the Base Page class database table
244
        $queryIden = implode('-', array_merge($selectFields, [$joinTableName, $whereField, $record->ID]));
245
        if (array_key_exists($queryIden, $this->queryIdens)) {
246
            return null;
247
        }
248
        $this->queryIdens[$queryIden] = true;
249
250
        return SQLSelect::create(
251
            sprintf('"' . implode('", "', $selectFields) . '"'),
252
            sprintf('"%s"', $joinTableName),
253
            sprintf('"%s" = %u', $whereField, $record->ID)
254
        )->execute();
255
    }
256
257
    /**
258
     * Contains special logic for some many_many_through relationships
259
     * $joinTableName, instead of the name of the join table, it will be a namespaced classname
260
     * Example $class: SilverStripe\Assets\Shortcodes\FileLinkTracking
261
     * Example $joinTableName: SilverStripe\Assets\Shortcodes\FileLink
262
     *
263
     * @param array $data
264
     * @return string
265
     */
266
    private function deriveJoinTableName(array $data): string
267
    {
268
        $joinTableName = $data['join'];
269
        if (!ClassInfo::hasTable($joinTableName) && class_exists($joinTableName)) {
270
            $class = $joinTableName;
271
            if (!isset($this->classToTableName[$class])) {
272
                return null;
273
            }
274
            $joinTableName = $this->classToTableName[$class];
275
        }
276
        return $joinTableName;
277
    }
278
279
    /**
280
     * @param array $classIDs
281
     * @param DataObject $record
282
     * @param string $class
283
     */
284
    private function addRelatedReverseHasOnes(array &$classIDs, DataObject $record, string $class): void
285
    {
286
        foreach (singleton($class)->hasOne() as $component => $componentClass) {
287
            if (!($record instanceof $componentClass)) {
288
                continue;
289
            }
290
            $results = $this->fetchReverseHasOneResults($record, $class, $component);
291
            $this->addResultsToClassIDs($classIDs, $results, $class);
292
        }
293
    }
294
295
    /**
296
     * Query the database to retrieve has_one results
297
     *
298
     * @param DataObject $record - The DataObject whose usage data is being retrieved, usually a File
299
     * @param string $class - Name of class with the relation to record
300
     * @param string $component - Name of relation to `$record` on `$class`
301
     * @return Query|null
302
     */
303
    private function fetchReverseHasOneResults(DataObject $record, string $class, string $component): ?Query
304
    {
305
        // Ensure table exists, this is required for TestOnly SapphireTest classes
306
        if (!isset($this->classToTableName[$class])) {
307
            return null;
308
        }
309
        $componentIDField = "{$component}ID";
310
311
        // Only get database fields from the current class model, not parent class model
312
        $dbFields = $this->dataObjectSchema->databaseFields($class, false);
313
        if (!isset($dbFields[$componentIDField])) {
314
            return null;
315
        }
316
        $tableName = $this->dataObjectSchema->tableName($class);
317
        $where = sprintf('"%s" = %u', $componentIDField, $record->ID);
318
319
        // Polymorphic
320
        if ($componentIDField === 'ParentID' && isset($dbFields['ParentClass'])) {
321
            $where .= sprintf(' AND "ParentClass" = %s', $this->prepareClassNameLiteral(get_class($record)));
322
        }
323
324
        // Example SQL:
325
        // Normal:
326
        //   SELECT "ID" FROM "MyPage" WHERE "MyFileID" = 123;
327
        // Polymorphic:
328
        //   SELECT "ID" FROM "MyPage" WHERE "ParentID" = 456 AND "ParentClass" = 'MyFile';
329
        return SQLSelect::create(
330
            '"ID"',
331
            sprintf('"%s"', $tableName),
332
            $where
333
        )->execute();
334
    }
335
336
    /**
337
     * @param array $classIDs
338
     * @param DataObject $record
339
     * @param string $class
340
     * @param string[] $throughClasses
341
     */
342
    private function addRelatedReverseManyManys(
343
        array &$classIDs,
344
        DataObject $record,
345
        string $class,
346
        array &$throughClasses
347
    ): void {
348
        foreach (singleton($class)->manyMany() as $component => $componentClass) {
349
            $componentClass = $this->updateComponentClass($componentClass, $throughClasses);
350
            if (!($record instanceof $componentClass) ||
351
                // Ignore belongs_many_many_through with dot syntax
352
                strpos($componentClass, '.') !== false
353
            ) {
354
                continue;
355
            }
356
            $results = $this->fetchManyManyResults($record, $class, $component, true);
357
            $this->addResultsToClassIDs($classIDs, $results, $class);
358
        }
359
    }
360
361
    /**
362
     * Update the `$classIDs` array with the relationship IDs from database `$results`
363
     *
364
     * @param array $classIDs
365
     * @param Query|null $results
366
     * @param string $class
367
     */
368
    private function addResultsToClassIDs(array &$classIDs, ?Query $results, string $class): void
369
    {
370
        if (is_null($results) || (!is_subclass_of($class, DataObject::class) && $class !== DataObject::class)) {
371
            return;
372
        }
373
        foreach ($results as $row) {
374
            if (count(array_keys($row)) === 2 && isset($row['ParentClass']) && isset($row['ParentID'])) {
375
                // Example $class: SilverStripe\Assets\Shortcodes\FileLinkTracking
376
                // Example $parentClass: Page
377
                $parentClass = $row['ParentClass'];
378
                $classIDs[$parentClass] = $classIDs[$parentClass] ?? [];
379
                $classIDs[$parentClass][] = $row['ParentID'];
380
            } else {
381
                if ($class === DataObject::class) {
382
                    continue;
383
                }
384
                foreach (array_values($row) as $classID) {
385
                    $classIDs[$class] = $classIDs[$class] ?? [];
386
                    $classIDs[$class][] = $classID;
387
                }
388
            }
389
        }
390
    }
391
392
    /**
393
     * Prepare an FQCN literal for database querying so that backslashes are escaped properly
394
     *
395
     * @param string $value
396
     * @return string
397
     */
398
    private function prepareClassNameLiteral(string $value): string
399
    {
400
        $c = chr(92);
401
        $escaped = str_replace($c, "{$c}{$c}", $value);
402
        // postgres
403
        if (stripos(get_class(DB::get_conn()), 'postgres') !== false) {
404
            return "E'{$escaped}'";
405
        }
406
        // mysql
407
        return "'{$escaped}'";
408
    }
409
410
    /**
411
     * Convert a many_many_through $componentClass array to the 'to' component on the 'through' object
412
     * If $componentClass represents a through object, then also update the $throughClasses array
413
     *
414
     * @param string|array $componentClass
415
     * @param string[] $throughClasses
416
     * @return string
417
     */
418
    private function updateComponentClass($componentClass, array &$throughClasses): string
419
    {
420
        if (!is_array($componentClass)) {
421
            return $componentClass;
422
        }
423
        $throughClass = $componentClass['through'];
424
        $throughClasses[$throughClass] = true;
425
        $lowercaseThroughClass = strtolower($throughClass);
426
        $toComponent = $componentClass['to'];
427
        return $this->config[$lowercaseThroughClass]['has_one'][$toComponent];
428
    }
429
430
    /**
431
     * Setup function to fix unit test specific issue
432
     */
433
    private function initClassToTableName(): void
434
    {
435
        $this->classToTableName = $this->dataObjectSchema->getTableNames();
436
437
        // Fix issue that only happens when unit-testing via SapphireTest
438
        // TestOnly class tables are only created if they're defined in SapphireTest::$extra_dataobject
439
        // This means there's a large number of TestOnly classes, unrelated to the UsedOnTable, that
440
        // do not have tables.  Remove these table-less classes from $classToTableName.
441
        foreach ($this->classToTableName as $class => $tableName) {
442
            if (!ClassInfo::hasTable($tableName)) {
443
                unset($this->classToTableName[$class]);
444
            }
445
        }
446
    }
447
448
    /**
449
     * Remove classes excluded via Extensions
450
     * Remove "through" classes used in many-many relationships
451
     *
452
     * @param array $classIDs
453
     * @param string[] $excludedClasses
454
     * @param string[] $throughClasses
455
     */
456
    private function removeClasses(array &$classIDs, array $excludedClasses, array $throughClasses): void
457
    {
458
        foreach (array_keys($classIDs) as $class) {
459
            if (isset($throughClasses[$class]) || in_array($class, $excludedClasses)) {
460
                unset($classIDs[$class]);
461
            }
462
        }
463
    }
464
465
    /**
466
     * Fetch all objects of a class in a single query for better performance
467
     *
468
     * @param array $classIDs
469
     * @return array
470
     */
471
    private function fetchClassObjs(array $classIDs): array
472
    {
473
        /** @var DataObject $class */
474
        $classObjs = [];
475
        foreach ($classIDs as $class => $ids) {
476
            $classObjs[$class] = [];
477
            foreach ($class::get()->filter('ID', $ids) as $obj) {
478
                $classObjs[$class][$obj->ID] = $obj;
479
            }
480
        }
481
        return $classObjs;
482
    }
483
484
    /**
485
     * Returned ArrayList can have multiple entries for the same DataObject
486
     * For example, the File is used multiple times on a single Page
487
     *
488
     * @param array $classIDs
489
     * @param array $classObjs
490
     * @return ArrayList
491
     */
492
    private function deriveList(array $classIDs, array $classObjs): ArrayList
493
    {
494
        $list = ArrayList::create();
495
        foreach ($classIDs as $class => $ids) {
496
            foreach ($ids as $id) {
497
                // Ensure the $classObj exists, this is to cover an edge case where there is an orphaned
498
                // many-many join table database record with no corresponding DataObject database record
499
                if (!isset($classObjs[$class][$id])) {
500
                    continue;
501
                }
502
                $list->push($classObjs[$class][$id]);
503
            }
504
        }
505
        return $list;
506
    }
507
}
508