From 6e77d5eada39bae6a0d310439a3a18667e1e91b2 Mon Sep 17 00:00:00 2001 From: Steve Boyd Date: Thu, 29 Oct 2020 09:29:26 +1300 Subject: [PATCH] NEW DataObject related objects service --- _config/relateddata.yml | 6 + src/ORM/DataObject.php | 17 + src/ORM/RelatedData/RelatedDataService.php | 25 + .../StandardRelatedDataService.php | 507 ++++++++++++++++++ tests/php/ORM/RelatedDataServiceTest.php | 376 +++++++++++++ tests/php/ORM/RelatedDataServiceTest/Base.php | 15 + .../ORM/RelatedDataServiceTest/Belongs.php | 12 + .../ORM/RelatedDataServiceTest/HasMany.php | 12 + tests/php/ORM/RelatedDataServiceTest/Hub.php | 52 ++ .../RelatedDataServiceTest/HubExtension.php | 18 + .../php/ORM/RelatedDataServiceTest/HubSub.php | 12 + .../ORM/RelatedDataServiceTest/ManyMany.php | 12 + .../ManyManyNoBelongs.php | 13 + .../ManyManyThrough.php | 16 + .../ManyManyThroughNoBelongs.php | 19 + tests/php/ORM/RelatedDataServiceTest/Node.php | 8 + .../RelatedDataServiceTest/Polymorphic.php | 14 + .../SelfReferentialNode.php | 18 + .../RelatedDataServiceTest/ThroughObject.php | 13 + .../ThroughObjectMMT.php | 13 + .../ThroughObjectMMTNB.php | 13 + .../ThroughObjectPolymorphic.php | 15 + 22 files changed, 1206 insertions(+) create mode 100644 _config/relateddata.yml create mode 100644 src/ORM/RelatedData/RelatedDataService.php create mode 100644 src/ORM/RelatedData/StandardRelatedDataService.php create mode 100644 tests/php/ORM/RelatedDataServiceTest.php create mode 100644 tests/php/ORM/RelatedDataServiceTest/Base.php create mode 100644 tests/php/ORM/RelatedDataServiceTest/Belongs.php create mode 100644 tests/php/ORM/RelatedDataServiceTest/HasMany.php create mode 100644 tests/php/ORM/RelatedDataServiceTest/Hub.php create mode 100644 tests/php/ORM/RelatedDataServiceTest/HubExtension.php create mode 100644 tests/php/ORM/RelatedDataServiceTest/HubSub.php create mode 100644 tests/php/ORM/RelatedDataServiceTest/ManyMany.php create mode 100644 tests/php/ORM/RelatedDataServiceTest/ManyManyNoBelongs.php create mode 100644 tests/php/ORM/RelatedDataServiceTest/ManyManyThrough.php create mode 100644 tests/php/ORM/RelatedDataServiceTest/ManyManyThroughNoBelongs.php create mode 100644 tests/php/ORM/RelatedDataServiceTest/Node.php create mode 100644 tests/php/ORM/RelatedDataServiceTest/Polymorphic.php create mode 100644 tests/php/ORM/RelatedDataServiceTest/SelfReferentialNode.php create mode 100644 tests/php/ORM/RelatedDataServiceTest/ThroughObject.php create mode 100644 tests/php/ORM/RelatedDataServiceTest/ThroughObjectMMT.php create mode 100644 tests/php/ORM/RelatedDataServiceTest/ThroughObjectMMTNB.php create mode 100644 tests/php/ORM/RelatedDataServiceTest/ThroughObjectPolymorphic.php diff --git a/_config/relateddata.yml b/_config/relateddata.yml new file mode 100644 index 000000000..b2a5d29af --- /dev/null +++ b/_config/relateddata.yml @@ -0,0 +1,6 @@ +--- +Name: relateddata +--- +SilverStripe\Core\Injector\Injector: + SilverStripe\ORM\RelatedData\RelatedDataService: + class: SilverStripe\ORM\RelatedData\StandardRelatedDataService diff --git a/src/ORM/DataObject.php b/src/ORM/DataObject.php index 2e8888bde..d7e60a34f 100644 --- a/src/ORM/DataObject.php +++ b/src/ORM/DataObject.php @@ -26,6 +26,7 @@ use SilverStripe\ORM\FieldType\DBField; use SilverStripe\ORM\Filters\SearchFilter; use SilverStripe\ORM\Queries\SQLDelete; use SilverStripe\ORM\Search\SearchContext; +use SilverStripe\ORM\RelatedData\RelatedDataService; use SilverStripe\ORM\UniqueKey\UniqueKeyInterface; use SilverStripe\ORM\UniqueKey\UniqueKeyService; use SilverStripe\Security\Member; @@ -4352,4 +4353,20 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity { return $this->extend('cacheKeyComponent'); } + + /** + * Find all other DataObject instances that are related to this DataObject in the database + * through has_one and many_many relationships. For example: + * This method is called on a File. The MyPage model $has_one File. There is a Page record that has + * a FileID = $this->ID. This SS_List returned by this method will include that Page instance. + * + * @param string[] $excludedClasses + * @return SS_List + * @internal + */ + public function findAllRelatedData(array $excludedClasses = []): SS_List + { + $service = Injector::inst()->get(RelatedDataService::class); + return $service->findAll($this, $excludedClasses); + } } diff --git a/src/ORM/RelatedData/RelatedDataService.php b/src/ORM/RelatedData/RelatedDataService.php new file mode 100644 index 000000000..ecf344602 --- /dev/null +++ b/src/ORM/RelatedData/RelatedDataService.php @@ -0,0 +1,25 @@ + 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); + if ( + // Ignore belongs_many_many_through with dot syntax + strpos($componentClass, '.') !== false || + // Prevent duplicate counting of self-referential relations e.g. + // MyFile::$many_many = [ 'MyFile' => MyFile::class ] + // This relation will still be counted in $this::addRelatedReverseManyManys() + $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; + } +} diff --git a/tests/php/ORM/RelatedDataServiceTest.php b/tests/php/ORM/RelatedDataServiceTest.php new file mode 100644 index 000000000..dd1988698 --- /dev/null +++ b/tests/php/ORM/RelatedDataServiceTest.php @@ -0,0 +1,376 @@ +findAllRelatedData(); + $this->assertTrue($list instanceof SS_List); + $this->assertSame(0, $list->count()); + } + + public function testUsageUnrelated() + { + $myFile = new Node(); + $myFile->write(); + $myPage = new Hub(); + $myPage->Title = 'Unrelated page'; + $myPage->write(); + $list = $myFile->findAllRelatedData(); + $this->assertSame(0, $list->count()); + } + + public function testUsageHasOne() + { + $pageTitle = 'My Page that has_one File'; + $myFile = new Node(); + $myFile->write(); + $myPage = new Hub(); + $myPage->Title = $pageTitle; + $myPage->HO = $myFile; + $myPage->write(); + $list = $myFile->findAllRelatedData(); + $this->assertSame(1, $list->count()); + $this->assertSame($pageTitle, $list->first()->Title); + } + + public function testUsageHasOneHubExtension() + { + // Add DataExtension and reset database so that tables + columns get added + Hub::add_extension(HubExtension::class); + DataObject::reset(); + self::resetDBSchema(true, true); + // + $pageTitle = 'My Page that has_one File using HubExtension'; + $myFile = new Node(); + $myFile->write(); + $myPage = new Hub(); + $myPage->Title = $pageTitle; + $myPage->ExtHO = $myFile; + $myPage->write(); + $list = $myFile->findAllRelatedData(); + $this->assertSame(1, $list->count()); + $this->assertSame($pageTitle, $list->first()->Title); + } + + public function testUsageHubSub() + { + $pageTitle = 'My Sub Page'; + $pageSubTitle = 'My SubTitle'; + $myFile = new Node(); + $myFile->write(); + $myPage = new HubSub(); + $myPage->Title = $pageTitle; + $myPage->SubTitle = $pageSubTitle; + $myPage->HO = $myFile; + $myPage->write(); + $list = $myFile->findAllRelatedData(); + $this->assertSame(1, $list->count()); + $this->assertSame($pageTitle, $list->first()->Title); + $this->assertSame($pageSubTitle, $list->first()->SubTitle); + } + + public function testUsageHasOnePolymorphic() + { + $pageTitle = 'My Page that has_one File polymorphic'; + $myFile = new Node(); + $myFile->write(); + $myPage = new Hub(); + $myPage->Title = $pageTitle; + $myPage->Parent = $myFile; + $myPage->write(); + $list = $myFile->findAllRelatedData(); + $this->assertSame(1, $list->count()); + $this->assertSame($pageTitle, $list->first()->Title); + } + + public function testUsageHasOnePolymorphicOnNode() + { + $pageTitle = 'My Page that that belongs to a polymorphic File'; + $myPage = new Hub(); + $myPage->Title = $pageTitle; + $myPage->write(); + $myFile = new Polymorphic(); + $myFile->Parent = $myPage; + $myFile->write(); + $list = $myFile->findAllRelatedData(); + $this->assertSame(1, $list->count()); + $this->assertSame($pageTitle, $list->first()->Title); + } + + public function testUsageHasMany() + { + $pageTitle = 'My Page that has_many File'; + $myPage = new Hub(); + $myPage->Title = $pageTitle; + $myPage->write(); + $myFile = new HasMany(); + $myFile->write(); + $myPage->HM()->add($myFile); + $list = $myFile->findAllRelatedData(); + $this->assertSame(1, $list->count()); + $this->assertSame($pageTitle, $list->first()->Title); + } + + public function testUsageManyManyWithBelongs() + { + $pageTitle = 'My Page that many_many File with belong_many_many Page'; + $myPage = new Hub(); + $myPage->Title = $pageTitle; + $myPage->write(); + $myFile = new Belongs(); + $myFile->write(); + $myPage->MMtoBMM()->add($myFile); + $list = $myFile->findAllRelatedData(); + $this->assertSame(1, $list->count()); + $this->assertSame($pageTitle, $list->first()->Title); + } + + public function testUsageManyManyWithoutBelongs() + { + $pageTitle = 'My Page that many_many File without belong_many_many Page'; + $myPage = new Hub(); + $myPage->Title = $pageTitle; + $myPage->write(); + $myFile = new Node(); + $myFile->write(); + $myPage->MMtoNoBMM()->add($myFile); + $list = $myFile->findAllRelatedData(); + $this->assertSame(1, $list->count()); + $this->assertSame($pageTitle, $list->first()->Title); + } + + public function testUsageManyManyWithoutBelongsHubExtension() + { + // Add DataExtension and reset database so that tables + columns get added + Hub::add_extension(HubExtension::class); + DataObject::reset(); + self::resetDBSchema(true, true); + // + $pageTitle = 'My Page that many_many File without belong_many_many Page using HubExtension'; + $myPage = new Hub(); + $myPage->Title = $pageTitle; + $myPage->write(); + $myFile = new Node(); + $myFile->write(); + $myPage->ExtMMtoNoBMM()->add($myFile); + $list = $myFile->findAllRelatedData(); + $this->assertSame(1, $list->count()); + $this->assertSame($pageTitle, $list->first()->Title); + } + + public function testUsageManyManyWithoutBelongsOrphanedJoinTable() + { + $pageTitle = 'My Page that many_many File without belong_many_many Page orphaned join table'; + $myPage = new Hub(); + $myPage->Title = $pageTitle; + $myPage->write(); + $myFile = new Node(); + $myFile->write(); + $myPage->MMtoNoBMM()->add($myFile); + // manually delete Page record from database, leaving join table record intact + SQLDelete::create('"TestOnly_RelatedDataServiceTest_Hub"', sprintf('"ID" = %s', $myPage->ID))->execute(); + SQLDelete::create('"TestOnly_RelatedDataServiceTest_Base"', sprintf('"ID" = %s', $myPage->ID))->execute(); + $list = $myFile->findAllRelatedData(); + $this->assertSame(0, $list->count()); + } + + public function testUsageBelongsManyMany() + { + $pageTitle = 'My Page that belongs_many_many File with many_many Page'; + $pageTitle2 = 'My other Page that belongs_many_many File with many_many Page'; + $myPage = new Hub(); + $myPage->Title = $pageTitle; + $myPage->write(); + $myPage2 = new Hub(); + $myPage2->Title = $pageTitle2; + $myPage2->write(); + $myFile = new ManyMany(); + $myFile->write(); + // add from both pages from different directions + $myPage->BMMtoMM()->add($myFile); + $myFile->Hubs()->add($myPage2); + $list = $myFile->findAllRelatedData(); + $this->assertSame(2, $list->count()); + $this->assertSame($pageTitle, $list->first()->Title); + $this->assertSame($pageTitle2, $list->last()->Title); + } + + public function testUsageManyManyThrough() + { + $pageTitle = 'My Page that many_many_through File'; + $myPage = new Hub(); + $myPage->Title = $pageTitle; + $myPage->write(); + $myFile = new Node(); + $myFile->write(); + $myPage->MMT()->add($myFile); + $list = $myFile->findAllRelatedData(); + $this->assertSame(1, $list->count()); + $this->assertSame($pageTitle, $list->first()->Title); + } + + public function testUsageManyManyThroughPolymorphic() + { + $pageTitle = 'My Page that many_many_through_parent_class File'; + $myPage = new Hub(); + $myPage->Title = $pageTitle; + $myPage->write(); + $myFile = new Node(); + $myFile->write(); + $myPage->MMTP()->add($myFile); + $list = $myFile->findAllRelatedData(); + $this->assertSame(1, $list->count()); + $this->assertSame($pageTitle, $list->first()->Title); + } + + public function testUsageFileManyManyWithoutPageBelongs() + { + $pageTitle = 'My Page that not belongs_many_many File with many_many Page'; + $myPage = new Hub(); + $myPage->Title = $pageTitle; + $myPage->write(); + $myFile = new ManyManyNoBelongs(); + $myFile->write(); + $myFile->Hubs()->add($myPage); + $list = $myFile->findAllRelatedData(); + $this->assertSame(1, $list->count()); + $this->assertSame($pageTitle, $list->first()->Title); + } + + public function testUsageFileManyManyThroughWithPageBelongs() + { + $pageTitle = 'My Page that many_many_belongs File with many_many_through Page'; + $pageTitle2 = 'My other Page that many_many_belongs File with many_many_through Page'; + $myPage = new Hub(); + $myPage->Title = $pageTitle; + $myPage->write(); + $myPage2 = new Hub(); + $myPage2->Title = $pageTitle2; + $myPage2->write(); + $myFile = new ManyManyThrough(); + $myFile->write(); + // add from both pages from different directions + $myPage->BMMtoMMT()->add($myFile); + $myFile->Hubs()->add($myPage2); + $list = $myFile->findAllRelatedData(); + $this->assertSame(2, $list->count()); + $this->assertSame($pageTitle, $list->first()->Title); + $this->assertSame($pageTitle2, $list->last()->Title); + } + + public function testUsageFileManyManyThroughWithoutPageBelongs() + { + $pageTitle = 'My Page that does not many_many_belongs File that many_many_through Page'; + $myPage = new Hub(); + $myPage->Title = $pageTitle; + $myPage->write(); + $myFile = new ManyManyThroughNoBelongs(); + $myFile->write(); + $myFile->Hubs()->add($myPage); + $list = $myFile->findAllRelatedData(); + $this->assertSame(1, $list->count()); + $this->assertSame($pageTitle, $list->first()->Title); + } + + public function testSelfReferentialHasOne() + { + $myFile = new SelfReferentialNode(); + $myFile->Title = 'My self referential file has_one'; + $myFile->write(); + $myFile->HOA = $myFile; + $myFile->HOB = $myFile; + $myFile->write(); + $list = $myFile->findAllRelatedData(); + $this->assertSame(2, $list->count()); + $this->assertSame($myFile->Title, $list->first()->Title); + $this->assertTrue($list->first() instanceof SelfReferentialNode); + $this->assertSame($myFile->Title, $list->last()->Title); + $this->assertTrue($list->last() instanceof SelfReferentialNode); + } + + public function testSelfReferentialManyMany() + { + $myFile = new SelfReferentialNode(); + $myFile->Title = 'My self referential file many_many'; + $myFile->write(); + $myFile->MMA()->add($myFile); + $myFile->MMB()->add($myFile); + $list = $myFile->findAllRelatedData(); + $this->assertSame(2, $list->count()); + $this->assertSame($myFile->Title, $list->first()->Title); + $this->assertTrue($list->first() instanceof SelfReferentialNode); + $this->assertSame($myFile->Title, $list->last()->Title); + $this->assertTrue($list->last() instanceof SelfReferentialNode); + } +} diff --git a/tests/php/ORM/RelatedDataServiceTest/Base.php b/tests/php/ORM/RelatedDataServiceTest/Base.php new file mode 100644 index 000000000..175014fec --- /dev/null +++ b/tests/php/ORM/RelatedDataServiceTest/Base.php @@ -0,0 +1,15 @@ + 'Varchar' + ]; +} diff --git a/tests/php/ORM/RelatedDataServiceTest/Belongs.php b/tests/php/ORM/RelatedDataServiceTest/Belongs.php new file mode 100644 index 000000000..bba3ae5eb --- /dev/null +++ b/tests/php/ORM/RelatedDataServiceTest/Belongs.php @@ -0,0 +1,12 @@ + Hub::class + ]; +} diff --git a/tests/php/ORM/RelatedDataServiceTest/HasMany.php b/tests/php/ORM/RelatedDataServiceTest/HasMany.php new file mode 100644 index 000000000..e897b1c97 --- /dev/null +++ b/tests/php/ORM/RelatedDataServiceTest/HasMany.php @@ -0,0 +1,12 @@ + Hub::class + ]; +} diff --git a/tests/php/ORM/RelatedDataServiceTest/Hub.php b/tests/php/ORM/RelatedDataServiceTest/Hub.php new file mode 100644 index 000000000..44769505a --- /dev/null +++ b/tests/php/ORM/RelatedDataServiceTest/Hub.php @@ -0,0 +1,52 @@ + Node::class, + 'Parent' => DataObject::class, // Will create a ParentID column + ParentColumn Enum column + ]; + + private static $has_many = [ + 'HM' => HasMany::class + ]; + + private static $many_many = [ + // has belongs_many_many on the other end + 'MMtoBMM' => Belongs::class, + // does not have belong_many_many on the other end + 'MMtoNoBMM' => Node::class, + // manyManyThrough + 'MMT' => [ + 'through' => ThroughObject::class, + 'from' => 'HubObj', + 'to' => 'NodeObj', + ], + // manyManyThrough Polymorphic + 'MMTP' => [ + 'through' => ThroughObjectPolymorphic::class, + 'from' => 'Parent', + 'to' => 'NodeObj', + ] + ]; + + private static $belongs_many_many = [ + // has many_many on the other end + 'BMMtoMM' => ManyMany::class, + 'BMMtoMMT' => ManyManyThrough::class + // Not testing the following, will throw this Silverstripe error: + // belongs_many_many relation ... points to ... without matching many_many + // does not have many_many on the other end + // 'BMMtoNoMM' => Node::class + ]; +} diff --git a/tests/php/ORM/RelatedDataServiceTest/HubExtension.php b/tests/php/ORM/RelatedDataServiceTest/HubExtension.php new file mode 100644 index 000000000..8639cda36 --- /dev/null +++ b/tests/php/ORM/RelatedDataServiceTest/HubExtension.php @@ -0,0 +1,18 @@ + Node::class + ]; + + private static $many_many = [ + // does not have belong_many_many on the other end + 'ExtMMtoNoBMM' => Node::class + ]; +} diff --git a/tests/php/ORM/RelatedDataServiceTest/HubSub.php b/tests/php/ORM/RelatedDataServiceTest/HubSub.php new file mode 100644 index 000000000..29ea9dede --- /dev/null +++ b/tests/php/ORM/RelatedDataServiceTest/HubSub.php @@ -0,0 +1,12 @@ + 'Varchar' + ]; +} diff --git a/tests/php/ORM/RelatedDataServiceTest/ManyMany.php b/tests/php/ORM/RelatedDataServiceTest/ManyMany.php new file mode 100644 index 000000000..8ef3afb4f --- /dev/null +++ b/tests/php/ORM/RelatedDataServiceTest/ManyMany.php @@ -0,0 +1,12 @@ + Hub::class + ]; +} diff --git a/tests/php/ORM/RelatedDataServiceTest/ManyManyNoBelongs.php b/tests/php/ORM/RelatedDataServiceTest/ManyManyNoBelongs.php new file mode 100644 index 000000000..dd59cc087 --- /dev/null +++ b/tests/php/ORM/RelatedDataServiceTest/ManyManyNoBelongs.php @@ -0,0 +1,13 @@ + Hub::class + ]; +} diff --git a/tests/php/ORM/RelatedDataServiceTest/ManyManyThrough.php b/tests/php/ORM/RelatedDataServiceTest/ManyManyThrough.php new file mode 100644 index 000000000..d76d68701 --- /dev/null +++ b/tests/php/ORM/RelatedDataServiceTest/ManyManyThrough.php @@ -0,0 +1,16 @@ + [ + 'through' => ThroughObjectMMT::class, + 'from' => 'MMTObj', + 'to' => 'HubObj', + ], + ]; +} diff --git a/tests/php/ORM/RelatedDataServiceTest/ManyManyThroughNoBelongs.php b/tests/php/ORM/RelatedDataServiceTest/ManyManyThroughNoBelongs.php new file mode 100644 index 000000000..7676e8fc2 --- /dev/null +++ b/tests/php/ORM/RelatedDataServiceTest/ManyManyThroughNoBelongs.php @@ -0,0 +1,19 @@ + [ + 'through' => ThroughObjectMMTNB::class, + // note: you cannot swap from/to around, Silverstripe MMT expects 'from' to be + // the type of class that defines the many_many_through relationship + // i.e. this class + 'from' => 'MMTNBObj', + 'to' => 'HubObj', + ], + ]; +} diff --git a/tests/php/ORM/RelatedDataServiceTest/Node.php b/tests/php/ORM/RelatedDataServiceTest/Node.php new file mode 100644 index 000000000..e0e507d47 --- /dev/null +++ b/tests/php/ORM/RelatedDataServiceTest/Node.php @@ -0,0 +1,8 @@ + DataObject::class + ]; +} diff --git a/tests/php/ORM/RelatedDataServiceTest/SelfReferentialNode.php b/tests/php/ORM/RelatedDataServiceTest/SelfReferentialNode.php new file mode 100644 index 000000000..185e4fa12 --- /dev/null +++ b/tests/php/ORM/RelatedDataServiceTest/SelfReferentialNode.php @@ -0,0 +1,18 @@ + Node::class, + 'HOB' => SelfReferentialNode::class + ]; + + private static $many_many = [ + 'MMA' => Node::class, + 'MMB' => SelfReferentialNode::class + ]; +} diff --git a/tests/php/ORM/RelatedDataServiceTest/ThroughObject.php b/tests/php/ORM/RelatedDataServiceTest/ThroughObject.php new file mode 100644 index 000000000..263272419 --- /dev/null +++ b/tests/php/ORM/RelatedDataServiceTest/ThroughObject.php @@ -0,0 +1,13 @@ + Hub::class, + 'NodeObj' => Node::class, + ]; +} diff --git a/tests/php/ORM/RelatedDataServiceTest/ThroughObjectMMT.php b/tests/php/ORM/RelatedDataServiceTest/ThroughObjectMMT.php new file mode 100644 index 000000000..5304e82fe --- /dev/null +++ b/tests/php/ORM/RelatedDataServiceTest/ThroughObjectMMT.php @@ -0,0 +1,13 @@ + Hub::class, + 'MMTObj' => ManyManyThrough::class, + ]; +} diff --git a/tests/php/ORM/RelatedDataServiceTest/ThroughObjectMMTNB.php b/tests/php/ORM/RelatedDataServiceTest/ThroughObjectMMTNB.php new file mode 100644 index 000000000..b68271cbe --- /dev/null +++ b/tests/php/ORM/RelatedDataServiceTest/ThroughObjectMMTNB.php @@ -0,0 +1,13 @@ + Hub::class, + 'MMTNBObj' => ManyManyThroughNoBelongs::class, + ]; +} diff --git a/tests/php/ORM/RelatedDataServiceTest/ThroughObjectPolymorphic.php b/tests/php/ORM/RelatedDataServiceTest/ThroughObjectPolymorphic.php new file mode 100644 index 000000000..0e405d219 --- /dev/null +++ b/tests/php/ORM/RelatedDataServiceTest/ThroughObjectPolymorphic.php @@ -0,0 +1,15 @@ + DataObject::class, // Will create a ParentID column + ParentColumn Enum column + 'NodeObj' => Node::class, + ]; +}