PHPStorm References to properties seems wrong - symfony

Updated question Title
Sorry if the title of this question is a little off, I'm really not sure how to phrase it.
I'm trying to create a service that will pull data from a json feed and persist/flush it to my database via doctrine.
After much searching/reading/trial and error I've managed to get my service registered:
services:
fantasyapi:
class: FantasyDataAPI\Client
arguments: ["%fantasyapi.key%"]
data_manager:
class: FantasyPro\DataBundle\DataManager\StadiumParser
arguments: ['#doctrine.orm.entity_manager', '#fantasyapi', ::DataBundle.Entity.Stadium]
I'm using php storm with the symfony2 plugin.
If i use:
$repo = $this->em->getRepository('DataBundle:Stadium');
PHP storm reports it as an undefined function.
However if i use
$repo = $this->em->em->getRepository('DataBundle:Stadium');
php storm does not report it as undefined.
I think i'm doing something wrong as using ->em->em->getRepository does not feel right.
Am i instantiating the service correctly and if so why do i have to duplicate the reference to the function?
heres the full code of my service:
<?php
namespace FantasyPro\DataBundle\DataManager;
use Doctrine\ORM\EntityManager;
use FantasyDataAPI\Client;
use FantasyPro\DataBundle\Entity\Stadium;
class StadiumParser {
private $em;
private $client;
private $stadium;
/**
* #param EntityManager $em
* #param Client $client
* #param Stadium $stadium
*/
public function __constuct(EntityManager $em, Client $client, Stadium $stadium ) {
$this->em = $em;
$this->client = $client;
$this->stadium = $stadium;
}
/**
* #return array
*/
public Function parseData(){
$stadiumData = $this->client->client->Stadiums();
//get the entity manager
$repo = $this->em->em->getRepository('DataBundle:Stadium');
$log = array();
foreach ($stadiumData as $stadium) {
// Get the current stadium in the list
$criteria = array( 'stadiumID' => $stadium['StadiumID'] );
$currentStadium = $repo-->FindOneBy( $criteria );
if ( ! $currentStadium) {
$currentStadium = new Stadium(); //no stadium with the StadiumID exists so create a new stadium
$logData = [
'action' => 'Added Stadium',
'itemID' => $stadium['StadiumID'],
'itemName' => $stadium['Name']
];
$log[] = $logData;
} else {
$logData = [
'action' => 'Updated '.$logTitle,
'itemID' => $stadium['PlayerID'],
'itemName' => $stadium['Name']
];
$log[] = $logData;
}
$currentStadium->setStadiumID( $stadium['StadiumID'] );
$currentStadium->setName( $stadium['Name'] );
$currentStadium->setCity( $stadium['City'] );
$currentStadium->setState( $stadium['State'] );
$currentStadium->setCountry( $stadium['Country'] );
$currentStadium->setCapacity( $stadium['Capacity'] );
$currentStadium->setPlayingSurface( $stadium['PlayingSurface'] );
$this->em->em->persist( $currentStadium );
}
$this->em->em->flush();
return $log;
}
}

You can document the type of each member of your class:
<?php
namespace FantasyPro\DataBundle\DataManager;
use Doctrine\ORM\EntityManager;
use FantasyDataAPI\Client;
use FantasyPro\DataBundle\Entity\Stadium;
class StadiumParser
{
/** #var EntityManager em */
private $em;
/** #var Client client */
private $client;
/** #var Stadium stadium */
private $stadium;
/* ... */
For some reason PHPStorm didn't recognized the classes when I only used the class name so I had to put the full namespace, e.g.:
/* ... */
/** #var \Doctrine\ORM\EntityManager em */
private $em;
/* ... */

Related

JMSSerializerBundle deserialization skip groups exclusion on id property using DoctrineObjectConstructor

I'm using jms/serializer-bundle 2.4.3 on a symfony 4.2 and a I noticed an annoying problem in my application :
when I post an entity, the DoctrineObjectConstructor uses id in content to retrieve another entity and thus patch it while it is excluded by my security groups
see rather entity
class Entity
{
/**
* #var int
*
* #ORM\Column(name="id", type="int")
* #ORM\Id
* #ORM\GeneratedValue(strategy="IDENTITY")
* #Serializer\Groups({"GetEntity"})
*/
private $id;
/**
* #var string
*
* #ORM\Column(name="name", type="string")
* #Serializer\Groups({"GetEntity", "PostEntity"})
*/
private $name;
}
controller
/**
* #Route("/entity", name="post_entity", methods={"POST"})
*/
public function postEntity(Request $request, EntityManagerInterface $entityManager, SerializerInterface $serializer): JsonResponse
{
$deserializationContext = DeserializationContext::create();
$deserializationContext->setGroups(['PostEntity']);
$entity = $serializer->deserialize($request->getContent(), Entity::class, 'json', $deserializationContext);
$entityManager->persist($entity);
$entityManager->flush();
return $this->json($entity, Response::HTTP_OK, [], ['groups' => ['GetEntity']]);
}
I have some JMS configurations changes in services
jms_serializer.object_constructor:
alias: jms_serializer.doctrine_object_constructor
public: true
jms_serializer.unserialize_object_constructor:
class: App\Serializer\ObjectConstructor
If anyone can explain to me how to ignore the id in this case I'm open to any suggestions.
Regards and thanks for any help
To resolve, just add override in your services.yaml
jms_serializer.doctrine_object_constructor:
class: App\Serializer\DoctrineObjectConstructor
arguments:
- '#doctrine'
- '#jms_serializer.unserialize_object_constructor'
jms_serializer.object_constructor:
alias: jms_serializer.doctrine_object_constructor
and add a local DoctrineObjectConstructor updated to ignore entities without current deserialization group on id property
class DoctrineObjectConstructor implements ObjectConstructorInterface
{
const ON_MISSING_NULL = 'null';
const ON_MISSING_EXCEPTION = 'exception';
const ON_MISSING_FALLBACK = 'fallback';
private $fallbackStrategy;
private $managerRegistry;
private $fallbackConstructor;
/**
* Constructor.
*
* #param ManagerRegistry $managerRegistry Manager registry
* #param ObjectConstructorInterface $fallbackConstructor Fallback object constructor
* #param string $fallbackStrategy
*/
public function __construct(ManagerRegistry $managerRegistry, ObjectConstructorInterface $fallbackConstructor, $fallbackStrategy = self::ON_MISSING_NULL)
{
$this->managerRegistry = $managerRegistry;
$this->fallbackConstructor = $fallbackConstructor;
$this->fallbackStrategy = $fallbackStrategy;
}
/**
* {#inheritdoc}
*/
public function construct(VisitorInterface $visitor, ClassMetadata $metadata, $data, array $type, DeserializationContext $context)
{
// Locate possible ObjectManager
$objectManager = $this->managerRegistry->getManagerForClass($metadata->name);
if (!$objectManager) {
// No ObjectManager found, proceed with normal deserialization
return $this->fallbackConstructor->construct($visitor, $metadata, $data, $type, $context);
}
// Locate possible ClassMetadata
$classMetadataFactory = $objectManager->getMetadataFactory();
if ($classMetadataFactory->isTransient($metadata->name)) {
// No ClassMetadata found, proceed with normal deserialization
return $this->fallbackConstructor->construct($visitor, $metadata, $data, $type, $context);
}
// Managed entity, check for proxy load
if (!\is_array($data)) {
// Single identifier, load proxy
return $objectManager->getReference($metadata->name, $data);
}
// Fallback to default constructor if missing identifier(s)
$classMetadata = $objectManager->getClassMetadata($metadata->name);
$identifierList = [];
foreach ($classMetadata->getIdentifierFieldNames() as $name) {
$propertyGroups = [];
if ($visitor instanceof AbstractVisitor) {
/** #var PropertyNamingStrategyInterface $namingStrategy */
$namingStrategy = $visitor->getNamingStrategy();
$dataName = $namingStrategy->translateName($metadata->propertyMetadata[$name]);
$propertyGroups = $metadata->propertyMetadata[$name]->groups;
} else {
$dataName = $name;
}
if (!array_key_exists($dataName, $data) || true === empty(array_intersect($context->getAttribute('groups'), $propertyGroups))) {
return $this->fallbackConstructor->construct($visitor, $metadata, $data, $type, $context);
}
$identifierList[$name] = $data[$dataName];
}
// Entity update, load it from database
$object = $objectManager->find($metadata->name, $identifierList);
if (null === $object) {
switch ($this->fallbackStrategy) {
case self::ON_MISSING_NULL:
return null;
case self::ON_MISSING_EXCEPTION:
throw new ObjectConstructionException(sprintf('Entity %s can not be found', $metadata->name));
case self::ON_MISSING_FALLBACK:
return $this->fallbackConstructor->construct($visitor, $metadata, $data, $type, $context);
default:
throw new InvalidArgumentException('The provided fallback strategy for the object constructor is not valid');
}
}
$objectManager->initializeObject($object);
return $object;
}
}

JMSSerializer deserialize entity by id

i'm using JMSSerializer to deserialize a JSON request and i'm having troubles with ManyToOne relations. I would like to deserialize the relation entity from a id given. Example:
Class Game {
/**
* #var Team
*
* #ORM\ManyToOne(targetEntity="Team")
* #ORM\JoinColumn(name="home_team_id", referencedColumnName="id")
* #JMSSerializer\SerializedName("home")
*/
private $homeTeam;
/**
* #ORM\ManyToOne(targetEntity="Team")
* #ORM\JoinColumn(name="visitor_team_id", referencedColumnName="id")
* #JMSSerializer\SerializedName("visitor")
*/
private $visitorTeam;
}
So when i get this Json
{"home": "id1", "visitor": "id2"}
Get the related entities. Any clouds?? i can't figure it out
Thanks in advance
Custom serializer handler allows to do it.
At first, you need to create your own serialization handler. Something like this:
<?php
namespace AppBundle\Serializer\Handler;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Doctrine\RegistryInterface;
use JMS\Serializer\Context;
use JMS\Serializer\Exception\InvalidArgumentException;
use JMS\Serializer\GenericDeserializationVisitor;
use JMS\Serializer\Handler\SubscribingHandlerInterface;
use JMS\Serializer\VisitorInterface;
use JMS\Serializer\GraphNavigator;
class EntityHandler implements SubscribingHandlerInterface
{
/**
* #var RegistryInterface
*/
protected $registry;
/**
* #return array
*/
public static function getSubscribingMethods()
{
$methods = [];
foreach (['json', 'xml', 'yml'] as $format) {
$methods[] = [
'type' => 'Entity',
'direction' => GraphNavigator::DIRECTION_DESERIALIZATION,
'format' => $format,
'method' => 'deserializeEntity',
];
$methods[] = [
'type' => 'Entity',
'format' => $format,
'direction' => GraphNavigator::DIRECTION_SERIALIZATION,
'method' => 'serializeEntity',
];
}
return $methods;
}
/**
* EntityHandler constructor.
* #param RegistryInterface $registry
*/
public function __construct(RegistryInterface $registry)
{
$this->registry = $registry;
}
/**
* #param VisitorInterface $visitor
* #param $entity
* #param array $type
* #param Context $context
* #return mixed
*/
public function serializeEntity(VisitorInterface $visitor, $entity, array $type, Context $context)
{
$entityClass = $this->getEntityClassFromParameters($type['params']);
if (!$entity instanceof $entityClass) {
throw new InvalidArgumentException(
sprintf("Entity class '%s' was expected, but '%s' got", $entityClass, get_class($entity))
);
}
$entityManager = $this->getEntityManager($entityClass);
$primaryKeyValues = $entityManager->getClassMetadata($entityClass)->getIdentifierValues($entity);
if (count($primaryKeyValues) > 1) {
throw new InvalidArgumentException(
sprintf("Composite primary keys does'nt supported now (found in class '%s')", $entityClass)
);
}
if (!count($primaryKeyValues)) {
throw new InvalidArgumentException(
sprintf("No primary keys found for entity '%s')", $entityClass)
);
}
$id = array_shift($primaryKeyValues);
if (is_int($id) || is_string($id)) {
return $visitor->visitString($id, $type, $context);
} else {
throw new InvalidArgumentException(
sprintf(
"Invalid primary key type for entity '%s' (only integer or string are supported",
$entityClass
)
);
}
}
/**
* #param GenericDeserializationVisitor $visitor
* #param string $id
* #param array $type
*/
public function deserializeEntity(GenericDeserializationVisitor $visitor, $id, array $type)
{
if (null === $id) {
return null;
}
if (!(is_array($type) && isset($type['params']) && is_array($type['params']) && isset($type['params']['0']))) {
return null;
}
$entityClass = $type['params'][0]['name'];
$entityManager = $this->getEntityManager($entityClass);
return $entityManager->getRepository($entityClass)->find($id);
}
/**
* #param array $parameters
* #return string
*/
protected function getEntityClassFromParameters(array $parameters)
{
if (!(isset($parameters[0]) && is_array($parameters[0]) && isset($parameters[0]['name']))) {
throw new InvalidArgumentException('Entity class is not defined');
}
if (!class_exists($parameters[0]['name'])) {
throw new InvalidArgumentException(sprintf("Entity class '%s' is not found", $parameters[0]['name']));
}
return $parameters[0]['name'];
}
/**
* #param string $entityClass
* #return EntityManagerInterface
*/
protected function getEntityManager($entityClass)
{
$entityManager = $this->registry->getEntityManagerForClass($entityClass);
if (!$entityManager) {
throw new InvalidArgumentException(
sprintf("Entity class '%s' is not mannaged by Doctrine", $entityClass)
);
}
return $entityManager;
}
}
Then you should register it in your service configuration file. If you use yaml, it will be something like that:
custom_serializer_handle:
class: AppBundle\Serializer\Handler\EntityHandler
arguments: ['#doctrine']
tags:
- {name: 'jms_serializer.subscribing_handler'}
In your entity, define JMSSerializer Type annotation
/**
* #var Team
* * #ORM\ManyToOne(targetEntity="Team")
* #ORM\JoinColumn(name="home_team_id", referencedColumnName="id")
* #JMSSerializer\SerializedName("home")
* #JMSSerializer\Type("Entity<AppBundle\Entity\Team>")
* List item
*/
private $homeTeam;
Don't forget clear caches.
That's all.

Symfony 2: Dynamic Form Event returns InvalidArgumentException only when editing

I got an entity called 'Activity', that defines a relation between 2 more entities, 'Service' and 'Location'.
Both 'Service' and 'Location', use another entity called 'Allocation', to define what services can be used in a concrete location.
When I create a new Activity, after selecting a service I want the location choice field update with the values defined by allocation.
I have followed symfony documentation to create this 'location' dependent choice field in the form.
Dynamic Form Modification
All works great on create/new form, but when i try to edit service field value in an already created Activity, location field does not update and the symfony profiler shows me the following message:
Uncaught PHP Exception Symfony\Component\PropertyAccess\Exception\InvalidArgumentException: "Expected argument of type "AppBundle\Entity\Location", "NULL" given" at F:\xampp\htdocs\gcd\vendor\symfony\symfony\src\Symfony\Component\PropertyAccess\PropertyAccessor.php line 253 Context: { "exception": "Object(Symfony\Component\PropertyAccess\Exception\InvalidArgumentException)" }
This is a section of my Activity Entity
/**
* Activity
*
* #ORM\Table(name="activity")
* #ORM\Entity(repositoryClass="AppBundle\Repository\ActivityRepository")
*/
class Activity
{
/**
* #var int
*
* #ORM\Column(name="id", type="integer")
* #ORM\Id
* #ORM\GeneratedValue(strategy="AUTO")
*/
private $id;
/**
* #var Service
*
* #ORM\ManyToOne(targetEntity="Service", fetch="EAGER")
* #ORM\JoinColumn(name="service_id", referencedColumnName="id", nullable=false)
*/
private $service;
/**
* #var Location
*
* #ORM\ManyToOne(targetEntity="Location", fetch="EAGER")
* #ORM\JoinColumn(name="location_id", referencedColumnName="id", nullable=false)
*/
private $location;
My Controller.
/**
* Creates a new Activity entity.
*
* #Route("/new", name="core_admin_activity_new")
* #Method({"GET", "POST"})
*/
public function newAction(Request $request)
{
$activity = new Activity();
$form = $this->createForm('AppBundle\Form\ActivityType', $activity);
$form->handleRequest($request);
if($form->isSubmitted() && $form->isValid()){
$locationAvailable = $this->isLocationAvailable($activity);
$activityOverlap = $this->hasOverlap($activity);
if($locationAvailable && !$activityOverlap){
$em = $this->getDoctrine()->getManager();
$em->persist($activity);
$em->flush();
return $this->redirectToRoute('core_admin_activity_show', array('id' => $activity->getId()));
}
}
return $this->render('activity/new.html.twig', array(
'activity' => $activity,
'form' => $form->createView(),
));
}
/**
* Displays a form to edit an existing Activity entity.
*
* #Route("/{id}/edit", name="core_admin_activity_edit")
* #Method({"GET", "POST"})
*/
public function editAction(Request $request, Activity $activity)
{
$deleteForm = $this->createDeleteForm($activity);
$editForm = $this->createForm('AppBundle\Form\ActivityType', $activity);
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
$locationAvailable = $this->isLocationAvailable($activity);
$activityOverlap = $this->hasOverlap($activity);
if($locationAvailable && !$activityOverlap){
$em = $this->getDoctrine()->getManager();
$em->persist($activity);
$em->flush();
return $this->redirectToRoute('core_admin_activity_show', array('id' => $activity->getId()));
}
}
return $this->render('activity/edit.html.twig', array(
'activity' => $activity,
'edit_form' => $editForm->createView(),
'delete_form' => $deleteForm->createView(),
));
}
My FormType
class ActivityType extends AbstractType
{
private $em;
public function __construct(EntityManager $entityManager)
{
$this->em = $entityManager;
}
/**
* #param FormBuilderInterface $builder
* #param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('service', EntityType::class, array(
'class' => 'AppBundle:Service',
'placeholder' => 'elige servicio',
))
->add('location', EntityType::class, array(
'class' => 'AppBundle:Location',
'choices' => array(),
))
->add('name')
->add('virtual')
->add('customerSeats')
->add('customerVacants')
->add('employeeSeats')
->add('firstDate', 'date')
->add('lastDate', 'date')
->add('weekday')
->add('beginTime', 'time')
->add('endTime', 'time')
->add('admissionType')
->add('status');
$formModifier = function (FormInterface $form, Service $service = null) {
$locations = null === $service ? array() : $this->em->getRepository('AppBundle:Allocation')->findLocationsByService($service);
$form->add('location', EntityType::class, array(
'class' => 'AppBundle:Location',
'choices' => $locations,
));
};
$builder->addEventListener(
FormEvents::PRE_SET_DATA,
function (FormEvent $event) use ($formModifier) {
$data = $event->getData();
$formModifier($event->getForm(), $data->getService());
}
);
$builder->get('service')->addEventListener(
FormEvents::POST_SUBMIT,
function (FormEvent $event) use ($formModifier) {
// It's important here to fetch $event->getForm()->getData(), as
// $event->getData() will get you the client data (that is, the ID)
$service = $event->getForm()->getData();
// since we've added the listener to the child, we'll have to pass on
// the parent to the callback functions!
$formModifier($event->getForm()->getParent(), $service);
}
);
}
/**
* #param OptionsResolver $resolver
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'AppBundle\Entity\Activity'
));
}
}
javaScript function
<script>
var $service = $('#activity_service');
// When sport gets selected ...
$service.change(function() {
// ... retrieve the corresponding form.
var $form = $(this).closest('form');
// Simulate form data, but only include the selected service value.
var data = {};
data[$service.attr('name')] = $service.val();
// Submit data via AJAX to the form's action path.
$.ajax({
url : $form.attr('action'),
type: $form.attr('method'),
data : data,
success: function(html) {
// Replace current position field ...
$('#activity_location').replaceWith(
// ... with the returned one from the AJAX response.
$(html).find('#activity_location')
);
}
});
});
</script>
Any help will be great, thanks.
I had a similar problem, I found a solution : Symfony - dynamic drop down lists not working only when editing
I had also faced similar issue and on tracing found that it is the EntityType class on other dropdown that is causing the problem while editing the form
The solution is to submit the full form via ajax instead of only one field like in the case of new form.
So change
var data = {};
data[$service.attr('name')] = $service.val();
To
var data = $form.serializeArray()
That should fix the issue.

Symfony2 Unit Testing a service

I'm still very new to symfony and really enjoying it.
I'm at the stage where I've managed to create and setup a service, the service itself uses 2 dependencies:
A Data API that returns json data (this is a separate library which
i have implemented as a service and came with its own unit tests).
The Doctrine Entity Manager.
The service pulls the data required using the api and then loops through the data and checks to see if the data already exists, if it does it updates the existing entity and persists it, Otherwise it creates a new entity assigns the data and persists it.
I now need to write a unit test for this, i've not used PHPUnit only from symfony2 tutorials which were testing responses from a controller.
How do i go about writing a unit test for this service?
In particular mocking the data that i would normally pull from the api.
and then checks to see if the entry needs to be updated or created?
A code example would be really helpful so i can then use this as a template to create tests for other similar services that i create.
Here's the Service i want to test:
<?php
namespace FantasyPro\DataBundle\DataManager;
use Doctrine\ORM\EntityManager;
use FantasyDataAPI\Client;
use FantasyPro\DataBundle\Entity\Stadium;
class StadiumParser {
/**
* #var EntityManager $em
*/
private $em;
/**
* #var Client $client
*/
private $client;
public function __construct( EntityManager $em, Client $client) {
$this->em = $em;
$this->client = $client;
}
/**
* #return array
*/
public Function parseData(){
//var_dump($this);
$stadiumData = $this->client->Stadiums();
//var_dump($stadiumData);
//get the Repo
$repo = $this->em->getRepository('DataBundle:Stadium');
$log = array();
foreach ($stadiumData as $stadium) {
// Get the current stadium in the list from the database
$criteria = array( 'stadiumID' => $stadium['StadiumID'] );
$currentStadium = $repo->FindOneBy( $criteria );
if ( ! $currentStadium) {
$currentStadium = new Stadium(); //no stadium with the StadiumID exists so create a new stadium
$logData = [
'action' => 'Added Stadium',
'itemID' => $stadium['StadiumID'],
'itemName' => $stadium['Name']
];
$log[] = $logData;
} else {
$logData = [
'action' => 'Updated Stadium',
'itemID' => $stadium['StadiumID'],
'itemName' => $stadium['Name']
];
$log[] = $logData;
}
$currentStadium->setStadiumID( $stadium['StadiumID'] );
$currentStadium->setName( $stadium['Name'] );
$currentStadium->setCity( $stadium['City'] );
$currentStadium->setState( $stadium['State'] );
$currentStadium->setCountry( $stadium['Country'] );
$currentStadium->setCapacity( $stadium['Capacity'] );
$currentStadium->setPlayingSurface( $stadium['PlayingSurface'] );
$this->em->persist( $currentStadium );
}
$this->em->flush();
return $log;
}
}
****** UPDATE *******
after reading ilpaijin's answer.
I've simplified the service so it no longer returns a log, i initially had this in so i could check what had been added by sending the log to a twig template in my controller, I eventually plan to have this running as a command so i can run a it via a cron job so the log bit is unnecessary.
I'm now setting the entity within my construct as i couldn't work out how to pass an entity as a injected dependency.
Now grabbing a new entity by using createNewStadium() method.
The Updated Service:
namespace FantasyPro\DataBundle\DataManager;
use Doctrine\ORM\EntityManager;
use FantasyDataAPI\Client;
use FantasyPro\DataBundle\Entity\Stadium;
class StadiumParser {
/**
* #var EntityManager $em
*/
private $em;
/**
* #var Client $client
*/
private $client;
/**
* #var Stadium Stadium
*/
private $stadium;
public function __construct( EntityManager $em, Client $client) {
$this->em = $em;
$this->client = $client;
}
/**
* Gets a list of stadiums using $this->client->Stadiums.
* loops through returned stadiums and persists them
* when loop has finished flush them to the db
*/
public Function parseData(){
$data = $this->client->Stadiums();
//get the Repo
$repo = $this->em->getRepository('DataBundle:Stadium');
foreach ($data as $item) {
// Get the current stadium in the list
$criteria = array( 'stadiumID' => $item['StadiumID'] );
$currentStadium = $repo->FindOneBy( $criteria );
if ( ! $currentStadium) {
$currentStadium = $this->createNewStadium; //no stadium with the StadiumID use the new stadium entity
}
$currentStadium->setStadiumID( $item['StadiumID'] );
$currentStadium->setName( $item['Name'] );
$currentStadium->setCity( $item['City'] );
$currentStadium->setState( $item['State'] );
$currentStadium->setCountry( $item['Country'] );
$currentStadium->setCapacity( $item['Capacity'] );
$currentStadium->setPlayingSurface( $item['PlayingSurface'] );
$this->em->persist( $currentStadium );
}
$this->em->flush();
}
// Adding this new method gives you the ability to mock this dependency when testing
private function createNewStadium()
{
return new Stadium();
}
}
What you basically need is Unit Testing the service using what is called "Test doubles".
This means you should mock the dependencies your service has, this way you are able to test only the service in isolation without really relying on the deps but only on a mocked version of them, with hardcoded values or behaviour.
A true example based on your real implementation isn't possible since you have tight coupled deps as $currentStadium = new Stadium();. You should pass deps like these in a constructor or via getter/setter in order to be able to mock it when Unit testing.
Once done it a very indicative example would be:
// class StadiumParser revisited and simplified
class StadiumParser
{
private $client;
public function __construct(Client $client)
{
$this->client = $client;
}
public function parseData()
{
$stadiumData = $this->client->Stadiums();
// do something with the repo
$log = array();
foreach ($stadiumData as $stadium) {
$logData = [
'action' => 'Added Stadium',
'itemID' => $stadium['StadiumID'],
'itemName' => $stadium['Name']
];
$log[] = $logData;
}
// do something else with Doctrine
return $log;
}
}
and the test
// StadiumParser Unit Test
class StadiumParserTest extends PHPUnit_Framework_TestCase
{
public function testItParseDataAndReturnTheLog()
{
$client = $this->getMock('FantasyDataAPI\Client');
// since you class is returning a log array, we mock it here
$expectedLog = array(
array(
'action' => 'Added Stadium',
'itemID' => $stadium['StadiumID'],
'itemName' => $stadium['Name']
)
);
// this is the mocked or test double part.
// We only need this method return something without really calling it
// So we mock it and we hardcode the expected return value
$stadiumData = array(
array(
"StadiumID" => 1,
"Name" => "aStadiumName"
)
);
$client->expects($this->once())
->method('Stadiums')
->will($this->returnValue($stadiumData));
$stadiumParser = new StadiumParser($client);
$this->assertEquals($expectedLog, $stadiumParser->parseData());
}
}
I voluntarily omitted the EntityManager part because I guess you should have a look at the Symfony Doc relative to how to unit test code interacting with the database
-----EDIT2-----
Yes he was right, you shouldn't. One possible way that comes in mind is to extract the creation of the entity in a protected/private method. Something like:
// class StadiumParser
public Function parseData()
{
...
foreach ($stadiumData as $stadium) {
...
if ( ! $currentStadium) {
$currentStadium = $this->createNewStadium();
...
}
// Adding this new method gives you the ability to mock this dependency when testing
private function createNewStadium()
{
return new Stadium();
}
-----EDIT3-----
I want to suggest you another approach. This should be, probably, a better choice should the Stadium entity needed in different services or different part of the same. What I'm proposing is called Builder pattern but a Factory could also be an option here. Browse a bit for their differences.
As you can see this extract some code from the method, distribute better the responsibility between the classes and leaves all cleaner and easier to read for you and your teammates. And you already know how to mock it when testing.
class StadiumParser
{
private $stadiumBuilder;
...
public function __construct( StadiumBuilder $builder, ...) {
$this->stadiumBuilder = $stadiumBuilder;
...
}
public Function parseData()
{
...
foreach ($stadiumData as $stadium) {
...
$currentStadium = $repo->FindOneBy( $criteria );
if ( ! $currentStadium) {
$currentStadium = $this->stadiumBuilder->build($currentStadium, $stadium);
}
$this->em->persist($currentStadium);
...
somewhere you have this new Builder that return a Stadium instance. this way your StadiumParser service is not coupled anymore with the entity, but the StadiumBuilder is it. The logic is something like:
// StadiumBuilder class
namespace ???
use FantasyPro\DataBundle\Entity\Stadium;
class StadiumBuilder
{
// depending on the needs but this should also has a different name
// like buildBasic or buildFull or buildBlaBlaBla or buildTest
public function build($currentStadium = null, $stadium)
{
if (!$currentStadium) {
$currentStadium = new Stadium();
}
$currentStadium->setStadiumID( $stadium['StadiumID'] );
$currentStadium->setName( $stadium['Name'] );
$currentStadium->setCity( $stadium['City'] );
$currentStadium->setState( $stadium['State'] );
$currentStadium->setCountry( $stadium['Country'] );
$currentStadium->setCapacity( $stadium['Capacity'] );
$currentStadium->setPlayingSurface( $stadium['PlayingSurface'] );
return $currentStadium;
}
}

Symfony 2.3 / Doctrine2 personal translations not working in Sonata Admin with TranslatedFieldType.php

I am currently working on a symfony2.3 project with doctrine2 trying to implement personal translations management in the Sonata backend.
Translations are based on the doctrine2 translatable behaviour model: https://github.com/l3pp4rd/DoctrineExtensions/blob/master/doc/translatable.md
and more precisely Personal Translations.
The admin form is using the symfony2.3 version of TranslatedFieldType.php.
My entity class is as follows:
<?php
namespace Hr\OnlineBundle\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Gedmo\Mapping\Annotation as Gedmo;
use Doctrine\ORM\Mapping as ORM;
/**
* #ORM\Entity
* #Gedmo\TranslationEntity(class="Hr\OnlineBundle\Entity\CategoryTranslation")
*/
class Category
{
/**
* #ORM\Column(type="integer")
* #ORM\Id
* #ORM\GeneratedValue
*/
private $id;
/**
* #Gedmo\Translatable
* #ORM\Column(length=64)
*/
private $title;
/**
* #Gedmo\Translatable
* #ORM\Column(type="text", nullable=true)
*/
private $description;
/**
* #ORM\OneToMany(
* targetEntity="CategoryTranslation",
* mappedBy="object",
* cascade={"persist", "remove"}
* )
*/
private $translations;
public function __construct()
{
$this->translations = new ArrayCollection();
}
public function getTranslations()
{
return $this->translations;
}
public function addTranslation(CategoryTranslation $t)
{
if (!$this->translations->contains($t)) {
$this->translations[] = $t;
$t->setObject($this);
}
}
public function getId()
{
return $this->id;
}
public function setTitle($title)
{
$this->title = $title;
}
public function getTitle()
{
return $this->title;
}
public function setDescription($description)
{
$this->description = $description;
}
public function getDescription()
{
return $this->description;
}
public function __toString()
{
return $this->getTitle();
}
}
The related Personal Translation class is this:
<?php
namespace Hr\OnlineBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Translatable\Entity\MappedSuperclass\AbstractPersonalTranslation;
/**
* #ORM\Entity
* #ORM\Table(name="category_translations",
* uniqueConstraints={#ORM\UniqueConstraint(name="lookup_unique_idx", columns={
* "locale", "object_id", "field"
* })}
* )
*/
class CategoryTranslation extends AbstractPersonalTranslation
{
/**
* Convinient constructor
*
* #param string $locale
* #param string $field
* #param string $value
*/
public function __construct($locale, $field, $value)
{
$this->setLocale($locale);
$this->setField($field);
$this->setContent($value);
}
/**
* #ORM\ManyToOne(targetEntity="Category", inversedBy="translations")
* #ORM\JoinColumn(name="object_id", referencedColumnName="id", onDelete="CASCADE")
*/
protected $object;
}
The admin form class is this:
<?php
namespace Hr\OnlineBundle\Admin;
use Sonata\AdminBundle\Admin\Admin;
use Sonata\AdminBundle\Datagrid\ListMapper;
use Sonata\AdminBundle\Datagrid\DatagridMapper;
use Sonata\AdminBundle\Form\FormMapper;
use Hr\OnlineBundle\Form\Type\TranslatedFieldType;
class CategoryAdmin extends Admin
{
// Fields to be shown on create/edit forms
protected function configureFormFields(FormMapper $formMapper)
{
$formMapper
->with('General')
->add('title', 'translatable_field', array(
'field' => 'title',
'personal_translation' => 'Hr\OnlineBundle\Entity\CategoryTranslation',
'property_path' => 'translations',
))
->end();
}
// Fields to be shown on filter forms
protected function configureDatagridFilters(DatagridMapper $datagridMapper)
{
$datagridMapper
->add('title')
;
}
// Fields to be shown on lists
protected function configureListFields(ListMapper $listMapper)
{
$listMapper
->addIdentifier('title')
;
}
}
The admin form is using the following form type class:
<?php
namespace Hr\OnlineBundle\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;
use Hr\OnlineBundle\Form\EventListener\addTranslatedFieldSubscriber;
class TranslatedFieldType extends AbstractType
{
protected $container;
public function __construct(ContainerInterface $container)
{
$this->container = $container;
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
if(! class_exists($options['personal_translation']))
{
Throw new \InvalidArgumentException(sprintf("Unable to find personal translation class: '%s'", $options['personal_translation']));
}
if(! $options['field'])
{
Throw new \InvalidArgumentException("You should provide a field to translate");
}
$subscriber = new addTranslatedFieldSubscriber($builder->getFormFactory(), $this->container, $options);
$builder->addEventSubscriber($subscriber);
}
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(array(
'remove_empty' => true,
'csrf_protection'=> false,
'field' => false,
'personal_translation' => false,
'locales'=>array('en', 'fr', 'de'),
'required_locale'=>array('en'),
'widget'=>'text',
'entity_manager_removal'=>true,
));
}
public function getName()
{
return 'translatable_field';
}
}
and the corresponding Event Listener:
<?php
namespace Hr\OnlineBundle\Form\EventListener;
use Symfony\Component\Form\Event\DataEvent;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Form\FormError;
class AddTranslatedFieldSubscriber implements EventSubscriberInterface
{
private $factory;
private $options;
private $container;
public function __construct(FormFactoryInterface $factory, ContainerInterface $container, Array $options)
{
$this->factory = $factory;
$this->options = $options;
$this->container = $container;
}
public static function getSubscribedEvents()
{
// Tells the dispatcher that we want to listen on the form.pre_set_data
// , form.post_data and form.bind_norm_data event
return array(
FormEvents::PRE_SET_DATA => 'preSetData',
FormEvents::POST_BIND => 'postBind',
FormEvents::BIND => 'bindNormData'
);
}
private function bindTranslations($data)
{
//Small helper function to extract all Personal Translation
//from the Entity for the field we are interested in
//and combines it with the fields
$collection = array();
$availableTranslations = array();
foreach($data as $Translation)
{
if(strtolower($Translation->getField()) == strtolower($this->options['field']))
{
$availableTranslations[ strtolower($Translation->getLocale()) ] = $Translation;
}
}
foreach($this->getFieldNames() as $locale => $fieldName)
{
if(isset($availableTranslations[ strtolower($locale) ]))
{
$Translation = $availableTranslations[ strtolower($locale) ];
}
else
{
$Translation = $this->createPersonalTranslation($locale, $this->options['field'], NULL);
}
$collection[] = array(
'locale' => $locale,
'fieldName' => $fieldName,
'translation' => $Translation,
);
}
return $collection;
}
private function getFieldNames()
{
//helper function to generate all field names in format:
// '<locale>' => '<field>|<locale>'
$collection = array();
foreach($this->options['locales'] as $locale)
{
$collection[ $locale ] = $this->options['field'] .":". $locale;
}
return $collection;
}
private function createPersonalTranslation($locale, $field, $content)
{
//creates a new Personal Translation
$className = $this->options['personal_translation'];
return new $className($locale, $field, $content);
}
public function bindNormData(FormEvent $event)
{
//Validates the submitted form
$data = $event->getData();
$form = $event->getForm();
$validator = $this->container->get('validator');
foreach($this->getFieldNames() as $locale => $fieldName)
{
$content = $form->get($fieldName)->getData();
if(
NULL === $content &&
in_array($locale, $this->options['required_locale']))
{
$form->addError(new FormError(sprintf("Field '%s' for locale '%s' cannot be blank", $this->options['field'], $locale)));
}
else
{
$Translation = $this->createPersonalTranslation($locale, $fieldName, $content);
$errors = $validator->validate($Translation, array(sprintf("%s:%s", $this->options['field'], $locale)));
if(count($errors) > 0)
{
foreach($errors as $error)
{
$form->addError(new FormError($error->getMessage()));
}
}
}
}
}
public function postBind(FormEvent $event)
{
//if the form passed the validattion then set the corresponding Personal Translations
$form = $event->getForm();
$data = $form->getData();
$entity = $form->getParent()->getData();
foreach($this->bindTranslations($data) as $binded)
{
$content = $form->get($binded['fieldName'])->getData();
$Translation = $binded['translation'];
// set the submitted content
$Translation->setContent($content);
//test if its new
if($Translation->getId())
{
//Delete the Personal Translation if its empty
if(
NULL === $content &&
$this->options['remove_empty']
)
{
$data->removeElement($Translation);
if($this->options['entity_manager_removal'])
{
$this->container->get('doctrine.orm.entity_manager')->remove($Translation);
}
}
}
elseif(NULL !== $content)
{
//add it to entity
$entity->addTranslation($Translation);
if(! $data->contains($Translation))
{
$data->add($Translation);
}
}
}
}
public function preSetData(FormEvent $event)
{
//Builds the custom 'form' based on the provided locales
$data = $event->getData();
$form = $event->getForm();
// During form creation setData() is called with null as an argument
// by the FormBuilder constructor. We're only concerned with when
// setData is called with an actual Entity object in it (whether new,
// or fetched with Doctrine). This if statement let's us skip right
// over the null condition.
if (null === $data)
{
return;
}
foreach($this->bindTranslations($data) as $binded)
{
$form->add($this->factory->createNamed(
$binded['fieldName'],
$this->options['widget'],
$binded['translation']->getContent(),
array(
'label' => $binded['locale'],
'required' => in_array($binded['locale'], $this->options['required_locale']),
'auto_initialize' => false,
)
));
}
}
}
So, the form shows the three fields for the three specified locales, which is fine, but when submitting the form the following error occurs:
FatalErrorException: Error: Call to a member function getField() on a non-object in C:\wamp\www\hronline\src\Hr\OnlineBundle\Form\EventListener\addTranslatedFieldSubscriber.php line 47
in C:\wamp\www\hronline\src\Hr\OnlineBundle\Form\EventListener\addTranslatedFieldSubscriber.php line 47
at ErrorHandler->handleFatal() in C:\wamp\www\hronline\vendor\symfony\symfony\src\Symfony\Component\Debug\ErrorHandler.php line 0
at AddTranslatedFieldSubscriber->bindTranslations() in C:\wamp\www\hronline\src\Hr\OnlineBundle\Form\EventListener\addTranslatedFieldSubscriber.php line 136
at AddTranslatedFieldSubscriber->postBind() in C:\wamp\www\hronline\app\cache\dev\classes.php line 1667
at ??call_user_func() in C:\wamp\www\hronline\app\cache\dev\classes.php line 1667
at EventDispatcher->doDispatch() in C:\wamp\www\hronline\app\cache\dev\classes.php line 1600
at EventDispatcher->dispatch() in C:\wamp\www\hronline\vendor\symfony\symfony\src\Symfony\Component\EventDispatcher\ImmutableEventDispatcher.php line 42
at ImmutableEventDispatcher->dispatch() in C:\wamp\www\hronline\vendor\symfony\symfony\src\Symfony\Component\Form\Form.php line 631
at Form->submit() in C:\wamp\www\hronline\vendor\symfony\symfony\src\Symfony\Component\Form\Form.php line 552
at Form->submit() in C:\wamp\www\hronline\vendor\symfony\symfony\src\Symfony\Component\Form\Form.php line 645
at Form->bind() in C:\wamp\www\hronline\vendor\sonata-project\admin-bundle\Sonata\AdminBundle\Controller\CRUDController.php line 498
at CRUDController->createAction() in C:\wamp\www\hronline\app\bootstrap.php.cache line 2844
at ??call_user_func_array() in C:\wamp\www\hronline\app\bootstrap.php.cache line 2844
at HttpKernel->handleRaw() in C:\wamp\www\hronline\app\bootstrap.php.cache line 2818
at HttpKernel->handle() in C:\wamp\www\hronline\app\bootstrap.php.cache line 2947
at ContainerAwareHttpKernel->handle() in C:\wamp\www\hronline\app\bootstrap.php.cache line 2249
at Kernel->handle() in C:\wamp\www\hronline\web\app_dev.php line 28
at ??{main}() in C:\wamp\www\hronline\web\app_dev.php line 0
It appears that on this line of the event listener AddTranslatedFieldSubscriber:
if(strtolower($Translation->getField()) == strtolower($this->options['field']))
the $Translation variable comes as a string (e.g. string 'Lorem' (length=5)) instead of an object.
For some reason the form data is converted to an array of strings instead of an array of objects of type CategoryTranslation.
What could be the reason for this? Thanks!
Do yourself a favour and don't use the Gedmo Translatable behaviour. It is very buggy, slow and has a lot of weird edge cases. I recommend using the KNP translatable behaviour which is quite good (requires PHP 5.4) or the Prezent Translatable which is similar but can work on PHP 5.3. Disclaimer: I wrote that last one. It's beta but works fine. I just added the documentation for it.
You can use the a2lix bundle to integrate it all in Sonata Admin.

Resources