Problem/Motivation
In Drupal 7 it was very common to temporarily put data on an entity by using an arbitrary property to hold the data. This was abusing the fact that entities were simple objects:
$entity->myproperty = 'my data';
This was intended to keep track of some temporary state until the end of the page request.
Unfortunately this practice is completely unreliable; whenever static caches are cleared, or an unchanged entity is loaded from the database, a new entity will be instantiated and the temporary data is lost.
I see this practice continuing in the wild in D8 code, even though we can now solve keeping track of temporary state by writing a custom service to hold the data. Writing custom services might be overkill though in many cases, and with the lack of an officially supported alternative we will see the continued abuse of properties on the entity.
Proposed resolution
Provide new methods:
EntityInterface::setTemporaryData($key, $value, $persist_on_reload = TRUE)EntityInterface::getTemporaryData($key)EntityInterface::clearTemporaryData($key)
Alongside this we could maybe deprecate the setting of arbitrary properties on entities, so that in D9 or beyond we can disable this completely. This could be as simple as a few lines of documentation on the EntityInterface, or even a deprecated warning being emitted whenever an unsupported property is accessed.
Remaining tasks
Discuss potential pitfalls.
User interface changes
None.
API changes
New methods added.
Data model changes
None.
Issue fork drupal-2896474
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
pfrenssenComment #3
pfrenssenComment #4
mpdonadioI think we could do a property_exists() in the magic __set() method and then throw deprecated warning. Checking in the __get() may not catch it b/c.
Comment #5
pfrenssenI think we can come up with a list. We have the fields that can be directly accessed, as well as some internal properties such as
$entity->original.The
__get()is maybe not so important, if we have a deprecation warning on__set()it would be more than enough to educate people about it. Where there's a get there's a set :)Anyway, the deprecation thing is a "nice to have", I can imagine people objecting to it because it is so heavily used in legacy code. This might be Drupal 10 territory.
Comment #6
berdir->original has its own issue to be deprecated in favor of setters/getters.
I think we should add the API, then open a follow-up to add a deprecation warning just to see what happens and then as part of that open specific issues to convert usages in core to that.
I'm also open to discussing to deprecate $content_entity->$field_name in favor of supporting only get()/set(). I'm not sure yet about field properties.
See #1977266: Fix ContentEntityBase::__get() to not return by reference for a special case of __get() that we can likely not do anymore on its own but we might want to focus on the cases that require it to be by reference here first as they are probably the weird ones.
Comment #8
sam152 commentedWhat about a seperate repository service or factory instead of adding additional responsibilities to
EntityInterface. Could look like something like the following:Comment #10
fabianx commentedI like:
a lot.
Especially as we could just potentially wrap it in a decorator when we need to access the additional properties:
However the disadvantage of keeping it separate from the entity itself is while it ensures that lazy builders could use "pack()" the entity back to array('entity_type', 'entity_id'), the problem is that when code actually relies on that there is no way anymore to see if the entity was modified.
e.g. what I used in a proof-of-concept while developing BigPipe was to serialize() the entity and serialize() a freshly loaded entity and compared those to see if it was modified in some way.
We could also allow to add a list of EntityModifiers - instead.
While that is still arbitrary, it is at least consistent.
e.g.
What about:
A get on an unused property would then ask if any modifier has the property and return it if it is there.
Though that does still not solve the problem of when an entity is reloaded, though e.g. the _initialPublished should not even persist for longer than the entity's lifetime.
---
So we have two things to take into account here:
Still brainstorming here ...
I think the problem we have is that the entity has a fixed interface and we want / need to enhance that interface during the runtime. [e.g. for the a theoretical EntityWorkspaceStatusInterface, which has isInitialPublished() / setInitialPublished() methods].
Also some things must persist in entity cache, while others are just temporary to the object.
Which is kinda what I though of with my modifiers:
with the big problem that an instanceof won't work unless it is a true dynamic child entity, but if that code was changed to check for:
Brainstorming end ;).
Comment #20
andypostComment #21
andypostthere's existing API for that purpose
- user_data service, associates and removes additions to user entity
- third-party-settings for config entities
and less known
- key value entity storage - #2208617: Add key value entity storage
Comment #22
andypostComment #24
joachim commentedIn addition to #21, another way to do it is to define a computed field on the entity, which returns a NULL value. You can then set $entity->mycomputedfield->value and retrieve it later from the same entity object.
Comment #26
bradjones1I'll drop in here and suggest that some variation of JSON storage for this could be a nice way to interact with this and avoid some of the weirdness in the user data model, which is truly prehistoric Drupal.
Comment #27
berdirComing from #3281720: [meta] Deprecate __get/__set() on ContentEntityBase I think there are kind of two things here.
One is a 1:1 replacement of the current magic __get()/__set() stuff. ->origin is now dealt with, but there are things like _referringItem, inPreview, view and various others. They are not expected to be persistent, but mostly on purpose only for the specific *object* as opposed to a given entity (identified by entity id/uuid).
Then there's this bit:
> Unfortunately this practice is completely unreliable; whenever static caches are cleared, or an unchanged entity is loaded from the database, a new entity will be instantiated and the temporary data is lost.
I think temporary kind of implies that it's... temporary? I think the persistent thing adds a fair bit of complexity that is somewhat unclear just how persistent it is? I'm tempted to say that if you want something persistent then do one of those custom service things so you control it? Possibly even like user.data which makes it actually persistent. Unsure what the exact use cases are for that.
Started a merge request with a basic implementation.
@bradjones1: I don't think this is related to *storage* at all, it about temporary, not-persisted data. The user.data service is something entirely different.
Comment #28
plachThe way I interpreted it, it seems the OP is talking about persistence throughout the request/response cycle. From time to time, you need to "persist" information between two points of the execution flow with no clean way to pass that around. In the dark days we would use a
$GLOBALvariable for that, but, for one, that's not compatible with the request stack. A saner alternative could to be to attach attributes to the request itself, but that may force you to inject the current request or the request stack into a context that has nothing to do with those concepts, which is suboptimal as well.Granted that the need to pass data around like this is itself not ideal and likely an indication that something is wrong in the subsystem relationships or the logic being implemented, I tried to address this use case for a few custom projects of mine through this code, which is close in spirit to what was proposed in #8. Something like that would support both the temporary entity data use case and the "persistent" one.
Personally, I'd like to drop entirely the habit of setting random data on entity objects.
Comment #29
bradjones1I'd agree with this. There are other options (e.g., the tempstore) that work for most of the use cases described.
Comment #30
berdirYes, I agree that storing information that should "persist", in some form or another typically should be done in a different way.
FWIW, tempstore is specifically not this use case either because that is used to store data across multiple requests.
That said, the simple use case for a single loaded entity that should explicitly _not_ persist across clones and static cache clears is IMHO somewhat valid, and if we want to deprecate magic get/set then we need at least an intermediate solution for this. Some things like isPrevie should be handled by the specific entity type as it's a feature they provide (using an interface + trait for example), but what about _referringItem, set during rendering an entity reference? There's the ->view property that's set when an entity is rendered within a view.
Storing that elsewhere requires some workarounds like storing it by object hash which can have weird edge cases (the documentation on that explicitly mentions that hashes may be reused), so I think storing that directly on the entities is a good fit.
Comment #31
plach@berdir
I agree, but my concern is that, by adding an API to support that use case on the entity class itself, we are encouraging people to take advantage of this pattern even more, which is likely to result in abuse, since it's hard to tell a legitimate API usage (very few IMHO) from an invalid one (most of them IMHO 🙂).
💯
Yes, this is a clear of example of an ephemeral/contextual property: a file can be referenced by multiple entities, so adding an explicit parent reference would make no sense and would not help here. IMO the right way to handle it would be something along these lines:
The
$displayContextwould be passed around as needed and discarded once the render array is returned. No need for "persistence" at all in this particular case, I think, as all the consuming code is in the formatter classes extendingEntityReferenceFormatterBase(at least in core).Another contextual property, I assume something like this would be viable in both the the occurrences I found:
I believe using a
WeakMapwould solve this: the entity contextual data would no longer be available as soon as the original entity object were destructed.Comment #32
plach@fabianx:
IMO a) could be solved by having a contextual data storage relying on \SplObjectStorage or \WeakMap. Depending on the use case, one could instantiate an
EphemeralContextclass (relying on\Weakmap) or aPersistentContextone (relying on\SplObjectStorage).I think we have (at least) one issue dealing with b) (#2862574: Add ability to track an entity object's dirty fields (and see if it has changed)), but, if we go with a separate contextual data storage, we don't have to worry about that here :)
Actually, a lazy builder could serialize the entity contextual data, which should be far cheaper than serializing the whole entity.
Maybe introducing a distinction between mutable and immutable entity objects, like we have for config, would help there (and would probably be desirable for other reasons), but that's definitely not a small undertaking :)
IMO this is exactly where the distinction between legitimate entity data addition and contextual data lies: (content) entity data should live in fields (stored or computed). When additional fields are defined that the base interface does not know about/support, you can always rely on decorators/wrappers to access them and benefit from static analysis and IDE autocompletion. The Content Translation module follows this pattern with its
ContentTranslationMetadataWrapperInterface.OTOH ephemeral data without a proper definition (including accessor methods on an interface), currently stored as a random property, only makes sense as contextual data and should not live on the entity IMO.
Comment #33
berdir> I agree, but my concern is that, by adding an API to support that use case on the entity class itself, we are encouraging people to take advantage of this pattern even more, which is likely to result in abuse, since it's hard to tell a legitimate API usage (very few IMHO) from an invalid one (most of them IMHO 🙂).
Fair.
> The $displayContext would be passed around as needed and discarded once the render array is returned.
That's the tricky part. There's a surprising amount of places where ->_referringItem is available that would possibly need to be refactored to be able to receive that. FWIW, most places probably _do_ have access to the render array, similar to your view example, so that might be a feasible option.
That was my reason for going with the getOriginal() method in #2839195: Add a method to access the original property instead of #1480696: Move $entity->original to a separate hook argument as entities being saved are passed around so many hooks and methods.
I guess my concern is that if we have to solve every single instance of these current cases, deal with BC and so on then we won't be able to do something about #3281720: [meta] Deprecate __get/__set() on ContentEntityBase for many, many years. But it's a fair argument that we don't really gain much in the end if we replace it with a 1:1 API doing the same thing in a different color.
Comment #34
plach@berdir
Right, we could definitely add the information to the render array instead of discarding it and deprecate accessing
_referringItem. This should definitely be ok BC-wise.IMO this is completely fine: the original object is not random/contextual data, I cannot think of a more legitimate use of a property on an entity object :)
If we ever end up introducing the distinction between immutable and mutable entity object, then a
::getOriginal()method would only make sense on the latter (and return the former), similar to the::isNew()one and friends, but for now I think that's a valid solution.Maybe we could use magic methods to populate a globally available
EphemeralContextinstance and stop storing them on the entity object? This way, in the BC phase we can throw deprecation messages warning people that they should not use magic methods and that they should refactor their code to pass data around properly, but at the same time we can drop magic methods and leave the globalEphemeralContextinstance around for (at least) one more major release, so that people not having the option to refactor their code can access the global context directly instead of via$entity->__get()?Pseudo-code ("tested" here):
Comment #35
catchUsing a service with a weakmap for this sounds good but also something like $entity->view I would assume was added 'just in case it will be useful' in about 2009 and we should deprecate it with no replacement. Would be good to ay least open issues for the spurious looking ones.
Comment #36
berdir> but also something like $entity->view I would assume was added 'just in case it will be useful' in about 2009 and we should deprecate it with no replacement. Would be good to ay least open issues for the spurious looking ones.
We actually have a valid and for us important use case for this. We have a views display plugin that allows to control certain options on customizing displayed teasers, in this case a customized block display that is then used through paragraphs/block_field so editors can display a list of content and control how it looks without requiring 10 different view modes.
Same with _referringItem in combination with entity reference fields.
Both are tricky to use because it's on you to adjust cache keys of the rendered nodes, but it's possible.
That said, views uses it to provide theme suggestions for that (without supporting caching properly) and that we should deprecate: #2728419: Deprecate node/comment views-based theme suggestions and variables, but it's blocked on #3159050: Allow deprecating theme suggestions and that got stuck. So many issues...
You can see some of the more frequent cases that we have for this in the current __get()/__set() MR where I added an ignore list for them just so I could get a better overview of other remaining bits: https://git.drupalcode.org/project/drupal/-/merge_requests/10629/diffs#7...
So we have things like:
* pass_raw/passRaw, used by drupalCreateUser/drupalLogin on the user entity
* _skipProtectedUserFieldConstraint again for user entities, actually used by user module itself. Explicitly "internal" but also kind of an API.
* _restSubmittedFields, which is a workaround for not having an entity factory (to create an entity without default values), which is another chain of issues a decade+ old
* _initialPublished, which I think is workspaces
* in_preview/preview/preview_view_mode, as mentioned already
* book/rdf, which are removed from core by now
* _serviceId, is also replaced with a new solution now I think
A slightly different version of this but related issue that's a problem for config entities and the main reason we had to make them #[AllowDynamicProperties] is \Drupal\Core\Entity\EntityForm::copyFormValuesToEntity(). That just slaps all form state values into the entity using set() (content entities subclass this and check with hasField()). For config entities, set() behaves like __set() for content entities. BC around that will be fun. And there's some content entity deprecations triggered there on form submission that I still have to track down.
Comment #37
amateescu commentedOpened an issue to remove
_initialPublishedfrom Workspaces: #3498115: Fix usage of temporary entity data in WorkspacesComment #38
plach@berdir
Do you think the solution proposed in #34 would be acceptable/viable for the use cases you summarized? I guess we could skip deprecating the global contextual storage, at least until we are done cleaning up all those occurrences.