Problem/Motivation

We currently store information about a paragraph's parent entity in basefields:

    $fields['parent_id'] = BaseFieldDefinition::create('string')
      ->setLabel(t('Parent ID'))
      ->setDescription(t('The ID of the parent entity of which this entity is referenced.'))
      ->setSetting('is_ascii', TRUE);

    $fields['parent_type'] = BaseFieldDefinition::create('string')
      ->setLabel(t('Parent type'))
      ->setDescription(t('The entity parent type to which this entity is referenced.'))
      ->setSetting('is_ascii', TRUE)
      ->setSetting('max_length', EntityTypeInterface::ID_MAX_LENGTH);

    $fields['parent_field_name'] = BaseFieldDefinition::create('string')
      ->setLabel(t('Parent field name'))
      ->setDescription(t('The entity parent field name to which this entity is referenced.'))
      ->setSetting('is_ascii', TRUE)
      ->setSetting('max_length', FieldStorageConfig::NAME_MAX_LENGTH);

However the current implementation falls short if we want to fully support revisioning scenarios, because we are currently not storing the parent entity's vid. This leads to issues described in #3090200: Paragraph view access check using incorrect revision of its parent, leading to issues viewing paragraphs when reverted host entities or content moderation is used and #3084934: Paragraph access check via parent entity incorrectly uses the default revision of the parent instead of the latest where the paragraph access control check is buggy because it uses the parent's default revision for access checks instead of the correct one.

Proposed resolution

- Make all parent-related basefields revisionable (DONE in #2904231: Parent fields are not revisionable)
- Create a new basefield to store the parent's vid
- Implement an upgrade path to migrate the data of existing sites

Remaining tasks

User interface changes

API changes

Data model changes

Comments

marcoscano created an issue. See original summary.

miro_dietiker’s picture

Priority: Normal » Critical

:-) It is.

miro_dietiker’s picture

I think the proper parent is this.

jstoller’s picture

I just ran into this bug and am wondering if there's any hope on the horizon.

I've got a "Logo Grid" paragraph with a nested paragraphs field containing any number of "Logo" paragraphs. Fields on the parent Logo Grid are supposed to effect the display of the individual Logos. I use getParentEntity() in a preprocess function to retrieve the values of those fields when the Logos are rendered. I just discovered that, when saving draft revisions, getParentEntity() always returns the default revision of the paragraph's parent, rather than the parent revision that references the current revision of the child paragraph. So changes to a Logo Grid are not reflected when viewing a draft node, but take effect once the draft is published. This kinda defeats the purpose of having drafts.

miro_dietiker’s picture

We have been hit by this multiple times, but in most cases the limitations were (unexpectedly) not critical.

So yes, i hope to summarise it correctly:
Nested child Paragraphs can not really have a dependency on the parent data as that wll always represent the default revision in a draft context. However, in our real world scenarios, we almost never have such a data situation.

What we are lacking here is a description of the impact of this limitation.

@jstoller:
Could you help us to define such common real world scenarios?

And then best would be to write a test to show how a common situation would fail.

I don't get exactly what you are doing. Rendering usually happens top down and then the right revision is loaded and rendered. How / when are you exactly calling getParentEntity?

jstoller’s picture

I was using getParentEntity() in a preprocess function so I could set classes on child paragraph items based on field values set on their parent paragraph. For instance my Logo Grid paragraph has a size field that was intended to set a class on child Logo paragraphs, controlling how they're rendered. It worked fine once a node was published, but my users were changing the logo size and saving drafts, then getting frustrated because they didn't see anything change. Because the draft was still pulling the size value from the published revision, instead of from the latest revision.

In this case I was able to find a CSS workaround, but not without compromises. And I can easily see myself wanting to change aspects of the child paragraph that cannot be managed with CSS. Like, what if I wanted to change the image source, or the actual structure of the markup? What if I have fields on the child paragraph that should be rendered conditionally, based on the field values of it's parent?

I think it's reasonable for a paragraph to know the revision id of its parent, in addition to the entity id. Maybe we need a getParentRevision() method.

rgpublic’s picture

@miro_dietiker: Perhaps see #2944653 (paragraphs_edit) for a real-world scenario where solving this bug matters. Currently we have to patch this to prevent this error from occurring. Obviously not an ideal situation.

eric_a’s picture

dalin’s picture

Another place this pops up:

1. Use getParentEntity() somewhere.
2. View a non-default revision of the node. `getParentEntity()` is always returning the default revision of the parent, so you see something broken.

If the parent is a node there's awkward ways to work around this by getting the route object. But if the parent is another paragraph, you're screwed.

twod’s picture

Yeah, this really bites sometimes.

We've got a use case doing a lot of programmatic edits to individual paragraphs and had to come up with a helper like this instead of calling getParentEntity() directly:

The extra logging is there because we've hit all those cases with existing content which previously just called getParentEntity() and assumed it "did the right thing".


use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\RevisionableStorageInterface;
use Drupal\node\NodeInterface;
use Drupal\paragraphs\ParagraphInterface;

trait ParagraphHelpers {

  protected EntityTypeManagerInterface $entityTypeManager;
  
