Passed
Push — fix-9163 ( 4cfde3...07a516 )
by Ingo
17:14
created

prepareClassNameLiteral()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 5
nc 2
nop 1
dl 0
loc 10
rs 10
c 1
b 0
f 0
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
            // Ignore belongs_many_many_through with dot syntax, AND
183
            // Prevent duplicate counting of self-referential relations e.g.
184
            // MyFile::$many_many = [ 'MyFile' => MyFile::class ]
185
            // This relation will still be counted in $this::addRelatedReverseManyManys()
186
            if (strpos($componentClass, '.') !== false || $record instanceof $componentClass) {
187
                continue;
188
            }
189
            $results = $this->fetchManyManyResults($record, $class, $component, false);
190
            $this->addResultsToClassIDs($classIDs, $results, $componentClass);
191
        }
192
    }
193
194
    /**
195
     * Query the database to retrieve many-many results
196
     *
197
     * @param DataObject $record - The DataObject whose usage data is being retrieved, usually a File
198
     * @param string $class - example: My\App\SomePageType
199
     * @param string $component - example: 'SomeFiles' - My\App\SomePageType::SomeFiles()
200
     * @param bool $reverse - true: SomePage::SomeFiles(), false: SomeFile::SomePages()
201
     * @return Query|null
202
     */
203
    private function fetchManyManyResults(
204
        DataObject $record,
205
        string $class,
206
        string $component,
207
        bool $reverse
208
    ): ?Query {
209
        // Example php file: class MyPage ... private static $many_many = [ 'MyFile' => File::class ]
210
        $data = $this->dataObjectSchema->manyManyComponent($class, $component);
211
        if (!$data || !($data['join'] ?? false)) {
212
            return null;
213
        }
214
        $joinTableName = $this->deriveJoinTableName($data);
215
        if (!ClassInfo::hasTable($joinTableName)) {
216
            return null;
217
        }
218
        $usesThroughTable = $data['join'] != $joinTableName;
219
220
        $parentField = preg_replace('#ID$#', '', $data['parentField']) . 'ID';
221
        $childField = preg_replace('#ID$#', '', $data['childField']) . 'ID';
222
        $selectField = !$reverse ? $childField : $parentField;
223
        $selectFields = [$selectField];
224
        $whereField = !$reverse ? $parentField : $childField;
225
226
        // Support for polymorphic through objects such FileLink that allow for multiple class types on one side e.g.
227
        // ParentID: int, ParentClass: enum('File::class, SiteTree::class, ElementContent::class, ...')
228
        if ($usesThroughTable) {
229
            $dbFields = $this->dataObjectSchema->databaseFields($data['join']);
230
            if ($parentField === 'ParentID' && isset($dbFields['ParentClass'])) {
231
                $selectFields[] = 'ParentClass';
232
                if (!$reverse) {
233
                    return null;
234
                }
235
            }
236
        }
237
238
        // Prevent duplicate queries which can happen when an Image is inserted on a Page subclass via TinyMCE
239
        // and FileLink will make the same query multiple times for all the different page subclasses because
240
        // the FileLink is associated with the Base Page class database table
241
        $queryIden = implode('-', array_merge($selectFields, [$joinTableName, $whereField, $record->ID]));
242
        if (array_key_exists($queryIden, $this->queryIdens)) {
243
            return null;
244
        }
245
        $this->queryIdens[$queryIden] = true;
246
247
        return SQLSelect::create(
248
            sprintf('"' . implode('", "', $selectFields) . '"'),
249
            sprintf('"%s"', $joinTableName),
250
            sprintf('"%s" = %u', $whereField, $record->ID)
251
        )->execute();
252
    }
253
254
    /**
255
     * Contains special logic for some many_many_through relationships
256
     * $joinTableName, instead of the name of the join table, it will be a namespaced classname
257
     * Example $class: SilverStripe\Assets\Shortcodes\FileLinkTracking
258
     * Example $joinTableName: SilverStripe\Assets\Shortcodes\FileLink
259
     *
260
     * @param array $data
261
     * @return string
262
     */
263
    private function deriveJoinTableName(array $data): string
264
    {
265
        $joinTableName = $data['join'];
266
        if (!ClassInfo::hasTable($joinTableName) && class_exists($joinTableName)) {
267
            $class = $joinTableName;
268
            if (!isset($this->classToTableName[$class])) {
269
                return null;
0 ignored issues
show
Bug Best Practice introduced by
The expression return null returns the type null which is incompatible with the type-hinted return string.
Loading history...
270
            }
271
            $joinTableName = $this->classToTableName[$class];
272
        }
273
        return $joinTableName;
274
    }
