Commit fbe0991b authored by alexpott's avatar alexpott

Issue #2577963 by yched, amateescu, klausi: Let entity_ref Selection handlers...

Issue #2577963 by yched, amateescu, klausi: Let entity_ref Selection handlers be in charge of the field validation
parent 0e507e42
......@@ -10,10 +10,10 @@
use Drupal\Component\Utility\Crypt;
use Drupal\Component\Utility\Tags;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityReferenceSelection\SelectionWithAutocreateInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\Element\Textfield;
use Drupal\Core\Site\Settings;
use Drupal\user\EntityOwnerInterface;
/**
* Provides an entity autocomplete form element.
......@@ -147,7 +147,7 @@ public static function validateEntityAutocomplete(array &$element, FormStateInte
'handler_settings' => $element['#selection_settings'],
);
$handler = \Drupal::service('plugin.manager.entity_reference_selection')->getInstance($options);
$autocreate = (bool) $element['#autocreate'];
$autocreate = (bool) $element['#autocreate'] && $handler instanceof SelectionWithAutocreateInterface;
$input_values = $element['#tags'] ? Tags::explode($element['#value']) : array($element['#value']);
foreach ($input_values as $input) {
......@@ -167,13 +167,14 @@ public static function validateEntityAutocomplete(array &$element, FormStateInte
// Auto-create item. See an example of how this is handled in
// \Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem::presave().
$value[] = array(
'entity' => static::createNewEntity($element['#target_type'], $element['#autocreate']['bundle'], $input, $element['#autocreate']['uid'])
'entity' => $handler->createNewEntity($element['#target_type'], $element['#autocreate']['bundle'], $input, $element['#autocreate']['uid']),
);
}
}
// Check that the referenced entities are valid, if needed.
if ($element['#validate_reference'] && !$autocreate && !empty($value)) {
if ($element['#validate_reference'] && !empty($value)) {
// Validate existing entities.
$ids = array_reduce($value, function ($return, $item) {
if (isset($item['target_id'])) {
$return[] = $item['target_id'];
......@@ -189,6 +190,30 @@ public static function validateEntityAutocomplete(array &$element, FormStateInte
}
}
}
// Validate newly created entities.
$new_entities = array_reduce($value, function ($return, $item) {
if (isset($item['entity'])) {
$return[] = $item['entity'];
}
return $return;
});
if ($new_entities) {
if ($autocreate) {
$valid_new_entities = $handler->validateReferenceableNewEntities($new_entities);
$invalid_new_entities = array_diff_key($new_entities, $valid_new_entities);
}
else {
// If the selection handler does not support referencing newly
// created entities, all of them should be invalidated.
$invalid_new_entities = $new_entities;
}
foreach ($invalid_new_entities as $entity) {
$form_state->setError($element, t('This entity (%type: %label) cannot be referenced.', array('%type' => $element['#target_type'], '%label' => $entity->label())));
}
}
}
// Use only the last value if the form element does not support multiple
......@@ -310,37 +335,4 @@ public static function extractEntityIdFromAutocompleteInput($input) {
return $match;
}
/**
* Creates a new entity from a label entered in the autocomplete input.
*
* @param string $entity_type_id
* The entity type ID.
* @param string $bundle
* The bundle name.
* @param string $label
* The entity label.
* @param int $uid
* The entity owner ID.
*
* @return \Drupal\Core\Entity\EntityInterface
*/
protected static function createNewEntity($entity_type_id, $bundle, $label, $uid) {
$entity_manager = \Drupal::entityManager();
$entity_type = $entity_manager->getDefinition($entity_type_id);
$bundle_key = $entity_type->getKey('bundle');
$label_key = $entity_type->getKey('label');
$entity = $entity_manager->getStorage($entity_type_id)->create(array(
$bundle_key => $bundle,
$label_key => $label,
));
if ($entity instanceof EntityOwnerInterface) {
$entity->setOwnerId($uid);
}
return $entity;
}
}
......@@ -30,7 +30,7 @@ interface SelectionInterface extends PluginFormInterface {
public function getReferenceableEntities($match = NULL, $match_operator = 'CONTAINS', $limit = 0);
/**
* Counts entities that are referenceable by a given field.
* Counts entities that are referenceable.
*
* @return int
* The number of referenceable entities.
......@@ -38,7 +38,7 @@ public function getReferenceableEntities($match = NULL, $match_operator = 'CONTA
public function countReferenceableEntities($match = NULL, $match_operator = 'CONTAINS');
/**
* Validates that entities can be referenced by this field.
* Validates which existing entities can be referenced.
*
* @return array
* An array of valid entity IDs.
......
<?php
/**
* @file
* Contains \Drupal\Core\Entity\EntityReferenceSelection\SelectionWithAutocreateInterface.
*/
namespace Drupal\Core\Entity\EntityReferenceSelection;
/**
* Interface for Selection plugins that support newly created entities.
*
* @see \Drupal\Core\Entity\EntityReferenceSelection\SelectionPluginManager
* @see \Drupal\Core\Entity\Annotation\EntityReferenceSelection
* @see plugin_api
*/
interface SelectionWithAutocreateInterface {
/**
* Creates a new entity object that can be used as a valid reference.
*
* @param string $entity_type_id
* The entity type ID.
* @param string $bundle
* The bundle name.
* @param string $label
* The entity label.
* @param int $uid
* The entity owner ID, if the entity type supports it.
*
* @return \Drupal\Core\Entity\EntityInterface
* An unsaved entity object.
*/
public function createNewEntity($entity_type_id, $bundle, $label, $uid);
/**
* Validates which newly created entities can be referenced.
*
* This method should replicate the logic implemented by
* \Drupal\Core\Entity\EntityReferenceSelection\SelectionInterface::validateReferenceableEntities(),
* but applied to newly created entities that have not been saved yet.
*
* @param \Drupal\Core\Entity\EntityInterface[] $entities
* An array of entities to check.
*
* @return \Drupal\Core\Entity\EntityInterface[]
* The incoming $entities parameter, filtered for valid entities. Array keys
* are preserved.
*/
public function validateReferenceableNewEntities(array $entities);
}
......@@ -11,6 +11,7 @@
use Drupal\Core\Database\Query\AlterableInterface;
use Drupal\Core\Database\Query\SelectInterface;
use Drupal\Core\Entity\EntityManagerInterface;
use Drupal\Core\Entity\EntityReferenceSelection\SelectionWithAutocreateInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem;
use Drupal\Core\Form\FormStateInterface;
......@@ -18,6 +19,7 @@
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Plugin\PluginBase;
use Drupal\Core\Session\AccountInterface;
use Drupal\user\EntityOwnerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
......@@ -40,7 +42,7 @@
* deriver = "Drupal\Core\Entity\Plugin\Derivative\DefaultSelectionDeriver"
* )
*/
class DefaultSelection extends PluginBase implements SelectionInterface, ContainerFactoryPluginInterface {
class DefaultSelection extends PluginBase implements SelectionInterface, SelectionWithAutocreateInterface, ContainerFactoryPluginInterface {
/**
* The entity manager.
......@@ -288,6 +290,38 @@ public function validateReferenceableEntities(array $ids) {
return $result;
}
/**
* {@inheritdoc}
*/
public function createNewEntity($entity_type_id, $bundle, $label, $uid) {
$entity_type = $this->entityManager->getDefinition($entity_type_id);
$bundle_key = $entity_type->getKey('bundle');
$label_key = $entity_type->getKey('label');
$entity = $this->entityManager->getStorage($entity_type_id)->create(array(
$bundle_key => $bundle,
$label_key => $label,
));
if ($entity instanceof EntityOwnerInterface) {
$entity->setOwnerId($uid);
}
return $entity;
}
/**
* {@inheritdoc}
*/
public function validateReferenceableNewEntities(array $entities) {
return array_filter($entities, function ($entity) {
if (isset($this->configuration['handler_settings']['target_bundles'])) {
return in_array($entity->bundle(), $this->configuration['handler_settings']['target_bundles']);
}
return TRUE;
});
}
/**
* Builds an EntityQuery to get referenceable entities.
*
......
......@@ -26,10 +26,24 @@ class ValidReferenceConstraint extends Constraint {
*
* @var string
*/
public $message = 'The referenced entity (%type: %id) does not exist.';
public $message = 'This entity (%type: %id) cannot be referenced.';
/**
* Validation message when the target_id is empty.
* Violation message when the entity does not exist.
*
* @var string
*/
public $nonExistingMessage = 'The referenced entity (%type: %id) does not exist.';
/**
* Violation message when a new entity ("autocreate") is invalid.
*
* @var string
*/
public $invalidAutocreateMessage = 'This entity (%type: %label) cannot be referenced.';
/**
* Violation message when the target_id is empty.
*
* @var string
*/
......
......@@ -7,39 +7,142 @@
namespace Drupal\Core\Entity\Plugin\Validation\Constraint;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityReferenceSelection\SelectionPluginManagerInterface;
use Drupal\Core\Entity\EntityReferenceSelection\SelectionWithAutocreateInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
/**
* Checks if referenced entities are valid.
*/
class ValidReferenceConstraintValidator extends ConstraintValidator {
class ValidReferenceConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
/**
* The selection plugin manager.
*
* @var \Drupal\Core\Entity\EntityReferenceSelection\SelectionPluginManagerInterface
*/
protected $selectionManager;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Constructs a ValidReferenceConstraintValidator object.
*
* @param \Drupal\Core\Entity\EntityReferenceSelection\SelectionPluginManagerInterface $selection_manager
* The selection plugin manager.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(SelectionPluginManagerInterface $selection_manager, EntityTypeManagerInterface $entity_type_manager) {
$this->selectionManager = $selection_manager;
$this->entityTypeManager = $entity_type_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('plugin.manager.entity_reference_selection'),
$container->get('entity_type.manager')
);
}
/**
* {@inheritdoc}
*/
public function validate($value, Constraint $constraint) {
/** @var \Drupal\Core\Field\FieldItemInterface $value */
/** @var \Drupal\Core\Field\FieldItemListInterface $value */
/** @var ValidReferenceConstraint $constraint */
if (!isset($value)) {
return;
}
// We don't use a regular NotNull constraint for the target_id property as
// a NULL value is valid if the entity property contains an unsaved entity.
// @see \Drupal\Core\TypedData\DataReferenceTargetDefinition::getConstraints
if (!$value->isEmpty() && $value->target_id === NULL && !$value->entity->isNew()) {
$this->context->addViolation($constraint->nullMessage);
return;
// Collect new entities and IDs of existing entities across the field items.
$new_entities = [];
$target_ids = [];
foreach ($value as $delta => $item) {
$target_id = $item->target_id;
// We don't use a regular NotNull constraint for the target_id property as
// NULL is allowed if the entity property contains an unsaved entity.
// @see \Drupal\Core\TypedData\DataReferenceTargetDefinition::getConstraints()
if (!$item->isEmpty() && $target_id === NULL) {
if (!$item->entity->isNew()) {
$this->context->buildViolation($constraint->nullMessage)
->atPath((string) $delta)
->addViolation();
return;
}
$new_entities[$delta] = $item->entity;
}
// '0' or NULL are considered valid empty references.
if (!empty($target_id)) {
$target_ids[$delta] = $target_id;
}
}
$id = $value->get('target_id')->getValue();
// '0' or NULL are considered valid empty references.
if (empty($id)) {
// Early opt-out if nothing to validate.
if (!$new_entities && !$target_ids) {
return;
}
$referenced_entity = $value->get('entity')->getValue();
if (!$referenced_entity) {
$type = $value->getFieldDefinition()->getSetting('target_type');
$this->context->addViolation($constraint->message, array('%type' => $type, '%id' => $id));
/** @var \Drupal\Core\Entity\EntityReferenceSelection\SelectionInterface $handler * */
$handler = $this->selectionManager->getSelectionHandler($value->getFieldDefinition());
$target_type_id = $value->getFieldDefinition()->getSetting('target_type');
// Add violations on deltas with a new entity that is not valid.
if ($new_entities) {
if ($handler instanceof SelectionWithAutocreateInterface) {
$valid_new_entities = $handler->validateReferenceableNewEntities($new_entities);
$invalid_new_entities = array_diff_key($new_entities, $valid_new_entities);
}
else {
// If the selection handler does not support referencing newly created
// entities, all of them should be invalidated.
$invalid_new_entities = $new_entities;
}
foreach ($invalid_new_entities as $delta => $entity) {
$this->context->buildViolation($constraint->invalidAutocreateMessage)
->setParameter('%type', $target_type_id)
->setParameter('%label', $entity->label())
->atPath((string) $delta . '.entity')
->setInvalidValue($entity)
->addViolation();
}
}
// Add violations on deltas with a target_id that is not valid.
if ($target_ids) {
$valid_target_ids = $handler->validateReferenceableEntities($target_ids);
if ($invalid_target_ids = array_diff($target_ids, $valid_target_ids)) {
// For accuracy of the error message, differentiate non-referenceable
// and non-existent entities.
$target_type = $this->entityTypeManager->getDefinition($target_type_id);
$existing_ids = $this->entityTypeManager->getStorage($target_type_id)->getQuery()
->condition($target_type->getKey('id'), $invalid_target_ids, 'IN')
->execute();
foreach ($invalid_target_ids as $delta => $target_id) {
$message = in_array($target_id, $existing_ids) ? $constraint->message : $constraint->nonExistingMessage;
$this->context->buildViolation($message)
->setParameter('%type', $target_type_id)
->setParameter('%id', $target_id)
->atPath((string) $delta . '.target_id')
->setInvalidValue($target_id)
->addViolation();
}
}
}
}
}
......@@ -15,6 +15,16 @@
*/
class EntityReferenceFieldItemList extends FieldItemList implements EntityReferenceFieldItemListInterface {
/**
* {@inheritdoc}
*/
public function getConstraints() {
$constraints = parent::getConstraints();
$constraint_manager = $this->getTypedDataManager()->getValidationConstraintManager();
$constraints[] = $constraint_manager->create('ValidReference', []);
return $constraints;
}
/**
* {@inheritdoc}
*/
......
......@@ -40,7 +40,6 @@
* default_widget = "entity_reference_autocomplete",
* default_formatter = "entity_reference_label",
* list_class = "\Drupal\Core\Field\EntityReferenceFieldItemList",
* constraints = {"ValidReference" = {}}
* )
*/
class EntityReferenceItem extends FieldItemBase implements OptionsProviderInterface, PreconfiguredFieldUiOptionsInterface {
......@@ -163,20 +162,6 @@ public function getConstraints() {
unset($constraints[$key]);
}
}
list($current_handler) = explode(':', $this->getSetting('handler'), 2);
if ($current_handler === 'default') {
$handler_settings = $this->getSetting('handler_settings');
if (isset($handler_settings['target_bundles'])) {
$constraint_manager = \Drupal::typedDataManager()->getValidationConstraintManager();
$constraints[] = $constraint_manager->create('ComplexData', [
'entity' => [
'Bundle' => [
'bundle' => $handler_settings['target_bundles'],
],
],
]);
}
}
return $constraints;
}
......
......@@ -39,6 +39,34 @@ protected function buildEntityQuery($match = NULL, $match_operator = 'CONTAINS')
return $query;
}
/**
* {@inheritdoc}
*/
public function createNewEntity($entity_type_id, $bundle, $label, $uid) {
$comment = parent::createNewEntity($entity_type_id, $bundle, $label, $uid);
// In order to create a referenceable comment, it needs to published.
/** @var \Drupal\comment\CommentInterface $comment */
$comment->setPublished(TRUE);
return $comment;
}
/**
* {@inheritdoc}
*/
public function validateReferenceableNewEntities(array $entities) {
$entities = parent::validateReferenceableNewEntities($entities);
// Mirror the conditions checked in buildEntityQuery().
if (!$this->currentUser->hasPermission('administer comments')) {
$entities = array_filter($entities, function ($comment) {
/** @var \Drupal\comment\CommentInterface $comment */
return $comment->isPublished();
});
}
return $entities;
}
/**
* {@inheritdoc}
*/
......
......@@ -39,7 +39,7 @@ protected function setUp() {
*/
public function testValidation() {
// Add a user.
$user = User::create(array('name' => 'test'));
$user = User::create(array('name' => 'test', 'status' => TRUE));
$user->save();
// Add comment type.
......
......@@ -7,6 +7,8 @@
namespace Drupal\field\Tests\EntityReference;
use Drupal\comment\Entity\Comment;
use Drupal\Component\Render\FormattableMarkup;
use Drupal\Component\Utility\Unicode;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\FieldItemInterface;
......@@ -17,8 +19,11 @@
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\field\Tests\FieldUnitTestBase;
use Drupal\file\Entity\File;
use Drupal\node\Entity\Node;
use Drupal\taxonomy\Entity\Term;
use Drupal\taxonomy\Entity\Vocabulary;
use Drupal\user\Entity\User;
/**
......@@ -35,7 +40,7 @@ class EntityReferenceItemTest extends FieldUnitTestBase {
*
* @var array
*/
public static $modules = ['taxonomy', 'text', 'filter', 'views', 'field'];
public static $modules = ['node', 'comment', 'file', 'taxonomy', 'text', 'filter', 'views', 'field'];
/**
* The taxonomy vocabulary to test with.
......@@ -66,6 +71,11 @@ protected function setUp() {
$this->installEntitySchema('entity_test_string_id');
$this->installEntitySchema('taxonomy_term');
$this->installEntitySchema('node');
$this->installEntitySchema('comment');
$this->installEntitySchema('file');
$this->installSchema('comment', ['comment_entity_statistics']);
$this->vocabulary = entity_create('taxonomy_vocabulary', array(
'name' => $this->randomMachineName(),
......@@ -90,6 +100,10 @@ protected function setUp() {
$this->createEntityReferenceField('entity_test', 'entity_test', 'field_test_taxonomy_term', 'Test content entity reference', 'taxonomy_term');
$this->createEntityReferenceField('entity_test', 'entity_test', 'field_test_entity_test_string_id', 'Test content entity reference with string ID', 'entity_test_string_id');
$this->createEntityReferenceField('entity_test', 'entity_test', 'field_test_taxonomy_vocabulary', 'Test config entity reference', 'taxonomy_vocabulary');
$this->createEntityReferenceField('entity_test', 'entity_test', 'field_test_node', 'Test node entity reference', 'node');
$this->createEntityReferenceField('entity_test', 'entity_test', 'field_test_user', 'Test user entity reference', 'user');
$this->createEntityReferenceField('entity_test', 'entity_test', 'field_test_comment', 'Test comment entity reference', 'comment');
$this->createEntityReferenceField('entity_test', 'entity_test', 'field_test_file', 'Test file entity reference', 'file');
}
/**
......@@ -333,9 +347,9 @@ public function testSelectionHandlerSettings() {
}
/**
* Tests validation constraint.
* Tests ValidReferenceConstraint with newly created and unsaved entities.
*/
public function testValidation() {
public function testAutocreateValidation() {
// The term entity is unsaved here.
$term = Term::create(array(
'name' => $this->randomMachineName(),
......@@ -367,6 +381,100 @@ public function testValidation() {
$entity->save();
$errors = $entity->validate();
$this->assertEqual(0, count($errors));
// Test with an unpublished and unsaved node.
$title = $this->randomString();
$node = Node::create([
'title' => $title,
'type' => 'node',
'status' => NODE_NOT_PUBLISHED,
]);
$entity = EntityTest::create([
'field_test_node' => [
'entity' => $node,
],
]);
$errors = $entity->validate();
$this->assertEqual(1, count($errors));
$this->assertEqual($errors[0]->getMessage(), new FormattableMarkup('This entity (%type: %label) cannot be referenced.', ['%type' => 'node', '%label' => $title]));
$this->assertEqual($errors[0]->getPropertyPath(), 'field_test_node.0.entity');
// Publish the node and try again.
$node->setPublished(TRUE);
$errors = $entity->validate();
$this->assertEqual(0, count($errors));
// Test with an unpublished and unsaved comment.
$title = $this->randomString();
$comment = Comment::create([
'subject' => $title,
'comment_type' => 'comment',
'status' => 0,
]);
$entity = EntityTest::create([
'field_test_comment' => [
'entity' => $comment,
],
]);
$errors = $entity->validate();
$this->assertEqual(1, count($errors));
$this->assertEqual($errors[0]->getMessage(), new FormattableMarkup('This entity (%type: %label) cannot be referenced.', ['%type' => 'comment', '%label' => $title]));
$this->assertEqual($errors[0]->getPropertyPath(), 'field_test_comment.0.entity');
// Publish the comment and try again.
$comment->setPublished(TRUE);
$errors = $entity->validate();
$this->assertEqual(0, count($errors));
// Test with an inactive and unsaved user.
$name = $this->randomString();
$user = User::create([
'name' => $name,
'status' => 0,
]);
$entity = EntityTest::create([
'field_test_user' => [
'entity' => $user,
],
]);
$errors = $entity->validate();
$this->assertEqual(1, count($errors));
$this->assertEqual($errors[0]->getMessage(), new FormattableMarkup('This entity (%type: %label) cannot be referenced.', ['%type' => 'user', '%label' => $name]));
$this->assertEqual($errors[0]->getPropertyPath(), 'field_test_user.0.entity');
// Activate the user and try again.
$user->activate();
$errors = $entity->validate();
$this->assertEqual(0, count($errors));
// Test with a temporary and unsaved file.
$filename = $this->randomMachineName() . '.txt';
$file = File::create([
'filename' => $filename,
'status' => 0,
]);
$entity = EntityTest::create([
'field_test_file' => [
'entity' => $file,
],
]);
$errors = $entity->validate();
$this->assertEqual(1, count($errors));
$this->assertEqual($errors[0]->getMessage(), new FormattableMarkup('This entity (%type: %label) cannot be referenced.', ['%type' => 'file', '%label' => $filename]));
$this->assertEqual($errors[0]->getPropertyPath(), 'field_test_file.0.entity');
// Set the file as permanent and try again.
$file->setPermanent();
$errors = $entity->validate();
$this->assertEqual(0, count($errors));
}
}