Unverified Commit 8d70081b authored by Alex Pott's avatar Alex Pott
Browse files

Issue #3476224 by pwolanin, heddn, bbrala, tstoeckler: JSON:API assumes entity...

Issue #3476224 by pwolanin, heddn, bbrala, tstoeckler: JSON:API assumes entity reference field's main property must be the entity ID

(cherry picked from commit 71cc5fd8)
parent 2adf694f
Loading
Loading
Loading
Loading
Loading
+7 −5
Original line number Diff line number Diff line
@@ -672,9 +672,10 @@ public function addToRelationshipData(ResourceType $resource_type, FieldableEnti
      return $this->getRelationship($resource_type, $entity, $related, $request, $status);
    }

    $main_property_name = $field_definition->getItemDefinition()->getMainPropertyName();
    foreach ($new_resource_identifiers as $new_resource_identifier) {
      $new_field_value = [$main_property_name => $this->getEntityFromResourceIdentifier($new_resource_identifier)->id()];
      // We assume all entity reference fields have an 'entity' computed
      // property that can be used to assign the needed values.
      $new_field_value = ['entity' => $this->getEntityFromResourceIdentifier($new_resource_identifier)];
      // Remove `arity` from the received extra properties, otherwise this
      // will fail field validation.
      $new_field_value += array_diff_key($new_resource_identifier->getMeta(), array_flip([ResourceIdentifier::ARITY_KEY]));
@@ -760,9 +761,10 @@ protected function doPatchIndividualRelationship(EntityInterface $entity, array
   *   The field definition of the entity field to be updated.
   */
  protected function doPatchMultipleRelationship(EntityInterface $entity, array $resource_identifiers, FieldDefinitionInterface $field_definition) {
    $main_property_name = $field_definition->getItemDefinition()->getMainPropertyName();
    $entity->{$field_definition->getName()} = array_map(function (ResourceIdentifier $resource_identifier) use ($main_property_name) {
      $field_properties = [$main_property_name => $this->getEntityFromResourceIdentifier($resource_identifier)->id()];
    $entity->{$field_definition->getName()} = array_map(function (ResourceIdentifier $resource_identifier) {
      // We assume all entity reference fields have an 'entity' computed
      // property that can be used to assign the needed values.
      $field_properties = ['entity' => $this->getEntityFromResourceIdentifier($resource_identifier)];
      // Remove `arity` from the received extra properties, otherwise this
      // will fail field validation.
      $field_properties += array_diff_key($resource_identifier->getMeta(), array_flip([ResourceIdentifier::ARITY_KEY]));
+11 −0
Original line number Diff line number Diff line
field.storage_settings.jsonapi_test_field_type_entity_reference_uuid:
  type: field.storage_settings.entity_reference
  label: 'Entity reference field storage settings'

field.field_settings.jsonapi_test_field_type_entity_reference_uuid:
  type: field.field_settings.entity_reference
  label: 'Entity reference field settings'

field.value.jsonapi_test_field_type_entity_reference_uuid:
  type: field.value.entity_reference
  label: 'Default value'
+241 −0
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\jsonapi_test_field_type\Plugin\Field\FieldType;

use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\TypedData\EntityDataDefinition;
use Drupal\Core\Field\Attribute\FieldType;
use Drupal\Core\Field\EntityReferenceFieldItemList;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\TypedData\DataReferenceDefinition;
use Drupal\Core\TypedData\DataReferenceTargetDefinition;

/**
 * Defines the 'entity_reference_uuid' entity field type.
 *
 * Supported settings (below the definition's 'settings' key) are:
 * - target_type: The entity type to reference. Required.
 *
 * @property string $target_uuid
 */
#[FieldType(
  id: 'jsonapi_test_field_type_entity_reference_uuid',
  label: new TranslatableMarkup('Entity reference UUID'),
  description: new TranslatableMarkup('An entity field containing an entity reference by UUID.'),
  category: 'reference',
  default_widget: 'entity_reference_autocomplete',
  default_formatter: 'entity_reference_label',
  list_class: EntityReferenceFieldItemList::class,
)]
class EntityReferenceUuidItem extends EntityReferenceItem {

  /**
   * {@inheritdoc}
   */
  public static function propertyDefinitions(FieldStorageDefinitionInterface $field_definition) {
    $settings = $field_definition->getSettings();
    $target_type_info = \Drupal::entityTypeManager()->getDefinition($settings['target_type']);

    $properties = parent::propertyDefinitions($field_definition);

    $target_uuid_definition = DataReferenceTargetDefinition::create('string')
      ->setLabel(new TranslatableMarkup('@label UUID', ['@label' => $target_type_info->getLabel()]));

    $target_uuid_definition->setRequired(TRUE);
    $properties['target_uuid'] = $target_uuid_definition;

    $properties['entity'] = DataReferenceDefinition::create('entity')
      ->setLabel($target_type_info->getLabel())
      ->setDescription(new TranslatableMarkup('The referenced entity by UUID'))
      // The entity object is computed out of the entity ID.
      ->setComputed(TRUE)
      ->setReadOnly(FALSE)
      ->setTargetDefinition(EntityDataDefinition::create($settings['target_type']))
      // We can add a constraint for the target entity type. The list of
      // referenceable bundles is a field setting, so the corresponding
      // constraint is added dynamically in ::getConstraints().
      ->addConstraint('EntityType', $settings['target_type']);

    return $properties;
  }

  /**
   * {@inheritdoc}
   */
  public static function mainPropertyName() {
    return 'target_uuid';
  }

  /**
   * {@inheritdoc}
   */
  public static function schema(FieldStorageDefinitionInterface $field_definition) {
    $columns = [
      'target_uuid' => [
        'description' => 'The UUID of the target entity.',
        'type' => 'varchar_ascii',
        'length' => 128,
      ],
    ];

    return [
      'columns' => $columns,
      'indexes' => [
        'target_uuid' => ['target_uuid'],
      ],
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function setValue($values, $notify = TRUE): void {
    if (isset($values) && !is_array($values)) {
      // If either a scalar or an object was passed as the value for the item,
      // assign it to the 'entity' or 'target_uuid' depending on values type.
      if (is_object($values)) {
        $this->set('entity', $values, $notify);
      }
      else {
        $this->set('target_uuid', $values, $notify);
      }
    }
    else {
      parent::setValue($values, FALSE);
      // Support setting the field item with only one property, but make sure
      // values stay in sync if only property is passed.
      // NULL is a valid value, so we use array_key_exists().
      if (is_array($values) && array_key_exists('target_uuid', $values) && !isset($values['entity'])) {
        $this->onChange('target_uuid', FALSE);
      }
      elseif (is_array($values) && !array_key_exists('target_uuid', $values) && isset($values['entity'])) {
        $this->onChange('entity', FALSE);
      }
      elseif (is_array($values) && array_key_exists('target_uuid', $values) && isset($values['entity'])) {
        // If both properties are passed, verify the passed values match. The
        // only exception we allow is when we have a new entity: in this case
        // its actual id and target_uuid will be different, due to the new
        // entity marker.
        $entity_uuid = $this->get('entity')->get('uuid');
        // If the entity has been saved and we're trying to set both the
        // target_uuid and the entity values with a non-null target UUID, then
        // the value for target_uuid should match the UUID of the entity value.
        if (!$this->entity->isNew() && $values['target_uuid'] !== NULL && ($entity_uuid !== $values['target_uuid'])) {
          throw new \InvalidArgumentException('The target UUID and entity passed to the entity reference item do not match.');
        }
      }
      // Notify the parent if necessary.
      if ($notify && $this->parent) {
        $this->parent->onChange($this->getName());
      }
    }

  }

  /**
   * {@inheritdoc}
   */
  public function onChange($property_name, $notify = TRUE): void {
    // Make sure that the target UUID and the target property stay in sync.
    if ($property_name === 'entity') {
      $property = $this->get('entity');
      if ($target_uuid = $property->isTargetNew() ? NULL : $property->getValue()->uuid()) {
        $this->writePropertyValue('target_uuid', $target_uuid);
      }
    }
    elseif ($property_name === 'target_uuid') {
      $property = $this->get('entity');
      $entity_type = $property->getDataDefinition()->getConstraint('EntityType');
      $entities = \Drupal::entityTypeManager()->getStorage($entity_type)->loadByProperties(['uuid' => $this->get('target_uuid')->getValue()]);
      if ($entity = array_shift($entities)) {
        assert($entity instanceof EntityInterface);
        $this->writePropertyValue('target_uuid', $entity->uuid());
        $this->writePropertyValue('entity', $entity);
      }
    }
    parent::onChange($property_name, $notify);
  }

  /**
   * {@inheritdoc}
   */
  public function isEmpty() {
    // Avoid loading the entity by first checking the 'target_uuid'.
    if ($this->target_uuid !== NULL) {
      return FALSE;
    }
    if ($this->entity && $this->entity instanceof EntityInterface) {
      return FALSE;
    }
    return TRUE;
  }

  /**
   * {@inheritdoc}
   */
  public function preSave(): void {
    if ($this->hasNewEntity()) {
      // Save the entity if it has not already been saved by some other code.
      if ($this->entity->isNew()) {
        $this->entity->save();
      }
      // Make sure the parent knows we are updating this property so it can
      // react properly.
      $this->target_uuid = $this->entity->uuid();
    }
    if (!$this->isEmpty() && $this->target_uuid === NULL) {
      $this->target_uuid = $this->entity->uuid();
    }
  }

  /**
   * {@inheritdoc}
   */
  public static function generateSampleValue(FieldDefinitionInterface $field_definition): array {
    $manager = \Drupal::service('plugin.manager.entity_reference_selection');

    // Instead of calling $manager->getSelectionHandler($field_definition)
    // replicate the behavior to be able to override the sorting settings.
    $options = [
      'target_type' => $field_definition->getFieldStorageDefinition()->getSetting('target_type'),
      'handler' => $field_definition->getSetting('handler'),
      'handler_settings' => $field_definition->getSetting('handler_settings') ?: [],
      'entity' => NULL,
    ];

    $entity_type = \Drupal::entityTypeManager()->getDefinition($options['target_type']);
    $options['handler_settings']['sort'] = [
      'field' => $entity_type->getKey('uuid'),
      'direction' => 'DESC',
    ];
    $selection_handler = $manager->getInstance($options);

    // Select a random number of references between the last 50 referenceable
    // entities created.
    if ($referenceable = $selection_handler->getReferenceableEntities(NULL, 'CONTAINS', 50)) {
      $group = array_rand($referenceable);
      return ['target_uuid' => array_rand($referenceable[$group])];
    }
    return [];
  }

  /**
   * Determines whether the item holds an unsaved entity.
   *
   * This is notably used for "autocreate" widgets, and more generally to
   * support referencing freshly created entities (they will get saved
   * automatically as the hosting entity gets saved).
   *
   * @return bool
   *   TRUE if the item holds an unsaved entity.
   */
  public function hasNewEntity() {
    return !$this->isEmpty() && $this->target_uuid === NULL && $this->entity->isNew();
  }

}
+157 −0
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\Tests\jsonapi\Functional;

use Drupal\Core\Url;
use Drupal\entity_test\EntityTestHelper;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use GuzzleHttp\RequestOptions;

/**
 * JSON:API resource tests.
 *
 * @group jsonapi
 *
 * @internal
 */
class JsonApiRelationshipTest extends JsonApiFunctionalTestBase {

  /**
   * {@inheritdoc}
   */
  protected static $modules = [
    'basic_auth',
    'entity_test',
    'jsonapi_test_field_type',
  ];

  /**
   * The entity type ID.
   */
  protected string $entityTypeId = 'entity_test';

  /**
   * The entity bundle.
   */
  protected string $bundle = 'entity_test';

  /**
   * The field name.
   */
  protected string $fieldName = 'field_child';

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();

    EntityTestHelper::createBundle($this->bundle, 'Parent', $this->entityTypeId);

    FieldStorageConfig::create([
      'field_name' => $this->fieldName,
      'type' => 'jsonapi_test_field_type_entity_reference_uuid',
      'entity_type' => $this->entityTypeId,
      'cardinality' => 1,
      'settings' => [
        'target_type' => $this->entityTypeId,
      ],
    ])->save();
    FieldConfig::create([
      'field_name' => $this->fieldName,
      'entity_type' => $this->entityTypeId,
      'bundle' => $this->bundle,
      'label' => $this->randomString(),
      'settings' => [
        'handler' => 'default',
        'handler_settings' => [],
      ],
    ])->save();

    \Drupal::service('router.builder')->rebuild();
  }

  /**
   * Test relationships without target_id as main property.
   *
   * @see https://www.drupal.org/project/drupal/issues/3476224
   */
  public function testPatchHandleUUIDPropertyReferenceFieldIssue3127883(): void {
    $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
    $user = $this->drupalCreateUser([
      'administer entity_test content',
      'view test entity',
    ]);

    // Create parent and child entities.
    $storage = $this->container->get('entity_type.manager')
      ->getStorage($this->entityTypeId);
    $parentEntity = $storage
      ->create([
        'type' => $this->bundle,
      ]);
    $parentEntity->save();
    $childUuid = $this->container->get('uuid')->generate();
    $childEntity = $storage
      ->create([
        'type' => $this->bundle,
        'uuid' => $childUuid,
      ]);
    $childEntity->save();
    $uuid = $childEntity->uuid();
    $this->assertEquals($childUuid, $uuid);

    // 1. Successful PATCH to the related endpoint.
    $url = Url::fromUri(sprintf('internal:/jsonapi/%s/%s/%s/relationships/%s', $this->entityTypeId, $this->bundle, $parentEntity->uuid(), $this->fieldName));
    $request_options = [
      RequestOptions::HEADERS => [
        'Content-Type' => 'application/vnd.api+json',
        'Accept' => 'application/vnd.api+json',
      ],
      RequestOptions::AUTH => [$user->getAccountName(), $user->pass_raw],
      RequestOptions::JSON => [
        'data' => [
          'id' => $childUuid,
          'type' => sprintf('%s--%s', $this->entityTypeId, $this->bundle),
        ],
      ],
    ];
    $response = $this->request('PATCH', $url, $request_options);
    $this->assertSame(204, $response->getStatusCode(), (string) $response->getBody());
    $parentEntity = $storage->loadUnchanged($parentEntity->id());
    $this->assertEquals($childEntity->uuid(), $parentEntity->get($this->fieldName)->target_uuid);

    // Reset the relationship.
    $parentEntity->set($this->fieldName, NULL)
      ->save();
    $parentEntity = $storage->loadUnchanged($parentEntity->id());
    $this->assertTrue($parentEntity->get($this->fieldName)->isEmpty());

    // 2. Successful PATCH to individual endpoint.
    $url = Url::fromUri(sprintf('internal:/jsonapi/%s/%s/%s', $this->entityTypeId, $this->bundle, $parentEntity->uuid()));
    $request_options[RequestOptions::JSON] = [
      'data' => [
        'id' => $parentEntity->uuid(),
        'type' => sprintf('%s--%s', $this->entityTypeId, $this->bundle),
        'relationships' => [
          $this->fieldName => [
            'data' => [
              [
                'id' => $childUuid,
                'type' => sprintf('%s--%s', $this->entityTypeId, $this->bundle),
              ],
            ],
          ],
        ],
      ],
    ];
    $response = $this->request('PATCH', $url, $request_options);
    $this->assertSame(200, $response->getStatusCode(), (string) $response->getBody());
    $parentEntity = $storage->loadUnchanged($parentEntity->id());
    $this->assertEquals($childEntity->uuid(), $parentEntity->get($this->fieldName)->target_uuid);
  }

}