Total Complexity | 74 |
Total Lines | 479 |
Duplicated Lines | 0 % |
Changes | 1 | ||
Bugs | 0 | Features | 0 |
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 |
||
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) { |
||
|
|||
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( |
||
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 |
||
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 |
||
506 | } |
||
507 | } |
||
508 |