diff options
Diffstat (limited to 'core/modules')
4 files changed, 416 insertions, 5 deletions
diff --git a/core/modules/jsonapi/src/Controller/EntityResource.php b/core/modules/jsonapi/src/Controller/EntityResource.php index 6416d6cb27ac..53822cdf1f1f 100644 --- a/core/modules/jsonapi/src/Controller/EntityResource.php +++ b/core/modules/jsonapi/src/Controller/EntityResource.php @@ -672,9 +672,10 @@ class EntityResource { 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 @@ class EntityResource { * 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])); diff --git a/core/modules/jsonapi/tests/modules/jsonapi_test_field_type/config/schema/jsonapi_test_field_type.schema.yml b/core/modules/jsonapi/tests/modules/jsonapi_test_field_type/config/schema/jsonapi_test_field_type.schema.yml new file mode 100644 index 000000000000..9d082c7649c3 --- /dev/null +++ b/core/modules/jsonapi/tests/modules/jsonapi_test_field_type/config/schema/jsonapi_test_field_type.schema.yml @@ -0,0 +1,11 @@ +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' diff --git a/core/modules/jsonapi/tests/modules/jsonapi_test_field_type/src/Plugin/Field/FieldType/EntityReferenceUuidItem.php b/core/modules/jsonapi/tests/modules/jsonapi_test_field_type/src/Plugin/Field/FieldType/EntityReferenceUuidItem.php new file mode 100644 index 000000000000..f9aa3e77bbe9 --- /dev/null +++ b/core/modules/jsonapi/tests/modules/jsonapi_test_field_type/src/Plugin/Field/FieldType/EntityReferenceUuidItem.php @@ -0,0 +1,241 @@ +<?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(); + } + +} diff --git a/core/modules/jsonapi/tests/src/Functional/JsonApiRelationshipTest.php b/core/modules/jsonapi/tests/src/Functional/JsonApiRelationshipTest.php new file mode 100644 index 000000000000..110e1a6840b3 --- /dev/null +++ b/core/modules/jsonapi/tests/src/Functional/JsonApiRelationshipTest.php @@ -0,0 +1,157 @@ +<?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); + } + +} |