Skip to content

Commit

Permalink
NEW Allow a single has_one to manage multiple reciprocal has_many
Browse files Browse the repository at this point in the history
  • Loading branch information
GuySartorelli committed Dec 4, 2023
1 parent 809f9e7 commit a12982c
Show file tree
Hide file tree
Showing 12 changed files with 330 additions and 23 deletions.
2 changes: 2 additions & 0 deletions _config/model.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion src/Dev/FixtureBlueprint.php
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ public function createObject($identifier, $data = null, $fixtures = null)
if ($className = $schema->hasOneComponent($class, $hasOneField)) {
$obj->{$hasOneField . 'ID'} = $this->parseValue($fieldVal, $fixtures, $fieldClass);
// Inject class for polymorphic relation
if ($className === 'SilverStripe\\ORM\\DataObject') {
if ($className === DataObject::class) {
$obj->{$hasOneField . 'Class'} = $fieldClass;
}
}
Expand Down
29 changes: 28 additions & 1 deletion src/Dev/Validation/RelationValidationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -291,6 +293,26 @@ protected function validateHasOne(string $class): void
$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 (isset($spec[DataObjectSchema::HASONE_MULTIPLE_HASMANY])
&& $spec[DataObjectSchema::HASONE_MULTIPLE_HASMANY] === 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;
}
Expand All @@ -305,6 +327,11 @@ protected function validateHasOne(string $class): void
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,
Expand Down Expand Up @@ -616,7 +643,7 @@ protected function parseManyManyRelation($relationData): ?string
return null;
}

return $throughRelations[$to];
return DataObject::getSchema()->hasOneComponent($through, $to);
}

return $relationData;
Expand Down
8 changes: 8 additions & 0 deletions src/Forms/GridField/GridFieldDetailForm_ItemRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,14 @@ public function ItemEditForm()
$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;
}
}
}

Expand Down
34 changes: 24 additions & 10 deletions src/ORM/DataObject.php
Original file line number Diff line number Diff line change
Expand Up @@ -1972,12 +1972,14 @@ public function getComponents($componentName, $id = null)
}

// 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']) {
$result->setForeignRelation($componentName);
}
} else {
$result = HasManyList::create($componentClass, $joinField);
$result = HasManyList::create($componentClass, $details['joinField']);
}

return $result
Expand All @@ -1993,16 +1995,21 @@ public function getComponents($componentName, $id = null)
*/
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 : []
);
Expand Down Expand Up @@ -2236,7 +2243,14 @@ public function getManyManyComponents($componentName, $id = null)
*/
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($this->objectClass, $relationName);
}
}
return $hasOne;
}

/**
Expand Down
103 changes: 94 additions & 9 deletions src/ORM/DataObjectSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 HASONE_MULTIPLE_HASMANY = 'handles_multiple_has_many';

/**
* Default separate for table namespaces. Can be set to any string for
* databases that do not support some characters.
Expand Down Expand Up @@ -501,7 +506,20 @@ protected function cacheDatabaseFields($class)

// 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 (isset($spec[self::HASONE_MULTIPLE_HASMANY]) && $spec[self::HASONE_MULTIPLE_HASMANY] === true) {
$compositeFields[$fieldName] = 'PolymorphicRelationAwareForeignKey';
continue;
}
} else {
$hasOneClass = $spec;
}
if ($hasOneClass === DataObject::class) {
$compositeFields[$fieldName] = 'PolymorphicForeignKey';
} else {
Expand Down Expand Up @@ -902,12 +920,36 @@ public function hasOneComponent($class, $component)
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 hasOneComponentHandlesMultipleHasMany(string $class, string $component): bool
{
$hasOnes = Config::forClass($class)->get('has_one');
if (!isset($hasOnes[$component])) {
return false;
}

$spec = $hasOnes[$component];
return is_array($spec)
&& isset($spec[self::HASONE_MULTIPLE_HASMANY])
&& $spec[self::HASONE_MULTIPLE_HASMANY] === true;
}

/**
* Return data for a specific belongs_to component.
*
Expand Down Expand Up @@ -1046,6 +1088,16 @@ protected function getManyManyInverseRelationship($childClass, $parentClass)
* @throws Exception
*/
public function getRemoteJoinField($class, $component, $type = 'has_many', &$polymorphic = false)
{
return $this->getBelongsAndHasManyDetails($class, $component, $type, $polymorphic)['joinField'];
}

public function getHasManyComponentDetails(string $class, string $component)
{
return $this->getBelongsAndHasManyDetails($class, $component);
}

private function getBelongsAndHasManyDetails(string $class, string $component, string $type = 'has_many', &$polymorphic = false)
{
// Extract relation from current object
if ($type === 'has_many') {
Expand All @@ -1071,6 +1123,11 @@ public function getRemoteJoinField($class, $component, $type = 'has_many', &$pol

// 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
Expand Down Expand Up @@ -1104,14 +1161,23 @@ public function getRemoteJoinField($class, $component, $type = 'has_many', &$pol
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->hasOneComponentHandlesMultipleHasMany($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,
];
}

/**
Expand Down Expand Up @@ -1204,6 +1270,25 @@ protected function checkManyManyJoinClass($parentClass, $component, $specificati
return $joinClass;
}

protected 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 (isset($spec[self::HASONE_MULTIPLE_HASMANY])
&& $spec[self::HASONE_MULTIPLE_HASMANY] === 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
*
Expand Down
11 changes: 9 additions & 2 deletions src/ORM/DataQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -1025,8 +1025,6 @@ public function applyRelation($relation, $linearOnly = false)
* 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
Expand Down Expand Up @@ -1065,6 +1063,15 @@ protected function joinHasManyRelation(
$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}";
Expand Down
48 changes: 48 additions & 0 deletions src/ORM/FieldType/DBPolymorphicRelationAwareForeignKey.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?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 $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;
}

public function setValue($value, $record = null, $markChanged = true)
{
// We can't map dataobject value because we need to know what has_many relation to store.
if ($value instanceof DataObject) {
throw new InvalidArgumentException('$value must be an associative array - DataObject found');
}

parent::setValue($value, $record, $markChanged);
}
}
Loading

0 comments on commit a12982c

Please sign in to comment.