Skip to content
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 for user-created objects to have values passed in the constructor #8591

Merged
merged 3 commits into from
Aug 20, 2020
Merged

Conversation

sminnee
Copy link
Member

@sminnee sminnee commented Nov 10, 2018

This change extends isSingleton into a tri-state creationType, which
recognises a new option, CREATE_HYDRATED. This is used to create records
from database content, which bypasses both setters and defaults.

Which that in place, the default, non-hydrated call will use setters
via DataObject::update(), and is therefore allowable for user-code
instantiating new objects.

Some quirks of existing behaviour are retained, notably that for new
objects data created by populateDefaults() will register as a change in
$this->original vs $this->record, but not $this->changed.

Parent issue

To do

  • Remove stray var_dump
  • Fix type-hint on creationType: boolean|int
  • Add another test case for all 3 creation modes

@sminnee sminnee changed the title NEW: Allow for user-created objects to have values passed in the cons… NEW: Allow for user-created objects to have values passed in the constructor Nov 10, 2018
@sminnee
Copy link
Member Author

sminnee commented Nov 10, 2018

Some quirks of existing behaviour are retained, notably that for new
objects data created by populateDefaults() will register as a change in
$this->original vs $this->record, but not $this->changed.

I might actually revisit this — it looks like both original and changed should reflect the content currently in the database, which in the case of new records should have none of the content derived from populateDefaults(). However, I'll get the tests passing without touching this before diving into that, and I'll see if there are tests that rely on this quirky behaviour, and if so, why...

Copy link
Contributor

@ScopeyNZ ScopeyNZ left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like a nice addition to me. There's a stray var_dump and some tests would be nice for this - especially some that confirm the existing isSingleton functionality asserting there's appropriate BC although that LGTM.

I might have a go at adding some later. I feel like I need to be more familiar with the ORM - but don't wait for it!

src/ORM/DataObject.php Outdated Show resolved Hide resolved
$item = Injector::inst()->create($class, $row, false, $this->getQueryParams());
$creationType = empty($row['ID']) ? DataObject::CREATE_OBJECT : DataObject::CREATE_HYDRATED;

$item = Injector::inst()->create($class, $row, $creationType, $this->getQueryParams());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't this just be in DataObject::__construct, and check the creation type from $row? I'm not sure why the tri-state needs to be outside of the DataObject. The existing (singleton / prototype) seemed sufficient as an external API for all objects.

I'm worried about the tri-state for dataobjects, bool state for all other objects and coupling of injector to dataobject mostly.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm worried about the tri-state for dataobjects, bool state for all other objects and coupling of injector to dataobject mostly.

AFAIK DataObject is the only class that makes use of this 2nd argument, so what we're talking about here is turning a bi-state argument tri-state argument.

Hyrdation, Creation, and Singletons are different cases. In the status-quo code, Creation and Hydration are coupled with the assumption that the Creation case will have an empty record. Frankly, Hydration and Singleton have more in common so you could argue "maybe hydration should set isSingleton = true". But I think that this will risk more messy edge-cases and also risk semver breakages. So I think a tri-state field is better.

