I'm quite new to Zend and unit testing in general. I have come up with a small application that uses Zend Framework 2 and Doctrine. It has only one model and controller and I want to run some unit tests on them.
Here's what I have so far:
Base doctrine 'entity' class, containing methods I want to use in all of my entities:
<?php
/**
* Base entity class containing some functionality that will be used by all
* entities
*/
namespace Perceptive\Database;
use Zend\Validator\ValidatorChain;
class Entity{
//An array of validators for various fields in this entity
protected $validators;
/**
* Returns the properties of this object as an array for ease of use. Will
* return only properties with the ORM\Column annotation as this way we know
* for sure that it is a column with data associated, and won't pick up any
* other properties.
* #return array
*/
public function toArray(){
//Create an annotation reader so we can read annotations
$reader = new \Doctrine\Common\Annotations\AnnotationReader();
//Create a reflection class and retrieve the properties
$reflClass = new \ReflectionClass($this);
$properties = $reflClass->getProperties();
//Create an array in which to store the data
$array = array();
//Loop through each property. Get the annotations for each property
//and add to the array to return, ONLY if it contains an ORM\Column
//annotation.
foreach($properties as $property){
$annotations = $reader->getPropertyAnnotations($property);
foreach($annotations as $annotation){
if($annotation instanceof \Doctrine\ORM\Mapping\Column){
$array[$property->name] = $this->{$property->name};
}
}
}
//Finally, return the data array to the user
return $array;
}
/**
* Updates all of the values in this entity from an array. If any property
* does not exist a ReflectionException will be thrown.
* #param array $data
* #return \Perceptive\Database\Entity
*/
public function fromArray($data){
//Create an annotation reader so we can read annotations
$reader = new \Doctrine\Common\Annotations\AnnotationReader();
//Create a reflection class and retrieve the properties
$reflClass = new \ReflectionClass($this);
//Loop through each element in the supplied array
foreach($data as $key=>$value){
//Attempt to get at the property - if the property doesn't exist an
//exception will be thrown here.
$property = $reflClass->getProperty($key);
//Access the property's annotations
$annotations = $reader->getPropertyAnnotations($property);
//Loop through all annotations to see if this is actually a valid column
//to update.
$isColumn = false;
foreach($annotations as $annotation){
if($annotation instanceof \Doctrine\ORM\Mapping\Column){
$isColumn = true;
}
}
//If it is a column then update it using it's setter function. Otherwise,
//throw an exception.
if($isColumn===true){
$func = 'set'.ucfirst($property->getName());
$this->$func($data[$property->getName()]);
}else{
throw new \Exception('You cannot update the value of a non-column using fromArray.');
}
}
//return this object to facilitate a 'fluent' interface.
return $this;
}
/**
* Validates a field against an array of validators. Returns true if the value is
* valid or an error string if not.
* #param string $fieldName The name of the field to validate. This is only used when constructing the error string
* #param mixed $value
* #param array $validators
* #return boolean|string
*/
protected function setField($fieldName, $value){
//Create a validator chain
$validatorChain = new ValidatorChain();
$validators = $this->getValidators();
//Try to retrieve the validators for this field
if(array_key_exists($fieldName, $this->validators)){
$validators = $this->validators[$fieldName];
}else{
$validators = array();
}
//Add all validators to the chain
foreach($validators as $validator){
$validatorChain->attach($validator);
}
//Check if the value is valid according to the validators. Return true if so,
//or an error string if not.
if($validatorChain->isValid($value)){
$this->{$fieldName} = $value;
return $this;
}else{
$err = 'The '.$fieldName.' field was not valid: '.implode(',',$validatorChain->getMessages());
throw new \Exception($err);
}
}
}
My 'config' entity, which represents a one-row table containing some configuration options:
<?php
/**
* #todo: add a base entity class which handles validation via annotations
* and includes toArray function. Also needs to get/set using __get and __set
* magic methods. Potentially add a fromArray method?
*/
namespace Application\Entity;
use Doctrine\ORM\Mapping as ORM;
use Zend\Validator;
use Zend\I18n\Validator as I18nValidator;
use Perceptive\Database\Entity;
/**
* #ORM\Entity
* #ORM\HasLifecycleCallbacks
*/
class Config extends Entity{
/**
* #ORM\Id
* #ORM\Column(type="integer")
*/
protected $minLengthUserId;
/**
* #ORM\Id
* #ORM\Column(type="integer")
*/
protected $minLengthUserName;
/**
* #ORM\Id
* #ORM\Column(type="integer")
*/
protected $minLengthUserPassword;
/**
* #ORM\Id
* #ORM\Column(type="integer")
*/
protected $daysPasswordReuse;
/**
* #ORM\Id
* #ORM\Column(type="boolean")
*/
protected $passwordLettersAndNumbers;
/**
* #ORM\Id
* #ORM\Column(type="boolean")
*/
protected $passwordUpperLower;
/**
* #ORM\Id
* #ORM\Column(type="integer")
*/
protected $maxFailedLogins;
/**
* #ORM\Id
* #ORM\Column(type="integer")
*/
protected $passwordValidity;
/**
* #ORM\Id
* #ORM\Column(type="integer")
*/
protected $passwordExpiryDays;
/**
* #ORM\Id
* #ORM\Column(type="integer")
*/
protected $timeout;
// getters/setters
/**
* Get the minimum length of the user ID
* #return int
*/
public function getMinLengthUserId(){
return $this->minLengthUserId;
}
/**
* Set the minmum length of the user ID
* #param int $minLengthUserId
* #return \Application\Entity\Config This object
*/
public function setMinLengthUserId($minLengthUserId){
//Use the setField function, which checks whether the field is valid,
//to set the value.
return $this->setField('minLengthUserId', $minLengthUserId);
}
/**
* Get the minimum length of the user name
* #return int
*/
public function getminLengthUserName(){
return $this->minLengthUserName;
}
/**
* Set the minimum length of the user name
* #param int $minLengthUserName
* #return \Application\Entity\Config
*/
public function setMinLengthUserName($minLengthUserName){
//Use the setField function, which checks whether the field is valid,
//to set the value.
return $this->setField('minLengthUserName', $minLengthUserName);
}
/**
* Get the minimum length of the user password
* #return int
*/
public function getMinLengthUserPassword(){
return $this->minLengthUserPassword;
}
/**
* Set the minimum length of the user password
* #param int $minLengthUserPassword
* #return \Application\Entity\Config
*/
public function setMinLengthUserPassword($minLengthUserPassword){
//Use the setField function, which checks whether the field is valid,
//to set the value.
return $this->setField('minLengthUserPassword', $minLengthUserPassword);
}
/**
* Get the number of days before passwords can be reused
* #return int
*/
public function getDaysPasswordReuse(){
return $this->daysPasswordReuse;
}
/**
* Set the number of days before passwords can be reused
* #param int $daysPasswordReuse
* #return \Application\Entity\Config
*/
public function setDaysPasswordReuse($daysPasswordReuse){
//Use the setField function, which checks whether the field is valid,
//to set the value.
return $this->setField('daysPasswordReuse', $daysPasswordReuse);
}
/**
* Get whether the passwords must contain letters and numbers
* #return boolean
*/
public function getPasswordLettersAndNumbers(){
return $this->passwordLettersAndNumbers;
}
/**
* Set whether passwords must contain letters and numbers
* #param int $passwordLettersAndNumbers
* #return \Application\Entity\Config
*/
public function setPasswordLettersAndNumbers($passwordLettersAndNumbers){
//Use the setField function, which checks whether the field is valid,
//to set the value.
return $this->setField('passwordLettersAndNumbers', $passwordLettersAndNumbers);
}
/**
* Get whether password must contain upper and lower case characters
* #return type
*/
public function getPasswordUpperLower(){
return $this->passwordUpperLower;
}
/**
* Set whether password must contain upper and lower case characters
* #param type $passwordUpperLower
* #return \Application\Entity\Config
*/
public function setPasswordUpperLower($passwordUpperLower){
//Use the setField function, which checks whether the field is valid,
//to set the value.
return $this->setField('passwordUpperLower', $passwordUpperLower);
}
/**
* Get the number of failed logins before user is locked out
* #return int
*/
public function getMaxFailedLogins(){
return $this->maxFailedLogins;
}
/**
* Set the number of failed logins before user is locked out
* #param int $maxFailedLogins
* #return \Application\Entity\Config
*/
public function setMaxFailedLogins($maxFailedLogins){
//Use the setField function, which checks whether the field is valid,
//to set the value.
return $this->setField('maxFailedLogins', $maxFailedLogins);
}
/**
* Get the password validity period in days
* #return int
*/
public function getPasswordValidity(){
return $this->passwordValidity;
}
/**
* Set the password validity in days
* #param int $passwordValidity
* #return \Application\Entity\Config
*/
public function setPasswordValidity($passwordValidity){
//Use the setField function, which checks whether the field is valid,
//to set the value.
return $this->setField('passwordValidity', $passwordValidity);
}
/**
* Get the number of days prior to expiry that the user starts getting
* warning messages
* #return int
*/
public function getPasswordExpiryDays(){
return $this->passwordExpiryDays;
}
/**
* Get the number of days prior to expiry that the user starts getting
* warning messages
* #param int $passwordExpiryDays
* #return \Application\Entity\Config
*/
public function setPasswordExpiryDays($passwordExpiryDays){
//Use the setField function, which checks whether the field is valid,
//to set the value.
return $this->setField('passwordExpiryDays', $passwordExpiryDays);
}
/**
* Get the timeout period of the application
* #return int
*/
public function getTimeout(){
return $this->timeout;
}
/**
* Get the timeout period of the application
* #param int $timeout
* #return \Application\Entity\Config
*/
public function setTimeout($timeout){
//Use the setField function, which checks whether the field is valid,
//to set the value.
return $this->setField('timeout', $timeout);
}
/**
* Returns a list of validators for each column. These validators are checked
* in the class' setField method, which is inherited from the Perceptive\Database\Entity class
* #return array
*/
public function getValidators(){
//If the validators array hasn't been initialised, initialise it
if(!isset($this->validators)){
$validators = array(
'minLengthUserId' => array(
new I18nValidator\Int(),
new Validator\GreaterThan(1),
),
'minLengthUserName' => array(
new I18nValidator\Int(),
new Validator\GreaterThan(2),
),
'minLengthUserPassword' => array(
new I18nValidator\Int(),
new Validator\GreaterThan(3),
),
'daysPasswordReuse' => array(
new I18nValidator\Int(),
new Validator\GreaterThan(-1),
),
'passwordLettersAndNumbers' => array(
new I18nValidator\Int(),
new Validator\GreaterThan(-1),
new Validator\LessThan(2),
),
'passwordUpperLower' => array(
new I18nValidator\Int(),
new Validator\GreaterThan(-1),
new Validator\LessThan(2),
),
'maxFailedLogins' => array(
new I18nValidator\Int(),
new Validator\GreaterThan(0),
),
'passwordValidity' => array(
new I18nValidator\Int(),
new Validator\GreaterThan(1),
),
'passwordExpiryDays' => array(
new I18nValidator\Int(),
new Validator\GreaterThan(1),
),
'timeout' => array(
new I18nValidator\Int(),
new Validator\GreaterThan(0),
)
);
$this->validators = $validators;
}
//Return the list of validators
return $this->validators;
}
/**
* #todo: add a lifecyle event which validates before persisting the entity.
* This way there is no chance of invalid values being saved to the database.
* This should probably be implemented in the parent class so all entities know
* to validate.
*/
}
And my controller, which can read from and write to the entity:
<?php
/**
* A restful controller that retrieves and updates configuration information
*/
namespace Application\Controller;
use Zend\Mvc\Controller\AbstractRestfulController;
use Zend\View\Model\JsonModel;
class ConfigController extends AbstractRestfulController
{
/**
* The doctrine EntityManager for use with database operations
* #var \Doctrine\ORM\EntityManager
*/
protected $em;
/**
* Constructor function manages dependencies
* #param \Doctrine\ORM\EntityManager $em
*/
public function __construct(\Doctrine\ORM\EntityManager $em){
$this->em = $em;
}
/**
* Retrieves the configuration from the database
*/
public function getList(){
//locate the doctrine entity manager
$em = $this->em;
//there should only ever be one row in the configuration table, so I use findAll
$config = $em->getRepository("\Application\Entity\Config")->findAll();
//return a JsonModel to the user. I use my toArray function to convert the doctrine
//entity into an array - the JsonModel can't handle a doctrine entity itself.
return new JsonModel(array(
'data' => $config[0]->toArray(),
));
}
/**
* Updates the configuration
*/
public function replaceList($data){
//locate the doctrine entity manager
$em = $this->em;
//there should only ever be one row in the configuration table, so I use findAll
$config = $em->getRepository("\Application\Entity\Config")->findAll();
//use the entity's fromArray function to update the data
$config[0]->fromArray($data);
//save the entity to the database
$em->persist($config[0]);
$em->flush();
//return a JsonModel to the user. I use my toArray function to convert the doctrine
//entity into an array - the JsonModel can't handle a doctrine entity itself.
return new JsonModel(array(
'data' => $config[0]->toArray(),
));
}
}
Because of character limits on I was unable to paste in my unit tests, but here are links to my unit tests so far:
For the entity:
https://github.com/hputus/config-app/blob/master/module/Application/test/ApplicationTest/Entity/ConfigTest.php
For the controller:
https://github.com/hputus/config-app/blob/master/module/Application/test/ApplicationTest/Controller/ConfigControllerTest.php
Some questions:
Am I doing anything obviously wrong here?
In the tests for the entity, I am repeating the same tests for many different fields - is there a way to minimise this? Like have a standard battery of tests to run on integer columns for instance?
In the controller I am trying to 'mock up' doctrine's entity manager so that changes aren't really saved into the database - am I doing this properly?
Is there anything else in the controller which I should test?
Thanks in advance!
While your code appears to be solid enough, it presents a couple of design oversights.
First of all, Doctrine advise to treat entities like simple, dumb value objects, and states that the data they hold is always assumed to be valid.
This means that any business logic, like hydration, filtering and validation, should be moved outside entities to a separate layer.
Speaking of hydration, rather than implementing by yourself fromArray and toArray methods, you could use the supplied DoctrineModule\Stdlib\Hydrator\DoctrineObject hydrator, which can also blend flawlessly with Zend\InputFilter, to handle filtering and validation. This would make entity testing much much less verbose, and arguably not so needed, since you would test the filter separately.
Another important suggestion coming from Doctrine devs is to not inject an ObjectManager directly inside controllers. This is for encapsulation purposes: it is desirable to hide implementation details of your persistence layer to the Controller and, again, expose only an intermediate layer.
In your case, all this could be done by having a ConfigService class, designed by contract, which will only provide the methods you really need (i.e. findAll(), persist() and other handy proxies), and will hide the dependencies that are not strictly needed by the controller, like the EntityManager, input filters and the like. It will also contribute to easier mocking.
This way, if one day you would want to do some changes in your persistence layer, you would just have to change how your entity service implements its contract: think about adding a custom cache adapter, or using Doctrine's ODM rather than the ORM, or even not using Doctrine at all.
Other than that, your unit testing approach looks fine.
TL;DR
You should not embed business logic inside Doctrine entities.
You should use hydrators with input filters together.
You should not inject the EntityManager inside controllers.
An intermediate layer would help implementing these variations, preserving at the same time Model and Controller decoupling.
Your tests look very similar to ours, so there's nothing immediately obvious that you are doing incorrectly. :)
I agree that this "smells" a bit weird, but I don't have an answer for you on this one. Our standard is to make all of our models "dumb" and we do not test them. This is not something I recommend, but because I havent encountered your scenario before I don't want to just guess.
You seem to be testing pretty exhaustively, although I would really recommend checking out the mocking framework: Phake (http://phake.digitalsandwich.com/docs/html/) It really helps to seperate your assertions from your mocking, as well as provides a much more digestable syntax than the built in phpunit mocks.
good luck!
Related
I have the structure like below.
----------------
MESSAGE
----------------
id
subject
body
----------------
----------------
USER
----------------
id
name
category
region
----------------
----------------
RECIPIENT
----------------
user_id
message_id
is_read
read_at
----------------
So Message 1:n Recipient m:1 User.
Recipient is not an #ApiResource.
A Backoffice user will "write" a message and choose the audience by a set of specific criteria (user region, user category, user tags...).
To POST the message i'm using a Dto
class MessageInputDto
{
/**
* #var string
*
* #Groups({"msg_message:write"})
*/
public string $subject;
/**
* #var string
*
* #Groups({"msg_message:write"})
*/
public string $body;
/**
* #var bool
*
* #Groups({"msg_message:write"})
*/
public bool $isPublished;
/**
* #var DateTimeInterface
*
* #Groups({"msg_message:write"})
*/
public DateTimeInterface $publishDate;
/**
* #var DateTimeInterface|null
*
* #Groups({"msg_message:write"})
*/
public ?DateTimeInterface $expiryDate = null;
/**
* #var MessageCategory|null
*
* #Groups({"msg_message:write"})
*/
public ?MessageCategory $category = null;
/**
* #var array
*/
public array $criteria = [];
}
The $criteria field is used to choose the audience of that message and is skipped by the DataTransformer as it is not a mapped field, a property of Message Entity that is returned by the transformer.
class MessageInputDataTransformer implements \ApiPlatform\Core\DataTransformer\DataTransformerInterface
{
/**
* #var MessageInputDto $object
* #inheritDoc
*/
public function transform($object, string $to, array $context = [])
{
$message = new Message($object->subject, $object->body);
$message->setIsPublished($object->isPublished);
$message->setPublishDate($object->publishDate);
$message->setExpiryDate($object->expiryDate);
$message->setCategory($object->category);
return $message;
}
/**
* #inheritDoc
*/
public function supportsTransformation($data, string $to, array $context = []): bool
{
// in the case of an input, the value given here is an array (the JSON decoded).
// if it's a book we transformed the data already
if ($data instanceof Message) {
return false;
}
return Message::class === $to && null !== ($context['input']['class'] ?? null);
}
}
As side effect, will be performed a bulk insert in the join table (Recipient) that keeps the m:n relations between Message and User.
My problem is how/where to perform this bulk insert and how pass the $criteria to the service that will manage it.
The only solution that i've found now (and it's working but i don't think is a good practice) is to put the bulk insert procedure in the POST_WRITE event of the Message, get the Request object and process the $criteria contained there.
class MessageSubscriber implements EventSubscriberInterface
{
/**
* #inheritDoc
*/
public static function getSubscribedEvents()
{
return [
KernelEvents::VIEW => [
['handleCriteria', EventPriorities::POST_WRITE]
],
];
}
public function handleCriteria(ViewEvent $event)
{
/** #var Message $message */
$message = $event->getControllerResult();
$method = $event->getRequest()->getMethod();
$e = $event->getRequest();
$collectionOperation = $e->get('_api_collection_operation_name');
if (!$message instanceof Message ||
$method !== Request::METHOD_POST ||
$collectionOperation !== 'post') {
return;
}
$content = json_decode($event->getRequest()->getContent(), true);
if(array_key_exists('audienceCriteria', $content)){
$criteria = Criteria::createFromArray($content['audienceCriteria']);
// Todo: Create the audience
}
}
}
So the idea is that, when the Message is persisted, the system must generate the "relations" public.
This is why i think that the post write event could be a good choice, but as i said i'm not sure this could be a good practice.
Any idea? Thanks.
As the docs on DTO's state: "in most cases the DTO pattern should be implemented using an API Resource class representing the public data model exposed through the API and a custom data provider. In such cases, the class marked with #ApiResource will act as a DTO."
IOW specifying an Input or an Output Data Representation and a DataTransformer is the exception. It does not work if the DTO holds more data then the entity or if the dto's are not one to one with the entities (for example with a report that does a group by).
Here is your DTO class as a resource:
namespace App\DTO;
use ApiPlatform\Core\Annotation\ApiResource;
use Symfony\Component\Serializer\Annotation\Groups;
use App\Entity\Message;
/**
* Class defining Message data transfer
*
* #ApiResource(
* denormalizationContext= {"groups" = {"msg_message:write"}},
* itemOperations={
* },
* collectionOperations={
* "post"={
* "path"="/messages",
* "openapi_context" = {
* "summary" = "Creates a Message",
* "description" = "Creates a Message"
* }
* }
* },
* output=Message::class
* )
*/
class MessageInputDto
{
/**
* #var string
*
* #Groups({"msg_message:write"})
*/
public string $subject;
/**
* #var string
*
* #Groups({"msg_message:write"})
*/
public string $body;
/**
* #var bool
*
* #Groups({"msg_message:write"})
*/
public bool $isPublished;
/**
* #var \DateTimeInterface
*
* #Groups({"msg_message:write"})
*/
public \DateTimeInterface $publishDate;
/**
* #var \DateTimeInterface|null
*
* #Groups({"msg_message:write"})
*/
public ?\DateTimeInterface $expiryDate = null;
/**
* #var MessageCategory|null
*
* #Groups({"msg_message:write"})
*/
public ?MessageCategory $category = null;
/**
* #var array
* #Groups({"msg_message:write"})
*/
public array $criteria = [];
}
Make sure the folder your class is in is in the paths list in api/config/packages/api_platform.yaml. There usually is the following configuration:
api_platform:
mapping:
paths: ['%kernel.project_dir%/src/Entity']
If MessageInputDto is in /src/DTO make it like:
api_platform:
mapping:
paths:
- '%kernel.project_dir%/src/Entity'
- '%kernel.project_dir%/src/DTO'
The post operation may have the same path as dhe default post operation on your Message resource. Remove that by explicitly defining collectionOperations for your Message resource without "post".
The post operation of MessageInputDto will deserialize the MessageInputDto. Your DataTransformer will not act on it so that it will arrive as is to the DataPersister:
namespace App\DataPersister;
use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
use App\DTO\MessageInputDto;
use App\Entity\Message;
use Doctrine\Persistence\ManagerRegistry;
use App\DataTransformer\MessageInputDataTransformer;
use ApiPlatform\Core\Exception\InvalidArgumentException;
class MessageDataPersister implements ContextAwareDataPersisterInterface
{
private $dataPersister;
private $entityManager;
private $dataTransformer;
public function __construct(ContextAwareDataPersisterInterface $dataPersister, ManagerRegistry $managerRegistry, MessageInputDataTransformer $dataTransformer)
{
$this->dataPersister = $dataPersister;
$this->entityManager = $managerRegistry->getManagerForClass(Message::class);
$this->dataTransformer = $dataTransformer;
}
public function supports($data, array $context = []): bool
{
$transformationContext = ['input' => ['class' => Message::class]];
return get_class($data) == MessageInputDto::class
&& $this->dataTransformer->supportsTransformation($data, Message::class, $transformationContext)
&& null !== $this->entityManager;
}
public function persist($data, array $context = [])
{
$message = $this->dataTransformer->transform($data, Message::class);
// dataPersister will flush the entityManager but we do not want incomplete data inserted
$this->entityManager->beginTransaction();
$commit = true;
$result = $this->dataPersister->persist($message, []);
if(!empty($data->criteria)){
$criteria = Criteria::createFromArray($data->criteria);
try {
// Todo: Create the audience, preferably with a single INSERT query SELECTing FROM user_table WHERE meeting the criteria
// (Or maybe better postpone until message is really sent, user region, category, tags may change over time)
} catch (\Exception $e) {
$commit = false;
$this->entityManager->rollback();
}
}
if ($commit) {
$this->entityManager->commit();
}
return $result;
}
public function remove($data, array $context = [])
{
throw new InvalidArgumentException('Operation not supported: delete');
}
}
(Maybe it should have been called MessageInputDtoDataPersister - depending on how you look at it)
Even with service autowiring and autoconfiguration enabled, you must still configure it to get the right dataPersister to delegate to:
# api/config/services.yaml
services:
# ...
'App\DataPersister\MessageDataPersister':
arguments:
$dataPersister: '#api_platform.doctrine.orm.data_persister'
This way you do not need MessageSubscriber.
Be aware that all the other phases inbetween deserialization and data persist (validation, security post denormalize) work on the MessageInputDto.
One solution when you have to generate multiple custom entities is to use data persisters: https://api-platform.com/docs/core/data-persisters/
There you have 2 options:
Decorate the doctrine persister - meaning the message will still be saved by Doctrine, but you can do something before or afterwards.
Implement a custom persister - saving both message and other related entities that you like. Or doing something completely custom, without calling Doctrine at all.
I have the following structure:
Category property that contains link to property and its value:
<?php
class CategoryProperty
{
// ...
/**
* #var Property
*
* #ORM\ManyToOne(targetEntity="App\Entity\Property")
* #ORM\JoinColumn(onDelete="cascade", nullable=false)
*/
private $property;
/**
* Набор значений свойства доступных в product builder, null если любое значение.
*
* #var PropertyValueEntry
* #Assert\Valid
*
* #ORM\OneToOne(targetEntity="App\Entity\Properties\PropertyValues\PropertyValueEntry",
* cascade={"persist", "remove"})
*/
private $propertyValue;
// ...
}
Abstract property value type with a discriminator map:
<?php
/**
* #ORM\Entity
* #ORM\InheritanceType("JOINED")
* #ORM\DiscriminatorColumn(name="type", type="integer")
* #ORM\DiscriminatorMap({
* "1": "StringValue",
* "2": "IntegerValue",
* "3": "BooleanValue",
* "4": "TextValue",
* "6": "EnumValue",
* "7": "SetValue",
* "9": "LengthValue",
* "10": "AreaValue",
* "11": "VolumeValue",
* "12": "MassValue",
* })
* #ORM\Table(name="properties_values__value_entry")
*/
abstract class PropertyValueEntry
{
/**
* #var Property
*
* #ORM\ManyToOne(targetEntity="App\Entity\Property")
*/
private $property;
public function __construct(Property $property)
{
$this->property = $property;
}
public function getProperty(): Property
{
return $this->property;
}
/**
* #return mixed
*/
abstract public function getValue();
/**
* #param mixed $value
*/
abstract public function setValue($value): void;
}
And a sample concrete value type:
<?php
/**
* #ORM\Entity
* #ORM\Table(name="properties_values__integer_value")
*/
class IntegerValue extends PropertyValueEntry
{
/**
* #var int
* #Assert\NotNull
*
* #ORM\Column(type="integer")
*/
private $value = 0;
public function getValue(): int
{
return $this->value;
}
/**
* #param int|null $value
*/
public function setValue($value): void
{
if (!\is_int($value)) {
throw new InvalidArgumentException('BooleanValue accepts integer values only');
}
$this->value = $value;
}
}
For some reason, when form is submitted, instead of updating a value for IntegerValue, a new entity gets created, and new row in properties_values__value_entry / properties_values__integer_value. I tried tracking through the $this->em->persist($entity), where $entity is CategoryProperty, and it seems that IntegerValue gets marked as dirty and created anew. How can I track the cause of this happening? My form processing is pretty standard:
<?php
public function editAction(): Response
{
$id = $this->request->query->get('id');
$easyadmin = $this->request->attributes->get('easyadmin');
$entity = $easyadmin['item'];
$isReload = 'reload' === $this->request->request->get('action');
$editForm = $this->createForm(CategoryPropertyType::class, $entity, [
'category' => $this->getCatalog(),
'is_reload' => $isReload,
]);
$deleteForm = $this->createDeleteForm($this->entity['name'], $id);
$editForm->handleRequest($this->request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
if (!$isReload) {
$this->em->persist($entity);
$this->em->flush();
return $this->redirectToReferrer();
}
}
return $this->render($this->entity['templates']['edit'], [
'form' => $editForm->createView(),
'entity' => $entity,
'delete_form' => $deleteForm->createView(),
]);
}
UPDATE #1
What I already tried:
Retrieve category property by ID from entity manager through
$entity = $this->em->find(CategoryProperty::class, $id);
Altogether it seems this may be related to the fact that I have a dynamic form being created based on the selection. When I add a category property, I display a dropdown with a list of property types (integer, string, area, volume etc), and after selection a new form for that property is displayed. Though this works fine and adds new property without a problem, it seems that the code for EDITING same property is missing something, and instead of update it creates it anew.
Possibility #1: Load entity from entity manager directly
You don't appear to be retrieving an existing entity to modify at all.
$entity = $easyadmin['item'];
Shouldn't this be using Doctrine to retrieve an existing entity? For example:
if (!($entity = $this->getRepository(CategoryProperty::class)->findOneById($id))) {
throw $this->createNotFoundException("Category property not found.");
}
Semi-related: You may also want to check that a integer ID was specified at all, as $id = $this->request->query->get('id'); is very assumptive:
if (intval($id = $this->request->query->get('id')) < 1) {
throw $this->createNotFoundException("Category property not specified.");
}
Possibility 2: Missing identifier reference with one-to-one relationship
I think you may be getting duplication because CategoryProperty doesn't persist any reference to a PropertyValueEntry.
/**
* Набор значений свойства доступных в product builder, null если любое значение.
*
* #var PropertyValueEntry
* #Assert\Valid
*
* #ORM\OneToOne(targetEntity="App\Entity\Properties\PropertyValues\PropertyValueEntry", cascade={"persist", "remove"})
*/
private $propertyValue;
However PropertyValueEntry doesn't have an inverse relationship back to CategoryProperty.
A unidirectional one-to-one is fine, but it must have a #ORM\JoinColumn directive to ensure the identifier of the foreign PropertyValueEntry is persisted. Otherwise an edit form won't have any information to know which existing PropertyValueEntry (or derivative) it needs to edit. This is why your "properties_values__value_entry" form field is being reset with a new instance of PropertyValueEntry (or derivative) created when submitting the form.
You've not shown the source for entity class Property so I can't inspect for any further issues in your entity relationships.
Thanks to everyone participating, I have been reading through Symfony documentation and came across the 'by_reference' form attribute. Having considered that my form structure overall looks like this:
Category => CategoryPropertyType => PropertyValueType => [Set, Enum, Integer, Boolean, String, Volume]
for the form, I decided to set it to true in PropertyValueType configureOptions method. As it is explained in the documentation, with it being set to false, the entity is getting cloned (which in my case is true), thus creating a new object at the end.
Note that I'm still learning Symfony and will be refining the answer when I get a better understanding of what's going on behind the scenes.
So I'm using the Laravel model event observerables to fire custom event logic, but they only accept the model as a single argument. What I'd like to do is call a custom event that I can also pass some extra arguments to that would in turn get passed to the Observer method. Something like this:
$this->fireModelEvent('applied', $user, $type);
And then in the Observer
/**
* Listen to the applied event.
*
* #param Item $item
* #param User $user
* #param string $type
* #return void
*/
public function applied(Item $item, $user, string $type) {
Event::fire(new Applied($video, $user, $type));
}
As you can see i'm interested in passing a user that performed this action, which is not the one that necessarily created the item. I don't think temporary model attributes are the answer because my additional event logic gets queued off as jobs to keep response time as low as possible. Anyone have any ideas on how I could extend Laravel to let me do this?
My theory would be to do a custom trait that overrides one or more functions in the base laravel model class that handles this logic. Thought I'd see if anyone else has needed to do this while I look into it.
Also here's the docs reference
I've accomplished this task by implementing some custom model functionality using a trait.
/**
* Stores event key data
*
* #var array
*/
public $eventData = [];
/**
* Fire the given event for the model.
*
* #param string $event
* #param bool $halt
* #param array $data
* #return mixed
*/
protected function fireModelEvent($event, $halt = true, array $data = []) {
$this->eventData[$event] = $data;
return parent::fireModelEvent($event, $halt);
}
/**
* Get the event data by event
*
* #param string $event
* #return array|NULL
*/
public function getEventData(string $event) {
if (array_key_exists($event, $this->eventData)) {
return $this->eventData[$event];
}
return NULL;
}
<?php
/**
* #link http://www.yiiframework.com/
* #copyright Copyright (c) 2008 Yii Software LLC
* #license http://www.yiiframework.com/license/
*/
namespace yii\data;
use Yii;
use yii\base\Component;
use yii\base\InvalidParamException;
/**
* BaseDataProvider provides a base class that implements the [[DataProviderInterface]].
*
* #property integer $count The number of data models in the current page. This property is read-only.
* #property array $keys The list of key values corresponding to [[models]]. Each data model in [[models]] is
* uniquely identified by the corresponding key value in this array.
* #property array $models The list of data models in the current page.
* #property Pagination|boolean $pagination The pagination object. If this is false, it means the pagination
* is disabled. Note that the type of this property differs in getter and setter. See [[getPagination()]] and
* [[setPagination()]] for details.
* #property Sort|boolean $sort The sorting object. If this is false, it means the sorting is disabled. Note
* that the type of this property differs in getter and setter. See [[getSort()]] and [[setSort()]] for details.
* #property integer $totalCount Total number of possible data models.
*
* #author Qiang Xue <qiang.xue#gmail.com>
* #since 2.0
*/
abstract class BaseDataProvider extends Component implements DataProviderInterface
{
/**
* #var string an ID that uniquely identifies the data provider among all data providers.
* You should set this property if the same page contains two or more different data providers.
* Otherwise, the [[pagination]] and [[sort]] may not work properly.
*/
public $id;
private $_sort;
private $_pagination;
private $_keys;
private $_models;
private $_totalCount;
/**
* Prepares the data models that will be made available in the current page.
* #return array the available data models
*/
abstract protected function prepareModels();
/**
* Prepares the keys associated with the currently available data models.
* #param array $models the available data models
* #return array the keys
*/
abstract protected function prepareKeys($models);
/**
* Returns a value indicating the total number of data models in this data provider.
* #return integer total number of data models in this data provider.
*/
abstract protected function prepareTotalCount();
/**
* Prepares the data models and keys.
*
* This method will prepare the data models and keys that can be retrieved via
* [[getModels()]] and [[getKeys()]].
*
* This method will be implicitly called by [[getModels()]] and [[getKeys()]] if it has not been called before.
*
* #param boolean $forcePrepare whether to force data preparation even if it has been done before.
*/
public function prepare($forcePrepare = false)
{
if ($forcePrepare || $this->_models === null) {
$this->_models = $this->prepareModels();
}
if ($forcePrepare || $this->_keys === null) {
$this->_keys = $this->prepareKeys($this->_models);
}
}
/**
* Returns the data models in the current page.
* #return array the list of data models in the current page.
*/
public function getModels()
{
$this->prepare();
return $this->_models;
}
/**
* Sets the data models in the current page.
* #param array $models the models in the current page
*/
public function setModels($models)
{
$this->_models = $models;
}
/**
* Returns the key values associated with the data models.
* #return array the list of key values corresponding to [[models]]. Each data model in [[models]]
* is uniquely identified by the corresponding key value in this array.
*/
public function getKeys()
{
$this->prepare();
return $this->_keys;
}
/**
* Sets the key values associated with the data models.
* #param array $keys the list of key values corresponding to [[models]].
*/
public function setKeys($keys)
{
$this->_keys = $keys;
}
/**
* Returns the number of data models in the current page.
* #return integer the number of data models in the current page.
*/
public function getCount()
{
return count($this->getModels());
}
/**
* Returns the total number of data models.
* When [[pagination]] is false, this returns the same value as [[count]].
* Otherwise, it will call [[prepareTotalCount()]] to get the count.
* #return integer total number of possible data models.
*/
public function getTotalCount()
{
if ($this->getPagination() === false) {
return $this->getCount();
} elseif ($this->_totalCount === null) {
$this->_totalCount = $this->prepareTotalCount();
}
return $this->_totalCount;
}
/**
* Sets the total number of data models.
* #param integer $value the total number of data models.
*/
public function setTotalCount($value)
{
$this->_totalCount = $value;
}
/**
* Returns the pagination object used by this data provider.
* Note that you should call [[prepare()]] or [[getModels()]] first to get correct values
* of [[Pagination::totalCount]] and [[Pagination::pageCount]].
* #return Pagination|boolean the pagination object. If this is false, it means the pagination is disabled.
*/
public function getPagination()
{
if ($this->_pagination === null) {
$this->setPagination([]);
}
return $this->_pagination;
}
/**
* Sets the pagination for this data provider.
* #param array|Pagination|boolean $value the pagination to be used by this data provider.
* This can be one of the following:
*
* - a configuration array for creating the pagination object. The "class" element defaults
* to 'yii\data\Pagination'
* - an instance of [[Pagination]] or its subclass
* - false, if pagination needs to be disabled.
*
* #throws InvalidParamException
*/
public function setPagination($value)
{
if (is_array($value)) {
$config = ['class' => Pagination::className()];
if ($this->id !== null) {
$config['pageParam'] = $this->id . '-page';
$config['pageSizeParam'] = $this->id . '-per-page';
}
$this->_pagination = Yii::createObject(array_merge($config, $value));
} elseif ($value instanceof Pagination || $value === false) {
$this->_pagination = $value;
} else {
throw new InvalidParamException('Only Pagination instance, configuration array or false is allowed.');
}
}
/**
* Returns the sorting object used by this data provider.
* #return Sort|boolean the sorting object. If this is false, it means the sorting is disabled.
*/
public function getSort()
{
if ($this->_sort === null) {
$this->setSort([]);
}
return $this->_sort;
}
/**
* Sets the sort definition for this data provider.
* #param array|Sort|boolean $value the sort definition to be used by this data provider.
* This can be one of the following:
*
* - a configuration array for creating the sort definition object. The "class" element defaults
* to 'yii\data\Sort'
* - an instance of [[Sort]] or its subclass
* - false, if sorting needs to be disabled.
*
* #throws InvalidParamException
*/
public function setSort($value)
{
if (is_array($value)) {
$config = ['class' => Sort::className()];
if ($this->id !== null) {
$config['sortParam'] = $this->id . '-sort';
}
$this->_sort = Yii::createObject(array_merge($config, $value));
} elseif ($value instanceof Sort || $value === false) {
$this->_sort = $value;
} else {
throw new InvalidParamException('Only Sort instance, configuration array or false is allowed.');
}
}
/**
* Refreshes the data provider.
* After calling this method, if [[getModels()]], [[getKeys()]] or [[getTotalCount()]] is called again,
* they will re-execute the query and return the latest data available.
*/
public function refresh()
{
$this->_totalCount = null;
$this->_models = null;
$this->_keys = null;
}
}
The code above is the BaseDataProvider for yii2. My question is how i can set the _models and _keys in yii2? Which file do i need to change to link to that? Sorry i am quite new to yii. Please provide an example if possible thank you.
That what's You pasted here is abstract Yii2 class, which You should NEVER edit.
To use this thing i suggest You to read about ActiveDataProvider here: Docs
$query = Post::find()->where(['status' => 1]);
$provider = new ActiveDataProvider([
'query' => $query,
]);
Here's an example how to use it, first line defines data which will be used to populate ActiveDataProvider (it's a SQL query), and then You create ActiveDataProvider instance with query as config parameter.
this may be a Simple issue and im just overseeing it.
What I wanna do:
I have a Model and I simply create an instance of it, fill it with Data and then use the add($object) function of my Repository.
Then I Call the persistenceManager to persist my Entry.
The whole Controller is part of an Backend Module.
I have to Persist the Entry Manualy, because there is a #header Redirect, because of an SSO API Call which redirects me afterwards back.
What is the Problem:
My Debugs hint that after adding Values to the Object the Propertys filled to the Object correctly.
After calling the persistAll function, my Object is Persisted, but the Values appear as "modified".
The result is:
I have an Empty Object in my Table.
Time for some Code (shortened)
/**
* myObjectRepository
*
* #var \MyVendor\MyExt\Domain\Repository\MyObjectRepository
* #inject
*/
protected $myObjectRepository;
/**
* #var \TYPO3\CMS\Extbase\Object\ObjectManagerInterface
* #inject
*/
protected $objectManager;
/**
* #var \TYPO3\CMS\Extbase\Persistence\PersistenceManagerInterface
* #inject
*/
protected $persistenceManager;
/**
* action verify
*
* #return void
*/
public function verifyAction() {
$myObject = $this->objectManager->get('\\MyVendor\\MyExt\\Domain\\Model\\MyObject');
$myObject->setName('Nice Name');
$myObject->setAge(20);
$this->myObjectRepository->add($myObject);
$this->persistenceManager->persistAll();
}
This is to give an Idea of what I try to do.
I'm kinda stunned with this issue. The normal Behaviour should be a Persisted Database Entry. Maybe its just a Simple detail overseen.
same problem here! Debugging shows, that the persistObject-function called the $dataMap->isPersistableProperty($propertyName) function before pass the data to DB.
this function needs an TCA
/**
* Returns TRUE if the property is persistable (configured in $TCA)
*
* #param string $propertyName The property name
* #return boolean TRUE if the property is persistable (configured in $TCA)
*/
public function isPersistableProperty($propertyName) {
return isset($this->columnMaps[$propertyName]);
}
so, be sure your TCA isset and correct to pass the properties to the DB