- ItemList considers setting NULL value as just "empty list" : ItemList::setValue(NULL) internally sets array().
- FieldItemList differs on that: FieldItemList::setValue(NULL) does set NULL, FieldItemList::setValue(array()) does set array().

Turns out is this is only used by the code handling Rest PATCH, that uses "FieldItemList with a special NULL value" as an internal convention between ContentEntityNormalizer::denormalize() and EntityResource::patch(). This is tightly related to the current code flow for PATCH requests. See comment #14.

For all other regular entity code, a FieldItemList with a NULL value just behaves as an empty List.

This is wrong. Entity/Field API shouldn't have to maintain code & logic to care for a "special value" that only has semantics for Rest PATCH - and Rest PATCH can totally be organized differently and not need this special value anymore.

In practice, this means we currently have 3 flavors of "empty" for a FieldItemLiist
- FieldItemLiist::$list = array()
- FieldItemLiist::$list = NULL
- FieldItemLiist::$list = array(an Item with a NULL value)

#2164601: Stop auto-creating FieldItems on mere reading of $entity->field[N] is about removing the last one.
This issue here removes the second one.

API changes

- Field API no longer considers "NULL" values as a "special flavor of 'empty' on which you can attach whatever semantics you see fit".
Entities have fields that are either empty or not empty, NULL doesn't mean anything.
- $entity->field_name = NULL and unset($entity->field_name) do not result in "a special flavor of empty", but behave exactly like $entity->field_name = array().
- isset($entity->field_name) does not mean "does the field has the special NULL flavor of empty" anymore.
Instead, since a lot of existing code does unset($entity->field_name) for "empty the field", isset($entity->field_name) now returns whether the field is empty or not.

Beta phase evaluation

Reference: https://www.drupal.org/core/beta-changes
Issue category Task because this is about improving understandabilty and understandability of Field API runtime structures
Issue priority Major because having subtly different flavors of "empty" is a WTF
Disruption Minor API change : the semantics of isset($entity->field) is changed. Not a real BC break, since the previous semantics ("does it have a very specific value of NULL, which never happens in the regular entity flow") meant that isset($entity->field) did not provide any meaningful information so far.
It has the positive impact of not requiring developers to differentiate between field item lists being NULL or an empty array any more, what has no semantic difference.

Original report by yched:

(was split in two separate issues, see comment #29 below)

FieldItemList::setValue($values) overrides the parent implementation in ItemList with an almost exact duplicate.

Differences :

1) It allows passing just the array of properties for the first field item. Fine.
That is legit and specific to the case FieldItemList / FieldItem. Can be preserved while deferring to the parent for the bulk of the work.

2) When passing $values NULL or array(), it sets $this->list to $values, while the parent sets to array().
Thus, passing $values NULL sets $this->list to NULL, which doesn't seem like something we want / need. No explanation in comments.

Turns out is this is only used by the code handling Rest PATCH, that uses "FieldItemList with a special NULL value" as an internal convention between ContentEntityNormalizer::denormalize() and EntityResource::patch(). This is tightly related to the current code flow for PATCH requests. See comment #14.

In practice, this means we have 3 flavors of "empty" for a FieldItemLiist
- FieldItemLiist::$list = array()
- FieldItemLiist::$list = NULL
- FieldItemLiist::$list = array(an Item with a NULL value)

#2164601: Stop auto-creating FieldItems on mere reading of $entity->field[N] is about removing the last one.
This issue here removes the second one.

3) When a value is assigned to a delta that already exists, it does $item->setValue($value, FALSE), whil the parent does ->setValue($value) (notify the parent).
No explanation in comments either, and not sure why ItemList choses to notify the parent of the Item (which is ... itself).

Proposed resolution

Patch :
- Preserves 1) by keeping FieldItemList::setvalue() just as a thin override on top of ItemList::setValue()
- Removes 3) by not notifying self ($item->setValue($value, FALSE) in ItemList::setValue().

Support from Acquia helps fund testing for Drupal Acquia logo

Comments

yched’s picture

Status: Active » Needs review
FileSize
2.35 KB

Patch
- makes FieldItemList::setValue() call the parent, keeping only a thin override for 1).
- changes ItemList::setvalue() to call $item->setValue($value, FALSE)

