File::class ] * - $component: 'MyFile' * - $componentClassName: SilverStripe\Assets\File::class * * @internal */ class StandardRelatedDataService implements RelatedDataService { /** * Used to prevent duplicate database queries * * @var array */ private $queryIdens = []; /** * @var array */ private $config; /** * @var DataObjectSchema */ private $dataObjectSchema; /** * @var array */ private $classToTableName; /** * Find all DataObject instances that have a linked relationship with $record * * @param DataObject $record * @param string[] $excludedClasses * @return SS_List */ public function findAll(DataObject $record, array $excludedClasses = []): SS_List { // Do not query unsaved DataObjects if (!$record->exists()) { return ArrayList::create(); } $this->config = Config::inst()->getAll(); $this->dataObjectSchema = DataObjectSchema::create(); $this->initClassToTableName(); $classIDs = []; $throughClasses = []; // "regular" relations i.e. point from $record to different DataObject $this->addRelatedHasOnes($classIDs, $record); $this->addRelatedManyManys($classIDs, $record, $throughClasses); // Loop config data to find "reverse" relationships pointing back to $record foreach (array_keys($this->config) as $lowercaseClassName) { if (!class_exists($lowercaseClassName)) { continue; } // Example of $class: My\App\MyPage (extends SiteTree) try { $class = ClassInfo::class_name($lowercaseClassName); } catch (ReflectionException $e) { continue; } if (!is_subclass_of($class, DataObject::class)) { continue; } $this->addRelatedReverseHasOnes($classIDs, $record, $class); $this->addRelatedReverseManyManys($classIDs, $record, $class, $throughClasses); } $this->removeClasses($classIDs, $excludedClasses, $throughClasses); $classObjs = $this->fetchClassObjs($classIDs); return $this->deriveList($classIDs, $classObjs); } /** * Loop has_one relationships on the DataObject we're getting usage for * e.g. File.has_one = Page, Page.has_many = File * * @param array $classIDs * @param DataObject $record */ private function addRelatedHasOnes(array &$classIDs, DataObject $record): void { $class = get_class($record); foreach ($record->hasOne() as $component => $componentClass) { $componentIDField = "{$component}ID"; $tableName = $this->findTableNameContainingComponentIDField($class, $componentIDField); if ($tableName === '') { continue; } $select = sprintf('"%s"', $componentIDField); $where = sprintf('"ID" = %u AND "%s" > 0', $record->ID, $componentIDField); // Polymorphic // $record->ParentClass will return null if the column doesn't exist if ($componentIDField === 'ParentID' && $record->ParentClass) { $select .= ', "ParentClass"'; } // Prevent duplicate counting of self-referential relations // The relation will still be fetched by $this::fetchReverseHasOneResults() if ($record instanceof $componentClass) { $where .= sprintf(' AND "%s" != %u', $componentIDField, $record->ID); } // Example SQL: // Normal: // SELECT "MyPageID" FROM "MyFile" WHERE "ID" = 789 AND "MyPageID" > 0; // Prevent self-referential e.g. File querying File: // SELECT "MyFileSubClassID" FROM "MyFile" WHERE "ID" = 456 // AND "MyFileSubClassID" > 0 AND MyFileSubClassID != 456; // Polymorphic: // SELECT "ParentID", "ParentClass" FROM "MyFile" WHERE "ID" = 789 AND "ParentID" > 0; $results = SQLSelect::create( $select, sprintf('"%s"', $tableName), $where )->execute(); $this->addResultsToClassIDs($classIDs, $results, $componentClass); } } /** * Find the table that contains $componentIDField - this is relevant for subclassed DataObjects * that live in the database as two tables that are joined together * * @param string $class * @param string $componentIDField * @return string */ private function findTableNameContainingComponentIDField(string $class, string $componentIDField): string { $tableName = ''; $candidateClass = $class; while ($candidateClass) { $dbFields = $this->dataObjectSchema->databaseFields($candidateClass, false); if (array_key_exists($componentIDField, $dbFields)) { $tableName = $this->dataObjectSchema->tableName($candidateClass); break; } $candidateClass = get_parent_class($class); } return $tableName; } /** * Loop many_many relationships on the DataObject we're getting usage for * * @param array $classIDs * @param DataObject $record * @param string[] $throughClasses */ private function addRelatedManyManys(array &$classIDs, DataObject $record, array &$throughClasses): void { $class = get_class($record); foreach ($record->manyMany() as $component => $componentClass) { $componentClass = $this->updateComponentClass($componentClass, $throughClasses); // Ignore belongs_many_many_through with dot syntax, AND // Prevent duplicate counting of self-referential relations e.g. // MyFile::$many_many = [ 'MyFile' => MyFile::class ] // This relation will still be counted in $this::addRelatedReverseManyManys() if (strpos($componentClass, '.') !== false || $record instanceof $componentClass) { continue; } $results = $this->fetchManyManyResults($record, $class, $component, false); $this->addResultsToClassIDs($classIDs, $results, $componentClass); } } /** * Query the database to retrieve many-many results * * @param DataObject $record - The DataObject whose usage data is being retrieved, usually a File * @param string $class - example: My\App\SomePageType * @param string $component - example: 'SomeFiles' - My\App\SomePageType::SomeFiles() * @param bool $reverse - true: SomePage::SomeFiles(), false: SomeFile::SomePages() * @return Query|null */ private function fetchManyManyResults( DataObject $record, string $class, string $component, bool $reverse ): ?Query { // Example php file: class MyPage ... private static $many_many = [ 'MyFile' => File::class ] $data = $this->dataObjectSchema->manyManyComponent($class, $component); if (!$data || !($data['join'] ?? false)) { return null; } $joinTableName = $this->deriveJoinTableName($data); if (!ClassInfo::hasTable($joinTableName)) { return null; } $usesThroughTable = $data['join'] != $joinTableName; $parentField = preg_replace('#ID$#', '', $data['parentField']) . 'ID'; $childField = preg_replace('#ID$#', '', $data['childField']) . 'ID'; $selectField = !$reverse ? $childField : $parentField; $selectFields = [$selectField]; $whereField = !$reverse ? $parentField : $childField; // Support for polymorphic through objects such FileLink that allow for multiple class types on one side e.g. // ParentID: int, ParentClass: enum('File::class, SiteTree::class, ElementContent::class, ...') if ($usesThroughTable) { $dbFields = $this->dataObjectSchema->databaseFields($data['join']); if ($parentField === 'ParentID' && isset($dbFields['ParentClass'])) { $selectFields[] = 'ParentClass'; if (!$reverse) { return null; } } } // Prevent duplicate queries which can happen when an Image is inserted on a Page subclass via TinyMCE // and FileLink will make the same query multiple times for all the different page subclasses because // the FileLink is associated with the Base Page class database table $queryIden = implode('-', array_merge($selectFields, [$joinTableName, $whereField, $record->ID])); if (array_key_exists($queryIden, $this->queryIdens)) { return null; } $this->queryIdens[$queryIden] = true; return SQLSelect::create( sprintf('"' . implode('", "', $selectFields) . '"'), sprintf('"%s"', $joinTableName), sprintf('"%s" = %u', $whereField, $record->ID) )->execute(); } /** * Contains special logic for some many_many_through relationships * $joinTableName, instead of the name of the join table, it will be a namespaced classname * Example $class: SilverStripe\Assets\Shortcodes\FileLinkTracking * Example $joinTableName: SilverStripe\Assets\Shortcodes\FileLink * * @param array $data * @return string */ private function deriveJoinTableName(array $data): string { $joinTableName = $data['join']; if (!ClassInfo::hasTable($joinTableName) && class_exists($joinTableName)) { $class = $joinTableName; if (!isset($this->classToTableName[$class])) { return null; } $joinTableName = $this->classToTableName[$class]; } return $joinTableName; } /** * @param array $classIDs * @param DataObject $record * @param string $class */ private function addRelatedReverseHasOnes(array &$classIDs, DataObject $record, string $class): void { foreach (singleton($class)->hasOne() as $component => $componentClass) { if (!($record instanceof $componentClass)) { continue; } $results = $this->fetchReverseHasOneResults($record, $class, $component); $this->addResultsToClassIDs($classIDs, $results, $class); } } /** * Query the database to retrieve has_one results * * @param DataObject $record - The DataObject whose usage data is being retrieved, usually a File * @param string $class - Name of class with the relation to record * @param string $component - Name of relation to `$record` on `$class` * @return Query|null */ private function fetchReverseHasOneResults(DataObject $record, string $class, string $component): ?Query { // Ensure table exists, this is required for TestOnly SapphireTest classes if (!isset($this->classToTableName[$class])) { return null; } $componentIDField = "{$component}ID"; // Only get database fields from the current class model, not parent class model $dbFields = $this->dataObjectSchema->databaseFields($class, false); if (!isset($dbFields[$componentIDField])) { return null; } $tableName = $this->dataObjectSchema->tableName($class); $where = sprintf('"%s" = %u', $componentIDField, $record->ID); // Polymorphic if ($componentIDField === 'ParentID' && isset($dbFields['ParentClass'])) { $where .= sprintf(' AND "ParentClass" = %s', $this->prepareClassNameLiteral(get_class($record))); } // Example SQL: // Normal: // SELECT "ID" FROM "MyPage" WHERE "MyFileID" = 123; // Polymorphic: // SELECT "ID" FROM "MyPage" WHERE "ParentID" = 456 AND "ParentClass" = 'MyFile'; return SQLSelect::create( '"ID"', sprintf('"%s"', $tableName), $where )->execute(); } /** * @param array $classIDs * @param DataObject $record * @param string $class * @param string[] $throughClasses */ private function addRelatedReverseManyManys( array &$classIDs, DataObject $record, string $class, array &$throughClasses ): void { foreach (singleton($class)->manyMany() as $component => $componentClass) { $componentClass = $this->updateComponentClass($componentClass, $throughClasses); if (!($record instanceof $componentClass) || // Ignore belongs_many_many_through with dot syntax strpos($componentClass, '.') !== false ) { continue; } $results = $this->fetchManyManyResults($record, $class, $component, true); $this->addResultsToClassIDs($classIDs, $results, $class); } } /** * Update the `$classIDs` array with the relationship IDs from database `$results` * * @param array $classIDs * @param Query|null $results * @param string $class */ private function addResultsToClassIDs(array &$classIDs, ?Query $results, string $class): void { if (is_null($results) || (!is_subclass_of($class, DataObject::class) && $class !== DataObject::class)) { return; } foreach ($results as $row) { if (count(array_keys($row)) === 2 && isset($row['ParentClass']) && isset($row['ParentID'])) { // Example $class: SilverStripe\Assets\Shortcodes\FileLinkTracking // Example $parentClass: Page $parentClass = $row['ParentClass']; $classIDs[$parentClass] = $classIDs[$parentClass] ?? []; $classIDs[$parentClass][] = $row['ParentID']; } else { if ($class === DataObject::class) { continue; } foreach (array_values($row) as $classID) { $classIDs[$class] = $classIDs[$class] ?? []; $classIDs[$class][] = $classID; } } } } /** * Prepare an FQCN literal for database querying so that backslashes are escaped properly * * @param string $value * @return string */ private function prepareClassNameLiteral(string $value): string { $c = chr(92); $escaped = str_replace($c, "{$c}{$c}", $value); // postgres if (stripos(get_class(DB::get_conn()), 'postgres') !== false) { return "E'{$escaped}'"; } // mysql return "'{$escaped}'"; } /** * Convert a many_many_through $componentClass array to the 'to' component on the 'through' object * If $componentClass represents a through object, then also update the $throughClasses array * * @param string|array $componentClass * @param string[] $throughClasses * @return string */ private function updateComponentClass($componentClass, array &$throughClasses): string { if (!is_array($componentClass)) { return $componentClass; } $throughClass = $componentClass['through']; $throughClasses[$throughClass] = true; $lowercaseThroughClass = strtolower($throughClass); $toComponent = $componentClass['to']; return $this->config[$lowercaseThroughClass]['has_one'][$toComponent]; } /** * Setup function to fix unit test specific issue */ private function initClassToTableName(): void { $this->classToTableName = $this->dataObjectSchema->getTableNames(); // Fix issue that only happens when unit-testing via SapphireTest // TestOnly class tables are only created if they're defined in SapphireTest::$extra_dataobject // This means there's a large number of TestOnly classes, unrelated to the UsedOnTable, that // do not have tables. Remove these table-less classes from $classToTableName. foreach ($this->classToTableName as $class => $tableName) { if (!ClassInfo::hasTable($tableName)) { unset($this->classToTableName[$class]); } } } /** * Remove classes excluded via Extensions * Remove "through" classes used in many-many relationships * * @param array $classIDs * @param string[] $excludedClasses * @param string[] $throughClasses */ private function removeClasses(array &$classIDs, array $excludedClasses, array $throughClasses): void { foreach (array_keys($classIDs) as $class) { if (isset($throughClasses[$class]) || in_array($class, $excludedClasses)) { unset($classIDs[$class]); } } } /** * Fetch all objects of a class in a single query for better performance * * @param array $classIDs * @return array */ private function fetchClassObjs(array $classIDs): array { /** @var DataObject $class */ $classObjs = []; foreach ($classIDs as $class => $ids) { $classObjs[$class] = []; foreach ($class::get()->filter('ID', $ids) as $obj) { $classObjs[$class][$obj->ID] = $obj; } } return $classObjs; } /** * Returned ArrayList can have multiple entries for the same DataObject * For example, the File is used multiple times on a single Page * * @param array $classIDs * @param array $classObjs * @return ArrayList */ private function deriveList(array $classIDs, array $classObjs): ArrayList { $list = ArrayList::create(); foreach ($classIDs as $class => $ids) { foreach ($ids as $id) { // Ensure the $classObj exists, this is to cover an edge case where there is an orphaned // many-many join table database record with no corresponding DataObject database record if (!isset($classObjs[$class][$id])) { continue; } $list->push($classObjs[$class][$id]); } } return $list; } }