-
Notifications
You must be signed in to change notification settings - Fork 822
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
FIX Singleton creation #7850
FIX Singleton creation #7850
Conversation
tests/model/DataObjectTest.php
Outdated
@@ -33,6 +33,25 @@ class DataObjectTest extends SapphireTest { | |||
'ManyManyListTest_Category', | |||
); | |||
|
|||
public function testSingleton() { | |||
// Test that populateDefaults() isn't called on singletons, which can lead to SQL errors during build, and endless loops | |||
$inst = DataObjectTest_Fixture::create(); |
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.
might be better to use a dataProvider here
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.
what would be the advantage?
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 one test fails it won't block all the other tests.
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.
Do the other tests in this class use dataProviders? Or can you provide an example? I'm not really sure how to proceed.
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.
@jonom see https://phpunit.de/manual/current/en/writing-tests-for-phpunit.html#writing-tests-for-phpunit.data-providers
I've made the change for you and pushed it up
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've made the change for you and pushed it up
Thanks @dhensby! I didn't even realise you could do that. Were you able to push straight to my branch with your regular credentials or is it more complicated? I'll have to remember that option for the future.
Tests failed after your commit was introduced but passed before. I can't see anything wrong with your commit though so I'm quite confused.
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.
From the docs you linked:
All data providers are executed before both the call to the setUpBeforeClass static method and the first call to the setUp method. Because of that you can't access any variables you create there from within a data provider. This is required in order for PHPUnit to be able to compute the total number of tests.
I guess SapphireTest:setUp() is running after the objects are instantiated?
control/injector/Injector.php
Outdated
@@ -551,6 +551,14 @@ protected function instantiate($spec, $id=null, $type = null) { | |||
$constructorParams = $spec['constructor']; | |||
} | |||
|
|||
// If we're dealing with a DataObject, pass through Singleton flag as second argument | |||
if ($type != 'prototype' && in_array('DataObject', ClassInfo::ancestry($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.
A lot of binding here to DataObject. How about looking into a DataObjectFactory configured via yaml?
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 don't really know what that means. I really want to see this ancient issue fixed but I don't have a lot of time to look further in to it or solve it more elegantly unfortunately. I had thought this would be only for 3.x too but actually glancing at Injector and DataObject in SS4 this is probably still an issue and would need to be merged up.
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.
Ok well, fair enough. I'd probably not want this leaking up into 4.x branch though. 4 should be less concerned about singleton-specific construction.
94920ca
to
82cd8bb
Compare
Switched to |
68bd0bc
to
7e3bc76
Compare
} | ||
} | ||
|
||
public function provideSingletons() |
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.
The first two here are not singletons, should that be indicated?
tests/model/DataObjectTest.php
Outdated
@@ -33,6 +33,25 @@ class DataObjectTest extends SapphireTest { | |||
'ManyManyListTest_Category', | |||
); | |||
|
|||
public function testSingleton() { | |||
// Test that populateDefaults() isn't called on singletons, which can lead to SQL errors during build, and endless loops | |||
$inst = DataObjectTest_Fixture::create(); |
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've made the change for you and pushed it up
Thanks @dhensby! I didn't even realise you could do that. Were you able to push straight to my branch with your regular credentials or is it more complicated? I'll have to remember that option for the future.
Tests failed after your commit was introduced but passed before. I can't see anything wrong with your commit though so I'm quite confused.
well, that's fun, using a dataprovider seems to make the test fail :/ |
@dhensby not sure if you would have seen my comment since it's on an outdate diff: From the docs you linked:
I guess SapphireTest:setUp() is running after the objects are instantiated now and something critical happens in there? |
Just make the first argument a closure that creates the singleton. :) |
control/injector/Injector.php
Outdated
if (!count($constructorParams)) { | ||
$constructorParams[0] = null; | ||
} | ||
$constructorParams[1] = 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.
Sometimes constructor args are named.
MyObject:
class: MyObject
constructor:
record: []
singleton: true
however that's super edge case. Maybe only apply this fix if $constructorParams are totally empty?
Not a review change request, because it feels edge case enough we can ignore it, but worth thinking about before we modify this in 4.x.
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.
Could we just convert to numeric keys:
$constructorParams = array_values($constructorParams);
The constructor args will be passed by position rather than name right? So I assume it should be safe to discard any non-numeric keys.
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 safer to only supply default constructor params, in this case based on $type != 'prototype' && empty($constructorParams)
. Trying to merge in based on position / key is guesswork at best.
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 don't think we can add a empty($constructorParams)
check to this because $constructorParams = [null]
would not be considered empty, would it?
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.
Yes but when would we have [null]
?
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.
Don't know :) I'm just guessing so I'll trust you on this lol. Have added a commit with a empty($constructorParams)
condition.
Also, @jonom does this affect 3.5 too? or just 3.6+? |
7e3bc76
to
452ff3f
Compare
It was a feature added a while ago: https://github.com/blog/2247-improving-collaboration-with-forks By default users that open PRs allow repo owners / contributors the ability to push into that branch until permission is revoked or the PR is closed/merged. |
@tractorcow we are green - thanks for the closure suggestion - can you decide if you're happy to merge, please? |
I'm guessing this bug was introduced in 3.0 or whenever the Injector first became a thing. Sorry I thought we were only patching the latest versions but I can target 3.5 branch instead if you like. |
control/injector/Injector.php
Outdated
if (!count($constructorParams)) { | ||
$constructorParams[0] = null; | ||
} | ||
$constructorParams[1] = 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.
It's safer to only supply default constructor params, in this case based on $type != 'prototype' && empty($constructorParams)
. Trying to merge in based on position / key is guesswork at best.
Ensure DataObject instances are aware they are singletons so functions like populateDefaults() can be skipped. (fixes silverstripe#4878)
Merge on green. |
I have a squashed version of this ready for 3.5 if you want me to push that up and change the base? This is targeting 3.6 currently. |
We should merge into 3.5, better. |
a53eb6e
to
c767e47
Compare
Thanks for your help guys. @dhensby sorry I couldn't work out how to move this to 3.5 and keep a credit on your commit but I figured it was likely to be squashed in to mine anyway. |
Scrutinizer got a bit confused |
OK - so I've merged this up to 3 but it's still an issue in 4 and @tractorcow has asked I don't merge the "fix" up into 4 as we want a less tightly coupled approach in @tractorcow I looked into creating a So bad news is this PR isn't going to get merged up into 4 and all and we'll need a new PR for 4 if we want this fix to make its way up :( |
I really want to get away from singletons needing special arguments to work. I'm highly reluctant to keep supporting this feature going forward. |
It seems the only use for the Is dropping |
Only in cases where you have db-dependant populateDefaults() implementations. We could just guard those with |
Sure it's an empty catch, but it's a specific guard against a known use case, rather than a global Exception suppressor. public function populateDefaults()
{
try {
$first = self::get()->first();
if ($first) {
$this->owner->Title = $first->Title . ' new';
}
} catch (DatabaseException $ex) {
// guard against errors during db bootstrapping
}
} |
So that I'm not actually sure what the reasoning is for DataObject Singletons to not have their defaults populated. Besides preventing build errors, I had assumed perhaps performance but if the whole point of a singleton is that only one for each class exists, executing a little extra code for each one shouldn't be a big concern, and this is what's been happening as long as this bug has existed anyway. I tried to look up the reasoning for the Maybe the fix we implemented for 3 should be merged up to 4, but in master we could remove the $isSingleton arg from the DataObject constructor and let singletons have their defaults populated? |
Pretty much as you said it... it was more a performance / error prevention step. The solution in my mind is to simply avoid heavy lifting when populating defaults, as well as allowing it to silently ignore DB errors. |
Singleton objects created by the Injector in 3.x always have the
populateDefaults()
method fired, despite a check in the DataObject constructor intended to prevent this.The result is that populateDefaults() runs a lot of times that it shouldn't, including during the building of tables, which can easily break a build if you're trying to pull data from a table that isn't ready yet.
Additionally, infinite loops can occur if you try to access another object of the same class from within the populateDefaults() method. Example:
If you paste that code example in to the Page class, the CMS won't load at all.
Not sure if
in_array('DataObject', ClassInfo::ancestry($class))
oris_subclass_of($class, 'DataObject')
would perform better, or if it matters?