Let's see what bot says.

tstoeckler’s picture

+++ b/core/lib/Drupal/Core/TypedData/Plugin/DataType/ItemList.php
@@ -79,7 +79,7 @@ public function setValue($values, $notify = TRUE) {
+          $this->list[$delta]->setValue($value, FALSE);

Why not pass $notify through?

yched’s picture

Well, the $notify param in ItemList::setValue($values, $notify) is about notifying the parent *of the List*.

Here we're talking about $list->setValue($values) assigning a $value to an $item, and chosing to notify (or not) the parent of the item (i.e itself). One implementation notifies, the other doesn't, but no reasons given.

Honestly, not sure of what's the correct behavior here. I'm trying "don't notify", since it feels weird for the list to notify itself. But I might be missing something.

Status: Needs review » Needs work

The last submitted patch, 1: 2368807-FieldItemList_setValue-1.patch, failed testing.

yched’s picture

Asked for feedback in #1869562: Avoid instantiating EntityNG field value objects by default, that added the $notify param in TypedData::setValue(), and had FieldItemList::setValue() & ItemList::setValue() use it differently on the items :

- FieldItemList.php (was called Field.php back then...) :

@@ -90,7 +94,7 @@ public function setValue($values) {
-          $this->list[$delta]->setValue($value);
+          $this->list[$delta]->setValue($value, FALSE);

- but didn't change ItemList similarly.

yched’s picture

Status: Needs work » Needs review
FileSize
2.37 KB
728 bytes

Logic error in the override. We don't want to catch $values === array().

Status: Needs review » Needs work

The last submitted patch, 6: 2368807-FieldItemList_setValue-6.patch, failed testing.

yched’s picture

Remaining fails seem due to item 2) in the OP, and the semantics of "$entity->field is empty"

- In HEAD,
$entity->field->setValue(NULL) ---> $entity->field has an internal $list === NULL
because FieldItemList::setValue($value) sets $this->list to $value if $value is NULL or array()
while ItemList::setValue() sets $this->list to array()

- With patch,
$entity->field->setValue(NULL) ---> $entity->field has an internal $list === array()
because FieldItemList::setValue() behaves like ItemList::setValue()

I do think the patch is right - ItemList::setValue(NULL) behavior is correct, and FieldItemList should not override it :

- Having two flavors of "empty" ($list = NULL / $list = array()) is a WTF. An ItemList is a List of Items, an empty List is one with an empty array of Items. A NULL list of items makes no sense.
- There should be no semantic difference between List::setValue(NULL) and List::setvalue(array()). In HEAD, there is none for ItemLists, but there is one (quite unclear) for FieldItemList.
- If an entity "has a field" (i.e. there is a field definition for the field name in that entity type & bundle), then there is no "unsetting" $entity->field or "setting it to NULL", it always exists as a FieldItemList object, which might be "empty" as a list:

$entity->field = NULL; // or unset($entity->field), both do $entity->field->setValue(NULL)
$entity->field instanceof FieldItemListInterface; // TRUE

So in a way, unsetting / setting to NULL is a lie, most it can actually do is emptying the ItemList. And it should do it in a special flavor.

This being said, aside from the fails in EntityFieldTest that test the current behavior and will need to be updated, it does look like some existing code relies on those "two flavors of empty" - mostly in the normalize / rest area. Will look into that.

yched’s picture

Priority: Normal » Major

Also, bumping to "major" for the "two flavors of empty" WTF.

yched’s picture

Issue summary: View changes
Berdir’s picture

The NULL stuff is needed by rest.module/serializations and it's crazy hacks for doing PATCH, which comes back to not having a factory to create empty entity objects.

yched’s picture

Yeah - rest PATCH :-/

In D7, we supported saving "incomplete entities", where "missing field" meant "leave the current value unchanged". Some forms used that, for example. Thus we had to support some NULL/unspecified concept on fields.

We don't do incomplete entities in D8. Entities are always saved as a whole. Yet we currently maintain a hidden/undocumented notion of "NULL != empty" in our FieldItemLists, just to support the specific case of rest PATCH. That feels very wrong :-).

More on that in next comment.

yched’s picture

Status: Needs work » Needs review
FileSize
8.35 KB
4.9 KB

First, a patch that fixes the non-rest tests for the new code behavior.

Still needs some thinking on what unset($entity->field) / isset($entity->field) should mean exactly since $entity->field is *always* a FieldItemListInterface object, but for now this just makes the tests pass.

yched’s picture

Then, about rest PATCH requiring "NULL" field values:

The PATCH case seems to be mostly a question of how rest code flow is currently organized and who receives what information.

The current code doesn't unserialize the incoming PATCH request $data on top of the entity to update, but :
- ContentEntityNormalizer::denormalize() creates a fresh $entity from the $data. That $entity thus also contains "other stuff" (default values for unrelated fields)
- EntityResource::patch() receives the fresh $entity from denormalization and the $original_entity to update, and copies field values from one to the other.
It doesn't receive the $data itself, so it doesn't know which fields it should copy over from $entity to $original_entity. Thus it relies on the denormalization code putting some special "NULL / don't mind me" field value on the unserialized $entity, for which it requires the whole "field values" data structure to support the concept. Again, that feels really wrong.

Attached patch simply uses a custom, hardcoded property on the temporary $entity to pass the "which fields should be merged" information down to EntityResource::patch(), instead of using a "special field value" to convey that info.

Not ideally clean either, but at least the weirdness is kept between ContentEntityNormalizer and EntityResource and how they chose to work together, instead of spilling on the whole runtime values structure we have to support, so IMO this is still way cleaner than the current.

yched’s picture

The last submitted patch, 13: 2368807-FieldItemList_setValue-13.patch, failed testing.

yched’s picture

Issue summary: View changes

Status: Needs review » Needs work

The last submitted patch, 14: 2368807-FieldItemList_setValue-14.patch, failed testing.

yched’s picture

Status: Needs work » Needs review
FileSize
12.31 KB
1.56 KB

DenormalizeTest needs to be updated for the new PATCH behavior.

yched’s picture

yched’s picture

Issue summary: View changes
yched’s picture

Issue summary: View changes
yched’s picture

Reroll, just in case

klausi’s picture

The REST PATCH support in the serializer was not ideal before and a bit of a hack already, so the $entity->_restPatchFields don't make it worse. At least we are able to pass on explicitly which fields should be updated now. So no objection from me :-)

fago’s picture

  1. +++ b/core/lib/Drupal/Core/Entity/ContentEntityBase.php
    @@ -790,6 +790,7 @@ public function __set($name, $value) {
    +      // @todo Now returns array() after $entity->field = NULL.
           return $this->get($name)->getValue() !== NULL;
    

    So our fields are alway set now, right? If it's defined, it might be empty but it's always set now.

  2. +++ b/core/lib/Drupal/Core/TypedData/Plugin/DataType/ItemList.php
    @@ -79,7 +79,7 @@ public function setValue($values, $notify = TRUE) {
    -          $this->list[$delta]->setValue($value);
    +          $this->list[$delta]->setValue($value, FALSE);
    

    Yep, makes sense. It's probably just an oversight this was not added here.

  3. +++ b/core/modules/hal/src/Normalizer/ContentEntityNormalizer.php
    @@ -171,6 +162,12 @@ public function denormalize($data, $class, $format = NULL, array $context = arra
    +      $entity->_restPatchFields = array_keys($data);
    +    }
    

    Yes. Introducing NULL value state only for REST was a bad idea. Back then, the argumentation was that if REST has that use case others might have as well - but until now, none showed up. So +1 on keeping that information separately even this is ugly.

So, this changes the API to not support the NULL state anymore. But as yched pointed out, having multiple different empty states is a pity to work with and this makes the API simpler, so it seems to make sense to do still.

yched’s picture

Disccussed with @fago on IRC, making isset($entity->field_name) behave like "field has values" doesn't fit too great in the Entity/Field syntax metaphor.

- Would be weird to have isset($entity->field_name) == FALSE;
while $entity->field_name is still an object you can call methods on.

- It would make empty($entity->field_name) work as "field has no values"
because PHP evaluates empty($object->magic) as isset($object->magic) ? empty(__get($object->magic)) : TRUE.
In our case, that de facto translates to empty($object->field_name) === !isset($object->field_name)

That might sound kind of nice, but it's actually really surprising magic, since PHP doesn't offer magic callbacks for empty(), so you don't expect magic behavior for empty($something_that_is_an_object).

- We do have $entity->field_name->isEmpty() or $entity->field_name->count() for "does the field have values ?".

[edit - also:
- It would be a bigger API change, which we don't necessarily want if we're not convinced it's a good idea]

yched’s picture

As per IRC discussion summarized in #26, attached patch goes for "isset($entity->property) is always TRUE if 'property' is a Field API field".

Plus, changes __unset() so that unset($entity->field_name) assigns value array() rather than NULL. This is just for clarity, since FieldItemList::setValue() turns NULL into array() anyway.

yched’s picture

Issue summary: View changes
yched’s picture

Title: Deduplicate setValue() in FieldItemList & ItemList » Remove special support for NULL values in FieldItemList
Issue summary: View changes
FileSize
10.44 KB

OK, we are all in agreement on what the patch does, but what it does really is two things, that would be best separated for commit message and history tracking :

1) Remove support for NULL values for FieldItemList, and adjust rest PATCH accordingly.
Since most of the discussions here are about this aspect, repurposing this issue here to do just that.

2) Switch FieldItemList::setValue() to do $item->setvalue($value, FALSE), like FieldItemList does - as a side effect, once 1) is done, this also lets us turn FieldItemList::setValue() into mostly a parent call rather than a full code duplicate.
Will move that part to a separate issue.

Attached patch is just the 1) part. All of the code has been vetted already.

