From c405ed6cf3977f2502be158432816aa1a06957dd Mon Sep 17 00:00:00 2001 From: Guy Sartorelli <36352093+GuySartorelli@users.noreply.github.com> Date: Tue, 12 Dec 2023 10:18:25 +1300 Subject: [PATCH] NEW Allow a single has_one to manage multiple reciprocal has_many (#11084) --- _config/model.yml | 2 + .../Validation/RelationValidationService.php | 29 ++- .../GridFieldDetailForm_ItemRequest.php | 8 + src/ORM/DataObject.php | 41 ++-- src/ORM/DataObjectSchema.php | 104 ++++++++- src/ORM/DataQuery.php | 11 +- .../DBPolymorphicRelationAwareForeignKey.php | 38 ++++ src/ORM/PolymorphicHasManyList.php | 77 ++++++- tests/php/Dev/Validation/Member.php | 2 + .../Dev/Validation/RelationValidationTest.php | 49 ++++ tests/php/Dev/Validation/Team.php | 8 + .../GridField/GridFieldDetailFormTest.php | 27 +++ .../MultiReciprocalPeopleGroup.php | 38 ++++ .../GridFieldDetailFormTest/Person.php | 5 + .../PolymorphicPeopleGroup.php | 2 +- tests/php/ORM/DataObjectSchemaTest.php | 214 +++++++++++++----- .../ORM/DataObjectSchemaTest/WithRelation.php | 13 +- tests/php/ORM/DataObjectTest/Player.php | 6 + tests/php/ORM/DataObjectTest/Team.php | 5 +- tests/php/ORM/DataQueryTest.php | 29 +++ .../ObjectHasMultiReciprocalHasMany.php | 21 ++ .../ObjectHasMultiReciprocalHasOne.php | 24 ++ tests/php/ORM/PolymorphicHasManyListTest.php | 126 ++++++++++- tests/php/ORM/PolymorphicHasManyListTest.yml | 25 ++ 24 files changed, 808 insertions(+), 96 deletions(-) create mode 100644 src/ORM/FieldType/DBPolymorphicRelationAwareForeignKey.php create mode 100644 tests/php/Forms/GridField/GridFieldDetailFormTest/MultiReciprocalPeopleGroup.php create mode 100644 tests/php/ORM/DataQueryTest/ObjectHasMultiReciprocalHasMany.php create mode 100644 tests/php/ORM/DataQueryTest/ObjectHasMultiReciprocalHasOne.php create mode 100644 tests/php/ORM/PolymorphicHasManyListTest.yml diff --git a/_config/model.yml b/_config/model.yml index 9520d3933..cfb60c02a 100644 --- a/_config/model.yml +++ b/_config/model.yml @@ -48,6 +48,8 @@ SilverStripe\Core\Injector\Injector: class: SilverStripe\ORM\FieldType\DBPercentage PolymorphicForeignKey: class: SilverStripe\ORM\FieldType\DBPolymorphicForeignKey + PolymorphicRelationAwareForeignKey: + class: SilverStripe\ORM\FieldType\DBPolymorphicRelationAwareForeignKey PrimaryKey: class: SilverStripe\ORM\FieldType\DBPrimaryKey Text: diff --git a/src/Dev/Validation/RelationValidationService.php b/src/Dev/Validation/RelationValidationService.php index 5fc4a75e3..0b85bbbbf 100644 --- a/src/Dev/Validation/RelationValidationService.php +++ b/src/Dev/Validation/RelationValidationService.php @@ -2,12 +2,14 @@ namespace SilverStripe\Dev\Validation; +use InvalidArgumentException; use ReflectionException; use SilverStripe\Core\ClassInfo; use SilverStripe\Core\Config\Configurable; use SilverStripe\Core\Injector\Injectable; use SilverStripe\Core\Resettable; use SilverStripe\ORM\DataObject; +use SilverStripe\ORM\DataObjectSchema; use SilverStripe\ORM\DB; /** @@ -291,6 +293,25 @@ class RelationValidationService implements Resettable $relations = (array) $singleton->config()->uninherited('has_one'); foreach ($relations as $relationName => $relationData) { + if (is_array($relationData)) { + $spec = $relationData; + if (!isset($spec['class'])) { + $this->logError($class, $relationName, 'No class has been defined for this relation.'); + continue; + } + $relationData = $spec['class']; + if (($spec[DataObjectSchema::HAS_ONE_MULTI_RELATIONAL] ?? false) === true + && $relationData !== DataObject::class + ) { + $this->logError( + $class, + $relationName, + 'has_one relation that can handle multiple reciprocal has_many relations must be polymorphic.' + ); + continue; + } + } + if ($this->isIgnored($class, $relationName)) { continue; } @@ -305,6 +326,11 @@ class RelationValidationService implements Resettable return; } + // Skip checking for back relations when has_one is polymorphic + if ($relationData === DataObject::class) { + continue; + } + if (!is_subclass_of($relationData, DataObject::class)) { $this->logError( $class, @@ -616,7 +642,8 @@ class RelationValidationService implements Resettable return null; } - return $throughRelations[$to]; + $spec = $throughRelations[$to]; + return is_array($spec) ? $spec['class'] ?? null : $spec; } return $relationData; diff --git a/src/Forms/GridField/GridFieldDetailForm_ItemRequest.php b/src/Forms/GridField/GridFieldDetailForm_ItemRequest.php index e3b5b8c3b..8804c69ee 100644 --- a/src/Forms/GridField/GridFieldDetailForm_ItemRequest.php +++ b/src/Forms/GridField/GridFieldDetailForm_ItemRequest.php @@ -204,6 +204,14 @@ class GridFieldDetailForm_ItemRequest extends RequestHandler $classKey = $list->getForeignClassKey(); $class = $list->getForeignClass(); $this->record->$classKey = $class; + + // If the has_one relation storing the data can handle multiple reciprocal has_many relations, + // make sure we tell it which has_many relation this belongs to. + $relation = $list->getForeignRelation(); + if ($relation) { + $relationKey = $list->getForeignRelationKey(); + $this->record->$relationKey = $relation; + } } } diff --git a/src/ORM/DataObject.php b/src/ORM/DataObject.php index 3e978eb05..e2760f647 100644 --- a/src/ORM/DataObject.php +++ b/src/ORM/DataObject.php @@ -12,6 +12,7 @@ use SilverStripe\Core\Config\Config; use SilverStripe\Core\Injector\Injector; use SilverStripe\Core\Resettable; use SilverStripe\Dev\Debug; +use SilverStripe\Dev\Deprecation; use SilverStripe\Forms\FieldList; use SilverStripe\Forms\FormField; use SilverStripe\Forms\FormScaffolder; @@ -1972,12 +1973,14 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } // Determine type and nature of foreign relation - $joinField = $schema->getRemoteJoinField(static::class, $componentName, 'has_many', $polymorphic); - /** @var HasManyList $result */ - if ($polymorphic) { - $result = PolymorphicHasManyList::create($componentClass, $joinField, static::class); + $details = $schema->getHasManyComponentDetails(static::class, $componentName); + if ($details['polymorphic']) { + $result = PolymorphicHasManyList::create($componentClass, $details['joinField'], static::class); + if ($details['needsRelation']) { + Deprecation::withNoReplacement(fn () => $result->setForeignRelation($componentName)); + } } else { - $result = HasManyList::create($componentClass, $joinField); + $result = HasManyList::create($componentClass, $details['joinField']); } return $result @@ -1993,16 +1996,21 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity */ public function getRelationClass($relationName) { - // Parse many_many - $manyManyComponent = $this->getSchema()->manyManyComponent(static::class, $relationName); + // Parse many_many, which can have an array instead of a class name + $manyManyComponent = static::getSchema()->manyManyComponent(static::class, $relationName); if ($manyManyComponent) { return $manyManyComponent['childClass']; } - // Go through all relationship configuration fields. + // Parse has_one, which can have an array instead of a class name + $hasOneComponent = static::getSchema()->hasOneComponent(static::class, $relationName); + if ($hasOneComponent) { + return $hasOneComponent; + } + + // Go through all remaining relationship configuration fields. $config = $this->config(); $candidates = array_merge( - ($relations = $config->get('has_one')) ? $relations : [], ($relations = $config->get('has_many')) ? $relations : [], ($relations = $config->get('belongs_to')) ? $relations : [] ); @@ -2228,15 +2236,20 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } /** - * Return the class of a one-to-one component. If $component is null, return all of the one-to-one components and - * their classes. If the selected has_one is a polymorphic field then 'DataObject' will be returned for the type. + * Return the class of a all has_one relations. * - * @return string|array The class of the one-to-one component, or an array of all one-to-one components and - * their classes. + * @return array An array of all has_one components and their classes. */ public function hasOne() { - return (array)$this->config()->get('has_one'); + $hasOne = (array) $this->config()->get('has_one'); + // Boil down has_one spec to just the class name + foreach ($hasOne as $relationName => $spec) { + if (is_array($spec)) { + $hasOne[$relationName] = DataObject::getSchema()->hasOneComponent(static::class, $relationName); + } + } + return $hasOne; } /** diff --git a/src/ORM/DataObjectSchema.php b/src/ORM/DataObjectSchema.php index 09e15149a..c6e9cf449 100644 --- a/src/ORM/DataObjectSchema.php +++ b/src/ORM/DataObjectSchema.php @@ -24,6 +24,11 @@ class DataObjectSchema use Injectable; use Configurable; + /** + * Configuration key for has_one relations that can support multiple reciprocal has_many relations. + */ + public const HAS_ONE_MULTI_RELATIONAL = 'multirelational'; + /** * Default separate for table namespaces. Can be set to any string for * databases that do not support some characters. @@ -501,7 +506,20 @@ class DataObjectSchema // Add in all has_ones $hasOne = Config::inst()->get($class, 'has_one', Config::UNINHERITED) ?: []; - foreach ($hasOne as $fieldName => $hasOneClass) { + foreach ($hasOne as $fieldName => $spec) { + if (is_array($spec)) { + if (!isset($spec['class'])) { + throw new LogicException("has_one relation {$class}.{$fieldName} must declare a class"); + } + // Handle has_one which handles multiple reciprocal has_many relations + $hasOneClass = $spec['class']; + if (($spec[self::HAS_ONE_MULTI_RELATIONAL] ?? false) === true) { + $compositeFields[$fieldName] = 'PolymorphicRelationAwareForeignKey'; + continue; + } + } else { + $hasOneClass = $spec; + } if ($hasOneClass === DataObject::class) { $compositeFields[$fieldName] = 'PolymorphicForeignKey'; } else { @@ -902,12 +920,34 @@ class DataObjectSchema return null; } + $spec = $hasOnes[$component]; + // Validate - $relationClass = $hasOnes[$component]; + if (is_array($spec)) { + $this->checkHasOneArraySpec($class, $component, $spec); + } + $relationClass = is_array($spec) ? $spec['class'] : $spec; $this->checkRelationClass($class, $component, $relationClass, 'has_one'); + return $relationClass; } + /** + * Check if a has_one relation handles multiple reciprocal has_many relations. + * + * @return bool True if the relation exists and handles multiple reciprocal has_many relations. + */ + public function hasOneComponentHandlesMultipleRelations(string $class, string $component): bool + { + $hasOnes = Config::forClass($class)->get('has_one'); + if (!isset($hasOnes[$component])) { + return false; + } + + $spec = $hasOnes[$component]; + return ($spec[self::HAS_ONE_MULTI_RELATIONAL] ?? false) === true; + } + /** * Return data for a specific belongs_to component. * @@ -1047,6 +1087,20 @@ class DataObjectSchema */ public function getRemoteJoinField($class, $component, $type = 'has_many', &$polymorphic = false) { + return $this->getBelongsToAndHasManyDetails($class, $component, $type, $polymorphic)['joinField']; + } + + public function getHasManyComponentDetails(string $class, string $component): array + { + return $this->getBelongsToAndHasManyDetails($class, $component); + } + + private function getBelongsToAndHasManyDetails( + string $class, + string $component, + string $type = 'has_many', + &$polymorphic = false + ): array { // Extract relation from current object if ($type === 'has_many') { $remoteClass = $this->hasManyComponent($class, $component, false); @@ -1071,6 +1125,11 @@ class DataObjectSchema // Reference remote has_one to check against $remoteRelations = Config::inst()->get($remoteClass, 'has_one'); + foreach ($remoteRelations as $key => $value) { + if (is_array($value)) { + $remoteRelations[$key] = $this->hasOneComponent($remoteClass, $key); + } + } // Without an explicit field name, attempt to match the first remote field // with the same type as the current class @@ -1104,14 +1163,23 @@ class DataObjectSchema on class {$class}"); } - // Inspect resulting found relation - if ($remoteRelations[$remoteField] === DataObject::class) { - $polymorphic = true; - return $remoteField; // Composite polymorphic field does not include 'ID' suffix - } else { - $polymorphic = false; - return $remoteField . 'ID'; + $polymorphic = $this->hasOneComponent($remoteClass, $remoteField) === DataObject::class; + $remoteClassField = $polymorphic ? $remoteField . 'Class' : null; + $needsRelation = $type === 'has_many' && $polymorphic && $this->hasOneComponentHandlesMultipleRelations($remoteClass, $remoteField); + $remoteRelationField = $needsRelation ? $remoteField . 'Relation' : null; + + // This must be after the above assignments, as they rely on the original value. + if (!$polymorphic) { + $remoteField .= 'ID'; } + + return [ + 'joinField' => $remoteField, + 'relationField' => $remoteRelationField, + 'classField' => $remoteClassField, + 'polymorphic' => $polymorphic, + 'needsRelation' => $needsRelation, + ]; } /** @@ -1204,6 +1272,24 @@ class DataObjectSchema return $joinClass; } + private function checkHasOneArraySpec(string $class, string $component, array $spec): void + { + if (!array_key_exists('class', $spec)) { + throw new InvalidArgumentException( + "has_one relation {$class}.{$component} doesn't define a class for the relation" + ); + } + + if (($spec[self::HAS_ONE_MULTI_RELATIONAL] ?? false) === true + && $spec['class'] !== DataObject::class + ) { + throw new InvalidArgumentException( + "has_one relation {$class}.{$component} must be polymorphic, or not support multiple" + . 'reciprocal has_many relations' + ); + } + } + /** * Validate a given class is valid for a relation * diff --git a/src/ORM/DataQuery.php b/src/ORM/DataQuery.php index 327a0c4ea..05692c3f7 100644 --- a/src/ORM/DataQuery.php +++ b/src/ORM/DataQuery.php @@ -1025,8 +1025,6 @@ class DataQuery * Join the given has_many relation to this query. * Also works with belongs_to * - * Doesn't work with polymorphic relationships - * * @param string $localClass Name of class that has the has_many to the joined class * @param string $localField Name of the has_many relationship to join * @param string $foreignClass Class to join @@ -1065,6 +1063,15 @@ class DataQuery $localClassColumn = $schema->sqlColumnForField($localClass, 'ClassName', $localPrefix); $joinExpression = "{$foreignKeyIDColumn} = {$localIDColumn} AND {$foreignKeyClassColumn} = {$localClassColumn}"; + + // Add relation key if the has_many points to a has_one that could handle multiple reciprocal has_many relations + if ($type === 'has_many') { + $details = $schema->getHasManyComponentDetails($localClass, $localField); + if ($details['needsRelation']) { + $foreignKeyRelationColumn = $schema->sqlColumnForField($foreignClass, "{$foreignKey}Relation", $foreignPrefix); + $joinExpression .= " AND {$foreignKeyRelationColumn} = {$localField}"; + } + } } else { $foreignKeyIDColumn = $schema->sqlColumnForField($foreignClass, $foreignKey, $foreignPrefix); $joinExpression = "{$foreignKeyIDColumn} = {$localIDColumn}"; diff --git a/src/ORM/FieldType/DBPolymorphicRelationAwareForeignKey.php b/src/ORM/FieldType/DBPolymorphicRelationAwareForeignKey.php new file mode 100644 index 000000000..682b8e5ff --- /dev/null +++ b/src/ORM/FieldType/DBPolymorphicRelationAwareForeignKey.php @@ -0,0 +1,38 @@ + 'Varchar', + ]; + + /** + * Get the value of the "Relation" this key points to + * + * @return string Name of the has_many relation being stored + */ + public function getRelationValue(): string + { + return $this->getField('Relation'); + } + + /** + * Set the value of the "Relation" this key points to + * + * @param string $value Name of the has_many relation being stored + * @param bool $markChanged Mark this field as changed? + */ + public function setRelationValue(string $value, bool $markChanged = true): static + { + $this->setField('Relation', $value, $markChanged); + return $this; + } +} diff --git a/src/ORM/PolymorphicHasManyList.php b/src/ORM/PolymorphicHasManyList.php index 499d8e003..36f5b4fe6 100644 --- a/src/ORM/PolymorphicHasManyList.php +++ b/src/ORM/PolymorphicHasManyList.php @@ -5,9 +5,11 @@ namespace SilverStripe\ORM; use SilverStripe\Core\ClassInfo; use SilverStripe\Core\Convert; use InvalidArgumentException; +use SilverStripe\Dev\Deprecation; +use Traversable; /** - * Represents a has_many list linked against a polymorphic relationship + * Represents a has_many list linked against a polymorphic relationship. */ class PolymorphicHasManyList extends HasManyList { @@ -20,7 +22,13 @@ class PolymorphicHasManyList extends HasManyList protected $classForeignKey; /** - * Retrieve the name of the class this relation is filtered by + * Name of the foreign key field that references the relation name, for has_one + * relations that can handle multiple reciprocal has_many relations. + */ + protected string $relationForeignKey; + + /** + * Retrieve the name of the class this (has_many) relation is filtered by * * @return string */ @@ -30,19 +38,51 @@ class PolymorphicHasManyList extends HasManyList } /** - * Gets the field name which holds the related object class. + * Retrieve the name of the has_many relation this list is filtered by + */ + public function getForeignRelation(): ?string + { + return $this->dataQuery->getQueryParam('Foreign.Relation'); + } + + /** + * Retrieve the name of the has_many relation this list is filtered by + * + * @deprecated 5.2.0 Will be replaced with a parameter in the constructor + */ + public function setForeignRelation(string $relationName): static + { + Deprecation::notice('5.2.0', 'Will be replaced with a parameter in the constructor'); + $this->dataQuery->where(["\"{$this->relationForeignKey}\"" => $relationName]); + $this->dataQuery->setQueryParam('Foreign.Relation', $relationName); + return $this; + } + + /** + * Gets the field name which holds the related (has_many) object class. */ public function getForeignClassKey(): string { return $this->classForeignKey; } + /** + * Gets the field name which holds the has_many relation name. + * + * Note that this will return a value even if the has_one relation + * doesn't support multiple reciprocal has_many relations. + */ + public function getForeignRelationKey(): string + { + return $this->relationForeignKey; + } + /** * Create a new PolymorphicHasManyList relation list. * * @param string $dataClass The class of the DataObjects that this will list. - * @param string $foreignField The name of the composite foreign relation field. Used - * to generate the ID and Class foreign keys. + * @param string $foreignField The name of the composite foreign (has_one) relation field. Used + * to generate the ID, Class, and Relation foreign keys. * @param string $foreignClass Name of the class filter this relation is filtered against */ public function __construct($dataClass, $foreignField, $foreignClass) @@ -50,6 +90,7 @@ class PolymorphicHasManyList extends HasManyList // Set both id foreign key (as in HasManyList) and the class foreign key parent::__construct($dataClass, "{$foreignField}ID"); $this->classForeignKey = "{$foreignField}Class"; + $this->relationForeignKey = "{$foreignField}Relation"; // Ensure underlying DataQuery globally references the class filter $this->dataQuery->setQueryParam('Foreign.Class', $foreignClass); @@ -98,11 +139,19 @@ class PolymorphicHasManyList extends HasManyList return; } + // set the {$relationName}Class field value $foreignKey = $this->foreignKey; $classForeignKey = $this->classForeignKey; $item->$foreignKey = $foreignID; $item->$classForeignKey = $this->getForeignClass(); + // set the {$relationName}Relation field value if appropriate + $foreignRelation = $this->getForeignRelation(); + if ($foreignRelation) { + $relationForeignKey = $this->getForeignRelationKey(); + $item->$relationForeignKey = $foreignRelation; + } + $item->write(); } @@ -129,6 +178,13 @@ class PolymorphicHasManyList extends HasManyList return; } + // Don't remove item with unrelated relation key + $foreignRelation = $this->getForeignRelation(); + $relationForeignKey = $this->getForeignRelationKey(); + if (!$this->relationMatches($item->$relationForeignKey, $foreignRelation)) { + return; + } + // Don't remove item which doesn't belong to this list $foreignID = $this->getForeignID(); $foreignKey = $this->foreignKey; @@ -137,9 +193,20 @@ class PolymorphicHasManyList extends HasManyList || $foreignID == $item->$foreignKey || (is_array($foreignID) && in_array($item->$foreignKey, $foreignID ?? [])) ) { + // Unset the foreign relation key if appropriate + if ($foreignRelation) { + $item->$relationForeignKey = null; + } + + // Unset the rest of the relation and write the record $item->$foreignKey = null; $item->$classForeignKey = null; $item->write(); } } + + private function relationMatches(?string $actual, ?string $expected): bool + { + return (empty($actual) && empty($expected)) || $actual === $expected; + } } diff --git a/tests/php/Dev/Validation/Member.php b/tests/php/Dev/Validation/Member.php index 1af4a81f7..9c302e0d8 100644 --- a/tests/php/Dev/Validation/Member.php +++ b/tests/php/Dev/Validation/Member.php @@ -43,6 +43,8 @@ class Member extends DataObject implements TestOnly */ private static $has_many = [ 'TemporaryMembers' => Freelancer::class . '.TemporaryMember', + 'ManyTeams' => Team::class . '.SingleMember', + 'ManyMoreTeams' => Team::class . '.SingleMember', ]; /** diff --git a/tests/php/Dev/Validation/RelationValidationTest.php b/tests/php/Dev/Validation/RelationValidationTest.php index adc8022e9..04f57c4af 100644 --- a/tests/php/Dev/Validation/RelationValidationTest.php +++ b/tests/php/Dev/Validation/RelationValidationTest.php @@ -6,6 +6,8 @@ use Page; use SilverStripe\Core\Config\Config; use SilverStripe\Dev\SapphireTest; use SilverStripe\Dev\Validation\RelationValidationService; +use SilverStripe\ORM\DataObject; +use SilverStripe\ORM\DataObjectSchema; class RelationValidationTest extends SapphireTest { @@ -107,6 +109,14 @@ class RelationValidationTest extends SapphireTest 'SilverStripe\Dev\Tests\Validation\Member / Hat : Back relation is ambiguous', ], ], + 'polymorphic has one' => [ + Team::class, + 'has_one', + [ + 'SingleMember' => DataObject::class, + ], + [], + ], 'invalid has one' => [ Member::class, 'has_one', @@ -118,6 +128,45 @@ class RelationValidationTest extends SapphireTest 'SilverStripe\Dev\Tests\Validation\Member / HomeTeam : Relation SilverStripe\Dev\Tests\Validation\Team.UnnecessaryRelation is not in the expected format (needs class only format).' ], ], + 'has_one missing class in array config' => [ + Team::class, + 'has_one', + [ + 'SingleMember' => [ + DataObjectSchema::HAS_ONE_MULTI_RELATIONAL => true, + ], + ], + [ + 'SilverStripe\Dev\Tests\Validation\Team / SingleMember : No class has been defined for this relation.' + ], + ], + 'multi-relational has_one should be polymorphic' => [ + Team::class, + 'has_one', + [ + 'SingleMember' => [ + 'class' => Member::class, + DataObjectSchema::HAS_ONE_MULTI_RELATIONAL => true, + ], + ], + [ + 'SilverStripe\Dev\Tests\Validation\Team / SingleMember : has_one relation that can handle multiple reciprocal has_many relations must be polymorphic.' + ], + ], + 'has_one defines class in array config' => [ + Team::class, + 'has_one', + [ + 'SingleMember' => [ + 'class' => Member::class, + ], + ], + // Note there's no message about the has_one class, which is technically correctly defined. + // The bad thing now is just that we still have multiple has_many relations pointing at it. + [ + 'SilverStripe\Dev\Tests\Validation\Team / SingleMember : Back relation is ambiguous' + ], + ], 'ambiguous has_many - no relation name' => [ Team::class, 'has_many', diff --git a/tests/php/Dev/Validation/Team.php b/tests/php/Dev/Validation/Team.php index 701423380..aab011a0b 100644 --- a/tests/php/Dev/Validation/Team.php +++ b/tests/php/Dev/Validation/Team.php @@ -4,6 +4,7 @@ namespace SilverStripe\Dev\Tests\Validation; use SilverStripe\Dev\TestOnly; use SilverStripe\ORM\DataObject; +use SilverStripe\ORM\DataObjectSchema; use SilverStripe\ORM\HasManyList; use SilverStripe\ORM\ManyManyList; use SilverStripe\ORM\ManyManyThroughList; @@ -31,6 +32,13 @@ class Team extends DataObject implements TestOnly 'Title' => 'Varchar(255)', ]; + private static array $has_one = [ + 'SingleMember' => [ + 'class' => DataObject::class, + DataObjectSchema::HAS_ONE_MULTI_RELATIONAL => true, + ], + ]; + /** * @var array */ diff --git a/tests/php/Forms/GridField/GridFieldDetailFormTest.php b/tests/php/Forms/GridField/GridFieldDetailFormTest.php index 8565a6f5d..894965403 100644 --- a/tests/php/Forms/GridField/GridFieldDetailFormTest.php +++ b/tests/php/Forms/GridField/GridFieldDetailFormTest.php @@ -13,6 +13,7 @@ use SilverStripe\Forms\HiddenField; use SilverStripe\Forms\Tests\GridField\GridFieldDetailFormTest\Category; use SilverStripe\Forms\Tests\GridField\GridFieldDetailFormTest\CategoryController; use SilverStripe\Forms\Tests\GridField\GridFieldDetailFormTest\GroupController; +use SilverStripe\Forms\Tests\GridField\GridFieldDetailFormTest\MultiRelationalPeopleGroup; use SilverStripe\Forms\Tests\GridField\GridFieldDetailFormTest\PeopleGroup; use SilverStripe\Forms\Tests\GridField\GridFieldDetailFormTest\Person; use SilverStripe\Forms\Tests\GridField\GridFieldDetailFormTest\PolymorphicPeopleGroup; @@ -26,6 +27,7 @@ class GridFieldDetailFormTest extends FunctionalTest Person::class, PeopleGroup::class, PolymorphicPeopleGroup::class, + MultiRelationalPeopleGroup::class, Category::class, ]; @@ -143,6 +145,31 @@ class GridFieldDetailFormTest extends FunctionalTest $this->assertEquals($group->ID, $record->PolymorphicGroupID); } + public function testAddFormWithMultiRelationalHasOne(): void + { + // Log in for permissions check + $this->logInWithPermission('ADMIN'); + // Prepare gridfield and other objects + $group = new MultiRelationalPeopleGroup(); + $group->write(); + $gridField = $group->getCMSFields()->dataFieldByName('People'); + $gridField->setForm(new Form()); + $detailForm = $gridField->getConfig()->getComponentByType(GridFieldDetailForm::class); + $record = new Person(); + + // Trigger creation of the item edit form + $reflectionDetailForm = new \ReflectionClass($detailForm); + $reflectionMethod = $reflectionDetailForm->getMethod('getItemRequestHandler'); + $reflectionMethod->setAccessible(true); + $itemrequest = $reflectionMethod->invoke($detailForm, $gridField, $record, new Controller()); + $itemrequest->ItemEditForm(); + + // The polymorphic and multi-relational values should be pre-loaded + $this->assertEquals(MultiRelationalPeopleGroup::class, $record->MultiRelationalGroupClass); + $this->assertEquals('People', $record->MultiRelationalGroupRelation); + $this->assertEquals($group->ID, $record->MultiRelationalGroupID); + } + public function testViewForm() { $this->logInWithPermission('ADMIN'); diff --git a/tests/php/Forms/GridField/GridFieldDetailFormTest/MultiReciprocalPeopleGroup.php b/tests/php/Forms/GridField/GridFieldDetailFormTest/MultiReciprocalPeopleGroup.php new file mode 100644 index 000000000..537140636 --- /dev/null +++ b/tests/php/Forms/GridField/GridFieldDetailFormTest/MultiReciprocalPeopleGroup.php @@ -0,0 +1,38 @@ + 'Varchar' + ]; + + private static $has_many = [ + 'People' => Person::class . '.MultiRelationalGroup' + ]; + + private static $default_sort = '"Name"'; + + public function getCMSFields() + { + $fields = parent::getCMSFields(); + $fields->replaceField( + 'People', + GridField::create( + 'People', + 'People', + $this->People(), + GridFieldConfig_RelationEditor::create() + ) + ); + return $fields; + } +} diff --git a/tests/php/Forms/GridField/GridFieldDetailFormTest/Person.php b/tests/php/Forms/GridField/GridFieldDetailFormTest/Person.php index 2d4ac82c0..c51876527 100644 --- a/tests/php/Forms/GridField/GridFieldDetailFormTest/Person.php +++ b/tests/php/Forms/GridField/GridFieldDetailFormTest/Person.php @@ -7,6 +7,7 @@ use SilverStripe\Forms\GridField\GridField; use SilverStripe\Forms\GridField\GridFieldConfig_RelationEditor; use SilverStripe\Forms\RequiredFields; use SilverStripe\ORM\DataObject; +use SilverStripe\ORM\DataObjectSchema; class Person extends DataObject implements TestOnly { @@ -20,6 +21,10 @@ class Person extends DataObject implements TestOnly private static $has_one = [ 'Group' => PeopleGroup::class, 'PolymorphicGroup' => DataObject::class, + 'MultiRelationalGroup' => [ + 'class' => DataObject::class, + DataObjectSchema::HAS_ONE_MULTI_RELATIONAL => true, + ], ]; private static $many_many = [ diff --git a/tests/php/Forms/GridField/GridFieldDetailFormTest/PolymorphicPeopleGroup.php b/tests/php/Forms/GridField/GridFieldDetailFormTest/PolymorphicPeopleGroup.php index ca5a5e617..fe941201d 100644 --- a/tests/php/Forms/GridField/GridFieldDetailFormTest/PolymorphicPeopleGroup.php +++ b/tests/php/Forms/GridField/GridFieldDetailFormTest/PolymorphicPeopleGroup.php @@ -16,7 +16,7 @@ class PolymorphicPeopleGroup extends DataObject implements TestOnly ]; private static $has_many = [ - 'People' => Person::class + 'People' => Person::class . '.PolymorphicGroup' ]; private static $default_sort = '"Name"'; diff --git a/tests/php/ORM/DataObjectSchemaTest.php b/tests/php/ORM/DataObjectSchemaTest.php index 3e2f1d21a..daa14152f 100644 --- a/tests/php/ORM/DataObjectSchemaTest.php +++ b/tests/php/ORM/DataObjectSchemaTest.php @@ -159,68 +159,94 @@ class DataObjectSchemaTest extends SapphireTest ); } - public function testFieldSpec() + /** + * @dataProvider provideFieldSpec + */ + public function testFieldSpec(array $args, array $expected): void { $schema = DataObject::getSchema(); - $this->assertEquals( - [ - 'ID' => 'PrimaryKey', - 'ClassName' => 'DBClassName', - 'LastEdited' => 'DBDatetime', - 'Created' => 'DBDatetime', - 'Title' => 'Varchar', - 'Description' => 'Varchar', - 'MoneyFieldCurrency' => 'Varchar(3)', - 'MoneyFieldAmount' => 'Decimal(19,4)', - 'MoneyField' => 'Money', - ], - $schema->fieldSpecs(HasFields::class) - ); - $this->assertEquals( - [ - 'ID' => DataObjectSchemaTest\HasFields::class . '.PrimaryKey', - 'ClassName' => DataObjectSchemaTest\BaseDataClass::class . '.DBClassName', - 'LastEdited' => DataObjectSchemaTest\BaseDataClass::class . '.DBDatetime', - 'Created' => DataObjectSchemaTest\BaseDataClass::class . '.DBDatetime', - 'Title' => DataObjectSchemaTest\BaseDataClass::class . '.Varchar', - 'Description' => DataObjectSchemaTest\HasFields::class . '.Varchar', - 'MoneyFieldCurrency' => DataObjectSchemaTest\HasFields::class . '.Varchar(3)', - 'MoneyFieldAmount' => DataObjectSchemaTest\HasFields::class . '.Decimal(19,4)', - 'MoneyField' => DataObjectSchemaTest\HasFields::class . '.Money', - ], - $schema->fieldSpecs(HasFields::class, DataObjectSchema::INCLUDE_CLASS) - ); - // DB_ONLY excludes composite field MoneyField - $this->assertEquals( - [ - 'ID' => DataObjectSchemaTest\HasFields::class . '.PrimaryKey', - 'ClassName' => DataObjectSchemaTest\BaseDataClass::class . '.DBClassName', - 'LastEdited' => DataObjectSchemaTest\BaseDataClass::class . '.DBDatetime', - 'Created' => DataObjectSchemaTest\BaseDataClass::class . '.DBDatetime', - 'Title' => DataObjectSchemaTest\BaseDataClass::class . '.Varchar', - 'Description' => DataObjectSchemaTest\HasFields::class . '.Varchar', - 'MoneyFieldCurrency' => DataObjectSchemaTest\HasFields::class . '.Varchar(3)', - 'MoneyFieldAmount' => DataObjectSchemaTest\HasFields::class . '.Decimal(19,4)' - ], - $schema->fieldSpecs( - HasFields::class, - DataObjectSchema::INCLUDE_CLASS | DataObjectSchema::DB_ONLY - ) - ); + $this->assertEquals($expected, $schema->fieldSpecs(...$args)); + } - // Use all options at once - $this->assertEquals( - [ - 'ID' => DataObjectSchemaTest\HasFields::class . '.PrimaryKey', - 'Description' => DataObjectSchemaTest\HasFields::class . '.Varchar', - 'MoneyFieldCurrency' => DataObjectSchemaTest\HasFields::class . '.Varchar(3)', - 'MoneyFieldAmount' => DataObjectSchemaTest\HasFields::class . '.Decimal(19,4)', + public function provideFieldSpec(): array + { + return [ + 'just pass a class' => [ + 'args' => [HasFields::class], + 'expected' => [ + 'ID' => 'PrimaryKey', + 'ClassName' => 'DBClassName', + 'LastEdited' => 'DBDatetime', + 'Created' => 'DBDatetime', + 'Title' => 'Varchar', + 'Description' => 'Varchar', + 'MoneyFieldCurrency' => 'Varchar(3)', + 'MoneyFieldAmount' => 'Decimal(19,4)', + 'MoneyField' => 'Money', + ], ], - $schema->fieldSpecs( - HasFields::class, - DataObjectSchema::INCLUDE_CLASS | DataObjectSchema::DB_ONLY | DataObjectSchema::UNINHERITED - ) - ); + 'prefix with classname' => [ + 'args' => [HasFields::class, DataObjectSchema::INCLUDE_CLASS], + 'expected' => [ + 'ID' => DataObjectSchemaTest\HasFields::class . '.PrimaryKey', + 'ClassName' => DataObjectSchemaTest\BaseDataClass::class . '.DBClassName', + 'LastEdited' => DataObjectSchemaTest\BaseDataClass::class . '.DBDatetime', + 'Created' => DataObjectSchemaTest\BaseDataClass::class . '.DBDatetime', + 'Title' => DataObjectSchemaTest\BaseDataClass::class . '.Varchar', + 'Description' => DataObjectSchemaTest\HasFields::class . '.Varchar', + 'MoneyFieldCurrency' => DataObjectSchemaTest\HasFields::class . '.Varchar(3)', + 'MoneyFieldAmount' => DataObjectSchemaTest\HasFields::class . '.Decimal(19,4)', + 'MoneyField' => DataObjectSchemaTest\HasFields::class . '.Money', + ], + ], + 'DB_ONLY excludes composite field MoneyField' => [ + 'args' => [ + HasFields::class, + DataObjectSchema::INCLUDE_CLASS | DataObjectSchema::DB_ONLY, + ], + 'expected' => [ + 'ID' => DataObjectSchemaTest\HasFields::class . '.PrimaryKey', + 'ClassName' => DataObjectSchemaTest\BaseDataClass::class . '.DBClassName', + 'LastEdited' => DataObjectSchemaTest\BaseDataClass::class . '.DBDatetime', + 'Created' => DataObjectSchemaTest\BaseDataClass::class . '.DBDatetime', + 'Title' => DataObjectSchemaTest\BaseDataClass::class . '.Varchar', + 'Description' => DataObjectSchemaTest\HasFields::class . '.Varchar', + 'MoneyFieldCurrency' => DataObjectSchemaTest\HasFields::class . '.Varchar(3)', + 'MoneyFieldAmount' => DataObjectSchemaTest\HasFields::class . '.Decimal(19,4)' + ], + ], + 'Use all options at once' => [ + 'args' => [ + HasFields::class, + DataObjectSchema::INCLUDE_CLASS | DataObjectSchema::DB_ONLY | DataObjectSchema::UNINHERITED + ], + 'expected' => [ + 'ID' => DataObjectSchemaTest\HasFields::class . '.PrimaryKey', + 'Description' => DataObjectSchemaTest\HasFields::class . '.Varchar', + 'MoneyFieldCurrency' => DataObjectSchemaTest\HasFields::class . '.Varchar(3)', + 'MoneyFieldAmount' => DataObjectSchemaTest\HasFields::class . '.Decimal(19,4)', + ], + ], + 'has_one relations are returned correctly' => [ + 'args' => [WithRelation::class], + 'expected' => [ + 'ID' => 'PrimaryKey', + 'ClassName' => 'DBClassName', + 'LastEdited' => 'DBDatetime', + 'Created' => 'DBDatetime', + 'Title' => 'Varchar', + 'RelationID' => 'ForeignKey', + 'PolymorphicRelationID' => 'Int', + 'PolymorphicRelationClass' => "DBClassName('SilverStripe\ORM\DataObject', ['index' => false])", + 'MultiRelationalRelationID' => 'Int', + 'MultiRelationalRelationClass' => "DBClassName('SilverStripe\ORM\DataObject', ['index' => false])", + 'MultiRelationalRelationRelation' => 'Varchar', + 'PolymorphicRelation' => 'PolymorphicForeignKey', + 'MultiRelationalRelation' => 'PolymorphicRelationAwareForeignKey', + 'ArraySyntaxRelationID' => 'ForeignKey', + ], + ], + ]; } /** @@ -373,4 +399,76 @@ class DataObjectSchemaTest extends SapphireTest AllIndexes::get() ); } + + /** + * @dataProvider provideHasOneComponent + */ + + public function testHasOneComponent(string $class, string $component, string $expected): void + { + $this->assertSame($expected, DataObject::getSchema()->hasOneComponent($class, $component)); + } + + public function provideHasOneComponent(): array + { + return [ + [ + 'class' => WithRelation::class, + 'component' => 'Relation', + 'expected' => HasFields::class, + ], + [ + 'class' => WithRelation::class, + 'component' => 'PolymorphicRelation', + 'expected' => DataObject::class, + ], + [ + 'class' => WithRelation::class, + 'component' => 'ArraySyntaxRelation', + 'expected' => HasFields::class, + ], + [ + 'class' => WithRelation::class, + 'component' => 'MultiRelationalRelation', + 'expected' => DataObject::class, + ], + ]; + } + + /** + * @dataProvider provideHasOneComponentHandlesMultipleRelations + */ + public function testHasOneComponentHandlesMultipleRelations(string $class, string $component, bool $expected): void + { + $this->assertSame( + $expected, + DataObject::getSchema()->hasOneComponentHandlesMultipleRelations($class, $component) + ); + } + + public function provideHasOneComponentHandlesMultipleRelations(): array + { + return [ + [ + 'class' => WithRelation::class, + 'component' => 'Relation', + 'expected' => false, + ], + [ + 'class' => WithRelation::class, + 'component' => 'PolymorphicRelation', + 'expected' => false, + ], + [ + 'class' => WithRelation::class, + 'component' => 'ArraySyntaxRelation', + 'expected' => false, + ], + [ + 'class' => WithRelation::class, + 'component' => 'MultiRelationalRelation', + 'expected' => true, + ], + ]; + } } diff --git a/tests/php/ORM/DataObjectSchemaTest/WithRelation.php b/tests/php/ORM/DataObjectSchemaTest/WithRelation.php index 5567f5077..0adee23d5 100644 --- a/tests/php/ORM/DataObjectSchemaTest/WithRelation.php +++ b/tests/php/ORM/DataObjectSchemaTest/WithRelation.php @@ -2,11 +2,22 @@ namespace SilverStripe\ORM\Tests\DataObjectSchemaTest; +use SilverStripe\ORM\DataObject; +use SilverStripe\ORM\DataObjectSchema; + class WithRelation extends NoFields { private static $table_name = 'DataObjectSchemaTest_WithRelation'; private static $has_one = [ - 'Relation' => HasFields::Class + 'Relation' => HasFields::class, + 'PolymorphicRelation' => DataObject::class, + 'MultiRelationalRelation' => [ + 'class' => DataObject::class, + DataObjectSchema::HAS_ONE_MULTI_RELATIONAL => true, + ], + 'ArraySyntaxRelation' => [ + 'class' => HasFields::class, + ], ]; } diff --git a/tests/php/ORM/DataObjectTest/Player.php b/tests/php/ORM/DataObjectTest/Player.php index 4aa2f69c7..c780e92b6 100644 --- a/tests/php/ORM/DataObjectTest/Player.php +++ b/tests/php/ORM/DataObjectTest/Player.php @@ -3,6 +3,8 @@ namespace SilverStripe\ORM\Tests\DataObjectTest; use SilverStripe\Dev\TestOnly; +use SilverStripe\ORM\DataObject; +use SilverStripe\ORM\DataObjectSchema; use SilverStripe\ORM\Tests\DataObjectTest; use SilverStripe\Security\Member; @@ -17,6 +19,10 @@ class Player extends Member implements TestOnly private static $has_one = [ 'FavouriteTeam' => DataObjectTest\Team::class, + 'MultiRelational' => [ + 'class' => DataObject::class, + DataObjectSchema::HAS_ONE_MULTI_RELATIONAL => true, + ], ]; private static $belongs_many_many = [ diff --git a/tests/php/ORM/DataObjectTest/Team.php b/tests/php/ORM/DataObjectTest/Team.php index e50af1cea..07c0b310e 100644 --- a/tests/php/ORM/DataObjectTest/Team.php +++ b/tests/php/ORM/DataObjectTest/Team.php @@ -44,7 +44,10 @@ class Team extends DataObject implements TestOnly 'SubTeams' => SubTeam::class, 'Comments' => TeamComment::class, 'Fans' => Fan::class . '.Favourite', // Polymorphic - Team fans - 'PlayerFans' => Player::class . '.FavouriteTeam' + 'PlayerFans' => Player::class . '.FavouriteTeam', + // multi-relational relation: + 'ManyPlayers1' => Player::class . '.MultiRelational', + 'ManyPlayers2' => Player::class . '.MultiRelational', ]; private static $many_many = [ diff --git a/tests/php/ORM/DataQueryTest.php b/tests/php/ORM/DataQueryTest.php index aa2aa6ea0..b0cfc9103 100644 --- a/tests/php/ORM/DataQueryTest.php +++ b/tests/php/ORM/DataQueryTest.php @@ -27,6 +27,8 @@ class DataQueryTest extends SapphireTest DataQueryTest\ObjectG::class, DataQueryTest\ObjectH::class, DataQueryTest\ObjectI::class, + DataQueryTest\ObjectHasMultiRelationalHasOne::class, + DataQueryTest\ObjectHasMultiRelationalHasMany::class, SQLSelectTest\CteRecursiveObject::class, SQLSelectTest\TestObject::class, SQLSelectTest\TestBase::class, @@ -99,6 +101,33 @@ class DataQueryTest extends SapphireTest $this->assertStringContainsString('"testctwo_DataQueryTest_C"."ID" = "DataQueryTest_B"."TestCTwoID"', $dq->sql()); } + /** + * @dataProvider provideApplyRelationMultiRelational + */ + public function testApplyRelationMultiRelational(string $relation): void + { + $dq = new DataQuery(DataQueryTest\ObjectHasMultiRelationalHasMany::class); + $dq->applyRelation($relation); + $joinAlias = strtolower($relation) . '_DataQueryTest_ObjectHasMultiRelationalHasOne'; + $joinAliasWithQuotes = '"' . $joinAlias . '"'; + $this->assertTrue($dq->query()->isJoinedTo($joinAlias)); + $this->assertStringContainsString($joinAliasWithQuotes . '."MultiRelationalID" = "DataQueryTest_ObjectHasMultiRelationalHasMany"."ID"', $dq->sql()); + $this->assertStringContainsString($joinAliasWithQuotes . '."MultiRelationalRelation" = ' . $relation, $dq->sql()); + $this->assertStringContainsString($joinAliasWithQuotes . '."MultiRelationalClass" = "DataQueryTest_ObjectHasMultiRelationalHasMany"."ClassName"', $dq->sql()); + } + + public function provideApplyRelationMultiRelational(): array + { + return [ + 'relation1' => [ + 'relation' => 'MultiRelational1', + ], + 'relation2' => [ + 'relation' => 'MultiRelational2', + ], + ]; + } + public function testApplyRelationDeepInheritance() { //test has_one relation diff --git a/tests/php/ORM/DataQueryTest/ObjectHasMultiReciprocalHasMany.php b/tests/php/ORM/DataQueryTest/ObjectHasMultiReciprocalHasMany.php new file mode 100644 index 000000000..c770b7c80 --- /dev/null +++ b/tests/php/ORM/DataQueryTest/ObjectHasMultiReciprocalHasMany.php @@ -0,0 +1,21 @@ + 'Varchar', + 'SortOrder' => 'Int', + ]; + + private static array $has_many = [ + 'MultiRelational1' => ObjectHasMultiRelationalHasOne::class . '.MultiRelational', + 'MultiRelational2' => ObjectHasMultiRelationalHasOne::class . '.MultiRelational', + ]; +} diff --git a/tests/php/ORM/DataQueryTest/ObjectHasMultiReciprocalHasOne.php b/tests/php/ORM/DataQueryTest/ObjectHasMultiReciprocalHasOne.php new file mode 100644 index 000000000..d2117bf47 --- /dev/null +++ b/tests/php/ORM/DataQueryTest/ObjectHasMultiReciprocalHasOne.php @@ -0,0 +1,24 @@ + 'Varchar', + 'SortOrder' => 'Int', + ]; + + private static array $has_one = [ + 'MultiRelational' => [ + 'class' => DataObject::class, + DataObjectSchema::HAS_ONE_MULTI_RELATIONAL => true, + ], + ]; +} diff --git a/tests/php/ORM/PolymorphicHasManyListTest.php b/tests/php/ORM/PolymorphicHasManyListTest.php index e7211ef4b..01cbf6695 100644 --- a/tests/php/ORM/PolymorphicHasManyListTest.php +++ b/tests/php/ORM/PolymorphicHasManyListTest.php @@ -15,7 +15,10 @@ class PolymorphicHasManyListTest extends SapphireTest { // Borrow the model from DataObjectTest - protected static $fixture_file = 'DataObjectTest.yml'; + protected static $fixture_file = [ + 'DataObjectTest.yml', + 'PolymorphicHasManyListTest.yml', + ]; public static function getExtraDataObjects() { @@ -32,6 +35,30 @@ class PolymorphicHasManyListTest extends SapphireTest $this->assertEquals([], $newTeam->Fans()->column('ID')); } + /** + * Validates that multiple has_many relations can point to a single multi-relational + * has_one relation and still be separate + */ + public function testMultiRelationalRelations(): void + { + $team = $this->objFromFixture(Team::class, 'multiRelationalTeam'); + $playersList1 = $team->ManyPlayers1(); + $playersList2 = $team->ManyPlayers2(); + + // Lists are separate + $this->assertSame( + ['MultiRelational Player 1', 'MultiRelational Player 2', 'MultiRelational Player 6'], + $playersList1->sort('FirstName')->column('FirstName') + ); + $this->assertSame( + ['MultiRelational Player 3', 'MultiRelational Player 4', 'MultiRelational Player 5'], + $playersList2->sort('FirstName')->column('FirstName') + ); + // The relation is saved on the has_one side of the relationship + $this->assertSame('ManyPlayers1', $playersList1->first()->MultiRelationalRelation); + $this->assertSame('ManyPlayers2', $playersList2->first()->MultiRelationalRelation); + } + /** * Test that DataList::relation works with PolymorphicHasManyList */ @@ -40,7 +67,7 @@ class PolymorphicHasManyListTest extends SapphireTest // Check that expected teams exist $list = Team::get(); $this->assertEquals( - ['Subteam 1', 'Subteam 2', 'Subteam 3', 'Team 1', 'Team 2', 'Team 3'], + ['MultiRelational team', 'Subteam 1', 'Subteam 2', 'Subteam 3', 'Team 1', 'Team 2', 'Team 3'], $list->sort('Title')->column('Title') ); @@ -65,16 +92,60 @@ class PolymorphicHasManyListTest extends SapphireTest $this->assertEquals(['Bobby', 'Damian', 'Mindy', 'Mitch', 'Richard'], $fans->sort('Name')->column('Name')); } + /** + * The same as testFilterRelation but for multi-relational relationships + */ + public function testFilterMultiRelationalRelation(): void + { + $list = Team::get(); + + $players1 = $list->relation('ManyPlayers1')->sort('FirstName')->column('FirstName'); + $players2 = $list->relation('ManyPlayers2')->sort('FirstName')->column('FirstName'); + // Test that each relation has the expected players + $this->assertSame( + ['MultiRelational Player 1', 'MultiRelational Player 2', 'MultiRelational Player 6'], + $players1 + ); + $this->assertSame( + ['MultiRelational Player 3', 'MultiRelational Player 4', 'MultiRelational Player 5'], + $players2 + ); + + // Modify list of fans + $team = $this->objFromFixture(DataObjectTest\Team::class, 'multiRelationalTeam'); + $newPlayer1 = new DataObjectTest\Player(['FirstName' => 'New player 1']); + $team->ManyPlayers1()->add($newPlayer1); + $this->assertSame('ManyPlayers1', $newPlayer1->MultiRelationalRelation); + $this->assertSame(Team::class, $newPlayer1->MultiRelationalClass); + $this->assertSame($team->ID, $newPlayer1->MultiRelationalID); + $newPlayer2 = new DataObjectTest\Player(['FirstName' => 'New player 2']); + $team->ManyPlayers2()->add($newPlayer2); + $this->assertSame('ManyPlayers2', $newPlayer2->MultiRelationalRelation); + $this->assertSame(Team::class, $newPlayer2->MultiRelationalClass); + $this->assertSame($team->ID, $newPlayer2->MultiRelationalID); + + // and retest + $players1 = $list->relation('ManyPlayers1')->sort('FirstName')->column('FirstName'); + $players2 = $list->relation('ManyPlayers2')->sort('FirstName')->column('FirstName'); + $this->assertSame( + ['MultiRelational Player 1', 'MultiRelational Player 2', 'MultiRelational Player 6', 'New player 1'], + $players1 + ); + $this->assertSame( + ['MultiRelational Player 3', 'MultiRelational Player 4', 'MultiRelational Player 5', 'New player 2'], + $players2 + ); + } + /** * Test that related objects can be removed from a relation */ public function testRemoveRelation() { - // Check that expected teams exist $list = Team::get(); $this->assertEquals( - ['Subteam 1', 'Subteam 2', 'Subteam 3', 'Team 1', 'Team 2', 'Team 3'], + ['MultiRelational team', 'Subteam 1', 'Subteam 2', 'Subteam 3', 'Team 1', 'Team 2', 'Team 3'], $list->sort('Title')->column('Title') ); @@ -109,10 +180,57 @@ class PolymorphicHasManyListTest extends SapphireTest $this->assertEmpty($subteam1fan->FavouriteClass); } + /** + * The same as testRemoveRelation but for multi-relational relationships + */ + public function testRemoveMultiRelationalRelation(): void + { + $team = $this->objFromFixture(DataObjectTest\Team::class, 'multiRelationalTeam'); + $originalPlayers1 = $team->ManyPlayers1()->sort('FirstName')->column('FirstName'); + $originalPlayers2 = $team->ManyPlayers2()->sort('FirstName')->column('FirstName'); + + // Test that each relation has the expected players as a baseline + $this->assertSame( + ['MultiRelational Player 1', 'MultiRelational Player 2', 'MultiRelational Player 6'], + $originalPlayers1 + ); + $this->assertSame( + ['MultiRelational Player 3', 'MultiRelational Player 4', 'MultiRelational Player 5'], + $originalPlayers2 + ); + + // Test that you can't remove items from relations they're not in + $playerFromGroup1 = $this->objFromFixture(DataObjectTest\Player::class, 'multiRelationalPlayer2'); + $team->ManyPlayers2()->remove($playerFromGroup1); + $this->assertSame($originalPlayers1, $team->ManyPlayers1()->sort('FirstName')->column('FirstName')); + $this->assertSame($originalPlayers2, $team->ManyPlayers2()->sort('FirstName')->column('FirstName')); + $this->assertSame('ManyPlayers1', $playerFromGroup1->MultiRelationalRelation); + $this->assertSame(Team::class, $playerFromGroup1->MultiRelationalClass); + $this->assertSame($team->ID, $playerFromGroup1->MultiRelationalID); + + // Test that you *can* remove items from relations they *are* in + $team->ManyPlayers1()->remove($playerFromGroup1); + $this->assertSame( + ['MultiRelational Player 1', 'MultiRelational Player 6'], + $team->ManyPlayers1()->sort('FirstName')->column('FirstName') + ); + $this->assertSame($originalPlayers2, $team->ManyPlayers2()->sort('FirstName')->column('FirstName')); + $this->assertEmpty($playerFromGroup1->MultiRelationalRelation); + $this->assertEmpty($playerFromGroup1->MultiRelationalClass); + $this->assertEmpty($playerFromGroup1->MultiRelationalID); + } + public function testGetForeignClassKey(): void { $team = $this->objFromFixture(DataObjectTest\Team::class, 'team1'); $list = $team->Fans(); $this->assertSame('FavouriteClass', $list->getForeignClassKey()); } + + public function getGetForeignRelationKey(): void + { + $team = $this->objFromFixture(DataObjectTest\Team::class, 'team1'); + $list = $team->Fans(); + $this->assertSame('FavouriteRelation', $list->getForeignRelationKey()); + } } diff --git a/tests/php/ORM/PolymorphicHasManyListTest.yml b/tests/php/ORM/PolymorphicHasManyListTest.yml new file mode 100644 index 000000000..f60844a0c --- /dev/null +++ b/tests/php/ORM/PolymorphicHasManyListTest.yml @@ -0,0 +1,25 @@ +SilverStripe\ORM\Tests\DataObjectTest\Player: + multiRelationalPlayer1: + FirstName: MultiRelational Player 1 + multiRelationalPlayer2: + FirstName: MultiRelational Player 2 + multiRelationalPlayer3: + FirstName: MultiRelational Player 3 + multiRelationalPlayer4: + FirstName: MultiRelational Player 4 + multiRelationalPlayer5: + FirstName: MultiRelational Player 5 + multiRelationalPlayer6: + FirstName: MultiRelational Player 6 + +SilverStripe\ORM\Tests\DataObjectTest\Team: + multiRelationalTeam: + Title: MultiRelational team + ManyPlayers1: + - =>SilverStripe\ORM\Tests\DataObjectTest\Player.multiRelationalPlayer1 + - =>SilverStripe\ORM\Tests\DataObjectTest\Player.multiRelationalPlayer2 + - =>SilverStripe\ORM\Tests\DataObjectTest\Player.multiRelationalPlayer6 + ManyPlayers2: + - =>SilverStripe\ORM\Tests\DataObjectTest\Player.multiRelationalPlayer3 + - =>SilverStripe\ORM\Tests\DataObjectTest\Player.multiRelationalPlayer4 + - =>SilverStripe\ORM\Tests\DataObjectTest\Player.multiRelationalPlayer5