275
276
    /**
277
     * @param array $classIDs
278
     * @param DataObject $record
279
     * @param string $class
280
     */
281
    private function addRelatedReverseHasOnes(array &$classIDs, DataObject $record, string $class): void
282
    {
283
        foreach (singleton($class)->hasOne() as $component => $componentClass) {
284
            if (!($record instanceof $componentClass)) {
285
                continue;
286
            }
287
            $results = $this->fetchReverseHasOneResults($record, $class, $component);
288
            $this->addResultsToClassIDs($classIDs, $results, $class);
289
        }
290
    }
291
292
    /**
293
     * Query the database to retrieve has_one results
294
     *
295
     * @param DataObject $record - The DataObject whose usage data is being retrieved, usually a File
296
     * @param string $class - Name of class with the relation to record
297
     * @param string $component - Name of relation to `$record` on `$class`
298
     * @return Query|null
299
     */
300
    private function fetchReverseHasOneResults(DataObject $record, string $class, string $component): ?Query
301
    {
302
        // Ensure table exists, this is required for TestOnly SapphireTest classes
303
        if (!isset($this->classToTableName[$class])) {
304
            return null;
305
        }
306
        $componentIDField = "{$component}ID";
307
308
        // Only get database fields from the current class model, not parent class model
309
        $dbFields = $this->dataObjectSchema->databaseFields($class, false);
310
        if (!isset($dbFields[$componentIDField])) {
311
            return null;
312
        }
313
        $tableName = $this->dataObjectSchema->tableName($class);
314
        $where = sprintf('"%s" = %u', $componentIDField, $record->ID);
315
316
        // Polymorphic
317
        if ($componentIDField === 'ParentID' && isset($dbFields['ParentClass'])) {
318
            $where .= sprintf(' AND "ParentClass" = %s', $this->prepareClassNameLiteral(get_class($record)));
319
        }
320
321
        // Example SQL:
322
        // Normal:
323
        //   SELECT "ID" FROM "MyPage" WHERE "MyFileID" = 123;
324
        // Polymorphic:
325
        //   SELECT "ID" FROM "MyPage" WHERE "ParentID" = 456 AND "ParentClass" = 'MyFile';
326
        return SQLSelect::create(
327
            '"ID"',
328
            sprintf('"%s"', $tableName),
329
            $where
330
        )->execute();
331
    }
332
333
    /**
334
     * @param array $classIDs
335
     * @param DataObject $record
336
     * @param string $class
337
     * @param string[] $throughClasses
338
     */
339
    private function addRelatedReverseManyManys(
340
        array &$classIDs,
341
        DataObject $record,
342
        string $class,
343
        array &$throughClasses
344
    ): void {
345
        foreach (singleton($class)->manyMany() as $component => $componentClass) {
346
            $componentClass = $this->updateComponentClass($componentClass, $throughClasses);
347
            if (!($record instanceof $componentClass) ||
348
                // Ignore belongs_many_many_through with dot syntax
349
                strpos($componentClass, '.') !== false
350
            ) {
351
                continue;
352
            }
353
            $results = $this->fetchManyManyResults($record, $class, $component, true);
354
            $this->addResultsToClassIDs($classIDs, $results, $class);
355
        }
356
    }
357
358
    /**
359
     * Update the `$classIDs` array with the relationship IDs from database `$results`
360
     *
361
     * @param array $classIDs
362
     * @param Query|null $results
363
     * @param string $class
364
     */
365
    private function addResultsToClassIDs(array &$classIDs, ?Query $results, string $class): void
366
    {
367
        if (is_null($results) || (!is_subclass_of($class, DataObject::class) && $class !== DataObject::class)) {
368
            return;
369
        }
370
        foreach ($results as $row) {
371
            if (count(array_keys($row)) === 2 && isset($row['ParentClass']) && isset($row['ParentID'])) {
372
                // Example $class: SilverStripe\Assets\Shortcodes\FileLinkTracking
373
                // Example $parentClass: Page
374
                $parentClass = $row['ParentClass'];
375
                $classIDs[$parentClass] = $classIDs[$parentClass] ?? [];
376
                $classIDs[$parentClass][] = $row['ParentID'];
377
            } else {
378
                if ($class === DataObject::class) {
379
                    continue;
380
                }
381
                foreach (array_values($row) as $classID) {
382
                    $classIDs[$class] = $classIDs[$class] ?? [];
383
                    $classIDs[$class][] = $classID;
384
                }
385
            }
386
        }
387
    }