We could arguably use the presence of an ID value to distinguish hydration (ID is present) from creation (ID isn't present), but that will cause bugs in two cases:

  • We attempt to hydrate a record without an ID
  • We attempt to create a record with a predefined ID

The former case seems unlikely unless we change our ORM, but the latter could conceivably occur in edge-cases such as copying content from stage to draft. I can't guarantee that it will introduce bugs, but it seems a risky proposition — I think a tri-state is cleaner.

Why is this conditional needed?
This condition was added to make ManyManyThroughList::add() work, which creates a new object by calling $hasManyList->createDataObject() on line 217.

So, if we wanted to reduce the use of the conditional here, we could potentially deprecate createDataObject() in favour of hydrateDataObject() and createNewDataObject(). I'd probably still leave this code as-is, but at least the messiness would be contain in a deprecated API.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with Sam, in that it's safer to make this an explicit decision of the API caller (tri-state), rather than keep the current signature and infer from the various constructor argument combinatoins (bi-state)

@sminnee
Copy link
Member Author

sminnee commented Mar 8, 2019

Damian gave some feedback here that I don't really think should be incorporated into this PR. I feel it pushes towards more like "possible bigger refactor in SS5", when here I have an SS4-appropriate fix.

There's one suggestion I had for partially addressing his concern:

So, if we wanted to reduce the use of the conditional here, we could potentially deprecate createDataObject() in favour of hydrateDataObject() and createNewDataObject(). I'd probably still leave this code as-is, but at least the messiness would be contain in a deprecated API.

Should I do this? If I do that, shall we merge it?

Cc @dhensby @chillu @kinglozzer @tractorcow for good measure.

chillu
chillu previously requested changes Mar 11, 2019
src/ORM/DataObject.php Outdated Show resolved Hide resolved
src/ORM/DataObject.php Show resolved Hide resolved
tests/php/ORM/DataObjectTest.php Outdated Show resolved Hide resolved
$item = Injector::inst()->create($class, $row, false, $this->getQueryParams());
$creationType = empty($row['ID']) ? DataObject::CREATE_OBJECT : DataObject::CREATE_HYDRATED;

$item = Injector::inst()->create($class, $row, $creationType, $this->getQueryParams());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with Sam, in that it's safer to make this an explicit decision of the API caller (tri-state), rather than keep the current signature and infer from the various constructor argument combinatoins (bi-state)

@sminnee
Copy link
Member Author

sminnee commented Mar 11, 2019

Cool I’ll address feedback on hackday.

@sminnee
Copy link
Member Author

sminnee commented May 27, 2019

Ok I've squashed the 3 agreed bits of feedback into the

Should be good to merge now?

@sminnee
Copy link
Member Author

sminnee commented May 27, 2019

OK got a few weird failures here I think when I rebased... a couple of fixes dropping in now.

@sminnee
Copy link
Member Author

sminnee commented May 28, 2019

A side-effect of this change is that NULL column values are now included in toMap(), whereas previously they were excluded. This is because the code previously had an "if !== null" check when populating $this->record.

I can't tell where this is a bugfix or a breaking change, although it has caused some issues with calculated field comparison when assertListEquals is passed the result of toMap() for a DataObject with calculated fields.

In particular:

  • MigrateSiteTreeLinkingTaskTest passes the result of toMap to assertListEquals, to test SiteTree records.
  • MenuTitle shows up as NULL in the toMap() result, but on the object a getMenuTitle() method returns the value of Title.
  • This means the record doesn't match and the test fails.

So, what's our conclusion? Should toMap() exclude NULL fields (the current behaviour on 4) or should MigrateSiteTreeLinkingTaskTest not rely so much on toMap() results (it's a bit of a nasty coding pattern at the mo)

@silverstripe/core-team is the restoration of NULL values to toMap() results a bugfix or an API change?

sminnee pushed a commit to sminnee/silverstripe-cms that referenced this pull request May 28, 2019
Comparing every single field is unnecessary and brittle, only the IDs
need to be compared.

Notably this tripped over a potential bug fix in
silverstripe/silverstripe-framework#8591
but the change should be incorporated regardless.
@sminnee
Copy link
Member Author

sminnee commented May 28, 2019

The CMS PR silverstripe/silverstripe-cms#2441 will fix the failing test on this, but it would be good to confirm whether what I've fixed is, in fact, an API change before merging this.

@robbieaverill
Copy link
Contributor

robbieaverill commented May 28, 2019

What was the behaviour in SilverStripe 3? I think having null values present would be making the API honest about its intention, but I also recognise that it might break someone's code somewhere.

@sminnee
Copy link
Member Author

sminnee commented May 28, 2019

So, what's our conclusion? Should toMap() exclude NULL fields (the current behaviour on 4) or should MigrateSiteTreeLinkingTaskTest not rely so much on toMap() results (it's a bit of a nasty coding pattern at the mo)

I've looked at 3. The behaviour of 4 matches 3, so there's no regression there. This is just one of those tightening up activities that is likely easier to achieve because of the type-integrity work I added in 4.4.

@robbieaverill
Copy link
Contributor

Would it be silly to suggest we change it but also provide a configuration option to revert back if people find it breaks something?

@sminnee
Copy link
Member Author

sminnee commented May 28, 2019