  /**
   * Get parent revision which actually refrences a paragraph.
   *
   * Paragraph parent references do not include the parent revision, so we may
   * need to check multiple parent revisions to find the one which actually
   * references the specific revision of the passed in paragraph.
   *
   * @param \Drupal\node\NodeInterface $node
   *   The top node.
   * @param \Drupal\paragraphs\ParagraphInterface $paragraph
   *   A paragraph.
   * @param string|null $langcode
   *   The language to load, default to the same as the paragraph.
   *
   * @return \Drupal\Core\Entity\ContentEntityInterface|null
   *   The parent entity revision referencing $paragraph, or NULL if not found.
   */
  protected function getParagraphParentRevision(NodeInterface $node, ParagraphInterface $paragraph, ?string $langcode = NULL): ?ContentEntityInterface {
    // Try the latest parent revision.
    /** @var \Drupal\Core\Entity\ContentEntityStorageInterface $parent_storage */
    $parent_storage = $this->entityTypeManager->getStorage($paragraph->get('parent_type')->value);
    assert($parent_storage instanceof RevisionableStorageInterface, 'Can only handle revisionable entities.');
    $latest_parent_revision = $parent_storage->getLatestRevisionId($paragraph->get('parent_id')->value);
    $parent = $parent_storage->loadRevision($latest_parent_revision);
    /** @var \Drupal\Core\Entity\ContentEntityInterface $parent */
    $current_paragraph_delta = $this->getParagraphDelta($paragraph, $parent);

    // Try the default parent revision.
    if ($current_paragraph_delta === FALSE) {
      $parent = $paragraph->getParentEntity();
      $current_paragraph_delta = $this->getParagraphDelta($paragraph, $parent);
    }

    // Brute force fallback.
    if ($current_paragraph_delta === FALSE) {
      // Sanity checks.
      while ($parent instanceof ParagraphInterface) {
        $parent = $parent->getParentEntity();
      }
      if (!$parent instanceof NodeInterface || $parent->id() !== $node->id()) {
        $this->logger->error('Something is very wrong with paragraph @id (@rid) in node @nid (@vid)!', [
          '@id' => $paragraph->id(),
          '@rid' => $paragraph->getRevisionId(),
          '@nid' => $parent->id(),
          '@vid' => $parent->getRevisionId(),
        ]);
        throw new \LogicException('The passed in node is not the parent of the paragraph.');
      }
      // We may have a broken node structure if it comes to this, but at
      // least we'll be able to edit it and preserve the structure.
      $tree = $this->getFlatParagraphTree($node);
      $paragraph_data = $tree[$paragraph->id()] ?? NULL;
      if ($paragraph_data
        && $paragraph_data['revision'] === $paragraph->getRevisionId()
        && $paragraph_data['parentType'] === $paragraph->get('parent_type')->value
        && $paragraph_data['parentId'] === $paragraph->get('parent_id')->value
      ) {
        if ($node->isLatestRevision() || $node->isDefaultRevision()) {
          $this->logger->notice('Paragraph @id (@rid) in node @nid (@vid) was not referenced from the latest or default', [
            '@id' => $paragraph->id(),
            '@rid' => $paragraph->getRevisionId(),
            '@nid' => $parent->id(),
            '@vid' => $parent->getRevisionId(),
          ]);
        }
        /** @var \Drupal\Core\Entity\ContentEntityInterface|null $parent */
        $parent = $parent_storage->loadRevision($paragraph_data['parentRevision']);
        $current_paragraph_delta = $parent instanceof ContentEntityInterface ? $this->getParagraphDelta($paragraph, $parent) : FALSE;
      }
    }

    if ($current_paragraph_delta === FALSE) {
      $this->logger->error('Paragraph @id (@rid) in node @nid (@vid) was not referenced from any parent.', [
        '@id' => $paragraph->id(),
        '@rid' => $paragraph->getRevisionId(),
        '@nid' => $parent->id(),
        '@vid' => $parent->getRevisionId(),
      ]);
    }

    return $current_paragraph_delta !== FALSE ? $parent : NULL;
  }

  /**
   * Get at which delta in the parent field a paragraph is referenced.
   *
   * @param \Drupal\paragraphs\ParagraphInterface $paragraph
   *   A paragraph.
   * @param \Drupal\Core\Entity\ContentEntityInterface $parent
   *   The parent element.
   *
   * @return false|int|string
   *   The delta as an int/string or FALSE if not found.
   */
  protected function getParagraphDelta(ParagraphInterface $paragraph, ContentEntityInterface $parent) {
    $field_items = $parent->get($paragraph->parent_field_name->value);
    foreach ($field_items as $delta => $item) {
      // Explicitly compare ids first to avoid loading a new instance of the
      // referenced revision, which gives a clone since they are not cached in
      // storage, but may be cached on the parent's reference field item.
      if (
        (
          !$paragraph->isNew()
          && (
            $item->target_revision_id === $paragraph->getRevisionId()
            && $item->target_id = $paragraph->id()
          )
        ) || (
          $paragraph->isNew()
          && !isset($item->target_revision_id)
          && $item->entity
          && $item->entity === $paragraph
        )
      ) {
        return $delta;
      }
    }
    return FALSE;
  }

}
abyss’s picture

Hi @amateescu, this is a bit strange, if you think that there is a duplicate problem here, then shouldn't you close this issue (child) instead of the parent issue which is described in #2807371: META Support Content Moderation module?

berliner’s picture

@twod You forgot the getFlatParagraphTree method in your code example.

rp7’s picture

Just posting here to point out that this would be incompatible with what's being discussed in #3267490: Allow composite entities to opt out of creating duplicate revisions. That would make it possible for a single paragraph revision to belong to multiple parent revisions.