-
Notifications
You must be signed in to change notification settings - Fork 821
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
NEW Allow a single has_one to manage multiple reciprocal has_many #11084
NEW Allow a single has_one to manage multiple reciprocal has_many #11084
Conversation
359851a
to
a12982c
Compare
src/ORM/DataObject.php
Outdated
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; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This one method is the reason this whole PR works without being a breaking change. Wherever this is called, people can rely on having an associative array of relation name to class name for has_one relations.
Anywhere people are currently using SomeClass::config()->get('has_one')
, they should probably have been using this instead - and that advice will be included in the changelog.
* Doesn't work with polymorphic relationships | ||
* |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This comment was already wrong - it had code here that made it work with polymorphic relationships before I came along. I'm just adding support for the new multiple-reciprocal has_one relations.
c42c13b
to
fcd6f8c
Compare
* 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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was just a plain lie before. Updated to indicate the actual functionality of this method.
d151c93
to
3f39f22
Compare
@@ -159,68 +159,94 @@ public function testTableForObjectField() | |||
); | |||
} | |||
|
|||
public function testFieldSpec() | |||
public function provideFieldSpec() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
All of the scenarios in this provider except for the last one come from the original test - I've just refactored it to use a provider so it's clearer what's going on.
3f39f22
to
fccccab
Compare
foreach ($hasOne as $relationName => $spec) { | ||
if (is_array($spec)) { | ||
$hasOne[$relationName] = DataObject::getSchema()->hasOneComponent(static::class, $relationName); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No early returns or breaks here? Can there be multiple and if so, is using the last ok?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure what you mean?
This will loop through all has_one
relation declarations, and reduce the new array syntax for any of those relations down to just their class name.
There's nothing to break early for because we have to check each relation.
We're not "using the last" of anything - note we're assigning the same element that we're looking at.
e.g:
private static $has_one = [
'NormalHasOne' => DataObject::class,
'FancyHasOne' => [
'class' => DataObject::class,
DataObjectSchema::HASONE_IS_MULTIRECIPROCAL => true,
],
'AnotherHasOne' => DataObject::class,
];
calling $record->hasOne()
for a model with the above definition will return an array like so:
[
'NormalHasOne' => DataObject::class,
'FancyHasOne' => DataObject::class,
'AnotherHasOne' => DataObject::class,
]
I looked at the linked ticket for linkfield and looked at the PR here but it's still not clear to me what high value issue we are solving here and whether it even was an issue to have to have a multiple has_ones for the same class. It made it clear and separated.. now it feels a bit more like magic with all the required extra config. I guess both approaches will now work. |
The specific scenario we're trying to solve here is that we want the This also makes it easier to say (for example) "I want 3 different lists of links on my
Having to define multiple Some\Vendor\Model:
has_one:
SiteConfig1: SilverStripe\SiteConfig\SiteConfig
SiteConfig2: SilverStripe\SiteConfig\SiteConfig
SiteConfig3: SilverStripe\SiteConfig\SiteConfig |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When looking up the has_many relation e.g. Link->Owner() should raise something that gets logged but not hard exception if {relationname}Relation does not exist on the model because the relation was either deleted or renamed. Can't use hard exception because we're dealing with data which can easily be different in prod than local when testing
Just an idea, is it possible to simplify this so that you don't need to 'opt-in' this this behaviour e.g. instead of this
'Owner' => [
'class' => DataObject::class,
DataObjectSchema::HASONE_IS_MULTIRECIPROCAL => true,
],
You just have this
'Owner' => DataObject::class,
And then do the new clever stuff if the relationship is polymorphic i.e. DataObject::class AND the {relationname}Relation
column is non empty?
I was hoping we could do this, but looking again at this, I don't think there's a sensible place where we can throw this sort of warning. The only times we really look at the relation name are:
Renaming relations is pretty uncommon anyway, I'd hope - and for most relations if you change the name you'll need to do some sort of data migration already. I think we can accept that this is another case where a manual data migration will be necessary and until such a migration is done there'll be orphaned records.
I can't think of a way to make that work without data loss, for essentially the same reason we can't throw a warning for has_ones. The way it works with the new config relies on passing the has_many relation to the If we were to apply multi-reciprocal behaviour to all polymorphic has_many relations, we'd have to filter all of them by both the relation name and relation name = null. But because we're allowing relation name to be null, this makes no distinction between "that item belongs in this list" and "we're really not sure where that item belongs". Why this breaks downPeople could decide "I want to add a second has_many to point at that existing polymorphic has_one". If they do so, they'll end up with all the records from their existing has_many relation showing up in their new has_many list, which is exactly what this PR is intended to avoid. |
I never had a need where
Fair enough. I would name them based on what they were representing, even if all would link to the same model for some reason, but more often we'd use an extension to the vendor model where you'd put all the config and code, like you'd do with project models. Even though the use case here might be narrow (and doesn't talk about the advantages of using has_one/has_many vs many_many), I'm not against it and could be solving a legitimate problem. |
public function setRelationValue(string $value, bool $markChanged = true): static | ||
{ | ||
$this->setField('Relation', $value, $markChanged); | ||
return $this; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
public function setRelationValue(string $value, bool $markChanged = true): static | |
{ | |
$this->setField('Relation', $value, $markChanged); | |
return $this; | |
public function setRelationValue(string $value, bool $markChanged = true): void | |
{ | |
$this->setField('Relation', $value, $markChanged); |
setIDValue() / setClassValue() both return void in DBPolymorphicForeignKey, seems weird making this one alone chainable
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm following the documented best practices for new setter method implementations: https://github.com/silverstripe/developer-docs/blob/5/en/05_Contributing/05_Coding_Conventions/01_PHP_Coding_Conventions.md#properties-and-accessors
src/ORM/DataObjectSchema.php
Outdated
/** | ||
* Configuration key for has_one relations that can support multiple reciprocal has_many relations. | ||
*/ | ||
public const HASONE_IS_MULTIRECIPROCAL = 'multireciprocal'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
public const HASONE_IS_MULTIRECIPROCAL = 'multireciprocal'; | |
public const HAS_ONE_MULTI_RELATIONAL = 'multi-relational'; |
HASONE_IS_MULTIRECIPROCAL
is a pretty weird name IMO
HAS_ONE_MULTI_RELATIONAL
seems much clearer:
- Uses 'RELATIONAL' instead of 'RECIPROCAL' which lines up with it adding a 'Relation' column. Reciprocal seems much less obvious why it's called that, at least to me
- Removes the redundant "IS"
- Adds in underscores
Seem alright?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
multi-relation makes it sound like this is multiple relations.... which it isn't. But I'll make this change because I know we'll never agree on this and at the end of the day it's more important that there is a name than that it's a name we agree on.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's probably worth noting though that reciprocal is a real word and was being used correctly, and has a precedent (if a fairly minor one) both in our docs and in code (e.g. DataObject::inferReciprocalComponent()
(where component
effectively means relation))
if (isset($spec[DataObjectSchema::HASONE_IS_MULTIRECIPROCAL]) | ||
&& $spec[DataObjectSchema::HASONE_IS_MULTIRECIPROCAL] === true | ||
&& $relationData !== DataObject::class |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if (isset($spec[DataObjectSchema::HASONE_IS_MULTIRECIPROCAL]) | |
&& $spec[DataObjectSchema::HASONE_IS_MULTIRECIPROCAL] === true | |
&& $relationData !== DataObject::class | |
if ($spec[DataObjectSchema::HASONE_IS_MULTIRECIPROCAL] ?? false === true | |
&& $relationData !== DataObject::class |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Everywhere you've asked for this change I've wrapped it in quotes because tests were failing when I didn't.
src/ORM/DataObject.php
Outdated
if ($details['polymorphic']) { | ||
$result = PolymorphicHasManyList::create($componentClass, $details['joinField'], static::class); | ||
if ($details['needsRelation']) { | ||
Deprecation::withNoReplacement(fn () =>$result->setForeignRelation($componentName)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Deprecation::withNoReplacement(fn () =>$result->setForeignRelation($componentName)); | |
Deprecation::withNoReplacement(fn () => $result->setForeignRelation($componentName)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
src/ORM/DataObjectSchema.php
Outdated
} | ||
// Handle has_one which handles multiple reciprocal has_many relations | ||
$hasOneClass = $spec['class']; | ||
if (isset($spec[self::HASONE_IS_MULTIRECIPROCAL]) && $spec[self::HASONE_IS_MULTIRECIPROCAL] === true) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if (isset($spec[self::HASONE_IS_MULTIRECIPROCAL]) && $spec[self::HASONE_IS_MULTIRECIPROCAL] === true) { | |
if ($spec[self::HASONE_IS_MULTIRECIPROCAL] ?? false === true) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
src/ORM/DataObjectSchema.php
Outdated
@@ -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']; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
return $this->getBelongsAndHasManyDetails($class, $component, $type, $polymorphic)['joinField']; | |
return $this->getBelongsToAndHasManyDetails($class, $component, $type, $polymorphic)['joinField']; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
src/ORM/DataObjectSchema.php
Outdated
if (isset($spec[self::HASONE_IS_MULTIRECIPROCAL]) | ||
&& $spec[self::HASONE_IS_MULTIRECIPROCAL] === true | ||
&& $spec['class'] !== DataObject::class |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if (isset($spec[self::HASONE_IS_MULTIRECIPROCAL]) | |
&& $spec[self::HASONE_IS_MULTIRECIPROCAL] === true | |
&& $spec['class'] !== DataObject::class | |
if ($spec[self::HASONE_IS_MULTIRECIPROCAL] ?? false === true | |
&& $spec['class'] !== DataObject::class |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done but had to wrap in brackets to avoid broken functionality
src/ORM/PolymorphicHasManyList.php
Outdated
$item->$foreignKey = null; | ||
$item->$classForeignKey = null; | ||
$item->write(); | ||
} | ||
} | ||
|
||
private function relationMatches(?string $actual, ?string $expected) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
private function relationMatches(?string $actual, ?string $expected) | |
private function relationMatches(?string $actual, ?string $expected): bool |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
@@ -143,6 +145,31 @@ public function testAddFormWithPolymorphicHasOne() | |||
$this->assertEquals($group->ID, $record->PolymorphicGroupID); | |||
} | |||
|
|||
public function testAddFormWithMultiReciprocalHasOne() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
public function testAddFormWithMultiReciprocalHasOne() | |
public function testAddFormWithMultiReciprocalHasOne(): void |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
/** | ||
* @dataProvider provideFieldSpec | ||
*/ | ||
public function testFieldSpec(array $args, array $expected) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
public function testFieldSpec(array $args, array $expected) | |
public function testFieldSpec(array $args, array $expected): void |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
973224d
to
915483b
Compare
915483b
to
d400125
Compare
This PR provides a new has_one syntax that allows for a single has_one relation to be pointed at by multiple has_many relations - and each will correctly retain only their own items, without bleeding between them.
Without this PR, if you wanted multiple has_many relations on class A all pointing at class B, you would need to have a separate has_one relation on class B for each of the has_many relations.
Issue