yched’s picture

Opened #2381777: Unify setValue() implementations in ItemList & FieldItemList for the $item->setValue($value, FALSE) part, postponed on that one.

yched’s picture

Still thinking about the "consistent metaphor" aspect for our magic syntax for entity fields.

The current patch does (from the issue summary) :

- $entity->field_name = NULL / unset($entity->field_name) is striclty equivalent to :
$entity->field_name = array()
- isset($entity->field_name) is always TRUE for Field API fields.

What this end up with, though, is:

unset($entity->field_name);
isset($entity->field_name) === TRUE; // Try again, still set :-)...

Since we state that "$entity->field_name is always set for Field API fields", it also means that you shouldn't be able to unset it... - and there's no actual use case for that: you can empty it, if that's what you want.

That is:
- isset($entity->field_name) is always true.
- unset($entity->field_name) and $entity->field_name = NULL both throw an exception. That's consistent with the above.
- $entity->field_name = array() empties the field, also consistent with the above.
- $entity->field_name->setValue(NULL) is still allowed, but internally sets array(), just like ItemList does.

@fago (& others) : toughts ?

yched’s picture

Patch does as per #31 : exception on unset($entity->field_name) / $entity->field_name = NULL

yched’s picture

Issue summary: View changes
yched’s picture

FileSize
7.64 KB