388
389
    /**
390
     * Prepare an FQCN literal for database querying so that backslashes are escaped properly
391
     *
392
     * @param string $value
393
     * @return string
394
     */
395
    private function prepareClassNameLiteral(string $value): string
396
    {
397
        $c = chr(92);
398
        $escaped = str_replace($c, "{$c}{$c}", $value);
399
        // postgres
400
        if (stripos(get_class(DB::get_conn()), 'postgres') !== false) {
401
            return "E'{$escaped}'";
402
        }
403
        // mysql
404
        return "'{$escaped}'";
405
    }
406
407
    /**
408
     * Convert a many_many_through $componentClass array to the 'to' component on the 'through' object
409
     * If $componentClass represents a through object, then also update the $throughClasses array
410
     *
411
     * @param string|array $componentClass
412
     * @param string[] $throughClasses
413
     * @return string
414
     */
415
    private function updateComponentClass($componentClass, array &$throughClasses): string
416
    {
417
        if (!is_array($componentClass)) {
418
            return $componentClass;
419
        }
420
        $throughClass = $componentClass['through'];
421
        $throughClasses[$throughClass] = true;
422
        $lowercaseThroughClass = strtolower($throughClass);
423
        $toComponent = $componentClass['to'];
424
        return $this->config[$lowercaseThroughClass]['has_one'][$toComponent];
425
    }
426
427
    /**
428
     * Setup function to fix unit test specific issue
429
     */
430
    private function initClassToTableName(): void
431
    {
432
        $this->classToTableName = $this->dataObjectSchema->getTableNames();
433
434
        // Fix issue that only happens when unit-testing via SapphireTest
435
        // TestOnly class tables are only created if they're defined in SapphireTest::$extra_dataobject
436
        // This means there's a large number of TestOnly classes, unrelated to the UsedOnTable, that
437
        // do not have tables.  Remove these table-less classes from $classToTableName.
438
        foreach ($this->classToTableName as $class => $tableName) {
439
            if (!ClassInfo::hasTable($tableName)) {
440
                unset($this->classToTableName[$class]);
441
            }
442
        }
443
    }
444
445
    /**
446
     * Remove classes excluded via Extensions
447
     * Remove "through" classes used in many-many relationships
448
     *
449
     * @param array $classIDs
450
     * @param string[] $excludedClasses
451
     * @param string[] $throughClasses
452
     */
453
    private function removeClasses(array &$classIDs, array $excludedClasses, array $throughClasses): void
454
    {
455
        foreach (array_keys($classIDs) as $class) {
456
            if (isset($throughClasses[$class]) || in_array($class, $excludedClasses)) {
457
                unset($classIDs[$class]);
458
            }
459
        }
460
    }
461
462
    /**
463
     * Fetch all objects of a class in a single query for better performance
464
     *
465
     * @param array $classIDs
466
     * @return array
467
     */
468
    private function fetchClassObjs(array $classIDs): array
469
    {
470
        /** @var DataObject $class */
471
        $classObjs = [];
472
        foreach ($classIDs as $class => $ids) {
473
            $classObjs[$class] = [];
474
            foreach ($class::get()->filter('ID', $ids) as $obj) {
475
                $classObjs[$class][$obj->ID] = $obj;
476
            }
477
        }
478
        return $classObjs;
479
    }
480
481
    /**
482
     * Returned ArrayList can have multiple entries for the same DataObject
483
     * For example, the File is used multiple times on a single Page
484
     *
485
     * @param array $classIDs
486
     * @param array $classObjs
487
     * @return ArrayList
488
     */
489
    private function deriveList(array $classIDs, array $classObjs): ArrayList
490
    {
491
        $list = ArrayList::create();
492
        foreach ($classIDs as $class => $ids) {
493
            foreach ($ids as $id) {
494
                // Ensure the $classObj exists, this is to cover an edge case where there is an orphaned
495
                // many-many join table database record with no corresponding DataObject database record
496
                if (!isset($classObjs[$class][$id])) {
497
                    continue;
498
                }
499
                $list->push($classObjs[$class][$id]);
500
            }
501
        }
502
        return $list;
503
    }
504
}
505