src/JsonApiResource/EntityCollection.php | 5 +- src/Normalizer/EntityReferenceFieldNormalizer.php | 47 +++++++++-------- src/Normalizer/RelationshipItem.php | 33 ++++++++++-- tests/src/Functional/JsonApiRegressionTest.php | 61 +++++++++++++++++++++-- 4 files changed, 114 insertions(+), 32 deletions(-) diff --git a/src/JsonApiResource/EntityCollection.php b/src/JsonApiResource/EntityCollection.php index b28de7d..574fb73 100644 --- a/src/JsonApiResource/EntityCollection.php +++ b/src/JsonApiResource/EntityCollection.php @@ -45,7 +45,7 @@ class EntityCollection implements \IteratorAggregate, \Countable { /** * Instantiates a EntityCollection object. * - * @param \Drupal\Core\Entity\EntityInterface|null[] $resources + * @param \Drupal\Core\Entity\EntityInterface|null[]|false[] $resources * The resources for the collection. * @param int $cardinality * The number of resources that this collection may contain. Related @@ -55,7 +55,8 @@ class EntityCollection implements \IteratorAggregate, \Countable { */ public function __construct(array $resources, $cardinality = -1) { assert(Inspector::assertAll(function ($entity) { - return $entity === NULL + return $entity === NULL + || $entity === FALSE || $entity instanceof EntityInterface || $entity instanceof LabelOnlyEntity || $entity instanceof EntityAccessDeniedHttpException; diff --git a/src/Normalizer/EntityReferenceFieldNormalizer.php b/src/Normalizer/EntityReferenceFieldNormalizer.php index c7ffc43..da94306 100644 --- a/src/Normalizer/EntityReferenceFieldNormalizer.php +++ b/src/Normalizer/EntityReferenceFieldNormalizer.php @@ -66,32 +66,35 @@ class EntityReferenceFieldNormalizer extends FieldNormalizer { // A non-empty entity reference field that refers to a non-existent entity // is not a data integrity problem. For example, Term entities' "parent" // entity reference field uses target_id zero to refer to the non-existent - // "" term. + // "" term. And references to entities that no longer exist are not + // cleaned up by Drupal; hence we map it to a "missing" resource if (!$item->isEmpty() && $item->get('entity')->getValue() === NULL) { - // @todo Either let Drupal core's Entity API require to declare which - // target IDs should be considered "virtual", *or* let it fix the - // dangling entity references problem. Until then, we have no choice but - // to omit dangling references altogether, because it is impossible to - // determine which resource type they referred to. We *could* - // automatically determine this for entity reference fields that only - // allow references to a single bundle, but since the "target_bundles" - // setting on FieldConfig can be changed, harmless configuration changes - // can cause JSON API to start behaving differently. To avoid BC breaks, - // it's better to be consistent, and never list dangling references. - if ($field->getFieldDefinition()->getFieldStorageDefinition()->getSetting('target_type') !== 'taxonomy_term' || $item->get('target_id')->getCastedValue() !== 0) { - continue; + if ($field->getFieldDefinition()->getFieldStorageDefinition()->getSetting('target_type') === 'taxonomy_term' && $item->get('target_id')->getCastedValue() === 0) { + $entity_list[] = NULL; + $entity_list_metadata[] = [ + 'links' => [ + 'help' => [ + 'href' => 'https://www.drupal.org/docs/8/modules/json-api/core-concepts#virtual', + 'meta' => [ + 'about' => "Usage and meaning of the 'virtual' resource identifier.", + ], + ], + ], + ]; } - $entity_list[] = NULL; - $entity_list_metadata[] = [ - 'links' => [ - 'help' => [ - 'href' => 'https://www.drupal.org/docs/8/modules/json-api/core-concepts#virtual', - 'meta' => [ - 'about' => "Usage and meaning of the 'virtual' resource identifier.", + else { + $entity_list[] = FALSE; + $entity_list_metadata[] = [ + 'links' => [ + 'help' => [ + 'href' => 'https://www.drupal.org/docs/8/modules/json-api/core-concepts#missing', + 'meta' => [ + 'about' => "Usage and meaning of the 'missing' resource identifier.", + ], ], ], - ], - ]; + ]; + } continue; } diff --git a/src/Normalizer/RelationshipItem.php b/src/Normalizer/RelationshipItem.php index 41e4182..9f1de6e 100644 --- a/src/Normalizer/RelationshipItem.php +++ b/src/Normalizer/RelationshipItem.php @@ -3,6 +3,7 @@ namespace Drupal\jsonapi\Normalizer; use Drupal\Core\Entity\EntityInterface; +use Drupal\jsonapi\ResourceType\ResourceType; use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface; /** @@ -52,8 +53,9 @@ class RelationshipItem { * * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository * The JSON API resource type repository. - * @param \Drupal\Core\Entity\EntityInterface|null $target_entity - * The entity this relationship points to, if any. + * @param \Drupal\Core\Entity\EntityInterface|null|false $target_entity + * The entity this relationship points to, if any. NULL if virtual resource. + * FALSE if missing resource (dangling entity reference). * @param \Drupal\jsonapi\Normalizer\Relationship $parent * The parent of this item. * @param string $target_key @@ -62,7 +64,7 @@ class RelationshipItem { * The list of metadata associated with this relationship item value. */ public function __construct(ResourceTypeRepositoryInterface $resource_type_repository, $target_entity, Relationship $parent, $target_key = 'target_id', array $metadata = []) { - assert($target_entity === NULL || $target_entity instanceof EntityInterface); + assert($target_entity === NULL || $target_entity === FALSE || $target_entity instanceof EntityInterface); if ($target_entity === NULL) { $host_entity = $parent->getHostEntity(); $relatable_resource_types = $resource_type_repository->get( @@ -74,6 +76,27 @@ class RelationshipItem { } $this->targetResourceType = reset($relatable_resource_types); } + elseif ($target_entity === FALSE) { + // In case of a dangling reference, it is impossible to determine which + // resource type it used to reference, because that requires knowing the + // referenced bundle, which Drupal does not store. + $field_definition = $parent->getHostEntity() + ->get($parent->getPropertyName()) + ->getFieldDefinition(); + $target_entity_type_id = $field_definition->getFieldStorageDefinition() + ->getSetting('target_type'); + $target_bundles = $field_definition + ->getSetting('handler_settings')['target_bundles']; + $target_bundle = count($target_bundles) === 1 + ? reset($target_bundles) + : '?'; + // If we can reliably determine the resource type of the dangling + // reference, use it; otherwise conjure a fake resource type out of thin + // air, one that indicates we don't know the bundle. + $this->targetResourceType = $target_bundle === '?' + ? new ResourceType($target_entity_type_id, $target_bundle, '') + : $resource_type_repository->get($target_entity_type_id, $target_bundle); + } else { $this->targetResourceType = $resource_type_repository->get( $target_entity->getEntityTypeId(), @@ -117,7 +140,9 @@ class RelationshipItem { public function getValue() { $target_uuid = $this->targetEntity === NULL ? 'virtual' - : $this->getTargetEntity()->uuid(); + : ($this->targetEntity === FALSE + ? 'missing' + : $this->getTargetEntity()->uuid()); return [ 'target_uuid' => $target_uuid, diff --git a/tests/src/Functional/JsonApiRegressionTest.php b/tests/src/Functional/JsonApiRegressionTest.php index b9d7ed5..6a91f4d 100644 --- a/tests/src/Functional/JsonApiRegressionTest.php +++ b/tests/src/Functional/JsonApiRegressionTest.php @@ -311,7 +311,7 @@ class JsonApiRegressionTest extends JsonApiFunctionalTestBase { } /** - * Cannot GET an entity with dangling references in a multi-bundle ER field. + * Cannot GET an entity with dangling references in an ER field. * * @see https://www.drupal.org/project/jsonapi/issues/2984647 */ @@ -330,6 +330,20 @@ class JsonApiRegressionTest extends JsonApiFunctionalTestBase { [ 'target_bundles' => [ 'journal_issue' => 'journal_issue', + ], + ], + FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED + ); + $this->createEntityReferenceField( + 'node', + 'journal_article', + 'field_mentioned_in', + NULL, + 'node', + 'default', + [ + 'target_bundles' => [ + 'journal_issue' => 'journal_issue', 'journal_conference' => 'journal_conference', ], ], @@ -358,6 +372,9 @@ class JsonApiRegressionTest extends JsonApiFunctionalTestBase { 'type' => 'journal_article', 'field_issue' => [ ['target_id' => $issue_node->id()], + ], + 'field_mentioned_in' => [ + ['target_id' => $issue_node->id()], ['target_id' => $conference_node->id()], ], ]); @@ -376,12 +393,48 @@ class JsonApiRegressionTest extends JsonApiFunctionalTestBase { $issue_node->delete(); $response = $this->request('GET', $url, $request_options); $this->assertSame(200, $response->getStatusCode()); + + // Entity reference field allowing a single bundle: dangling reference's + // resource type is deduced. $this->assertSame([ [ - 'type' => 'node--journal_conference', - 'id' => $conference_node->uuid() - ] + 'type' => 'node--journal_issue', + 'id' => 'missing', + 'meta' => [ + 'links' => [ + 'help' => [ + 'href' => 'https://www.drupal.org/docs/8/modules/json-api/core-concepts#missing', + 'meta' => [ + 'about' => "Usage and meaning of the 'missing' resource identifier.", + ], + ], + ], + ], + ], ], Json::decode((string)$response->getBody())['data']['relationships']['field_issue']['data']); + + // Entity reference field allowing multiple bundles: dangling reference's + // resource type is NOT deduced. + $this->assertSame([ + [ + 'type' => 'node--?', + 'id' => 'missing', + 'meta' => [ + 'links' => [ + 'help' => [ + 'href' => 'https://www.drupal.org/docs/8/modules/json-api/core-concepts#missing', + 'meta' => [ + 'about' => "Usage and meaning of the 'missing' resource identifier.", + ], + ], + ], + ], + ], + [ + 'type' => 'node--journal_conference', + 'id' => $conference_node->uuid(), + ], + ], Json::decode((string)$response->getBody())['data']['relationships']['field_mentioned_in']['data']); } }