Oops, #32 has the wrong interdiff.

yched’s picture

Minor - unintended change (PhpStorm glitch ?)

The last submitted patch, 32: 2368807-FieldItemList-NULL-32.patch, failed testing.

Status: Needs review » Needs work

The last submitted patch, 35: 2368807-FieldItemList-NULL-35.patch, failed testing.

yched’s picture

Status: Needs work » Needs review
FileSize
15.29 KB
442 bytes

Node forms were doing $node->revision_log = NULL

Status: Needs review » Needs work

The last submitted patch, 38: 2368807-FieldItemList-NULL-38.patch, failed testing.

yched’s picture

Found this while working on the fails : #2382493: Population of default field values in entity translation is incorrect - reviews welcome :-)

yched’s picture

Status: Needs work » Needs review

So, well, turns out there's a lot of code out there that uses unset($entity->field) / $entity->field = NULL for "empty the field".
Which means forbidding it as proposed in #31 is a fairly large API change at this point.

This circles back to the question of "should isset() mean empty ?", though, since the de-facto use is "unset() / assign NULL to empty the field"...

yched’s picture

OK, forget #31 to #40 - reroll & revert to the approach in #29 : unset($entity->field_name) and $entity->field_name = NULL do not throw an exception, and only empties the field.

yched’s picture

