Commit a23ebe23 authored by alexpott's avatar alexpott
Browse files

Issue #2429037 by fago, larowlan: Allow adding entity level constraints

parent 26984ad2
......@@ -12,6 +12,12 @@
*
* This data may be useful for more precise cache invalidation (especially
* on the client side) and concurrent editing locking.
*
* The entity system automatically adds in the 'EntityChanged' constraint for
* entity types implementing this interface in order to disallow concurrent
* editing.
*
* @see Drupal\Core\Entity\Plugin\Validation\Constraint\EntityChangedConstraint
*/
interface EntityChangedInterface {
......
......@@ -226,6 +226,13 @@ class EntityType implements EntityTypeInterface {
*/
protected $list_cache_tags = [];
/**
* Entity constraint definitions.
*
* @var array[]
*/
protected $constraints = array();
/**
* Constructs a new EntityType.
*
......@@ -261,6 +268,12 @@ public function __construct($definition) {
'access' => 'Drupal\Core\Entity\EntityAccessControlHandler',
);
// Automatically add the EntityChanged constraint if the entity type tracks
// the changed time.
if ($this->isSubclassOf('Drupal\Core\Entity\EntityChangedInterface') ) {
$this->addConstraint('EntityChanged');
}
// Ensure a default list cache tag is set.
if (empty($this->list_cache_tags)) {
$this->list_cache_tags = [$definition['id'] . '_list'];
......@@ -741,4 +754,27 @@ public function isCommonReferenceTarget() {
return $this->common_reference_target;
}
/**
* {@inheritdoc}
*/
public function getConstraints() {
return $this->constraints;
}
/**
* {@inheritdoc}
*/
public function setConstraints(array $constraints) {
$this->constraints = $constraints;
return $this;
}
/**
* {@inheritdoc}
*/
public function addConstraint($constraint_name, $options = NULL) {
$this->constraints[$constraint_name] = $options;
return $this;
}
}
......@@ -693,4 +693,53 @@ public function getConfigDependencyKey();
*/
public function isCommonReferenceTarget();
/**
* Returns an array of validation constraints.
*
* See \Drupal\Core\TypedData\DataDefinitionInterface::getConstraints() for
* details on how constraints are defined.
*
* @return array[]
* An array of validation constraint definitions, keyed by constraint name.
* Each constraint definition can be used for instantiating
* \Symfony\Component\Validator\Constraint objects.
*
* @see \Symfony\Component\Validator\Constraint
*/
public function getConstraints();
/**
* Sets the array of validation constraints for the FieldItemList.
*
* NOTE: This will overwrite any previously set constraints. In most cases
* ContentEntityTypeInterface::addConstraint() should be used instead.
* See \Drupal\Core\TypedData\DataDefinitionInterface::getConstraints() for
* details on how constraints are defined.
*
* @param array $constraints
* An array of validation constraint definitions, keyed by constraint name.
* Each constraint definition can be used for instantiating
* \Symfony\Component\Validator\Constraint objects.
*
* @return $this
*
* @see \Symfony\Component\Validator\Constraint
*/
public function setConstraints(array $constraints);
/**
* Adds a validation constraint.
*
* See \Drupal\Core\TypedData\DataDefinitionInterface::getConstraints() for
* details on how constraints are defined.
*
* @param string $constraint_name
* The name of the constraint to add, i.e. its plugin id.
* @param array|null $options
* The constraint options as required by the constraint plugin, or NULL.
*
* @return $this
*/
public function addConstraint($constraint_name, $options = NULL);
}
......@@ -83,7 +83,7 @@ public function getDerivativeDefinitions($base_plugin_definition) {
foreach ($this->entityManager->getDefinitions() as $entity_type_id => $entity_type) {
$this->derivatives[$entity_type_id] = array(
'label' => $entity_type->getLabel(),
'constraints' => array('EntityType' => $entity_type_id),
'constraints' => $entity_type->getConstraints(),
) + $base_plugin_definition;
// Incorporate the bundles as entity:$entity_type:$bundle, if any.
......@@ -91,10 +91,7 @@ public function getDerivativeDefinitions($base_plugin_definition) {
if ($bundle !== $entity_type_id) {
$this->derivatives[$entity_type_id . ':' . $bundle] = array(
'label' => $bundle_info['label'],
'constraints' => array(
'EntityType' => $entity_type_id,
'Bundle' => $bundle,
),
'constraints' => $this->derivatives[$entity_type_id]['constraints']
) + $base_plugin_definition;
}
}
......
......@@ -7,7 +7,6 @@
namespace Drupal\Core\Entity\Plugin\Validation\Constraint;
use Drupal\Core\Entity\EntityChangedInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
......@@ -19,14 +18,13 @@ class EntityChangedConstraintValidator extends ConstraintValidator {
/**
* {@inheritdoc}
*/
public function validate($value, Constraint $constraint) {
if (isset($value)) {
/** @var $entity \Drupal\Core\Entity\EntityInterface */
$entity = $this->context->getMetadata()->getTypedData()->getEntity();
public function validate($entity, Constraint $constraint) {
if (isset($entity)) {
/** @var \Drupal\Core\Entity\EntityInterface $entity */
if (!$entity->isNew()) {
$saved_entity = \Drupal::entityManager()->getStorage($entity->getEntityTypeId())->loadUnchanged($entity->id());
if ($saved_entity && ($saved_entity instanceof EntityChangedInterface) && ($saved_entity->getChangedTime() > $value)) {
if ($saved_entity && $saved_entity->getChangedTime() > $entity->getChangedTime()) {
$this->context->addViolation($constraint->message);
}
}
......
......@@ -10,16 +10,18 @@
/**
* Defines the 'changed' entity field type.
*
* Based on a field of this type, entity types can easily implement the
* EntityChangedInterface.
*
* @FieldType(
* id = "changed",
* label = @Translation("Last changed"),
* description = @Translation("An entity field containing a UNIX timestamp of when the entity has been last updated."),
* no_ui = TRUE,
* list_class = "\Drupal\Core\Field\ChangedFieldItemList",
* constraints = {
* "ComplexData" = {"value" = {"EntityChanged" = {}}}
* }
* list_class = "\Drupal\Core\Field\ChangedFieldItemList"
* )
*
* @see \Drupal\Core\Entity\EntityChangedInterface
*/
class ChangedItem extends CreatedItem {
......
......@@ -91,12 +91,17 @@ public static function propertyDefinitions(FieldStorageDefinitionInterface $fiel
// The entity object is computed out of the entity ID.
->setComputed(TRUE)
->setReadOnly(FALSE)
->setTargetDefinition(EntityDataDefinition::create($settings['target_type']));
->setTargetDefinition(EntityDataDefinition::create($settings['target_type']))
->addConstraint('EntityType', $settings['target_type']);
if (isset($settings['target_bundle'])) {
$properties['entity']->getTargetDefinition()->addConstraint('Bundle', $settings['target_bundle']);
$properties['entity']->addConstraint('Bundle', $settings['target_bundle']);
// Set any further bundle constraints on the target definition as well,
// such that it can derive more special data types if possible. For
// example, "entity:node:page" instead of "entity:node".
$properties['entity']->getTargetDefinition()
->addConstraint('Bundle', $settings['target_bundle']);
}
return $properties;
}
......
......@@ -400,10 +400,6 @@ public function getDefaultConstraints(DataDefinitionInterface $definition) {
if (is_subclass_of($definition->getClass(),'Drupal\Core\TypedData\OptionsProviderInterface')) {
$constraints['AllowedValues'] = array();
}
// Add any constraints about referenced data.
if ($definition instanceof DataReferenceDefinitionInterface) {
$constraints += $definition->getTargetDefinition()->getConstraints();
}
return $constraints;
}
......
......@@ -63,7 +63,7 @@ public function testValidation() {
$node->set('changed', 433918800);
$violations = $node->validate();
$this->assertEqual(count($violations), 1, 'Violation found when changed date is before the last changed date.');
$this->assertEqual($violations[0]->getPropertyPath(), 'changed.0.value');
$this->assertEqual($violations[0]->getPropertyPath(), '');
$this->assertEqual($violations[0]->getMessage(), 'The content has either been modified by another user, or you have already submitted modifications. As a result, your changes cannot be saved.');
}
}
......@@ -7,7 +7,6 @@
namespace Drupal\quickedit\Form;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityChangedInterface;
......@@ -18,6 +17,7 @@
use Drupal\Core\Entity\Entity\EntityFormDisplay;
use Drupal\user\PrivateTempStoreFactory;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Validator\ValidatorInterface;
/**
* Builds and process a form for editing a single entity field.
......@@ -45,6 +45,13 @@ class QuickEditFieldForm extends FormBase {
*/
protected $nodeTypeStorage;
/**
* The typed data validator.
*
* @var \Symfony\Component\Validator\ValidatorInterface
*/
protected $validator;
/**
* Constructs a new EditFieldForm.
*
......@@ -54,11 +61,14 @@ class QuickEditFieldForm extends FormBase {
* The module handler.
* @param \Drupal\Core\Entity\EntityStorageInterface $node_type_storage
* The node type storage.
* @param \Symfony\Component\Validator\ValidatorInterface $validator
* The typed data validator service.
*/
public function __construct(PrivateTempStoreFactory $temp_store_factory, ModuleHandlerInterface $module_handler, EntityStorageInterface $node_type_storage) {
public function __construct(PrivateTempStoreFactory $temp_store_factory, ModuleHandlerInterface $module_handler, EntityStorageInterface $node_type_storage, ValidatorInterface $validator) {
$this->moduleHandler = $module_handler;
$this->nodeTypeStorage = $node_type_storage;
$this->tempStoreFactory = $temp_store_factory;
$this->validator = $validator;
}
/**
......@@ -68,7 +78,8 @@ public static function create(ContainerInterface $container) {
return new static(
$container->get('user.private_tempstore'),
$container->get('module_handler'),
$container->get('entity.manager')->getStorage('node_type')
$container->get('entity.manager')->getStorage('node_type'),
$container->get('typed_data_manager')->getValidator()
);
}
......@@ -148,14 +159,16 @@ public function validateForm(array &$form, FormStateInterface $form_state) {
$form_state->get('form_display')->validateFormValues($entity, $form, $form_state);
// Do validation on the changed field as well and assign the error to the
// dummy form element we added for this. We don't know the name of this
// field on the entity, so we need to find it and validate it ourselves.
if ($changed_field_name = $this->getChangedFieldName($entity)) {
$changed_field_errors = $entity->$changed_field_name->validate();
if (count($changed_field_errors)) {
$form_state->setErrorByName('changed_field', $changed_field_errors[0]->getMessage());
}
// Run entity-level validation as well, while skipping validation of all
// fields. We can do so by fetching and validating the entity-level
// constraints manually.
// @todo: Improve this in https://www.drupal.org/node/2395831.
$typed_entity = $entity->getTypedData();
$violations = $this->validator
->validateValue($entity, $typed_entity->getConstraints());
foreach ($violations as $violation) {
$form_state->setErrorByName($violation->getPropertyPath(), $violation->getMessage());
}
}
......@@ -235,21 +248,4 @@ protected function simplify(array &$form, FormStateInterface $form_state) {
}
}
/**
* Finds the field name for the field carrying the changed timestamp, if any.
*
* @param \Drupal\Core\Entity\FieldableEntityInterface $entity
* The entity.
*
* @return string|null
* The name of the field found or NULL if not found.
*/
protected function getChangedFieldName(FieldableEntityInterface $entity) {
foreach ($entity->getFieldDefinitions() as $field) {
if ($field->getType() == 'changed') {
return $field->getName();
}
}
}
}
<?php
/**
* @file
* Contains Drupal\system\Tests\Entity\EntityTypeConstraintsTest.
*/
namespace Drupal\system\Tests\Entity;
use Drupal\system\Tests\TypedData;
/**
* Tests entity level validation constraints.
*
* @group Entity
*/
class EntityTypeConstraintsTest extends EntityUnitTestBase {
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->installEntitySchema('entity_test_constraints');
}
/**
* Tests defining entity constraints via entity type annotations and hooks.
*/
public function testConstraintDefinition() {
// Test reading the annotation. There should be two constraints, the defined
// constraint and the automatically added EntityChanged constraint.
$entity_type = $this->entityManager->getDefinition('entity_test_constraints');
$default_constraints = ['NotNull' => [], 'EntityChanged' => NULL];
$this->assertEqual($default_constraints, $entity_type->getConstraints());
// Enable our test module and test extending constraints.
$this->enableModules(array_merge(static::$modules, ['entity_test_constraints']));
$this->container->get('module_handler')->resetImplementations();
$extra_constraints = ['Test' => []];
$this->state->set('entity_test_constraints.build', $extra_constraints);
// Re-fetch the entity manager from the new container built after the new
// modules were enabled.
$this->entityManager = $this->container->get('entity.manager');
$this->entityManager->clearCachedDefinitions();
$entity_type = $this->entityManager->getDefinition('entity_test_constraints');
$this->assertEqual($default_constraints + $extra_constraints, $entity_type->getConstraints());
// Test altering constraints.
$altered_constraints = ['Test' => [ 'some_setting' => TRUE]];
$this->state->set('entity_test_constraints.alter', $altered_constraints);
// Clear the cache in state instance in the Drupal container, so it can pick
// up the modified value.
\Drupal::state()->resetCache();
$this->entityManager->clearCachedDefinitions();
$entity_type = $this->entityManager->getDefinition('entity_test_constraints');
$this->assertEqual($altered_constraints, $entity_type->getConstraints());
}
/**
* Tests entity constraints are validated.
*/
public function testConstraintValidation() {
$entity = $this->entityManager->getStorage('entity_test_constraints')->create();
$entity->user_id->target_id = 0;
$violations = $entity->validate();
$this->assertEqual($violations->count(), 0, 'Validation passed.');
$entity->save();
$entity->changed->value = REQUEST_TIME - 86400;
$violations = $entity->validate();
$this->assertEqual($violations->count(), 1, 'Validation failed.');
$this->assertEqual($violations[0]->getMessage(), t('The content has either been modified by another user, or you have already submitted modifications. As a result, your changes cannot be saved.'));
}
}
<?php
/**
* @file
* Contains \Drupal\entity_test\Entity\EntityTestConstraints.
*/
namespace Drupal\entity_test\Entity;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\EntityChangedInterface;
use Drupal\Core\Field\BaseFieldDefinition;
/**
* Defines a test class for testing the definition of entity level constraints.
*
* @ContentEntityType(
* id = "entity_test_constraints",
* label = @Translation("Test entity constraints"),
* entity_keys = {
* "id" = "id",
* "uuid" = "uuid",
* "bundle" = "type",
* "label" = "name"
* },
* base_table = "entity_test_constraints",
* persistent_cache = FALSE,
* constraints = {
* "NotNull" = {}
* }
* )
*/
class EntityTestConstraints extends EntityTest implements EntityChangedInterface {
/**
* {@inheritdoc}
*/
public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
$fields = parent::baseFieldDefinitions($entity_type);
$fields['changed'] = BaseFieldDefinition::create('changed')
->setLabel(t('Changed'));
return $fields;
}
/**
* {@inheritdoc}
*/
public function getChangedTime() {
return $this->get('changed')->value;
}
}
name: 'Entity constraints test module'
type: module
description: 'Tests extending and altering entity constraints.'
package: Testing
version: VERSION
core: 8.x
dependencies:
- entity_test
<?php
/**
* @file
* Test module file.
*/
/**
* Implements hook_entity_type_build().
*/
function entity_test_constraints_entity_type_build(array &$entity_types) {
if ($extra = \Drupal::state()->get('entity_test_constraints.build')) {
foreach ($extra as $id => $option) {
$entity_types['entity_test_constraints']->addConstraint($id, $option);
}
}
}
/**
* Implements hook_entity_type_alter().
*/
function entity_test_constraints_entity_type_alter(array &$entity_types) {
if ($alter = \Drupal::state()->get('entity_test_constraints.alter')) {
$entity_types['entity_test_constraints']->setConstraints($alter);
}
}
......@@ -251,4 +251,25 @@ public function testSetLinkTemplateWithInvalidPath() {
$entity_type->setLinkTemplate('test', 'invalid-path');
}
/**
* Tests the constraint methods.
*
* @covers ::getConstraints
* @covers ::setConstraints
* @covers ::addConstraint
*/
public function testConstraintMethods() {
$definition = [
'constraints' => [
'EntityChanged' => [],
],
];
$entity_type = $this->setUpEntityType($definition);
$this->assertEquals($definition['constraints'], $entity_type->getConstraints());
$entity_type->addConstraint('Test');
$this->assertEquals($definition['constraints'] + ['Test' => NULL], $entity_type->getConstraints());
$entity_type->setConstraints([]);
$this->assertEquals([], $entity_type->getConstraints());
}
}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment