diff --git a/src/Entity/ProjectSystem/Project.php b/src/Entity/ProjectSystem/Project.php index 352d42c4..f202beaa 100644 --- a/src/Entity/ProjectSystem/Project.php +++ b/src/Entity/ProjectSystem/Project.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\Entity\ProjectSystem; use App\Repository\Parts\DeviceRepository; +use App\Validator\Constraints\UniqueObjectCollection; use Doctrine\DBAL\Types\Types; use App\Entity\Attachments\ProjectAttachment; use App\Entity\Base\AbstractStructuralDBElement; @@ -56,6 +57,8 @@ class Project extends AbstractStructuralDBElement #[Assert\Valid] #[Groups(['extended', 'full'])] #[ORM\OneToMany(targetEntity: ProjectBOMEntry::class, mappedBy: 'project', cascade: ['persist', 'remove'], orphanRemoval: true)] + #[UniqueObjectCollection(fields: ['part'], message: 'project.bom_entry.part_already_in_bom')] + #[UniqueObjectCollection(fields: ['name'], message: 'project.bom_entry.name_already_in_bom')] protected Collection $bom_entries; #[ORM\Column(type: Types::INTEGER)] diff --git a/src/Entity/ProjectSystem/ProjectBOMEntry.php b/src/Entity/ProjectSystem/ProjectBOMEntry.php index af974b5f..8955b1cf 100644 --- a/src/Entity/ProjectSystem/ProjectBOMEntry.php +++ b/src/Entity/ProjectSystem/ProjectBOMEntry.php @@ -22,6 +22,7 @@ declare(strict_types=1); namespace App\Entity\ProjectSystem; +use App\Validator\UniqueValidatableInterface; use Doctrine\DBAL\Types\Types; use App\Entity\Base\AbstractDBElement; use App\Entity\Base\TimestampTrait; @@ -38,12 +39,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; /** * The ProjectBOMEntry class represents an entry in a project's BOM. */ -#[UniqueEntity(fields: ['part', 'project'], message: 'project.bom_entry.part_already_in_bom')] -#[UniqueEntity(fields: ['name', 'project'], message: 'project.bom_entry.name_already_in_bom', ignoreNull: true)] #[ORM\HasLifecycleCallbacks] #[ORM\Entity] #[ORM\Table('project_bom_entries')] -class ProjectBOMEntry extends AbstractDBElement +class ProjectBOMEntry extends AbstractDBElement implements UniqueValidatableInterface { use TimestampTrait; @@ -270,4 +269,11 @@ class ProjectBOMEntry extends AbstractDBElement } + public function getComparableFields(): array + { + return [ + 'name' => $this->getName(), + 'part' => $this->getPart()?->getID(), + ]; + } } diff --git a/src/Validator/Constraints/UniqueObjectCollection.php b/src/Validator/Constraints/UniqueObjectCollection.php new file mode 100644 index 00000000..574959a8 --- /dev/null +++ b/src/Validator/Constraints/UniqueObjectCollection.php @@ -0,0 +1,62 @@ +. + */ + +namespace App\Validator\Constraints; + +use InvalidArgumentException; +use Symfony\Component\Validator\Constraint; + +#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +class UniqueObjectCollection extends Constraint +{ + public const IS_NOT_UNIQUE = '7911c98d-b845-4da0-94b7-a8dac36bc55a'; + + public array|string $fields = []; + + protected const ERROR_NAMES = [ + self::IS_NOT_UNIQUE => 'IS_NOT_UNIQUE', + ]; + + public string $message = 'This collection should contain only unique elements.'; + public $normalizer; + + /** + * @param array|string $fields the combination of fields that must contain unique values or a set of options + */ + public function __construct( + array $options = null, + string $message = null, + callable $normalizer = null, + array $groups = null, + mixed $payload = null, + array|string $fields = null, + public bool $allowNull = true, + ) { + parent::__construct($options, $groups, $payload); + + $this->message = $message ?? $this->message; + $this->normalizer = $normalizer ?? $this->normalizer; + $this->fields = $fields ?? $this->fields; + + if (null !== $this->normalizer && !\is_callable($this->normalizer)) { + throw new InvalidArgumentException(sprintf('The "normalizer" option must be a valid callable ("%s" given).', get_debug_type($this->normalizer))); + } + } +} \ No newline at end of file diff --git a/src/Validator/Constraints/UniqueObjectCollectionValidator.php b/src/Validator/Constraints/UniqueObjectCollectionValidator.php new file mode 100644 index 00000000..60486c2b --- /dev/null +++ b/src/Validator/Constraints/UniqueObjectCollectionValidator.php @@ -0,0 +1,113 @@ +. + */ + +namespace App\Validator\Constraints; + +use App\Entity\Base\AbstractDBElement; +use App\Validator\UniqueValidatableInterface; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Serializer; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; +use Symfony\Component\Validator\Exception\UnexpectedValueException; + +class UniqueObjectCollectionValidator extends ConstraintValidator +{ + + public function validate(mixed $value, Constraint $constraint) + { + if (!$constraint instanceof UniqueObjectCollection) { + throw new UnexpectedTypeException($constraint, UniqueObjectCollection::class); + } + + $fields = (array) $constraint->fields; + + if (null === $value) { + return; + } + + if (!\is_array($value) && !$value instanceof \IteratorAggregate) { + throw new UnexpectedValueException($value, 'array|IteratorAggregate'); + } + + $collectionElements = []; + $normalizer = $this->getNormalizer($constraint); + foreach ($value as $key => $object) { + + if (!$object instanceof UniqueValidatableInterface) { + throw new UnexpectedValueException($object, UniqueValidatableInterface::class); + } + + //Convert the object to an array using the helper function + $element = $object->getComparableFields(); + + if ($fields && !$element = $this->reduceElementKeys($fields, $element, $constraint)) { + continue; + } + + $element = $normalizer($element); + + if (\in_array($element, $collectionElements, true)) { + + $violation = $this->context->buildViolation($constraint->message); + + $violation->atPath('[' . $key . ']' . '.' . $constraint->fields[0]); + + $violation->setParameter('{{ value }}', $this->formatValue($value)) + ->setCode(UniqueObjectCollection::IS_NOT_UNIQUE) + ->addViolation(); + + return; + } + $collectionElements[] = $element; + } + } + + private function getNormalizer(UniqueObjectCollection $unique): callable + { + if (null === $unique->normalizer) { + return static fn ($value) => $value; + } + + return $unique->normalizer; + } + + private function reduceElementKeys(array $fields, array $element, UniqueObjectCollection $constraint): array + { + $output = []; + foreach ($fields as $field) { + if (!\is_string($field)) { + throw new UnexpectedTypeException($field, 'string'); + } + if (\array_key_exists($field, $element)) { + //Ignore null values if specified + if ($element[$field] === null && $constraint->allowNull) { + continue; + } + + $output[$field] = $element[$field]; + } + } + + return $output; + } + +} \ No newline at end of file diff --git a/src/Validator/UniqueValidatableInterface.php b/src/Validator/UniqueValidatableInterface.php new file mode 100644 index 00000000..97e3a0b9 --- /dev/null +++ b/src/Validator/UniqueValidatableInterface.php @@ -0,0 +1,32 @@ +. + */ + +namespace App\Validator; + +interface UniqueValidatableInterface +{ + /** + * This method should return an array of fields that should be used to compare the objects for the UniqueObjectCollection constraint. + * All instances of the same class should return the same fields. + * The value must be a comparable value (e.g. string, int, float, bool, null). + * @return array An array of the form ['field1' => 'value1', 'field2' => 'value2', ...] + */ + public function getComparableFields(): array; +} \ No newline at end of file diff --git a/templates/form/collection_types_layout.html.twig b/templates/form/collection_types_layout.html.twig index 53311061..9cc1297b 100644 --- a/templates/form/collection_types_layout.html.twig +++ b/templates/form/collection_types_layout.html.twig @@ -38,16 +38,16 @@ - {{ form_errors(form.quantity) }} {{ form_widget(form.quantity) }} + {{ form_errors(form.quantity) }} - {{ form_errors(form.part) }} {{ form_widget(form.part) }} + {{ form_errors(form.part) }} - {{ form_errors(form.name) }} {{ form_widget(form.name) }} + {{ form_errors(form.name) }}