| 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 |