NEW Allow a single has_one to manage multiple reciprocal has_many

This commit is contained in:
Guy Sartorelli 2023-12-04 17:47:14 +13:00
parent 809f9e7ae0
commit d400125cf1
No known key found for this signature in database
GPG Key ID: F313E3B9504D496A
24 changed files with 808 additions and 96 deletions

View File

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

View File

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

View File

@ -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;
}
}
}

View File

@ -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;
}
/**

View File

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

View File

@ -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}";

View File

@ -0,0 +1,38 @@
<?php
namespace SilverStripe\ORM\FieldType;
use InvalidArgumentException;
use SilverStripe\ORM\DataObject;
/**
* A special polymorphic ForeignKey class that allows a single has_one relation to map to multiple has_many relations
*/
class DBPolymorphicRelationAwareForeignKey extends DBPolymorphicForeignKey
{
private static array $composite_db = [
'Relation' => '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;
}
}

View File

@ -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;
}
}

View File

@ -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',
];
/**

View File

@ -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',

View File

@ -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
*/

View File

@ -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');

View File

@ -0,0 +1,38 @@
<?php
namespace SilverStripe\Forms\Tests\GridField\GridFieldDetailFormTest;
use SilverStripe\Dev\TestOnly;
use SilverStripe\Forms\GridField\GridField;
use SilverStripe\Forms\GridField\GridFieldConfig_RelationEditor;
use SilverStripe\ORM\DataObject;
class MultiRelationalPeopleGroup extends DataObject implements TestOnly
{
private static $table_name = 'GridFieldDetailFormTest_MultiRelationalPeopleGroup';
private static $db = [
'Name' => '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;
}
}

View File

@ -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 = [

View File

@ -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"';

View File

@ -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,
],
];
}
}

View File

@ -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,
],
];
}

View File

@ -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 = [

View File

@ -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 = [

View File

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

View File

@ -0,0 +1,21 @@
<?php
namespace SilverStripe\ORM\Tests\DataQueryTest;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;
class ObjectHasMultiRelationalHasMany extends DataObject implements TestOnly
{
private static string $table_name = 'DataQueryTest_ObjectHasMultiRelationalHasMany';
private static array $db = [
'Name' => 'Varchar',
'SortOrder' => 'Int',
];
private static array $has_many = [
'MultiRelational1' => ObjectHasMultiRelationalHasOne::class . '.MultiRelational',
'MultiRelational2' => ObjectHasMultiRelationalHasOne::class . '.MultiRelational',
];
}

View File

@ -0,0 +1,24 @@
<?php
namespace SilverStripe\ORM\Tests\DataQueryTest;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DataObjectSchema;
class ObjectHasMultiRelationalHasOne extends DataObject implements TestOnly
{
private static string $table_name = 'DataQueryTest_ObjectHasMultiRelationalHasOne';
private static array $db = [
'Name' => 'Varchar',
'SortOrder' => 'Int',
];
private static array $has_one = [
'MultiRelational' => [
'class' => DataObject::class,
DataObjectSchema::HAS_ONE_MULTI_RELATIONAL => true,
],
];
}

View File

@ -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());
}
}

View File

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