diff --git a/core/config/schema/core.data_types.schema.yml b/core/config/schema/core.data_types.schema.yml index b7798d0373d7023a4a23eb2eff11f936b723f650..21a43e8dbb9a3d51c6bd6ca4a05448fb60dfedb6 100644 --- a/core/config/schema/core.data_types.schema.yml +++ b/core/config/schema/core.data_types.schema.yml @@ -533,16 +533,29 @@ base_entity_reference_field_settings: field_config_base: type: config_entity + constraints: + UniqueFieldCombination: [entity_type, bundle, field_name] mapping: id: type: string label: 'ID' + constraints: + StringParts: + separator: . + parts: + - '%parent.entity_type' + - '%parent.bundle' + - '%parent.field_name' field_name: type: string label: 'Field name' entity_type: type: string label: 'Entity type' + constraints: + PluginExists: + manager: entity_type.manager + interface: \Drupal\Core\Entity\FieldableEntityInterface bundle: type: string label: 'Bundle' @@ -554,6 +567,7 @@ field_config_base: description: type: text label: 'Help text' + nullable: true required: type: boolean label: 'Required field' @@ -563,14 +577,20 @@ field_config_base: default_value: type: sequence label: 'Default values' + nullable: true sequence: type: field.value.[%parent.%parent.field_type] label: 'Default value' + constraint: + # TBD, looks like \Symfony\Component\Validator\Constraints\ValidValidator *could* work here… + Valid: [] default_value_callback: type: string label: 'Default value callback' + # @todo Constraints — validate that this callback A) returns an entity, B) a *valid* entity? settings: type: field.field_settings.[%parent.field_type] + # @todo Constraints on each of the field type-specific settings config schemas BUT extra special challenge: until Drupal 11, at best only core field types will have validation constraints set! field_type: type: string label: 'Field type' @@ -582,6 +602,8 @@ field_config_base: core.base_field_override.*.*.*: type: field_config_base label: 'Base field bundle override' + constraints: + FullyValidatable: ~ core.date_format.*: type: config_entity @@ -666,6 +688,12 @@ field.storage_settings.string: type: boolean label: 'Contains US ASCII characters only' +field.field_settings.string: + type: mapping + label: 'String settings' + constraints: + FullyValidatable: ~ + field.value.string: type: mapping label: 'Default value' @@ -683,6 +711,14 @@ field.storage_settings.string_long: case_sensitive: type: boolean label: 'Case sensitive' + constraints: + FullyValidatable: ~ + +field.field_settings.string_long: + type: mapping + label: 'String (long) settings' + constraints: + FullyValidatable: ~ field.value.string_long: type: mapping @@ -698,6 +734,14 @@ field.value.string_long: field.storage_settings.password: type: field.storage_settings.string label: 'Password settings' + constraints: + FullyValidatable: ~ + +field.field_settings.password: + type: mapping + label: 'Password settings' + constraints: + FullyValidatable: ~ # Schema for the configuration of the URI field type. # This field type has no field instance settings, so no specific config schema type. @@ -713,6 +757,12 @@ field.storage_settings.uri: type: boolean label: 'Case sensitive' +field.field_settings.uri: + type: mapping + label: 'URI settings' + constraints: + FullyValidatable: ~ + field.value.uri: type: mapping label: 'Default value' @@ -722,10 +772,19 @@ field.value.uri: label: 'Value' # Schema for the configuration of the Created field type. -# This field type has no field storage settings, so no specific config schema type. -# @see `type: field.storage_settings.*` -# This field type has no field instance settings, so no specific config schema type. -# @see `type: field.field_settings.*` + +field.storage_settings.created: + type: mapping + label: 'Created timestamp settings' + constraints: + FullyValidatable: ~ + +field.field_settings.created: + type: mapping + label: 'Created timestamp settings' + constraints: + FullyValidatable: ~ + field.value.created: type: mapping label: 'Default value' @@ -735,10 +794,19 @@ field.value.created: label: 'Value' # Schema for the configuration of the Changed field type. -# This field type has no field storage settings, so no specific config schema type. -# @see `type: field.storage_settings.*` -# This field type has no field instance settings, so no specific config schema type. -# @see `type: field.field_settings.*` + +field.storage_settings.changed: + type: mapping + label: 'Changed timestamp settings' + constraints: + FullyValidatable: ~ + +field.field_settings.changed: + type: mapping + label: 'Changed timestamp settings' + constraints: + FullyValidatable: ~ + field.value.changed: type: mapping label: 'Default value' @@ -756,6 +824,9 @@ field.storage_settings.entity_reference: target_type: type: string label: 'Type of item to reference' + constraints: + PluginExists: + manager: entity_type.manager field.field_settings.entity_reference: type: mapping @@ -797,6 +868,8 @@ field.field_settings.boolean: off_label: type: required_label label: 'Off label' + constraints: + FullyValidatable: ~ field.value.boolean: type: mapping @@ -806,8 +879,13 @@ field.value.boolean: label: 'Value' # Schema for the configuration of the Email field type. -# This field type has no field storage settings, so no specific config schema type. -# @see `type: field.storage_settings.*` + +field.storage_settings.email: + type: mapping + label: 'Email settings' + constraints: + FullyValidatable: ~ + field.field_settings.email: type: mapping label: 'Email settings' @@ -904,6 +982,8 @@ field.value.decimal: field.storage_settings.float: type: mapping label: 'Float settings' + constraints: + FullyValidatable: ~ field.field_settings.float: type: mapping diff --git a/core/lib/Drupal/Core/Field/FieldConfigBase.php b/core/lib/Drupal/Core/Field/FieldConfigBase.php index 39405c37d437dae592076ecb5ce9a65ba96dd26b..3d8f166e8baf7c98c9e223da37e11569623a78fa 100644 --- a/core/lib/Drupal/Core/Field/FieldConfigBase.php +++ b/core/lib/Drupal/Core/Field/FieldConfigBase.php @@ -81,9 +81,9 @@ abstract class FieldConfigBase extends ConfigEntityBase implements FieldConfigIn * For example, the description will be the help text of Form API elements for * this field in entity edit forms. * - * @var string + * @var string|null */ - protected $description = ''; + protected $description = NULL; /** * Field-type specific settings. @@ -338,7 +338,7 @@ public function setLabel($label) { * {@inheritdoc} */ public function getDescription() { - return $this->description; + return $this->description ?? ''; } /** diff --git a/core/lib/Drupal/Core/Field/Plugin/Validation/Constraint/IsBaseFieldConstraint.php b/core/lib/Drupal/Core/Field/Plugin/Validation/Constraint/IsBaseFieldConstraint.php new file mode 100644 index 0000000000000000000000000000000000000000..c4a1e1cc5e42653f488bd78b09e0acd4ac4a5cc0 --- /dev/null +++ b/core/lib/Drupal/Core/Field/Plugin/Validation/Constraint/IsBaseFieldConstraint.php @@ -0,0 +1,24 @@ +<?php + +namespace Drupal\Core\Field\Plugin\Validation\Constraint; + +use Symfony\Component\Validator\Constraint; + +/** + * Validates that the value overrides a base field that actually exists. + * + * @Constraint( + * id = "IsBaseField", + * label = @Translation("Is a base field", context = "Validation") + * ) + */ +class IsBaseFieldConstraint extends Constraint { + + /** + * Tbe error message if validation fails. + * + * @var string + */ + public string $message = "'@field_name' is not a base field of the @entity_type entity type."; + +} diff --git a/core/lib/Drupal/Core/Field/Plugin/Validation/Constraint/IsBaseFieldConstraintValidator.php b/core/lib/Drupal/Core/Field/Plugin/Validation/Constraint/IsBaseFieldConstraintValidator.php new file mode 100644 index 0000000000000000000000000000000000000000..be309120e5f95b059e52ed0483cc1b07e28e4e33 --- /dev/null +++ b/core/lib/Drupal/Core/Field/Plugin/Validation/Constraint/IsBaseFieldConstraintValidator.php @@ -0,0 +1,54 @@ +<?php + +namespace Drupal\Core\Field\Plugin\Validation\Constraint; + +use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Drupal\Core\Entity\EntityFieldManagerInterface; +use Drupal\Core\Field\FieldDefinitionInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; + +/** + * Validates the IsBaseField constraint. + */ +class IsBaseFieldConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface { + + /** + * Constructs an IsBaseFieldConstraintValidator object. + * + * @param \Drupal\Core\Entity\EntityFieldManagerInterface $entityFieldManager + * The entity field manager service. + */ + public function __construct(protected readonly EntityFieldManagerInterface $entityFieldManager) {} + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container): static { + return new static( + $container->get(EntityFieldManagerInterface::class), + ); + } + + /** + * {@inheritdoc} + */ + public function validate(mixed $value, Constraint $constraint): void { + assert($constraint instanceof IsBaseFieldConstraint); + + if (!$value instanceof FieldDefinitionInterface) { + throw new UnexpectedTypeException($value, FieldDefinitionInterface::class); + } + $field_name = $value->getName(); + $entity_type_id = $value->getTargetEntityTypeId(); + if (!array_key_exists($field_name, $this->entityFieldManager->getBaseFieldDefinitions($entity_type_id))) { + $this->context->addViolation($constraint->message, [ + '@field_name' => $field_name, + '@entity_type' => $entity_type_id, + ]); + } + } + +} diff --git a/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/MatchesOtherConfigValueConstraint.php b/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/MatchesOtherConfigValueConstraint.php new file mode 100644 index 0000000000000000000000000000000000000000..d3007bf07b9be23a61618959beed60403b625e83 --- /dev/null +++ b/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/MatchesOtherConfigValueConstraint.php @@ -0,0 +1,55 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\Core\Validation\Plugin\Validation\Constraint; + +use Symfony\Component\Validator\Constraint; + +/** + * Checks that a value in this config matches a value in other config. + * + * @Constraint( + * id = "MatchesOtherConfigValue", + * label = @Translation("Value must match value in other config", context = "Validation") + * ) + */ +class MatchesOtherConfigValueConstraint extends Constraint { + + /** + * The error message if the string does not match. + * + * @var string + */ + public string $message = "Expected this to match the value in the '@other_config_name' config at the '@other_config_property_path' property: '@expected_value' was expected, not '@actual_value'."; + + /** + * The config prefix to use. + * + * @var string + */ + public string $prefix; + + /** + * One or more expressions that resolve to the config entity ID parts. + * + * @var string|string[] + * @see \Drupal\Core\Config\Schema\TypeResolver::resolveExpression() + */ + public string|array $id; + + /** + * The property path. + * + * @var string + */ + public string $propertyPath; + + /** + * {@inheritdoc} + */ + public function getRequiredOptions(): array { + return ['prefix', 'id', 'propertyPath']; + } + +} diff --git a/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/MatchesOtherConfigValueConstraintValidator.php b/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/MatchesOtherConfigValueConstraintValidator.php new file mode 100644 index 0000000000000000000000000000000000000000..fc1efcb38e25808420a2ac4f04b3376f65f15e6b --- /dev/null +++ b/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/MatchesOtherConfigValueConstraintValidator.php @@ -0,0 +1,121 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\Core\Validation\Plugin\Validation\Constraint; + +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Config\Schema\TypeResolver; +use Drupal\Core\Config\TypedConfigManagerInterface; +use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Drupal\Core\TypedData\PrimitiveInterface; +use Drupal\Core\TypedData\TypedDataInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; + +/** + * Validates the MatchesOtherConfigValue constraint. + */ +class MatchesOtherConfigValueConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface { + + /** + * Constructs a MatchesOtherConfigValueConstraintValidator object. + * + * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory + * The config factory service. + * @param \Drupal\Core\Config\TypedConfigManagerInterface $typedConfigManager + * The typed config manager. + */ + public function __construct( + protected readonly ConfigFactoryInterface $configFactory, + protected readonly TypedConfigManagerInterface $typedConfigManager, + ) {} + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container): static { + return new static( + $container->get(ConfigFactoryInterface::class), + $container->get(TypedConfigManagerInterface::class) + ); + } + + /** + * {@inheritdoc} + */ + public function validate(mixed $value, Constraint $constraint) { + if (!$constraint instanceof MatchesOtherConfigValueConstraint) { + throw new UnexpectedTypeException($constraint, MatchesOtherConfigValueConstraint::class); + } + + $this_property = $this->context->getObject(); + assert($this_property instanceof TypedDataInterface); + $this_data_definition = $this_property->getDataDefinition(); + + // This validation constraint should only be used for scalar values + // (primitives()), not for comparisons across entire arrays (sequences or + // mappings). That is possible in theory but not in practice yet. + // @todo Re-evaluate this after https://www.drupal.org/project/drupal/issues/3230826 + if (!is_subclass_of($this_data_definition->getClass(), PrimitiveInterface::class)) { + throw new \LogicException(sprintf( + 'The MatchesOtherConfigValue constraint used at %s uses the %s config schema type. Only config schema types implementing %s are supported.', + $this_property->getPropertyPath(), + $this_data_definition->getDataType(), + PrimitiveInterface::class, + )); + } + + // Determine the name of the other config object. + $id_parts = array_map( + fn (string $expression): mixed => TypeResolver::resolveExpression($expression, $this_property), + (array) $constraint->id + ); + $other_config_name = $constraint->prefix . implode('.', $id_parts); + + // When a developer uses this constraint inappropriately, guide them. + if ($other_config_name === $this->context->getRoot()->getName()) { + throw new \LogicException(sprintf('The MatchesOtherConfigValue constraint used in %s is configured to not look at another config object but at itself.', $this_property->getPropertyPath())); + } + if (!in_array($other_config_name, $this->configFactory->listAll(), TRUE)) { + throw new \LogicException(sprintf('The config %s does not exist, and is assumed to exist by this constraint. This means a RequiredConfigDependencies is absent from the config entity type.', $other_config_name)); + } + + $other_config_object = $this->typedConfigManager->get($other_config_name); + $other_property_to_match = $other_config_object->get($constraint->propertyPath); + + // When a developer uses this constraint inappropriately, guide them. + $other_data_definition = $other_property_to_match->getDataDefinition(); + if ($this_data_definition->getDataType() !== $other_data_definition->getDataType()) { + throw new \LogicException(sprintf( + 'The config schema type of this value (%s) does not match the config schema type (%s) of the %s property in the %s config object.', + $this_data_definition->getDataType(), + $other_data_definition->getDataType(), + $constraint->propertyPath, + $other_config_name, + )); + } + $missing_constraints = array_diff_key($other_data_definition->getConstraints(), $this_data_definition->getConstraints()); + if (!empty($missing_constraints)) { + throw new \LogicException(sprintf( + 'Fewer constraints are applied to this value than to the %s property in the %s config object. The following are missing: %s.', + $constraint->propertyPath, + $other_config_name, + implode(', ', array_keys($missing_constraints)), + )); + } + + assert($value === $this_property->getValue()); + if ($value !== $other_property_to_match->getValue()) { + $this->context->addViolation($constraint->message, [ + '@other_config_name' => $other_config_name, + '@other_config_property_path' => $constraint->propertyPath, + '@expected_value' => $other_property_to_match->getString(), + '@actual_value' => $this_property->getString(), + ]); + } + } + +} diff --git a/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/StringPartsConstraint.php b/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/StringPartsConstraint.php new file mode 100644 index 0000000000000000000000000000000000000000..59cda8e8f50e547ea768783ccc76d266f8b8d1df --- /dev/null +++ b/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/StringPartsConstraint.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\Core\Validation\Plugin\Validation\Constraint; + +use Symfony\Component\Validator\Constraint; + +/** + * Checks a string consists of specific parts found in the parent mapping. + * + * @Constraint( + * id = "StringParts", + * label = @Translation("String consists of specific parts", context = "Validation") + * ) + */ +class StringPartsConstraint extends Constraint { + + /** + * The error message if the string does not match. + * + * @var string + */ + public string $message = "Expected '@expected_string', not '@value'. Format: '@expected_format'."; + + /** + * The separator separating the parts. + * + * @var string + */ + public string $separator; + + /** + * The parent mapping's elements string values that should be used as parts. + * + * @var array + */ + public array $parts; + + /** + * {@inheritdoc} + */ + public function getRequiredOptions(): array { + return ['separator', 'parts']; + } + +} diff --git a/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/StringPartsConstraintValidator.php b/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/StringPartsConstraintValidator.php new file mode 100644 index 0000000000000000000000000000000000000000..c27c0711cfa8361d769bf4853e7f1c47a00c5ff0 --- /dev/null +++ b/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/StringPartsConstraintValidator.php @@ -0,0 +1,66 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\Core\Validation\Plugin\Validation\Constraint; + +use Drupal\Core\Config\Schema\TypeResolver; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; + +/** + * Validates the StringParts constraint. + */ +class StringPartsConstraintValidator extends ConstraintValidator { + + /** + * {@inheritdoc} + */ + public function validate(mixed $value, Constraint $constraint) { + if (!is_string($value)) { + throw new UnexpectedTypeException($value, 'string'); + } + + $resolved_parts = array_map( + fn (string $expression): mixed => TypeResolver::resolveExpression($expression, $this->context->getObject()), + $constraint->parts + ); + + // Verify the required parts are present; if not, that's a logical error in + // the config schema, not in concrete config. + $missing_properties = array_intersect($constraint->parts, $resolved_parts); + if (!empty($missing_properties)) { + throw new \LogicException(sprintf('This validation constraint is configured to inspect the properties %s, but some do not exist: %s.', + implode(', ', $constraint->parts), + implode(', ', $missing_properties) + )); + } + + // Retrieve the parts of the expected string. + $expected_string_parts = []; + foreach ($constraint->parts as $index => $part) { + $part_value = $resolved_parts[$index]; + if (!is_string($part_value)) { + throw new \LogicException(sprintf('The "%s" property does not contain a string, but a %s: "%s".', $part, gettype($part_value), (string) $part_value)); + } + $expected_string_parts[] = $part_value; + } + $expected_string = implode($constraint->separator, $expected_string_parts); + + if ($expected_string !== $value) { + $expected_format = implode( + $constraint->separator, + array_map(function (string $v) { + return sprintf('<%s>', $v); + }, $constraint->parts) + ); + $this->context->addViolation($constraint->message, [ + '@value' => $value, + '@expected_string' => $expected_string, + '@expected_format' => $expected_format, + ]); + } + } + +} diff --git a/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/TreeAwareConstraintTrait.php b/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/TreeAwareConstraintTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..b0b9dab8604df97beaa0a9f664549451ea93beac --- /dev/null +++ b/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/TreeAwareConstraintTrait.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\Core\Validation\Plugin\Validation\Constraint; + +use Drupal\Core\TypedData\ComplexDataInterface; +use Drupal\Core\TypedData\TypedDataInterface; + +/** + * Helper methods for tree-aware validation constraints. + * + * @todo Figure out whether to deal with TraversableTypedDataInterface, ComplexDataInterface, Mapping, or something else 😬 + * @todo Similarly, figure out whether to return TypedDataInterface or \Drupal\Core\Config\Schema\Element 😬 + */ +trait TreeAwareConstraintTrait { + + /** + * Finds the parent property. + * + * @return \Drupal\Core\TypedData\TypedDataInterface + * The parent property. + */ + private function getParentProperty(): TypedDataInterface { + $parent_property_path = array_slice(explode('.', $this->context->getPropertyPath()), 0, -1); + return self::findPropertyForPath($this->context->getRoot(), $parent_property_path); + } + + /** + * Finds the specified property path in the given tree. + * + * @todo consider adopting Symfony's PropertyAccess component. + * + * @param \Drupal\Core\TypedData\ComplexDataInterface $tree + * A config schema (sub)tree. + * @param string[] $property_path + * A property path, in array form. + * + * @return \Drupal\Core\TypedData\TypedDataInterface + * The property found at the specified property path. + * + * @throws \OutOfRangeException + * When requesting a non-existent property path. + */ + private static function findPropertyForPath(ComplexDataInterface $tree, array $property_path): TypedDataInterface { + // Edge case: root is requested. + if (empty($property_path)) { + return $tree; + } + + $elements = $tree->getElements(); + $name = array_shift($property_path); + + if (!isset($elements[$name])) { + throw new \OutOfRangeException(); + } + if (empty($property_path)) { + return $elements[$name]; + } + return self::findPropertyForPath($elements[$name], $property_path); + } + +} diff --git a/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/UniqueFieldCombinationConstraint.php b/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/UniqueFieldCombinationConstraint.php new file mode 100644 index 0000000000000000000000000000000000000000..7a4464a319ce2383ddc5c364ddf0be30820a111d --- /dev/null +++ b/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/UniqueFieldCombinationConstraint.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Validation\Plugin\Validation\Constraint; + +use Symfony\Component\Validator\Constraint; + +/** + * Checks if a set of config entity fields has a unique combination. + * + * @Constraint( + * id = "UniqueFieldCombination", + * label = @Translation("Unique field combination constraint", context = "Validation"), + * ) + */ +class UniqueFieldCombinationConstraint extends Constraint { + + /** + * @var string $message The error message. + */ + public $message = 'A @entity_type with this combination of values for the fields @field_name_list (%field_value_list) already exists.'; + + /** + * Fields which must have a unique combination among all entities of a type. + * + * @var string[] + */ + public array $fields; + + /** + * {@inheritdoc} + */ + public function getDefaultOption(): string { + return 'fields'; + } + + /** + * {@inheritdoc} + */ + public function getRequiredOptions(): array { + return ['fields']; + } + +} diff --git a/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/UniqueFieldCombinationConstraintValidator.php b/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/UniqueFieldCombinationConstraintValidator.php new file mode 100644 index 0000000000000000000000000000000000000000..9b3d6162a2cb58f11a4c4e4b470dfb890404079b --- /dev/null +++ b/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/UniqueFieldCombinationConstraintValidator.php @@ -0,0 +1,70 @@ +<?php + +namespace Drupal\Core\Validation\Plugin\Validation\Constraint; + +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; + +/** + * Validates that a field is unique for the given config entity type. + */ +class UniqueFieldCombinationConstraintValidator extends ConstraintValidator { + + use TreeAwareConstraintTrait; + + /** + * {@inheritdoc} + */ + public function validate($value, Constraint $constraint) { + assert($constraint instanceof UniqueFieldCombinationConstraint); + + if (!is_array($value)) { + throw new UnexpectedTypeException($value, 'array'); + } + + // Find the parent mapping. + $mapping = $this->getParentProperty(); + + // Verify the required fields are present; if not, that's a logical error in + // the config schema, not in concrete config. + $properties = $mapping->getProperties(); + $missing_fields = array_diff($constraint->fields, array_keys($properties)); + if (!empty($missing_fields)) { + throw new \LogicException(sprintf('This validation constraint is configured to inspect the fields %s, but some do not exist: %s.', + implode(', ', $constraint->fields), + implode(', ', $missing_fields) + )); + } + + // Construct an entity query. + // @see \Drupal\Core\Config\ConfigImporter::checkOp() + // @see \Drupal\Core\Config\ConfigInstaller::createConfiguration() + $config_manager = \Drupal::service('config.manager'); + $entity_type_id = $config_manager->getEntityTypeIdByName($mapping->getRoot()->getName()); + $entity_storage = $config_manager->getEntityTypeManager()->getStorage($entity_type_id); + $query = $entity_storage->getQuery() + ->accessCheck(FALSE); + foreach ($constraint->fields as $field_name) { + $query->condition($field_name, $mapping->get($field_name)->getValue()); + } + + $uuid = $mapping->get('uuid')->getValue(); + if (!empty($uuid)) { + $query->condition('uuid', $uuid, '<>'); + } + + $value_taken = (bool) $query + ->range(0, 1) + ->execute(); + + if ($value_taken) { + $this->context->addViolation($constraint->message, [ + '@entity_type' => $entity_type_id, + '@field_name_list' => implode(', ', $constraint->fields), + '%field_value_list' => implode(', ', array_map(fn (string $field_name) => $mapping->get($field_name)->getValue(), $constraint->fields)), + ]); + } + } + +} diff --git a/core/modules/field/config/schema/field.schema.yml b/core/modules/field/config/schema/field.schema.yml index cdd1d4bab05cbeba55c7f2abc1aa80a567ded688..b0a1dd0f84d40f5d650036e5726c5fcbbbb1e6c3 100644 --- a/core/modules/field/config/schema/field.schema.yml +++ b/core/modules/field/config/schema/field.schema.yml @@ -17,20 +17,40 @@ field.settings: field.storage.*.*: type: config_entity label: 'Field' + constraints: + FullyValidatable: ~ + UniqueFieldCombination: [entity_type, field_name] mapping: id: type: string label: 'ID' + constraints: + StringParts: + separator: . + parts: + - '%parent.entity_type' + - '%parent.field_name' field_name: type: string label: 'Field name' + + constraints: + NotBlank: {} + Length: + # @see \Drupal\field\Entity\FieldStorageConfig::NAME_MAX_LENGTH + max: 32 entity_type: type: string label: 'Entity type' + constraints: + PluginExists: + manager: entity_type.manager + interface: '\Drupal\Core\Entity\FieldableEntityInterface' type: type: string label: 'Type' constraints: + # @todo In principle, we should validate that this plugin was indeed provided by `module`. PluginExists: manager: plugin.manager.field.field_type interface: '\Drupal\Core\Field\FieldItemInterface' @@ -39,18 +59,24 @@ field.storage.*.*: module: type: string label: 'Module' + constraints: + ExtensionExists: 'module' locked: type: boolean label: 'Locked' cardinality: type: integer label: 'Maximum number of values users can enter' + constraints: {} + # @todo Create this constraint. + # NoEntitiesExistYetWithHigherCardinality: [] translatable: type: boolean label: 'Translatable' indexes: type: sequence label: 'Indexes' + # @todo Constraints — but shockingly this A) uses `type: ignore`, B) has zero validation logic in core?! sequence: type: sequence label: 'Indexes' @@ -67,3 +93,17 @@ field.storage.*.*: field.field.*.*.*: type: field_config_base label: 'Field' + mapping: + # Extend `field_config_base:field_type` with a FieldConfig entity-specific + # constraint. (This does not apply to BaseFieldOverride entities.) + field_type: + constraints: + MatchesOtherConfigValue: + # This field_storage_config entity must exist. This is already guaranteed. + # @see \Drupal\field\Entity\FieldConfig + # @see \Drupal\Core\Config\Plugin\Validation\Constraint\RequiredConfigDependenciesConstraint + prefix: field.storage. + id: ['%parent.entity_type', '%parent.field_name'] + propertyPath: type + constraints: + FullyValidatable: ~ diff --git a/core/modules/field/field.post_update.php b/core/modules/field/field.post_update.php index 3af41a483b1c715c33f20eaf86736cd4507a0f9b..f575fa16c476ed966fad2e31c7d8d9b976015556 100644 --- a/core/modules/field/field.post_update.php +++ b/core/modules/field/field.post_update.php @@ -5,6 +5,38 @@ * Post update functions for Field module. */ +use Drupal\Core\Config\Entity\ConfigEntityUpdater; +use Drupal\Core\Field\Entity\BaseFieldOverride; +use Drupal\field\FieldConfigInterface; + +/** + * Converts empty `description` on fields to NULL. + */ +function field_post_update_set_field_config_empty_description_to_null(array &$sandbox): void { + \Drupal::classResolver(ConfigEntityUpdater::class) + ->update($sandbox, 'field_config', function (FieldConfigInterface $entity): bool { + if (trim($entity->getDescription()) === '') { + $entity->set('description', NULL); + return TRUE; + } + return FALSE; + }); +} + +/** + * Converts empty `description` on base field overrides to NULL. + */ +function field_post_update_set_base_field_override_empty_description_to_null(array &$sandbox): void { + \Drupal::classResolver(ConfigEntityUpdater::class) + ->update($sandbox, 'base_field_override', function (BaseFieldOverride $entity): bool { + if (trim($entity->getDescription()) === '') { + $entity->set('description', NULL); + return TRUE; + } + return FALSE; + }); +} + /** * Implements hook_removed_post_updates(). */ diff --git a/core/modules/field/tests/src/Functional/Rest/FieldConfigResourceTestBase.php b/core/modules/field/tests/src/Functional/Rest/FieldConfigResourceTestBase.php index 0809ab417261e0defd697c0fe3f1a523c46b3fc4..dd73d7679815c94b2b52f2e300847dc858bb686d 100644 --- a/core/modules/field/tests/src/Functional/Rest/FieldConfigResourceTestBase.php +++ b/core/modules/field/tests/src/Functional/Rest/FieldConfigResourceTestBase.php @@ -79,7 +79,7 @@ protected function getExpectedNormalizedEntity() { 'text', ], ], - 'description' => '', + 'description' => NULL, 'entity_type' => 'node', 'field_name' => 'field_llama', 'field_type' => 'text', diff --git a/core/modules/field/tests/src/Kernel/Entity/FieldConfigValidationTest.php b/core/modules/field/tests/src/Kernel/Entity/FieldConfigValidationTest.php index 288c1babca0054a74f3f9f73195550aeb441018a..84ecf06874d5c81010a529eff49b3f3dace24bad 100644 --- a/core/modules/field/tests/src/Kernel/Entity/FieldConfigValidationTest.php +++ b/core/modules/field/tests/src/Kernel/Entity/FieldConfigValidationTest.php @@ -4,25 +4,26 @@ namespace Drupal\Tests\field\Kernel\Entity; +use Drupal\entity_test\Entity\EntityTestMulBundle; +use Drupal\field\FieldStorageConfigInterface; use Drupal\entity_test\Entity\EntityTestBundle; use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; -use Drupal\KernelTests\Core\Config\ConfigEntityValidationTestBase; -use Drupal\Tests\node\Traits\ContentTypeCreationTrait; /** * Tests validation of field_config entities. * * @group field */ -class FieldConfigValidationTest extends ConfigEntityValidationTestBase { - - use ContentTypeCreationTrait; +class FieldConfigValidationTest extends FieldStorageConfigValidationTest { /** * {@inheritdoc} */ - protected static $modules = ['field', 'node', 'entity_test', 'text', 'user']; + protected static array $propertiesWithOptionalValues = [ + 'default_value', + 'description', + ]; /** * {@inheritdoc} @@ -32,13 +33,33 @@ protected function setUp(): void { $this->installEntitySchema('node'); $this->installConfig('node'); - $this->createContentType(['type' => 'one']); - $this->createContentType(['type' => 'another']); EntityTestBundle::create(['id' => 'one'])->save(); + EntityTestMulBundle::create(['id' => 'one'])->save(); + + // Specifically create a bundle for `entity_test_bundle` and + // `entity_test_mul_with_bundle` content entities confusingly named + // `another`, to allow testing the modifying of the `entity_type` field on + // FieldConfig entities without triggering additional validation errors. + // @see ::providerImmutableFields() EntityTestBundle::create(['id' => 'another'])->save(); + EntityTestMulBundle::create([ + 'id' => 'another', + 'label' => $this->randomString(), + ])->save(); - $this->entity = FieldConfig::loadByName('node', 'one', 'body'); + // The field storage was created in the parent method. + $field_storage = $this->entity; + + $this->entity = FieldConfig::create([ + 'field_storage' => $field_storage, + 'bundle' => 'one', + 'settings' => [ + 'on_label' => 'Hello!', + 'off_label' => 'Goodbye.', + ], + ]); + $this->entity->save(); } /** @@ -67,13 +88,93 @@ public function testInvalidDependencies(): void { ]); } + /** + * Tests that the field type plugin is validated. + */ + public function testFieldTypePlugin(): void { + $this->entity->set('field_type', 'non_existent'); + // If we don't clear the previous settings here, we will get unrelated + // validation errors (in addition to the one we're expecting), because the + // settings from the *old* field_type won't match the config schema for the + // settings of the *new* field_type. + $this->entity->set('settings', []); + $this->assertValidationErrors([ + '' => "The 'field_type' property cannot be changed.", + 'field_type' => [ + "The 'non_existent' plugin does not exist.", + "Expected this to match the value in the 'field.storage.entity_test_mul_with_bundle.test' config at the 'type' property: 'boolean' was expected, not 'non_existent'.", + ], + ]); + } + + /** + * Tests that the target entity type is validated. + * + * 90% identical to the parent: an additional validation error is triggered + * due to bundle validation. + */ + public function testEntityType(): void { + // Ensure the target entity type is valid to begin with. + $this->assertValidationErrors([]); + + // Ensure that it is at least plausible that the `entity_type` is modified: + // a corresponding FieldStorageConfig must exist due to the entity-level + // `RequiredConfigDependencies` constraint. + FieldStorageConfig::create([ + 'type' => 'boolean', + 'field_name' => 'test', + 'entity_type' => 'entity_test', + ])->save(); + $this->entity->set('entity_type', 'entity_test'); + $this->assertValidationErrors([ + '' => "The 'entity_type' property cannot be changed.", + 'bundle' => "The 'one' bundle does not exist on the 'entity_test' entity type.", + ]); + } + + /** + * Tests that the bundle is validated. + */ + public function testBundle(): void { + $entity_type_id = 'entity_test_mul_with_bundle'; + $field_name = 'test'; + + // Assert that the FieldStorageConfig which this FieldConfig will depend on, + // already exists. + $field_storage_config = FieldStorageConfig::loadByName($entity_type_id, $field_name); + $this->assertInstanceOf(FieldStorageConfigInterface::class, $field_storage_config); + + // Try to create an instance of this field on a bundle that does not exist. + $this->entity = FieldConfig::create([ + 'bundle' => 'non_existent', + 'field_storage' => $field_storage_config, + ]); + // The field storage is not listed in the entity's config dependencies, + // because the dependencies have not been recalculated yet. They can't be + // recalculated until the target bundle exists, or we'll get an exception. + // So, for testing purposes, use reflection to access the protected + // addDependency() method and add the field storage as a dependency. + // @see \Drupal\Core\Field\FieldConfigBase::calculateDependencies() + (new \ReflectionMethod($this->entity, 'addDependency')) + ->invoke($this->entity, 'config', $field_storage_config->getConfigDependencyName()); + $this->assertValidationErrors([ + 'bundle' => "The 'non_existent' bundle does not exist on the 'entity_test_mul_with_bundle' entity type.", + ]); + + // Next, try to create it on a bundle that *does* exist. We need to + // recalculate dependencies because that's how we check whether or not the + // bundle exists. + $this->entity->set('bundle', 'one')->calculateDependencies(); + $this->assertValidationErrors([]); + } + /** * Tests validation of a field_config's default value. */ public function testMultilineTextFieldDefaultValue(): void { $this->installEntitySchema('user'); // First, create a field storage for which a complex default value exists. - $this->enableModules(['text']); + $this->enableModules(['text', 'user']); $text_field_storage_config = FieldStorageConfig::create([ 'type' => 'text_with_summary', 'field_name' => 'novel', @@ -107,23 +208,41 @@ public function testTargetBundleMustExist(): void { $this->entity->set('bundle', 'nope'); $this->assertValidationErrors([ '' => "The 'bundle' property cannot be changed.", - 'bundle' => "The 'nope' bundle does not exist on the 'node' entity type.", + 'bundle' => "The 'nope' bundle does not exist on the 'entity_test_mul_with_bundle' entity type.", ]); } /** * {@inheritdoc} */ - public function testImmutableProperties(array $valid_values = []): void { - // If we don't clear the previous settings here, we will get unrelated - // validation errors (in addition to the one we're expecting), because the - // settings from the *old* field_type won't match the config schema for the - // settings of the *new* field_type. - $this->entity->set('settings', []); - parent::testImmutableProperties([ + public function testImmutableProperties(array $valid_values = [], ?array $additional_expected_validation_errors_when_modified = NULL): void { + // Ensure that it is at least plausible that the `entity_type` is modified: + // a corresponding FieldStorageConfig must exist due to the entity-level + // `RequiredConfigDependencies` constraint. + FieldStorageConfig::create([ + 'type' => 'boolean', + 'field_name' => 'test', 'entity_type' => 'entity_test_with_bundle', + ])->save(); + // Same thing for `field_name`. + FieldStorageConfig::create([ + 'type' => 'boolean', + 'field_name' => 'foobar', + 'entity_type' => 'entity_test_mul_with_bundle', + ])->save(); + + parent::testImmutableProperties([ 'bundle' => 'another', - 'field_type' => 'string', + 'field_type' => 'email', + 'field_name' => 'foobar', + ], [ + 'field_type' => [ + 'field_type' => "Expected this to match the value in the 'field.storage.entity_test_mul_with_bundle.test' config at the 'type' property: 'boolean' was expected, not 'email'.", + 'settings' => [ + "'on_label' is an unknown key because field_type is email (see config schema type field.field_settings.email).", + "'off_label' is an unknown key because field_type is email (see config schema type field.field_settings.email).", + ], + ], ]); } @@ -137,6 +256,13 @@ public function testRequiredPropertyKeysMissing(?array $additional_expected_vali // @see \Drupal\Core\Config\Plugin\Validation\Constraint\RequiredConfigDependenciesConstraintValidator '' => 'This field requires a field storage.', ], + // If the settings are removed, we should see errors about them missing. + 'settings' => [ + 'settings' => [ + "'on_label' is a required key because field_type is boolean (see config schema type field.field_settings.boolean).", + "'off_label' is a required key because field_type is boolean (see config schema type field.field_settings.boolean).", + ], + ], ]); } @@ -166,7 +292,10 @@ public function testFieldTypePluginIsValidated(): void { ->set('field_type', 'invalid'); $this->assertValidationErrors([ - 'field_type' => "The 'invalid' plugin does not exist.", + 'field_type' => [ + "The 'invalid' plugin does not exist.", + "Expected this to match the value in the 'field.storage.entity_test_mul_with_bundle.test' config at the 'type' property: 'boolean' was expected, not 'invalid'.", + ], ]); } @@ -185,6 +314,7 @@ public function testEntityReferenceSelectionHandlerIsValidated(): void { ->set('settings', ['handler' => 'non_existent']); $this->assertValidationErrors([ + 'field_type' => "Expected this to match the value in the 'field.storage.entity_test_mul_with_bundle.test' config at the 'type' property: 'boolean' was expected, not 'entity_reference'.", 'settings.handler' => "The 'non_existent' plugin does not exist.", ]); } diff --git a/core/modules/field/tests/src/Kernel/Entity/FieldStorageConfigValidationTest.php b/core/modules/field/tests/src/Kernel/Entity/FieldStorageConfigValidationTest.php index 47532f40c84b9543e5082443b6be7e1f00a9c52d..b23857064e087dde50b49f32cb5c4fef530231b9 100644 --- a/core/modules/field/tests/src/Kernel/Entity/FieldStorageConfigValidationTest.php +++ b/core/modules/field/tests/src/Kernel/Entity/FieldStorageConfigValidationTest.php @@ -17,19 +17,19 @@ class FieldStorageConfigValidationTest extends ConfigEntityValidationTestBase { /** * {@inheritdoc} */ - protected static $modules = ['field', 'node', 'user']; + protected static $modules = ['field', 'entity_test']; /** * {@inheritdoc} */ protected function setUp(): void { parent::setUp(); - $this->installEntitySchema('user'); $this->entity = FieldStorageConfig::create([ 'type' => 'boolean', 'field_name' => 'test', - 'entity_type' => 'user', + 'entity_type' => 'entity_test_mul_with_bundle', + 'custom_storage' => FALSE, ]); $this->entity->save(); } @@ -37,9 +37,43 @@ protected function setUp(): void { /** * {@inheritdoc} */ - public function testImmutableProperties(array $valid_values = []): void { - $valid_values['type'] = 'string'; - parent::testImmutableProperties($valid_values); + public function testImmutableProperties(array $valid_values = [], ?array $additional_expected_validation_errors_when_modified = NULL): void { + parent::testImmutableProperties($valid_values + [ + 'entity_type' => 'entity_test_with_bundle', + 'type' => 'email', + ], $additional_expected_validation_errors_when_modified); + } + + /** + * Tests that the field type plugin is validated. + */ + public function testFieldTypePlugin(): void { + $this->entity->set('type', 'non_existent'); + $this->assertValidationErrors([ + '' => "The 'type' property cannot be changed.", + 'type' => "The 'non_existent' plugin does not exist.", + ]); + } + + /** + * Tests that the target entity type is validated. + */ + public function testEntityType(): void { + // Ensure the target entity type is valid to begin with. + $this->assertValidationErrors([]); + + $this->entity->set('entity_type', 'strange_entity'); + $this->assertValidationErrors([ + '' => "The 'entity_type' property cannot be changed.", + 'entity_type' => "The 'strange_entity' plugin does not exist.", + ]); + + // A valid, but non-fieldable, entity type should raise an error. + $this->entity->set('entity_type', 'field_config'); + $this->assertValidationErrors([ + '' => "The 'entity_type' property cannot be changed.", + 'entity_type' => "The 'field_config' plugin must implement or extend \Drupal\Core\Entity\FieldableEntityInterface.", + ]); } /** diff --git a/core/modules/field/tests/src/Kernel/FieldCrudTest.php b/core/modules/field/tests/src/Kernel/FieldCrudTest.php index e51a2db9d6e803af31c06869fd84687e88a3e090..bb56dba0fa43ce933d902727a4b842bfd5f6bce4 100644 --- a/core/modules/field/tests/src/Kernel/FieldCrudTest.php +++ b/core/modules/field/tests/src/Kernel/FieldCrudTest.php @@ -95,7 +95,7 @@ public function testCreateField(): void { // Check that default values are set. $this->assertFalse($config['required'], 'Required defaults to false.'); $this->assertSame($config['label'], $this->fieldDefinition['field_name'], 'Label defaults to field name.'); - $this->assertSame('', $config['description'], 'Description defaults to empty string.'); + $this->assertNull($config['description'], 'Description defaults to NULL.'); // Check that default settings are set. $this->assertEquals($config['settings'], $field_type_manager->getDefaultFieldSettings($this->fieldStorageDefinition['type']), 'Default field settings have been written.'); diff --git a/core/modules/jsonapi/tests/src/Functional/BaseFieldOverrideTest.php b/core/modules/jsonapi/tests/src/Functional/BaseFieldOverrideTest.php index d2ee111c28c107c95728f8417620eca35b4d5bd8..77c0e45806be45495caa808879ace9e689849a11 100644 --- a/core/modules/jsonapi/tests/src/Functional/BaseFieldOverrideTest.php +++ b/core/modules/jsonapi/tests/src/Functional/BaseFieldOverrideTest.php @@ -103,7 +103,7 @@ protected function getExpectedDocument(): array { 'node.type.camelids', ], ], - 'description' => '', + 'description' => NULL, 'entity_type' => 'node', 'field_name' => 'promote', 'field_type' => 'boolean', diff --git a/core/modules/jsonapi/tests/src/Functional/FieldConfigTest.php b/core/modules/jsonapi/tests/src/Functional/FieldConfigTest.php index 6d48f81ed965feb9fbea194c6b3774441e8fc140..345a03d2d48d68a51bc0587e7799b9991670715a 100644 --- a/core/modules/jsonapi/tests/src/Functional/FieldConfigTest.php +++ b/core/modules/jsonapi/tests/src/Functional/FieldConfigTest.php @@ -115,7 +115,7 @@ protected function getExpectedDocument(): array { 'text', ], ], - 'description' => '', + 'description' => NULL, 'entity_type' => 'node', 'field_name' => 'field_llama', 'field_type' => 'text', diff --git a/core/modules/language/tests/src/Kernel/ContentLanguageSettingsValidationTest.php b/core/modules/language/tests/src/Kernel/ContentLanguageSettingsValidationTest.php index 056779a0c2ff8cea4d15d90d38e4ad433cb93c74..319975b9e753a1bc8319d47d0ac17026880a7a1b 100644 --- a/core/modules/language/tests/src/Kernel/ContentLanguageSettingsValidationTest.php +++ b/core/modules/language/tests/src/Kernel/ContentLanguageSettingsValidationTest.php @@ -70,7 +70,7 @@ public function testTargetBundleMustExist(): void { /** * {@inheritdoc} */ - public function testImmutableProperties(array $valid_values = []): void { + public function testImmutableProperties(array $valid_values = [], ?array $additional_expected_validation_errors_when_modified = NULL): void { parent::testImmutableProperties([ 'target_entity_type_id' => 'entity_test_with_bundle', 'target_bundle' => 'bravo', diff --git a/core/modules/layout_builder/tests/src/Kernel/LayoutBuilderEntityViewDisplayValidationTest.php b/core/modules/layout_builder/tests/src/Kernel/LayoutBuilderEntityViewDisplayValidationTest.php index 7f632d87d7d7167195656b8fc80b41321e4cc63c..df689aff532229e69b060b745273bf74e707eee7 100644 --- a/core/modules/layout_builder/tests/src/Kernel/LayoutBuilderEntityViewDisplayValidationTest.php +++ b/core/modules/layout_builder/tests/src/Kernel/LayoutBuilderEntityViewDisplayValidationTest.php @@ -71,7 +71,7 @@ public function testLabelValidation(): void { /** * {@inheritdoc} */ - public function testImmutableProperties(array $valid_values = []): void { + public function testImmutableProperties(array $valid_values = [], ?array $additional_expected_validation_errors_when_modified = NULL): void { parent::testImmutableProperties([ 'id' => 'entity_test_with_bundle.two.full', 'targetEntityType' => 'entity_test_with_bundle', diff --git a/core/modules/media/tests/src/FunctionalJavascript/MediaSourceAudioVideoTest.php b/core/modules/media/tests/src/FunctionalJavascript/MediaSourceAudioVideoTest.php index 24c071a501ca64f5fae26b2206c9eadace0f3f6b..021f956bf8a21a53aad5ab89ec8c9546f410b765 100644 --- a/core/modules/media/tests/src/FunctionalJavascript/MediaSourceAudioVideoTest.php +++ b/core/modules/media/tests/src/FunctionalJavascript/MediaSourceAudioVideoTest.php @@ -32,6 +32,9 @@ public function testAudioTypeCreation(): void { $type_name = 'audio_type'; $field_name = 'field_media_' . $source_id; $this->doTestCreateMediaType($type_name, $source_id); + // @todo This line can be removed when the entity field manager's cache + // is automatically reset when a field is created. + $this->container->get('entity_field.manager')->clearCachedFieldDefinitions(); // Check that the source field was created with the correct settings. $storage = FieldStorageConfig::load("media.$field_name"); @@ -81,6 +84,9 @@ public function testVideoTypeCreation(): void { $type_name = 'video_type'; $field_name = 'field_media_' . $source_id; $this->doTestCreateMediaType($type_name, $source_id); + // @todo This line can be removed when the entity field manager's cache + // is automatically reset when a field is created. + $this->container->get('entity_field.manager')->clearCachedFieldDefinitions(); // Check that the source field was created with the correct settings. $storage = FieldStorageConfig::load("media.$field_name"); diff --git a/core/modules/media/tests/src/Kernel/MediaTypeValidationTest.php b/core/modules/media/tests/src/Kernel/MediaTypeValidationTest.php index 4fbc4e424bdcfd833fb8cdc15ed6d094fb992b0d..89e124204fe9e390b0b878c82cfb5fa4a9d1b5aa 100644 --- a/core/modules/media/tests/src/Kernel/MediaTypeValidationTest.php +++ b/core/modules/media/tests/src/Kernel/MediaTypeValidationTest.php @@ -36,7 +36,7 @@ protected function setUp(): void { /** * {@inheritdoc} */ - public function testImmutableProperties(array $valid_values = []): void { + public function testImmutableProperties(array $valid_values = [], ?array $additional_expected_validation_errors_when_modified = NULL): void { // If we don't clear the previous settings here, we will get unrelated // validation errors (in addition to the one we're expecting), because the // settings from the *old* source won't match the config schema for the diff --git a/core/modules/system/tests/src/Kernel/Entity/ActionValidationTest.php b/core/modules/system/tests/src/Kernel/Entity/ActionValidationTest.php index 1e3efa75252bf0a1e36c66fae212004a09950107..3e259eaec9e7ce9feb0574b419e590e81a0b3646 100644 --- a/core/modules/system/tests/src/Kernel/Entity/ActionValidationTest.php +++ b/core/modules/system/tests/src/Kernel/Entity/ActionValidationTest.php @@ -62,7 +62,7 @@ public function testInvalidPluginId(): void { /** * {@inheritdoc} */ - public function testImmutableProperties(array $valid_values = []): void { + public function testImmutableProperties(array $valid_values = [], ?array $additional_expected_validation_errors_when_modified = NULL): void { $valid_values['id'] = 'test_changed'; parent::testImmutableProperties($valid_values); } diff --git a/core/tests/Drupal/FunctionalTests/Rest/BaseFieldOverrideResourceTestBase.php b/core/tests/Drupal/FunctionalTests/Rest/BaseFieldOverrideResourceTestBase.php index 7003ecded8b09a99540f58d21feba9b5b1d9622e..942165ddf70794933fcd2234acfca61d25c5a84a 100644 --- a/core/tests/Drupal/FunctionalTests/Rest/BaseFieldOverrideResourceTestBase.php +++ b/core/tests/Drupal/FunctionalTests/Rest/BaseFieldOverrideResourceTestBase.php @@ -66,7 +66,7 @@ protected function getExpectedNormalizedEntity() { 'node.type.camelids', ], ], - 'description' => '', + 'description' => NULL, 'entity_type' => 'node', 'field_name' => 'promote', 'field_type' => 'boolean', diff --git a/core/tests/Drupal/KernelTests/Core/Config/ConfigEntityValidationTestBase.php b/core/tests/Drupal/KernelTests/Core/Config/ConfigEntityValidationTestBase.php index 18cd991e38d8a30d35783c1bcfe51dab56152249..c1143b1bc1e13233b08a973a441699daf7e1e762 100644 --- a/core/tests/Drupal/KernelTests/Core/Config/ConfigEntityValidationTestBase.php +++ b/core/tests/Drupal/KernelTests/Core/Config/ConfigEntityValidationTestBase.php @@ -469,15 +469,20 @@ public function testLangcode(): void { * (optional) The values to set for the immutable properties, keyed by name. * This should be used if the immutable properties can only accept certain * values, e.g. valid plugin IDs. + * @param string[]|null $additional_expected_validation_errors_when_modified + * Some immutable config entity properties have additional validation + * constraints that cause additional messages to appear. Keys must be + * config entity properties, values must be arrays as expected by + * ::assertValidationErrors(). */ - public function testImmutableProperties(array $valid_values = []): void { + public function testImmutableProperties(array $valid_values = [], ?array $additional_expected_validation_errors_when_modified = NULL): void { $constraints = $this->entity->getEntityType()->getConstraints(); $this->assertNotEmpty($constraints['ImmutableProperties'], 'All config entities should have at least one immutable ID property.'); foreach ($constraints['ImmutableProperties'] as $property_name) { $original_value = $this->entity->get($property_name); $this->entity->set($property_name, $valid_values[$property_name] ?? $this->randomMachineName()); - $this->assertValidationErrors([ + $this->assertValidationErrors(($additional_expected_validation_errors_when_modified[$property_name] ?? []) + [ '' => "The '$property_name' property cannot be changed.", ]); $this->entity->set($property_name, $original_value); diff --git a/core/tests/Drupal/KernelTests/Core/Entity/BaseFieldOverrideValidationTest.php b/core/tests/Drupal/KernelTests/Core/Entity/BaseFieldOverrideValidationTest.php index 6f71621dbb308dcacd70d8b60e8129e6929270c3..cbdb61c77560ae42fe459a106acc4e4ce1471274 100644 --- a/core/tests/Drupal/KernelTests/Core/Entity/BaseFieldOverrideValidationTest.php +++ b/core/tests/Drupal/KernelTests/Core/Entity/BaseFieldOverrideValidationTest.php @@ -6,6 +6,8 @@ use Drupal\Core\Field\Entity\BaseFieldOverride; use Drupal\entity_test\Entity\EntityTestBundle; +use Drupal\field\Entity\FieldConfig; +use Drupal\field\Entity\FieldStorageConfig; use Drupal\KernelTests\Core\Config\ConfigEntityValidationTestBase; use Drupal\Tests\node\Traits\ContentTypeCreationTrait; @@ -19,6 +21,14 @@ class BaseFieldOverrideValidationTest extends ConfigEntityValidationTestBase { use ContentTypeCreationTrait; + /** + * {@inheritdoc} + */ + protected static array $propertiesWithOptionalValues = [ + 'default_value', + 'description', + ]; + /** * {@inheritdoc} */ @@ -45,6 +55,25 @@ protected function setUp(): void { $this->entity->save(); } + /** + * Tests that the bundle is validated. + */ + public function testBundle(): void { + $fields = $this->container->get('entity_field.manager') + ->getBaseFieldDefinitions('user'); + + // Try to create an instance of this base field override on a bundle that + // does not exist. + $this->entity = BaseFieldOverride::createFromBaseFieldDefinition($fields['uuid'], 'non_existent'); + $this->assertValidationErrors([ + 'bundle' => "The 'non_existent' bundle does not exist on the 'user' entity type.", + ]); + + // Next, try to create it on a bundle that does exist. + $this->entity = BaseFieldOverride::createFromBaseFieldDefinition($fields['uuid'], 'user'); + $this->assertValidationErrors([]); + } + /** * Tests that the target bundle of the field is checked. */ @@ -59,7 +88,7 @@ public function testTargetBundleMustExist(): void { /** * {@inheritdoc} */ - public function testImmutableProperties(array $valid_values = []): void { + public function testImmutableProperties(array $valid_values = [], ?array $additional_expected_validation_errors_when_modified = NULL): void { // If we don't clear the previous settings here, we will get unrelated // validation errors (in addition to the one we're expecting), because the // settings from the *old* field_type won't match the config schema for the @@ -68,7 +97,8 @@ public function testImmutableProperties(array $valid_values = []): void { parent::testImmutableProperties([ 'entity_type' => 'entity_test_with_bundle', 'bundle' => 'another', - 'field_type' => 'string', + 'field_type' => 'email', + 'field_name' => 'title', ]); } @@ -85,4 +115,30 @@ public function testFieldTypePluginIsValidated(): void { ]); } + /** + * Tests that base field overrides must be overriding, well, base fields. + */ + public function testOverriddenFieldMustBeABaseField(): void { + $storage = FieldStorageConfig::create([ + 'entity_type' => 'node', + 'field_name' => 'field_mail', + 'type' => 'email', + ]); + $storage->save(); + + $field = FieldConfig::create([ + 'field_storage' => $storage, + 'bundle' => 'one', + ]); + $field->save(); + + // The `field_name` property is immutable, so we need to clone the entity + // in order to change it. + $this->entity = $this->entity->createDuplicate() + ->set('field_name', 'field_mail'); + $this->assertValidationErrors([ + '' => "'field_mail' is not a base field of the node entity type.", + ]); + } + } diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntityFormDisplayValidationTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityFormDisplayValidationTest.php index fa9754dfe9b6d183a6e28e2b3ea4a7cde3bea07d..85aa6b651e1034de447a62c3a612b0e4a8056c73 100644 --- a/core/tests/Drupal/KernelTests/Core/Entity/EntityFormDisplayValidationTest.php +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityFormDisplayValidationTest.php @@ -113,7 +113,7 @@ public function testTargetBundleMustExist(): void { /** * {@inheritdoc} */ - public function testImmutableProperties(array $valid_values = []): void { + public function testImmutableProperties(array $valid_values = [], ?array $additional_expected_validation_errors_when_modified = NULL): void { parent::testImmutableProperties([ 'id' => 'entity_test_with_bundle.two.default', 'targetEntityType' => 'entity_test_with_bundle', diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntityFormModeValidationTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityFormModeValidationTest.php index c9db7a0ef442ea564f908c8b11c041c0bcba2c7f..eea7df90b6dbb3cc7bf6cc06efead921ea0e8b3f 100644 --- a/core/tests/Drupal/KernelTests/Core/Entity/EntityFormModeValidationTest.php +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityFormModeValidationTest.php @@ -39,7 +39,7 @@ protected function setUp(): void { /** * {@inheritdoc} */ - public function testImmutableProperties(array $valid_values = []): void { + public function testImmutableProperties(array $valid_values = [], ?array $additional_expected_validation_errors_when_modified = NULL): void { $valid_values['id'] = 'user.test_changed'; parent::testImmutableProperties($valid_values); } diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntityViewDisplayValidationTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityViewDisplayValidationTest.php index 1b5797f0101db6dce9c1d0910e44c007a06fb90e..22a5bdc716b5c09d210d4f9d0a7980f5c504a0d3 100644 --- a/core/tests/Drupal/KernelTests/Core/Entity/EntityViewDisplayValidationTest.php +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityViewDisplayValidationTest.php @@ -91,7 +91,7 @@ public function testTargetBundleMustExist(): void { /** * {@inheritdoc} */ - public function testImmutableProperties(array $valid_values = []): void { + public function testImmutableProperties(array $valid_values = [], ?array $additional_expected_validation_errors_when_modified = NULL): void { parent::testImmutableProperties([ 'id' => 'entity_test_with_bundle.two.full', 'targetEntityType' => 'entity_test_with_bundle', diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntityViewModeValidationTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityViewModeValidationTest.php index 1f5e3bca4b34d0c3250eaa13ec15bc4be7c05248..61ad67001435933589470ef06a65311ddcfcc15f 100644 --- a/core/tests/Drupal/KernelTests/Core/Entity/EntityViewModeValidationTest.php +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityViewModeValidationTest.php @@ -39,7 +39,7 @@ protected function setUp(): void { /** * {@inheritdoc} */ - public function testImmutableProperties(array $valid_values = []): void { + public function testImmutableProperties(array $valid_values = [], ?array $additional_expected_validation_errors_when_modified = NULL): void { $valid_values['id'] = 'user.test_changed'; parent::testImmutableProperties($valid_values); } diff --git a/core/tests/Drupal/KernelTests/Core/Extension/ExtensionExistsConstraintValidatorTest.php b/core/tests/Drupal/KernelTests/Core/Extension/ExtensionExistsConstraintValidatorTest.php index 6471e2e60e8038f39856de9192c62d1d34c26e9a..b364a89867a7386b506c3ff0ad3081d5d4e832ae 100644 --- a/core/tests/Drupal/KernelTests/Core/Extension/ExtensionExistsConstraintValidatorTest.php +++ b/core/tests/Drupal/KernelTests/Core/Extension/ExtensionExistsConstraintValidatorTest.php @@ -47,8 +47,13 @@ public function testValidation(): void { $this->enableModules(['user']); $this->assertCount(0, $data->validate()); - // NULL should not trigger a validation error: a value may be nullable. - $data->setValue(NULL); + // Special case: the `core` module — this is not a real module but is the + // official module-like extension that provides many plugins. + $data = $typed_data->create($definition, 'core'); + $this->assertCount(0, $data->validate()); + // Special case: `NULL` — validation constraints should be compatible with + // optional values. + $data = $typed_data->create($definition, NULL); $this->assertCount(0, $data->validate()); $definition->setConstraints(['ExtensionExists' => 'theme']);