Would it be silly to suggest we change it but also provide a configuration option to revert back if people find it breaks something?

I don't think this will help much. I think the hard thing will be identifying that this is what's happening; once it's identified it's pretty easy to fix. And config flags for such deep behaviour is likely to make the system less reliable overall.

If we do change it, we should list it in the upgrade notes though.

I might break this out to a separate bug card.

@maxime-rainville
Copy link
Contributor

If this breaks someone's code, the proper fix would probably be to add a null check in their loop, not tweak some random configuration flag that they will never have heard off

If it's actually breaking our own existing test, it sounds like a sensible expectation that this will be breaking someone's code somewhere. So I would err on the side of not returning nulls.

@sminnee
Copy link
Member Author

sminnee commented May 28, 2019

In my view it broke a test that abused assertListEquals by pushing the result of toMap into it, and https://github.com/silverstripe/silverstripe-cms/pull/2441/files shows what was the right way to do it. But, yeah, a bit of a value-judgement there.

@sminnee
Copy link
Member Author

sminnee commented May 28, 2019

Broke out a separate bug card here #9021

@sminnee
Copy link
Member Author

sminnee commented May 28, 2019

Context for the test that broke:

The specific issue came with a field whose database value is a null but whose calculated value isn't. This happened in the case of MenuTitle, which defaults to return the value of Title if the database MenuTitle is null. assertListEquals was being passed the result of a toMap() in one badly written test, and because the null MenuTitle wasn't included in toMap(), it side-stepped this issue. So fixing this bug broke a test, but it was a very brittle situation to begin with.

@sminnee
Copy link
Member Author

sminnee commented May 29, 2019

Following up form my comment on #9021, I'm going to amend this PR to do the following:

  • Fix toMap() so that it doesn't return NULL values
  • Update the docblock of toMap() so that it says "neither NULL values nor results of custom getters will be included"
  • Add a test to highlight this odd behaviour.

@Cheddam
Copy link
Member

Cheddam commented Jul 31, 2020

@sminnee Do you have time to work on those amendments, or shall we close this out for the time being?

@sminnee sminnee changed the title NEW: Allow for user-created objects to have values passed in the constructor [WIP] NEW: Allow for user-created objects to have values passed in the constructor Aug 15, 2020
@sminnee sminnee self-assigned this Aug 15, 2020
@sminnee sminnee changed the title [WIP] NEW: Allow for user-created objects to have values passed in the constructor NEW: Allow for user-created objects to have values passed in the constructor Aug 16, 2020
@sminnee sminnee removed their assignment Aug 16, 2020
@sminnee sminnee requested review from chillu and ScopeyNZ August 16, 2020 05:58
@sminnee
Copy link
Member Author

sminnee commented Aug 16, 2020

Thanks for the nudge @Cheddam I've addressed the final work on this now

Copy link
Contributor

@maxime-rainville maxime-rainville left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few minor suggestions.

src/ORM/DataObject.php Outdated Show resolved Hide resolved
src/ORM/DataObject.php Outdated Show resolved Hide resolved
src/ORM/DataObject.php Outdated Show resolved Hide resolved
Sam Minnee added 3 commits August 17, 2020 12:03
…tructor.

This change extends isSingleton into a tri-state creationType, which
recognises a new option, CREATE_HYDRATED. This is used to create records
from database content, which bypasses both setters and defaults.

Which that in place, the default, non-hydrated call will use setters
via DataObject::update(), and is therefore allowable for user-code
instantiating new objects.

Some quirks of existing behaviour are retained, notably that for new
objects data created by populateDefaults() will register as a change in
$this->original vs $this->record, but not $this->changed.
This was latent behaviour in the previous implementation; now it is
explicit behaviour.
@sminnee sminnee dismissed stale reviews from chillu, ScopeyNZ, and maxime-rainville August 17, 2020 00:23

Addressed feedback

@sminnee
Copy link
Member Author

sminnee commented Aug 18, 2020

okay this is ready for another review thanks

Copy link
Contributor

@maxime-rainville maxime-rainville left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. Happy to merge as-is.

@silverstripe/core-team If someone has beef with this PR, make it known soonish.

Copy link
Contributor

@robbieaverill robbieaverill left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants