How detach relation without deleting entity in Sonata AdminBundle? - symfony

I have two entities: Specialisation and Course.
One specialisation has many courses, so relations are "OneToMany" and "ManyToOne".
I want to create specialisations and courses separately and then attach many courses to specialisation through multiple select.
And I also need to remove(detach) courses from specialisation but without deleting courses-entities.
So, I did it such way:
->add('courses', 'sonata_type_model', [
'multiple' => true,
'property' => 'title',
])
But when I remove related course from select-field in specialisation-edit -page, course-object deleting from DB too.
I tried to remove orphanRemoval property from relation, but then when I try to detach courses from specialisation, nothing happens.
So, my question is:
How I can achieve only detaching child-entities from parent-entity in SonataAdminBundle?

I solved it!
Solution:
I decided to use save-hooks (methods prePersist and preUpdate in my SpecialisationAdmin Class).
The main idea - to unset all related courses from specialisation and then set those that came from form.
But if I remove any courses from specialisation on edit-page, I would not get their objects in specialisation object in preUpdate method.
And if I dont get courses objects, I cant set their specialisation to NULL.
So, the solution of this problem is to use snapshot property to get all courses that specialisation had before submitting form and set their specialisation to NULL, and then set current specialisation to courses that came from form:
/**
* #param Specialisation $specialisation
*/
public function prePersist($specialisation)
{
$this->preUpdate($specialisation);
}
/**
* #param Specialisation $specialisation
*/
public function preUpdate($specialisation)
{
if (isset($specialisation->getCourses()->snapshot)) {
foreach ($specialisation->getCourses()->getSnapshot() as $course) {
$course->setSpecialisation(null);
}
}
foreach ($specialisation->getCourses() as $course) {
$course->setSpecialisation($specialisation);
}
}

Related

EntityType and many to many with extra field relation presented as dropdown (select)

I created a form like that
$builder->add('employees', EntityType::class, [
'class' => ActivityEmployee::class,
'choice_label' => function (ActivityEmployee $employee) {
return sprintf('%s %s', $employee->getEmployee()->getName(), $employee->getEmployee()->getLastName());
},
'multiple' => true,
])
As a result it presents already existing data fine. It shows me all employees with relation to edited activity.
However as choices there should be all employess to choose (employee entity) and as selected data only employess in activityEmployee relation like right now.
I tried to add a query_builder option to provide lists of all employess, but I can only use EntityRepository which means ActivityEmployeesRepository not EmployeesRepository per se.
A can't figure out how to implement it. Basically such relation can be done by CollectionType of custom activityEmployeeType but I'd like to use multi-select for selecting employees.
I can use another approach to not mapping my employees field to entity like that
$currentEmployees = [];
foreach ($activity->getEmployees() as $activityEmployee) {
$currentEmployees[] = $activityEmployee->getEmployee();
}
$builder->add('employees', EntityType::class, [
'class' => Employee::class,
'choice_label' => function (Employee $employee) {
return sprintf('%s %s', $employee->getName(), $employee->getLastName());
},
'mapped' => false,
'multiple' => true,
'data' => $currentEmployees,
]);
It works fine, but I need to deal with updating relation by myself. Which is ok, however I wonder how to achieve such thing in first approach.
Implementation details matter. As far as I can understand you have the following entities:
Activity (entity)
- employees (OneToMany -> ActivityEmployee)
ActivityEmployee (entity)
- activity (ManyToOne -> Activity)
- employee (ManyToOne -> Employee)
Employee (entity)
- activities (OneToMany -> ActivityEmployee) - this one might be missing, actually.
Now you apparently don't hide any implementation details. Meaning, your Activity::getEmployees() returns []ActivityEmployee.
I would have done it like this:
class Activity {
/** #ORM\OneToMany(targetEntity=ActivityEmployee::class) */
private $activityEmployees;
/** #return Employee[] */
public function getEmployees() :Collection {
return $this->activityEmployees->map(function(ActivityEmployee $ae) {
return $ae->getEmployee();
});
}
public function addEmployee(Employee $employee) {
// check, if the employee is already registered, add only then!
if(!$this->getEmployees()->contains($employee)) {
$this->activityEmployees->add(new ActivityEmployee($this, $employee));
}
}
public function removeEmployee(Employee $employee) {
foreach($this->activityEmployees as $activityEmployee) {
if($activityEmployee->getEmployee() === $employee) {
$this->activityEmployees->removeElement($activityEmployee);
}
}
}
}
This way, you hide away how Activity handles the employees and to the outside world (and specifically the PropertyAccessor, that the form component uses) it appears as if Activity has a property employees which are actually Employee[].
If you implement it like this, your first form should actually just work (obviously exchanging ActivityEmployee for Employee) - under the assumption that I didn't make some major mistake. Of course I would also add methods like getActivityEmployees when I would actually specificially need the relation objects.
This whole thing certainly is less beautiful if your many-to-many can contain duplicates.
IF your ActivityEmployee actually has NO other properties besides activity and employee, you could obviously replace the whole thing with a #ORM\ManyToMany and just work with Employee[] instead of the ActivityEmployee[]. However, I assume you have some additional columns like created or something.

Symfony 3 - Update Many-To-Many

I have been looking around for a clean solution on how to update (keep in sync) a many to many relationship?
I have the following scenario:
A Sprint Entity owns the Many To Many relationship towards the Ticket entity.
When editing a Ticket (or Sprint, but I am not there yet), I want to be able to select (checkboxes) the Sprints that this ticket belongs to.
Upon persistance (save), I want to update my join table tickets_sprint (which is just a join table on ticket_id, sprint_id).
Adding Sprints to the Ticket seems easy enough, but removing Sprints from the Ticket is not reflected at all.
Code
Ticket Entity contains this method for adding a Ticket to a Sprint:
public function setSprints($sprints) {
/**
* #var $sprint \AppBundle\Entity\Sprint
*/
foreach ($sprints as $sprint) {
$this->sprints[] = $sprint;
$sprint->addTicket($this);
}
}
I have read here that the only way to go would be to remove all relations and re-save them upon persistance.
Coming from the Laravel world, this hardly feels like a good idea :)
This is how it is done in Laravel:
/**
* #param \App\User $user
* #param \App\Http\Requests\StoreUserRequest $request
* #return \Illuminate\Http\RedirectResponse
* Update the specified resource in storage.
*/
public function update(User $user, StoreUserRequest $request)
{
$user->fill($request->input());
$user->employee_code = strtolower($user->employee_code);
$user->roles()->sync($request->role ? : []);
$user->save();
\Session::flash('flash_message_success', 'The user was successfully updated.');
return redirect()->route('frontend::users.show', [$user]);
}
All suggestions are welcome!
The EntityType that you may use to create a multiple selectbox doesn't have a by_reference option like CollectionType.
If your Ticket Entity use the "inversedBy" side, you don't need to add the reference in the other object. So you can symply do this :
public function setSprints($sprints) {
$this->sprints = $sprints;
}
Maybe this will be enough to add and remove your elements automatically (Sorry didn't try).
Otherwise you have to do it manually and you can create a new method to remove elements returns by the difference between your new ArrayCollection and the old one.

Update a field after Linking / Unlinking Many-Many records in SilverStripe

I have created a Customer DataObject by extending Member. Customer has a many_many data relation with a Package DataObject.
I would like increment/decrement a Credits field in the Customer DataObject when a Package is linked / unlinked through the CMS based on the Limit field in the Package table.
Customer
class Customer extends Member {
private static $db = array(
'Gender' => 'Varchar(2)',
'DateOfBirth' => 'Date',
'Featured' => 'Boolean',
'Credits' => 'Int'
);
private static $many_many = array(
'Packages' => 'Package'
);
public function getCMSFields() {
$fields = new FieldList();
$config = GridFieldConfig_RelationEditor::create();
$config->removeComponentsByType('GridFieldAddNewButton');
$packageField = new GridField(
'Packages',
'Package',
$this->Packages(),
$config
);
$fields->addFieldToTab('Root.Package', $packageField);
Session::set('SingleID', $this->ID);
$this->extend('updateCMSFields', $fields);
return $fields;
}
}
Package
class Package extends DataObject {
private static $db = array(
'Title' => 'Varchar(255)',
'Limit' => 'Int'
);
private static $belongs_many_many = array(
'Customers' => 'Customer'
);
}
When you create or delete many to many relationship just one record is modified in your database - the one in table which joins elements of both sides of the relationship. Therefore neither object the relationship is based on is updated. This is why methods like: onBeforeWrite, onAfterWrite, onBeforeDelete and onAfterDelete will not be called at all and you cannot use them to detect such change.
However, Silverstripe provides class ManyManyList which is responsible for all operations connected to many to many relationships. There are two methods which are of your interest: add and remove. You can override them and put inside action to do what you need. These methods are obviously called on each link or unlink operation no matter object types are, so you should make some filtering on classes you are particularly interested in.
The proper way to override the ManyManyList class is to use Injector mechanism, so as not to modify anything inside the framework or cms folder. The example below uses relationship between Members and Groups in Silverstripe but you can easily adopt it to your need (Customer -> Member; Package -> Group).
app.yml
Injector:
ManyManyList:
class: ManyManyListExtended
ManyManyListExtended.php
/**
* When adding or removing elements on a many to many relationship
* neither side of the relationship is updated (written or deleted).
* SilverStripe does not provide any built-in actions to get information
* that such event occurs. This is why this class is created.
*
* When it is uses together with SilverStripe Injector mechanism it can provide
* additional actions to run on many-to-many relations (see: class ManyManyList).
*/
class ManyManyListExtended extends ManyManyList {
/**
* Overwritten method for adding new element to many-to-many relationship.
*
* This is called for all many-to-many relationships combinations.
* 'joinTable' field is used to make actions on specific relation only.
*
* #param mixed $item
* #param null $extraFields
* #throws Exception
*/
public function add($item, $extraFields = null) {
parent::add($item, $extraFields);
if ($this->isGroupMembershipChange()) {
$memberID = $this->getMembershipID($item, 'MemberID');
$groupID = $this->getMembershipID($item, 'GroupID');
SS_Log::log("Member ($memberID) added to Group ($groupID)", SS_Log::INFO);
// ... put some additional actions here
}
}
/**
* Overwritten method for removing item from many-to-many relationship.
*
* This is called for all many-to-many relationships combinations.
* 'joinTable' field is used to make actions on specific relation only.
*
* #param DataObject $item
*/
public function remove($item) {
parent::remove($item);
if ($this->isGroupMembershipChange()) {
$memberID = $this->getMembershipID($item, 'MemberID');
$groupID = $this->getMembershipID($item, 'GroupID');
SS_Log::log("Member ($memberID) removed from Group ($groupID)", SS_Log::INFO);
// ... put some additional actions here
}
}
/**
* Check if relationship is of Group-Member type.
*
* #return bool
*/
private function isGroupMembershipChange() {
return $this->getJoinTable() === 'Group_Members';
}
/**
* Get the actual ID for many-to-many relationship part - local or foreign key value.
*
* This works both ways: make action on a Member being element of a Group OR
* make action on a Group being part of a Member.
*
* #param DataObject|int $item
* #param string $keyName
* #return bool|null
*/
private function getMembershipID($item, $keyName) {
if ($this->getLocalKey() === $keyName)
return is_object($item) ? $item->ID : $item;
if ($this->getForeignKey() === $keyName)
return $this->getForeignID();
return false;
}
}
The solution provided by 3dgoo should also work fine but IMO that code does much more "hacking" and that's why it is much less maintainable. It demands more modifications (in both classes) and needs to be multiplied if you would like to do any additional link/unlink managing, like adding custom admin module or some forms.
The problem is when adding or removing items on a many to many relationship neither side of the relationship is written. Therefore onAfterWrite and onBeforeWrite is not called on either object.
I've come across this problem before. The solution I used isn't great but it was the only thing that worked for me.
What we can do is set an ID list of Packages to a session variable when getCMSFields is called. Then when an item is added or removed on the grid field we refresh the CMS panel to call getCMSFields again. We then retrieve the previous list and compare it to the current list. If the lists are different we can do something.
Customer
class Customer extends Member {
// ...
public function getCMSFields() {
// Some JavaScript to reload the panel each time a package is added or removed
Requirements::javascript('/mysite/javascript/cms-customer.js');
// This is the code block that saves the package id list and checks if any changes have been made
if ($this->ID) {
if (Session::get($this->ID . 'CustomerPackages')) {
$initialCustomerPackages = json_decode(Session::get($this->ID . 'CustomerPackages'), true);
$currentCustomerPackages = $this->Packages()->getIDList();
// Check if the package list has changed
if($initialCustomerPackages != $currentCustomerPackages) {
// In here is where you put your code to do what you need
}
}
Session::set($this->ID . 'CustomerPackages', json_encode($this->Packages()->getIDList()));
}
$fields = parent::getCMSFields();
$config = GridFieldConfig_RelationEditor::create();
$config->removeComponentsByType('GridFieldAddNewButton');
$packageField = GridField::create(
'Packages',
'Package',
$this->Packages(),
$config
);
// This class needs to be added so our javascript gets called
$packageField->addExtraClass('refresh-on-reload');
$fields->addFieldToTab('Root.Package', $packageField);
Session::set('SingleID', $this->ID);
$this->extend('updateCMSFields', $fields);
return $fields;
}
}
The if ($this->ID) { ... } code block is where all our session code happens. Also note we add a class to our grid field so our JavaScript refresh works $packageField->addExtraClass('refresh-on-reload');
As mentioned before, we need to add some JavaScript to reload the panel each time a package is added or removed from the list.
cms-customer.js
(function($) {
$.entwine('ss', function($){
$('.ss-gridfield.refresh-on-reload').entwine({
reload: function(e) {
this._super(e);
$('.cms-content').addClass('loading');
$('.cms-container').loadPanel(location.href, null, null, true);
}
});
});
})(jQuery);
Inside the if($initialCustomerPackages != $currentCustomerPackages) { ... } code block there are a number of things you can do.
You could use $this->Packages() to fetch all the current packages associated to this customer.
You could call array_diff and array_merge to get just the packages that have been added and removed:
$changedPackageIDs = array_merge(array_diff($initialCustomerPackages, $currentCustomerPackages), array_diff($currentCustomerPackages, $initialCustomerPackages));
$changedPackages = Package::get()->byIDs($changedPackageIDs);
The above code will add this functionality to the Customer side of the relationship. If you also want to manage the many to many relationship on the Package side of the relationship you will need to add similar code to the Package getCMSFields function.
Hopefully someone can come up with a nicer solution. If not, I hope this works for you.
note: Didn't actually check does the model work but by visually checking this should help you:
On the link you provided you are using
$customer = Customer::get()->Filter...
That returns a DataList of objects, not a singular object unless you specify what is the object you want from the DataList.
If you are filtering the Customers then you want to get a SPECIFIC customer from the DataList, e.g. the first one in this case.
$customer = Customer::get()->filter(array('ID' => $this->CustomerID))->first();
But You should be able to get the singular DataObject with:
$customer = $this->Customer();
As you are defining the Customer as "has_one". If the relation was a Has many, using () would get you a DataList of objects.
Protip:
You don't need to write our own debug files in SilverStripe. It has own functions for it. For example Debug::log("yay"); what writes the output to a file and Debug::dump("yay") that dumps it directly out.
Tip is that you can check what is the object that you accessing right. Debug::dump(get_class($customer)); would output only the class of the object.

How to update counter field on the inverse side in many-to-many relation in Symfony?

I have two enteties User and Category, which are connected each other by many-to-many relation.
manyToMany:
categories:
targetEntity: Category
joinTable:
name: users_categories
joinColumns:
user_id:
referencedColumnName: id
inverseJoinColumns:
category_id:
referencedColumnName: id
In my UserAdmin.php (I'm using Sonata Admin and Sonata User bundles) they are handled by this field:
->add('categories', 'sonata_type_model', array('required' => false, 'expanded' => false, 'multiple' => true, 'label' => 'Chose your categories'))
Now I need to add extra field to my Category entity - user_counter, which store number of users, linked with it. It should be updated every time User add, update or delete his relations with Categories.
One idea was to make method in User entity which would get his categories before saving admin form, comparing them to current input and then making decision (categories counter to make +1 or -1). And switch on this method by lifecycleCallbacks (prePersist and preUpdate).
The problem: my code will get all User categories and compare them to current input on each form saving. How I can avoid such overhead? Maybe there is another solution for this task?
Thank you for help.
One way you could keep track is to modify your collection methods in your entity. For instance:
/*
* #ORM\Column(name="user_counter", type="integer")
*/
protected $user_counter;
public function addUser(User $user)
{
$this->users[] = $user;
$this->user_counter++;
}
public function removeUser(User $user)
{
$this->users->remove($user);
$this->user_counter--;
}
Alternatively, if you don't need the counts in the database, just do a $category->getUsers()->count();
Edit:
If you want to use an event listener to track this instead of modifying your entity setters, use a combination of $unitOfWork->getScheduledCollectionUpdates() and getDeleteDiff() and getInsertDiff().

How to update an unidirectional ManyToMany mapping in Symfony2?

I have been searching for an answer now for several hours and I can't find a clean solution by myself.
I have an entity called "Tag". You can add these tags to nearly everything, f.e. to an article, a news and so on. Therefor my entites (in this example an article) refer to these tags with an unidirectional ManyToMany mapping:
/**
* #ORM\ManyToMany(targetEntity="Tag")
* #ORM\JoinTable(name="tag2article",
* joinColumns={#ORM\JoinColumn(name="article_id", referencedColumnName="id")},
* inverseJoinColumns={#ORM\JoinColumn(name="tag_id", referencedColumnName="id")}
* )
*/
private $tags;
This relation is unidirectional, because then I don't want to have an articleTag, a newsTag etc. or several entities as joinTables. The doctrine manual calls unidirection ManyToMany relations "less common", because you can build such relations as 3 entities with an additional joinTableEntity. But this would result in too much entities of this kind.
When I create an article, these tags are added by:
foreach ($tagArray as $tagId) {
$tag = $entityManager->getRepository('myBundle:Tag')->findOneById($tagId);
if ($tag != null) {
$article->addTag($tag);
}
}
$entityManager->persist($article);
$entityManager->flush();
This works fine on insert. On update I do the same, but it doesn't work and I don't know why. The only solutions I found was to add this article to the tag, but this isn't possible though I am working with an unidirectonal relation.
In my ArticleForm, I just have a hidden field:
->add('tags', 'hidden', array(
'data' => '',
'property_path' => false
))
In this way I can add tags pretty easy with Ajax and write these tagIds in this hidden field.
My overall question is: Why does this way work on insert, but not on update? What can I do to fix this?
Many thanks for your help or any hints!!

Resources