diff --git a/core/Extension.php b/core/Extension.php index 7a17bcddd..d71f13dfd 100644 --- a/core/Extension.php +++ b/core/Extension.php @@ -12,6 +12,7 @@ * @subpackage core */ abstract class Extension { + /** * This is used by extensions designed to be applied to controllers. * It works the same way as {@link Controller::$allowed_actions}. @@ -32,10 +33,12 @@ abstract class Extension { protected $ownerBaseClass; /** - * Reference counter to ensure that the owner isn't cleared until clearOwner() has - * been called as many times as setOwner() + * Ownership stack for recursive methods. + * Last item is current owner. + * + * @var array */ - private $ownerRefs = 0; + private $ownerStack = []; public $class; @@ -55,6 +58,7 @@ abstract class Extension { /** * Set the owner of this extension. + * * @param Object $owner The owner object, * @param string $ownerBaseClass The base class that the extension is applied to; this may be * the class of owner, or it may be a parent. For example, if Versioned was applied to SiteTree, @@ -62,17 +66,32 @@ abstract class Extension { * would be 'SiteTree'. */ public function setOwner($owner, $ownerBaseClass = null) { - if($owner) $this->ownerRefs++; + if($owner) { + $this->ownerStack[] = $owner; + } $this->owner = $owner; - if($ownerBaseClass) $this->ownerBaseClass = $ownerBaseClass; - else if(!$this->ownerBaseClass && $owner) $this->ownerBaseClass = $owner->class; + // Set ownerBaseClass + if($ownerBaseClass) { + $this->ownerBaseClass = $ownerBaseClass; + } elseif(!$this->ownerBaseClass && $owner) { + $this->ownerBaseClass = get_class($owner); + } } + /** + * Clear the current owner, and restore extension to the state prior to the last setOwner() + */ public function clearOwner() { - if($this->ownerRefs <= 0) user_error("clearOwner() called more than setOwner()", E_USER_WARNING); - $this->ownerRefs--; - if($this->ownerRefs == 0) $this->owner = null; + if(empty($this->ownerStack)) { + throw new BadMethodCallException("clearOwner() called more than setOwner()"); + } + array_pop($this->ownerStack); + if($this->ownerStack) { + $this->owner = end($this->ownerStack); + } else { + $this->owner = null; + } } /** @@ -97,7 +116,4 @@ abstract class Extension { return $parts[0]; } - - } - diff --git a/docs/en/02_Developer_Guides/00_Model/10_Versioning.md b/docs/en/02_Developer_Guides/00_Model/10_Versioning.md index 6c67099e6..5ef223c68 100644 --- a/docs/en/02_Developer_Guides/00_Model/10_Versioning.md +++ b/docs/en/02_Developer_Guides/00_Model/10_Versioning.md @@ -148,7 +148,7 @@ is initialized. But it can also be set and reset temporarily to force a specific $obj = MyRecord::getComplexObjectRetrieval(); // returns 'Stage' records Versioned::set_reading_mode($origMode); // reset current mode -### File ownership +### DataObject ownership Typically when publishing versioned dataobjects, it is necessary to ensure that some linked components are published along with it. Unless this is done, site front-end content can appear incorrectly published. @@ -160,8 +160,8 @@ The solution to this problem is the ownership API, which declares a two-way rela objects along database relations. This relationship is similar to many_many/belongs_many_many and has_one/has_many, however it relies on a pre-existing relationship to function. -For instance, in order to specify this dependency, you must apply `owns` and `owned_by` config -on a relationship. +For instance, in order to specify this dependency, you must apply `owns` on the owner to point to any +owned relationships. When pages of type `MyPage` are published, any owned images and banners will be automatically published, without requiring any custom code. @@ -185,33 +185,52 @@ without requiring any custom code. 'Parent' => 'MyPage', 'Image' => 'Image', ); - private static $owned_by = array( - 'Parent' - ); private static $owns = array( 'Image' ); } - - class BannerImageExtension extends DataExtension { - private static $has_many = array( - 'Banners' => 'Banner' + + +Note that ownership cannot be used with polymorphic relations. E.g. has_one to non-type specific `DataObject`. + +#### DataObject ownership with custom relations + +In some cases you might need to apply ownership where there is no underlying db relation, such as +those calculated at runtime based on business logic. In cases where you are not backing ownership +with standard relations (has_one, has_many, etc) it is necessary to declare ownership on both +sides of the relation. + +This can be done by creating methods on both sides of your relation (e.g. parent and child class) +that can be used to traverse between each, and then by ensuring you configure both +`owns` config (on the parent) and `owned_by` (on the child). + +E.g. + + :::php + class MyParent extends DataObject { + private static $extensions = array( + 'Versioned' + ); + private static $owns = array( + 'ChildObjects' + ); + public function ChildObjects() { + return MyChild::get(); + } + } + class MyChild extends DataObject { + private static $extensions = array( + 'Versioned' ); private static $owned_by = array( - 'Banners' + 'Parent' ); + public function Parent() { + return MyParent::get()->first(); + } } -With the config: - - :::yaml - Image: - extensions: - - BannerImageExtension - - -Note that it's important to define both `owns` and `owned_by` components of the relationship, -similar to how you would apply `has_one` and `has_many`, or `many_many` and `belongs_many_many`. +#### DataObject Ownership in HTML Content If you are using `[api:HTMLText]` or `[api:HTMLVarchar]` fields in your `DataObject::$db` definitions, it's likely that your authors can insert images into those fields via the CMS interface. diff --git a/model/DataObject.php b/model/DataObject.php index 0906d0b57..48d38da6f 100644 --- a/model/DataObject.php +++ b/model/DataObject.php @@ -1626,7 +1626,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity /** * Find the foreign class of a relation on this DataObject, regardless of the relation type. * - * @param $relationName Relation name. + * @param string $relationName Relation name. * @return string Class name, or null if not found. */ public function getRelationClass($relationName) { @@ -1654,6 +1654,137 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity return null; } + /** + * Given a relation name, determine the relation type + * + * @param string $component Name of component + * @return string has_one, has_many, many_many, belongs_many_many or belongs_to + */ + public function getRelationType($component) { + $types = array('has_one', 'has_many', 'many_many', 'belongs_many_many', 'belongs_to'); + foreach($types as $type) { + $relations = Config::inst()->get($this->class, $type); + if($relations && isset($relations[$component])) { + return $type; + } + } + return null; + } + + /** + * Given a relation declared on a remote class, generate a substitute component for the opposite + * side of the relation. + * + * Notes on behaviour: + * - This can still be used on components that are defined on both sides, but do not need to be. + * - All has_ones on remote class will be treated as local has_many, even if they are belongs_to + * - Cannot be used on polymorphic relationships + * - Cannot be used on unsaved objects. + * + * @param string $remoteClass + * @param string $remoteRelation + * @return DataList|DataObject The component, either as a list or single object + * @throws BadMethodCallException + * @throws InvalidArgumentException + */ + public function inferReciprocalComponent($remoteClass, $remoteRelation) { + /** @var DataObject $remote */ + $remote = $remoteClass::singleton(); + $class = $remote->getRelationClass($remoteRelation); + + // Validate arguments + if(!$this->isInDB()) { + throw new BadMethodCallException(__METHOD__ . " cannot be called on unsaved objects"); + } + if(empty($class)) { + throw new InvalidArgumentException(sprintf( + "%s invoked with invalid relation %s.%s", + __METHOD__, + $remoteClass, + $remoteRelation + )); + } + if($class === 'DataObject') { + throw new InvalidArgumentException(sprintf( + "%s cannot generate opposite component of relation %s.%s as it is polymorphic. " . + "This method does not support polymorphic relationships", + __METHOD__, + $remoteClass, + $remoteRelation + )); + } + if(!is_a($this, $class, true)) { + throw new InvalidArgumentException(sprintf( + "Relation %s on %s does not refer to objects of type %s", + $remoteRelation, $remoteClass, get_class($this) + )); + } + + // Check the relation type to mock + $relationType = $remote->getRelationType($remoteRelation); + switch($relationType) { + case 'has_one': { + // Mock has_many + $joinField = "{$remoteRelation}ID"; + $componentClass = ClassInfo::table_for_object_field($remoteClass, $joinField); + $result = HasManyList::create($componentClass, $joinField); + if ($this->model) { + $result->setDataModel($this->model); + } + return $result + ->setDataQueryParam($this->getInheritableQueryParams()) + ->forForeignID($this->ID); + } + case 'belongs_to': + case 'has_many': { + // These relations must have a has_one on the other end, so find it + $joinField = $remote->getRemoteJoinField($remoteRelation, $relationType, $polymorphic); + if ($polymorphic) { + throw new InvalidArgumentException(sprintf( + "%s cannot generate opposite component of relation %s.%s, as the other end appears" . + "to be a has_one polymorphic. This method does not support polymorphic relationships", + __METHOD__, + $remoteClass, + $remoteRelation + )); + } + $joinID = $this->getField($joinField); + if (empty($joinID)) { + return null; + } + // Get object by joined ID + return DataObject::get($remoteClass) + ->filter('ID', $joinID) + ->setDataQueryParam($this->getInheritableQueryParams()) + ->first(); + } + case 'many_many': + case 'belongs_many_many': { + // Get components and extra fields from parent + list($componentClass, $parentClass, $componentField, $parentField, $table) + = $remote->manyManyComponent($remoteRelation); + $extraFields = $remote->manyManyExtraFieldsForComponent($remoteRelation) ?: array(); + + // Reverse parent and component fields and create an inverse ManyManyList + /** @var ManyManyList $result */ + $result = ManyManyList::create($componentClass, $table, $componentField, $parentField, $extraFields); + if($this->model) { + $result->setDataModel($this->model); + } + $this->extend('updateManyManyComponents', $result); + + // If this is called on a singleton, then we return an 'orphaned relation' that can have the + // foreignID set elsewhere. + return $result + ->setDataQueryParam($this->getInheritableQueryParams()) + ->forForeignID($this->ID); + } + default: { + return null; + } + } + } + /** * Tries to find the database key on another object that is used to store a * relationship to this class. If no join field can be found it defaults to 'ParentID'. diff --git a/model/versioning/Versioned.php b/model/versioning/Versioned.php index 9e65eb407..beca53a75 100644 --- a/model/versioning/Versioned.php +++ b/model/versioning/Versioned.php @@ -944,12 +944,112 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { * @param ArrayList $list Optional list to add items to * @return ArrayList list of objects */ - public function findOwners($recursive = true, $list = null) - { - // Find objects in these relationships - return $this->findRelatedObjects('owned_by', $recursive, $list); + public function findOwners($recursive = true, $list = null) { + if (!$list) { + $list = new ArrayList(); + } + + // Build reverse lookup for ownership + // @todo - Cache this more intelligently + $rules = $this->lookupReverseOwners(); + + // Hand off to recursive method + return $this->findOwnersRecursive($recursive, $list, $rules); } + /** + * Find objects which own this object. + * Note that objects will only be searched in the same stage as the given record. + * + * @param bool $recursive True if recursive + * @param ArrayList $list List to add items to + * @param array $lookup List of reverse lookup rules for owned objects + * @return ArrayList list of objects + */ + public function findOwnersRecursive($recursive, $list, $lookup) { + // First pass: find objects that are explicitly owned_by (e.g. custom relationships) + $owners = $this->findRelatedObjects('owned_by', false); + + // Second pass: Find owners via reverse lookup list + foreach($lookup as $ownedClass => $classLookups) { + // Skip owners of other objects + if(!is_a($this->owner, $ownedClass)) { + continue; + } + foreach($classLookups as $classLookup) { + // Merge new owners into this object's owners + $ownerClass = $classLookup['class']; + $ownerRelation = $classLookup['relation']; + $result = $this->owner->inferReciprocalComponent($ownerClass, $ownerRelation); + $this->mergeRelatedObjects($owners, $result); + } + } + + // Merge all objects into the main list + $newItems = $this->mergeRelatedObjects($list, $owners); + + // If recursing, iterate over all newly added items + if($recursive) { + foreach($newItems as $item) { + /** @var Versioned|DataObject $item */ + $item->findOwnersRecursive(true, $list, $lookup); + } + } + + return $list; + } + + /** + * Find a list of classes, each of which with a list of methods to invoke + * to lookup owners. + * + * @return array + */ + protected function lookupReverseOwners() { + // Find all classes with 'owns' config + $lookup = array(); + foreach(ClassInfo::subclassesFor(DataObject::class) as $class) { + // Ensure this class is versioned + if(!Object::has_extension($class, Versioned::class)) { + continue; + } + + // Check owned objects for this class + $owns = Config::inst()->get($class, 'owns', Config::UNINHERITED); + if(empty($owns)) { + continue; + } + + /** @var DataObject $instance */ + $instance = $class::singleton(); + foreach($owns as $owned) { + // Find owned class + $ownedClass = $instance->getRelationClass($owned); + // Skip custom methods that don't have db relationsm + if(!$ownedClass) { + continue; + } + if($ownedClass === 'DataObject') { + throw new LogicException(sprintf( + "Relation %s on class %s cannot be owned as it is polymorphic", + $owned, $class + )); + } + + // Add lookup for owned class + if(!isset($lookup[$ownedClass])) { + $lookup[$ownedClass] = array(); + } + $lookup[$ownedClass][] = [ + 'class' => $class, + 'relation' => $owned + ]; + } + } + return $lookup; + } + + /** * Find objects in the given relationships, merging them into the given list * @@ -985,33 +1085,55 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { // Inspect value of this relationship $items = $owner->{$relationship}(); - if(!$items) { - continue; - } - if($items instanceof DataObject) { - $items = array($items); - } - /** @var Versioned|DataObject $item */ - foreach($items as $item) { - // Identify item - $itemKey = $item->class . '/' . $item->ID; + // Merge any new item + $newItems = $this->mergeRelatedObjects($list, $items); - // Skip unsaved, unversioned, or already checked objects - if(!$item->isInDB() || !$item->has_extension('Versioned') || isset($list[$itemKey])) { - continue; - } - - // Save record - $list[$itemKey] = $item; - if($recursive) { + // Recurse if necessary + if($recursive) { + foreach($newItems as $item) { + /** @var Versioned|DataObject $item */ $item->findRelatedObjects($source, true, $list); - }; + } } } return $list; } + /** + * Helper method to merge owned/owning items into a list. + * Items already present in the list will be skipped. + * + * @param ArrayList $list Items to merge into + * @param mixed $items List of new items to merge + * @return ArrayList List of all newly added items that did not already exist in $list + */ + protected function mergeRelatedObjects($list, $items) { + $added = new ArrayList(); + if(!$items) { + return $added; + } + if($items instanceof DataObject) { + $items = array($items); + } + + /** @var Versioned|DataObject $item */ + foreach($items as $item) { + // Identify item + $itemKey = $item->class . '/' . $item->ID; + + // Skip unsaved, unversioned, or already checked objects + if(!$item->isInDB() || !$item->has_extension('Versioned') || isset($list[$itemKey])) { + continue; + } + + // Save record + $list[$itemKey] = $item; + $added[$itemKey] = $item; + } + return $added; + } + /** * This function should return true if the current user can publish this record. * It can be overloaded to customise the security model for an application. @@ -2070,6 +2192,7 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { public function onAfterRollback($version) { // Find record at this version $baseClass = ClassInfo::baseDataClass($this->owner); + /** @var Versioned|DataObject $recordVersion */ $recordVersion = static::get_version($baseClass, $this->owner->ID, $version); // Note that unlike other publishing actions, rollback is NOT recursive; diff --git a/tests/model/DataExtensionTest.php b/tests/model/DataExtensionTest.php index 3a3652731..06715999f 100644 --- a/tests/model/DataExtensionTest.php +++ b/tests/model/DataExtensionTest.php @@ -220,6 +220,39 @@ class DataExtensionTest extends SapphireTest { $this->assertNotEmpty($fields->dataFieldByName('ChildField')); $this->assertNotEmpty($fields->dataFieldByName('GrandchildField')); } + + /** + * Test setOwner behaviour + */ + public function testSetOwner() { + $extension = new DataExtensionTest_Ext1(); + $obj1 = $this->objFromFixture('DataExtensionTest_RelatedObject', 'obj1'); + $obj2 = $this->objFromFixture('DataExtensionTest_RelatedObject', 'obj1'); + + $extension->setOwner(null); + $this->assertNull($extension->getOwner()); + + // Set original owner + $extension->setOwner($obj1); + $this->assertEquals($obj1, $extension->getOwner()); + + // Set nested owner + $extension->setOwner($obj2); + $this->assertEquals($obj2, $extension->getOwner()); + + // Clear nested owner + $extension->clearOwner(); + $this->assertEquals($obj1, $extension->getOwner()); + + // Clear original owner + $extension->clearOwner(); + $this->assertNull($extension->getOwner()); + + // Another clearOwner should error + $this->setExpectedException("BadMethodCallException", "clearOwner() called more than setOwner()"); + $extension->clearOwner(); + } + } class DataExtensionTest_Member extends DataObject implements TestOnly { diff --git a/tests/model/DataObjectTest.php b/tests/model/DataObjectTest.php index 26907fd1a..01cceec44 100644 --- a/tests/model/DataObjectTest.php +++ b/tests/model/DataObjectTest.php @@ -318,6 +318,12 @@ class DataObjectTest extends SapphireTest { // There will be a method called $obj->relname() that returns the object itself $this->assertEquals($team1ID, $captain1->FavouriteTeam()->ID); + // Test that getNonReciprocalComponent can find has_one from the has_many end + $this->assertEquals( + $team1ID, + $captain1->inferReciprocalComponent('DataObjectTest_Team', 'PlayerFans')->ID + ); + // Check entity with polymorphic has-one $fan1 = $this->objFromFixture("DataObjectTest_Fan", "fan1"); $this->assertTrue((bool)$fan1->hasValue('Favourite')); @@ -408,10 +414,19 @@ class DataObjectTest extends SapphireTest { // Test getComponents() gets the ComponentSet of the other side of the relation $this->assertTrue($team1->Comments()->Count() == 2); + $team1Comments = [ + ['Comment' => 'This is a team comment by Joe'], + ['Comment' => 'This is a team comment by Bob'], + ]; + // Test the IDs on the DataObjects are set correctly - foreach($team1->Comments() as $comment) { - $this->assertEquals($team1->ID, $comment->TeamID); - } + $this->assertDOSEquals($team1Comments, $team1->Comments()); + + // Test that has_many can be infered from the has_one via getNonReciprocalComponent + $this->assertDOSEquals( + $team1Comments, + $team1->inferReciprocalComponent('DataObjectTest_TeamComment', 'Team') + ); // Test that we can add and remove items that already exist in the database $newComment = new DataObjectTest_TeamComment(); @@ -1220,6 +1235,7 @@ class DataObjectTest extends SapphireTest { public function testMultipleManyManyWithSameClass() { $team = $this->objFromFixture('DataObjectTest_Team', 'team1'); + $company2 = $this->objFromFixture('DataObjectTest_EquipmentCompany', 'equipmentcompany2'); $sponsors = $team->Sponsors(); $equipmentSuppliers = $team->EquipmentSuppliers(); @@ -1241,6 +1257,25 @@ class DataObjectTest extends SapphireTest { $this->assertInstanceOf('ManyManyList', $teamWithoutSponsor->Sponsors()); $this->assertEquals(0, $teamWithoutSponsor->Sponsors()->count()); + // Test that belongs_many_many can be infered from with getNonReciprocalComponent + $this->assertDOSEquals( + [ + ['Name' => 'Company corp'], + ['Name' => 'Team co.'], + ], + $team->inferReciprocalComponent('DataObjectTest_EquipmentCompany', 'SponsoredTeams') + ); + + // Test that many_many can be infered from getNonReciprocalComponent + $this->assertDOSEquals( + [ + ['Title' => 'Team 1'], + ['Title' => 'Team 2'], + ['Title' => 'Subteam 1'], + ], + $company2->inferReciprocalComponent('DataObjectTest_Team', 'Sponsors') + ); + // Check many_many_extraFields still works $equipmentCompany = $this->objFromFixture('DataObjectTest_EquipmentCompany', 'equipmentcompany1'); $equipmentCompany->SponsoredTeams()->add($teamWithoutSponsor, array('SponsorFee' => 1000)); @@ -1269,6 +1304,7 @@ class DataObjectTest extends SapphireTest { $subTeam->Sponsors()->add($subEquipmentCompany, array('SponsorFee' => 1200)); $this->assertEquals(1200, $subTeam->Sponsors()->byID($subEquipmentCompany->ID)->SponsorFee, 'Data from inherited many_many_extraFields was not stored/extracted correctly'); + } public function testManyManyExtraFields() { @@ -1537,6 +1573,7 @@ class DataObjectTest extends SapphireTest { $company = new DataObjectTest_Company(); $ceo = new DataObjectTest_CEO(); + $company->Name = 'New Company'; $company->write(); $ceo->write(); @@ -1546,6 +1583,19 @@ class DataObjectTest extends SapphireTest { $this->assertEquals($company->ID, $ceo->Company()->ID, 'belongs_to returns the right results.'); + // Test belongs_to can be infered via getNonReciprocalComponent + // Note: Will be returned as has_many since the belongs_to is ignored. + $this->assertDOSEquals( + [['Name' => 'New Company']], + $ceo->inferReciprocalComponent('DataObjectTest_Company', 'CEO') + ); + + // Test has_one to a belongs_to can be infered via getNonReciprocalComponent + $this->assertEquals( + $ceo->ID, + $company->inferReciprocalComponent('DataObjectTest_CEO', 'Company')->ID + ); + // Test automatic creation of class where no assigment exists $ceo = new DataObjectTest_CEO(); $ceo->write(); @@ -1749,7 +1799,8 @@ class DataObjectTest_Team extends DataObject implements TestOnly { private static $has_many = array( 'SubTeams' => 'DataObjectTest_SubTeam', 'Comments' => 'DataObjectTest_TeamComment', - 'Fans' => 'DataObjectTest_Fan.Favourite' // Polymorphic - Team fans + 'Fans' => 'DataObjectTest_Fan.Favourite', // Polymorphic - Team fans + 'PlayerFans' => 'DataObjectTest_Player.FavouriteTeam' ); private static $many_many = array( diff --git a/tests/model/VersionedOwnershipTest.php b/tests/model/VersionedOwnershipTest.php index 1b7f5f2e9..5825ad5d1 100644 --- a/tests/model/VersionedOwnershipTest.php +++ b/tests/model/VersionedOwnershipTest.php @@ -11,12 +11,15 @@ class VersionedOwnershipTest extends SapphireTest { 'VersionedOwnershipTest_Related', 'VersionedOwnershipTest_Attachment', 'VersionedOwnershipTest_RelatedMany', + 'VersionedOwnershipTest_Page', + 'VersionedOwnershipTest_Banner', + 'VersionedOwnershipTest_Image', + 'VersionedOwnershipTest_CustomRelation', ); protected static $fixture_file = 'VersionedOwnershipTest.yml'; - public function setUp() - { + public function setUp() { parent::setUp(); Versioned::set_stage(Versioned::DRAFT); @@ -27,7 +30,7 @@ class VersionedOwnershipTest extends SapphireTest { if(stripos($name, '_published') !== false) { /** @var Versioned|DataObject $object */ $object = DataObject::get($class)->byID($id); - $object->publish('Stage', 'Live'); + $object->publish(Versioned::DRAFT, Versioned::LIVE); } } } @@ -504,6 +507,80 @@ class VersionedOwnershipTest extends SapphireTest { ); } + /** + * Test that you can find owners without owned_by being defined explicitly + */ + public function testInferedOwners() { + // Make sure findOwned() works + /** @var VersionedOwnershipTest_Page $page1 */ + $page1 = $this->objFromFixture('VersionedOwnershipTest_Page', 'page1_published'); + /** @var VersionedOwnershipTest_Page $page2 */ + $page2 = $this->objFromFixture('VersionedOwnershipTest_Page', 'page2_published'); + $this->assertDOSEquals( + [ + ['Title' => 'Banner 1'], + ['Title' => 'Image 1'], + ['Title' => 'Custom 1'], + ], + $page1->findOwned() + ); + $this->assertDOSEquals( + [ + ['Title' => 'Banner 2'], + ['Title' => 'Banner 3'], + ['Title' => 'Image 1'], + ['Title' => 'Image 2'], + ['Title' => 'Custom 2'], + ], + $page2->findOwned() + ); + + // Check that findOwners works + /** @var VersionedOwnershipTest_Image $image1 */ + $image1 = $this->objFromFixture('VersionedOwnershipTest_Image', 'image1_published'); + /** @var VersionedOwnershipTest_Image $image2 */ + $image2 = $this->objFromFixture('VersionedOwnershipTest_Image', 'image2_published'); + + $this->assertDOSEquals( + [ + ['Title' => 'Banner 1'], + ['Title' => 'Banner 2'], + ['Title' => 'Page 1'], + ['Title' => 'Page 2'], + ], + $image1->findOwners() + ); + $this->assertDOSEquals( + [ + ['Title' => 'Banner 1'], + ['Title' => 'Banner 2'], + ], + $image1->findOwners(false) + ); + $this->assertDOSEquals( + [ + ['Title' => 'Banner 3'], + ['Title' => 'Page 2'], + ], + $image2->findOwners() + ); + $this->assertDOSEquals( + [ + ['Title' => 'Banner 3'], + ], + $image2->findOwners(false) + ); + + // Test custom relation can findOwners() + /** @var VersionedOwnershipTest_CustomRelation $custom1 */ + $custom1 = $this->objFromFixture('VersionedOwnershipTest_CustomRelation', 'custom1_published'); + $this->assertDOSEquals( + [['Title' => 'Page 1']], + $custom1->findOwners() + ); + + } + } /** @@ -622,3 +699,107 @@ class VersionedOwnershipTest_Attachment extends DataObject implements TestOnly { 'AttachedTo' ); } + +/** + * Page which owns a lits of banners + * + * @mixin Versioned + */ +class VersionedOwnershipTest_Page extends DataObject implements TestOnly { + private static $extensions = array( + 'Versioned', + ); + + private static $db = array( + 'Title' => 'Varchar(255)', + ); + + private static $many_many = array( + 'Banners' => 'VersionedOwnershipTest_Banner', + ); + + private static $owns = array( + 'Banners', + 'Custom' + ); + + /** + * All custom objects with the same number. E.g. 'Page 1' owns 'Custom 1' + * + * @return DataList + */ + public function Custom() { + $title = str_replace('Page', 'Custom', $this->Title); + return VersionedOwnershipTest_CustomRelation::get() + ->filter('Title', $title); + } +} + +/** + * Banner which doesn't declare its belongs_many_many, but owns an Image + * + * @mixin Versioned + */ +class VersionedOwnershipTest_Banner extends DataObject implements TestOnly { + private static $extensions = array( + 'Versioned', + ); + + private static $db = array( + 'Title' => 'Varchar(255)', + ); + + private static $has_one = array( + 'Image' => 'VersionedOwnershipTest_Image', + ); + + private static $owns = array( + 'Image', + ); +} + + +/** + * Object which is owned via a custom PHP method rather than DB relation + * + * @mixin Versioned + */ +class VersionedOwnershipTest_CustomRelation extends DataObject implements TestOnly { + private static $extensions = array( + 'Versioned', + ); + + private static $db = array( + 'Title' => 'Varchar(255)', + ); + + private static $owned_by = array( + 'Pages' + ); + + /** + * All pages with the same number. E.g. 'Page 1' owns 'Custom 1' + * + * @return DataList + */ + public function Pages() { + $title = str_replace('Custom', 'Page', $this->Title); + return VersionedOwnershipTest_Page::get()->filter('Title', $title); + } + +} + +/** + * Simple versioned dataobject + * + * @mixin Versioned + */ +class VersionedOwnershipTest_Image extends DataObject implements TestOnly { + private static $extensions = array( + 'Versioned', + ); + + private static $db = array( + 'Title' => 'Varchar(255)', + ); +} diff --git a/tests/model/VersionedOwnershipTest.yml b/tests/model/VersionedOwnershipTest.yml index 40f0b1276..94558eb77 100644 --- a/tests/model/VersionedOwnershipTest.yml +++ b/tests/model/VersionedOwnershipTest.yml @@ -43,3 +43,34 @@ VersionedOwnershipTest_RelatedMany: VersionedOwnershipTest_Object: object1: Title: 'Object 1' + +VersionedOwnershipTest_Image: + image1_published: + Title: 'Image 1' + image2_published: + Title: 'Image 2' + +VersionedOwnershipTest_Banner: + banner1_published: + Title: 'Banner 1' + Image: =>VersionedOwnershipTest_Image.image1_published + banner2_published: + Title: 'Banner 2' + Image: =>VersionedOwnershipTest_Image.image1_published + banner3_published: + Title: 'Banner 3' + Image: =>VersionedOwnershipTest_Image.image2_published + +VersionedOwnershipTest_Page: + page1_published: + Title: 'Page 1' + Banners: =>VersionedOwnershipTest_Banner.banner1_published + page2_published: + Title: 'Page 2' + Banners: =>VersionedOwnershipTest_Banner.banner2_published,=>VersionedOwnershipTest_Banner.banner3_published + +VersionedOwnershipTest_CustomRelation: + custom1_published: + Title: 'Custom 1' + custom2_published: + Title: 'Custom 2'