Problem/Motivation

To soft-delete a translation instead of hard-deleting it, TrashEntityHooks::entityPresave() re-adds the removed translation:

  $translation = $entity->addTranslation($langcode, ['deleted' => $request_time] + $original->getTranslation($langcode)->toArray());

ContentEntityBase::addTranslation() fires hook_entity_translation_create(). That hook means "a new translation is being created", and implementations are free to replace the values passed
in. But the translation is not new here, because it already exists and is only being flagged as deleted. So firing the hook is semantically wrong, and any implementation that rewrites values corrupts the re-added translation.
Paragraphs is the case we hit this problem. paragraphs_entity_translation_create() replaces every referenced paragraph in a translatable field with an unsaved createDuplicate().

  1. Wrong data. A soft-deleted translation must keep the values it was last saved with. After a restore the user would get empty duplicates instead of their content.
  2. The save errors. The duplicates never get target IDs. Field-level ::preSave() runs before module presave hooks ContentEntityStorageBase::invokeHook() calls invokeFieldMethod('preSave', $entity) and only then defers to EntityStorageBase::invokeHook(), which invokes hook_entity_presave(). saveToDedicatedTables() then writes the item ::filterEmptyItems() keeps it, because an item holding an entity object is not empty — with NULL columns:

Seen on Drupal 11.3.14 / PHP 8.3.31, Trash 3.1.0-beta1, Paragraphs 8.x-1.21.

Steps to reproduce

  1. Install Trash and Paragraphs on a multilingual site and enable Trash for nodes.
  2. Add a translatable Paragraphs field to a content type.
  3. Create a node with a paragraph in that field and translate it, so the field has a value in both languages.
  4. Delete the non-default translation via the "Delete translation" confirm form.

Expected: the translation is moved to trash and can be restored with its stored values.
Actual: EntityStorageException (NOT NULL constraint violation), nothing is deleted.

Proposed resolution

Tell a re-add apart from a real translation creation, and populate it without firing the creation hooks, so no implementation gets the chance to replace the values.

addTranslation() reports TRANSLATION_EXISTING when it re-adds a translation that is gone from the entity object but still in storage, and TRANSLATION_CREATED for a real creation. TrashStorageTrait::createTranslation() uses that to tell the two apart. For a re-add it writes the stored values onto the translatable fields itself, skipping hook_entity_field_values_init() and hook_entity_translation_create(). Real creations go to the parent implementation and fire both hooks as before.

Remaining tasks

  • ✅ Provide MR to fix the issue

User interface changes

None.

API changes

None.

Data model changes

None.

Issue fork trash-3613615

Command icon 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

steffenr created an issue. See original summary.

steffenr’s picture

Issue summary: View changes
steffenr’s picture

Issue summary: View changes

steffenr’s picture

Issue summary: View changes

amateescu made their first commit to this issue’s fork.

amateescu’s picture

Assigned: steffenr » Unassigned
Status: Active » Needs review

Thanks, the diagnosis is spot on! I went with preventing the hooks from firing rather than undoing their work. addTranslation() reports TRANSLATION_EXISTING for a re-add and TRANSLATION_CREATED for a real creation, so TrashStorageTrait::createTranslation() spots the re-add and populates the translation data itself.

Not only Paragraphs, by the way. layout_builder_entity_translation_create() empties the layout field too. Also added test coverage for this.

amateescu’s picture

Issue summary: View changes

amateescu’s picture

Status: Needs review » Fixed

Merged into 3.1.x

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

steffenr’s picture

@amateescu Thx for your quick response and the updates within the MR/ merge..