Then, checking what happens if we chose to do "isset($entity->field_name)" == "the field is not empty".

fago’s picture

unset($entity->field_name) and $entity->field_name = NULL do not throw an exception, and only empties the field.

Yes, I think that is what would you expect. And as a consequence of "doing that and a subsequent isset() is expected to return FALSE", it's expected that isset returns false for emptied fields. Consequently,
- isset is expected to return true for non-empty fields
- isset is expected to return false for emptied fields
thus isset is expected to return whether a field is empty :-)

fago’s picture

  1. +++ b/core/modules/quickedit/src/Form/QuickEditFieldForm.php
    @@ -187,7 +187,7 @@ protected function buildEntity(array $form, FormStateInterface $form_state) {
    +    if ($entity->getEntityTypeId() == 'node' && $entity->isNewRevision() && $entity->revision_log->isEmpty()) {
    

    That change should be deprecated now?

  2. +++ b/core/modules/system/src/Tests/Entity/EntityFieldTest.php
    @@ -171,25 +171,27 @@ protected function doTestReadWrite($entity_type) {
    +    foreach ([NULL, array(), 'unset'] as $empty) {
    +      // Make sure a value is present
    +      $entity->name->value = 'a value';
    +      $this->assertTrue(isset($entity->name->value), format_string('%entity_type: Name is set.', array('%entity_type' => $entity_type)));
    +      // Now, empty the field.
    +      if ($empty === 'unset') {
    +        unset($entity->name);
    

    That reads a bit weird. Maybe just move the assertions to a helper?

The last submitted patch, 42: 2368807-FieldItemList-NULL-42.patch, failed testing.

Status: Needs review » Needs work

The last submitted patch, 43: 2368807-FieldItemList-NULL-43.patch, failed testing.

yched’s picture

Issue summary: View changes
Status: Needs work » Needs review
FileSize
6.52 KB

- Fixed merge error that caused the rest test fails
- Fixed embarrasing logic error (isset($entity->field) = "field is empty" instead of "field is NOT empty") :-p.
Surprisingly little things broke though, which proves that isset($entity->field) is used very rarely in core at the moment :-)

#45.1 : yep, reverted
#45.2 : right, was not a fan either. moved to an assertFieldIsEmpty() helper.

Also, updated the issue summary for the current approach.

yched’s picture

Er, and the patch

yched’s picture

Issue tags: +Ghent DA sprint
fago’s picture

Status: Needs review » Reviewed & tested by the community

Looks good. Let's file the (non API-changing) follow-up for simplifying ItemList and FieldItemList interals then, as all those isset($this->list) checks can be removed there then. Simplification++

I think the issue will need a beta phase evaluation summary filled.

yched’s picture

fago’s picture

Status: Reviewed & tested by the community » Needs work

Setting needs work for the evaluation :/

yched’s picture

Issue summary: View changes
Status: Needs work » Reviewed & tested by the community

Added Beta phase evaluation

yched’s picture

Issue summary: View changes

Unfinished sentence :-)

yched’s picture

Issue summary: View changes
fago’s picture

Issue summary: View changes

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 49: 2368807-FieldItemList-NULL-48.patch, failed testing.

yched’s picture

Status: Needs work » Needs review
FileSize
14.17 KB

Reroll ?

yched’s picture

Status: Needs review » Reviewed & tested by the community

Green, back to RTBC

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 59: 2368807-FieldItemList-NULL-59.patch, failed testing.

yched’s picture

Aw shoot, InstallUninstallTest :-(

yched’s picture

Hm, yeah, but green locally :-)

Status: Needs work » Needs review
yched’s picture

Status: Needs review » Reviewed & tested by the community

Green, was a bot fluke apparently.

Back to RTBC...

  • catch committed da33162 on 8.0.x
    Issue #2368807 by yched: Remove special support for NULL values in...
catch’s picture

Status: Reviewed & tested by the community » Fixed

Committed/pushed to 8.0.x, thanks!

yched’s picture

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.