Mapping a property based on a join query - symfony

I'm using Symfony 5.4 with Entities for:
Org
UserConfig
User
Users are associated with Orgs through the UserConfig entity because Users can belong to more than 1 Org, with different configurations for each.
The UserConfig has the following relations:
/**
* #ORM\ManyToOne(targetEntity=Org::class)
* #ORM\JoinColumn(nullable=false)
*/
private $org;
/**
* #ORM\ManyToOne(targetEntity=User::class, inversedBy="configs")
* #ORM\JoinColumn(nullable=false)
*/
private $user;
Then the User entity relates with a OneToMany
/**
* #ORM\OneToMany(targetEntity=UserConfig::class, mappedBy="user", orphanRemoval=true)
*/
private $configs;
Most of my queries are in the context of a single Org, and I often need the related UserConfig when working with a User.
Is it possible to add a config property to the User entity which I can hydrate with a repository query which joins User and UserConfig on a specific Org?
For example, in my UserRepository I may fetch a group of users based on IDs. (This is a contrived example as I just want to show the config dependency)
public function findByIds(Org $org, array $ids): array
{
return $this->createQueryBuilder('user')
->innerJoin('user.configs', 'config', 'WITH', 'config.org = :org')
->setParameter('org', $org)
->andWhere('user.id IN (:ids)')->setParameter('ids', $ids)
->andWhere('config.active = :true')->setParameter('true', true)
->getQuery()
->getResult();
}
I'd like to call this method and access the related UserConfig as $user->getConfig() when iterating over the result of findByIds(). Is that possible?
It's not just for usability as I am serializing the user objects to JSON for API output and I'd like config to be nested in each user.

Related

Security voter on relational entity field when not using custom subresource path

I have started doing some more advanced security things in our application, where companies can create their own user roles with customizable CRUD for every module, which means you can create a custom role "Users read only" where you set "read" to "2" and create, update, delete to 0 for the user module. And the same for the teams module.
0 means that he have no access at all.
1 means can access all data under company,
2 means can access only things related to him (if he is owner
of an another user),
Which should result in the behavior that, when user requests a team over a get request, it returns the team with the users that are in the team, BUT, since the user role is configured with $capabilities["users"]["read"] = 2, then team.users should contain only him, without the other team members, because user cannot see users except himself and users that he created.
So far I have managed to limit collection-get operations with a doctrine extension that implements QueryCollectionExtensionInterface and filters out what results to return to the user:
when I query with a role that has $capabilities["teams"]["read"] = 2 then the collection returns only teams that user is part of, or teams that he created.
when I query for users with role that has $capabilities["teams"]["read"] = 1 then it returns all teams inside the company. Which is correct.
The problem comes when I query a single team. For security on item operations I use Voters, which checks the user capabilities before getting/updating/inserting/... a new entity to the DB, which works fine.
So the problem is, that when the team is returned, the user list from the manytomany user<->team relation, contains all the users that are part of the team. I need to somehow filter out this to match my role capabilities. So in this case if the user has $capabilities["users"]["read"] = 2, then the team.users should contain only the user making the request, because he has access to list the teams he is in, but he has no permission to view other users than himself.
So my question is, how can add a security voter on relational fields for item-operations and collection-operations.
A rough visual representation of what I want to achieve
/**
* #ORM\ManyToMany(targetEntity="User", mappedBy="teams")
* #Groups({"team.read","form.read"})
* #Security({itemOperations={
* "get"={
* "access_control"="is_granted('user.view', object)",
* "access_control_message"="Access denied."
* },
* "put"={
* "access_control"="is_granted('user.update', object)",
* "access_control_message"="Access denied."
* },
* "delete"={
* "access_control"="is_granted('user.delete', object)",
* "access_control_message"="Access denied."
* },
* },
* collectionOperations={
* "get"={
* "access_control"="is_granted('user.list', object)",
* "access_control_message"="Access denied."
* },
* "post"={
* "access_control"="is_granted('user.create', object)",
* "access_control_message"="Access denied."
* },
* }})
*/
private $users;
I don't think Normalizer is a good solution from a performance perspective, considering that the DB query was already made.
If I understand well, in the end the only problem is that when you make a request GET /api/teams/{id}, the property $users contains all users belonging to the team, but given user's permissions, you just want to display a subset.
Indeed Doctrine Extensions are not enough because they only limits the number of entities of the targeted entity, i.e Team in your case.
But it seems that Doctrine Filters cover this use case; they allow to add extra SQL clauses to your queries, even when fetching associated entities. But I never used them myself so I can't be 100% sure. Seems to be a very low level tool.
Otherwise, I deal with a similar use case on my project, but yet I'm not sure it fit all your needs:
Adding an extra $members array property without any #ORM annotation,
Excluding the $users association property from serialization, replacing it by $members,
Decorating the data provider of the Team entity,
Making the decorated data provider fill the new property with a restricted set of users.
// src/Entity/Team.php
/**
* #ApiResource(
* ...
* )
* #ORM\Entity(repositoryClass=TeamRepository::class)
*/
class Team
{
/**
* #ORM\Id
* #ORM\GeneratedValue
* #ORM\Column(type="integer")
*/
private $id;
/**
* #var User[]
* #ORM\ManyToMany(targetEntity=User::class) //This property is persisted but not serialized
*/
private $users;
/**
* #var User[] //This property is not persisted but serialized
* #Groups({read:team, ...})
*/
private $members = [];
// src/DataProvider/TeamDataProvider.php
class TeamDataProvider implements CollectionDataProviderInterface, ItemDataProviderInterface, RestrictedDataProviderInterface
{
/** #var ItemDataProvider */
private $itemDataProvider;
/** #var CollectionDataProvider*/
private $collectionDataProvider;
/** #var Security */
private $security;
public function __construct(ItemDataProvider $itemDataProvider,
CollectionDataProvider $collectionDataProvider,
Security $security)
{
$this->itemDataProvider = $itemDataProvider;
$this->collectionDataProvider = $collectionDataProvider;
$this->security = $security;
}
public function supports(string $resourceClass, string $operationName = null, array $context = []): bool
{
return $resourceClass === Team::class;
}
public function getCollection(string $resourceClass, string $operationName = null)
{
/** #var Team[] $manyTeams */
$manyTeams = $this->collectionDataProvider->getCollection($resourceClass, $operationName);
foreach ($manyTeams as $team) {
$this->fillMembersDependingUserPermissions($team);
}
return $manyTeams;
}
public function getItem(string $resourceClass, $id, string $operationName = null, array $context = [])
{
/** #var Team|null $team */
$team = $this->itemDataProvider->getItem($resourceClass, ['id' => $id], $operationName, $context);
if ($team !== null) {
$this->fillMembersDependingUserPermissions($team);
}
return $team;
}
private function fillMembersDependingUserPermissions(Team $team): void
{
$currentUser = $this->security->getUser();
if ($currentUser->getCapabilities()['users']['read'] === 2) {
$team->setMembers([$currentUser]);
} elseif ($currentUser->getCapabilities()['users']['read'] === 1) {
$members = $team->getUsers()->getValues();
$team->setMembers($members); //Current user is already within the collection
}
}
}
EDIT AFTER REPLY
The constructor of the TeamDataProvider use concrete classes instead of interfaces because it is meant to decorate precisely ORM data providers. I just forgot that those services use aliases. You need to configure a bit:
# config/services.yaml
App\DataProvider\TeamDataProvider:
arguments:
$itemDataProvider: '#api_platform.doctrine.orm.default.item_data_provider'
$collectionDataProvider: '#api_platform.doctrine.orm.default.collection_data_provider'
This way you keep advantages of your extensions.

Iterate efficiently on large OneToMany – ManyToOne associations

I have two entities User and Period, they have a ManyToMany association: a user belongs to many periods, and a period has multiple users. This association uses a UserPeriod entity.
class Period
{
/**
* #ORM\OneToMany(targetEntity="UserPeriod", mappedBy="period")
*/
private $users;
}
class UserPeriod
{
/**
* #ORM\Id
* #ORM\ManyToOne(targetEntity="User", inversedBy="periods")
*/
private $user;
/**
* #ORM\Id
* #ORM\ManyToOne(targetEntity="Period", inversedBy="users")
*/
private $period;
}
class User extends BaseUser
{
/**
* #ORM\OneToMany(targetEntity="UserPeriod", mappedBy="user")
*/
protected $periods;
}
What I'm trying to achieve is getting a list of all users from a defined period. Since there is a lot of users, I can't load them all in memory and must iterate on them (batch processing). Here is what I tried:
public function getUsersOfQuery($period)
{
return $this->_em->createQueryBuilder()
->select('u')
->from('SGLotteryUserBundle:LotteryUser', 'u')
->innerJoin('u.periods', 'p')
->where('p.period = :id')
->setParameter('id', $period->id())
->getQuery();
}
$it = $repo->getUsersOfQuery($period)->iterate();
But, this exception is raised:
[Doctrine\ORM\Query\QueryException]
Iterate with fetch join in class UserPeriod using association user not allowed.
I cannot use native queries since User uses table inheritance.
Github issue
This happens when using either MANY_TO_MANY or ONE_TO_MANY join in
your query then you cannot iterate over it because it is potentially
possible that the same entity could be in multiple rows.
If you add a distinct to your query then all will work as it will
guarantee each record is unique.
$qb = $this->createQueryBuilder('o');
$qb->distinct()->join('o.manyRelationship');
$i = $qb->iterator;
echo 'Profit!';

How to store historical data with symfony and doctrine?

I want to store store historical data with symfony2 and doctrine2. For example i am having 2 entities:
class Shop
{
private $id;
private $url;
private $version;
}
and the second entity:
class Version
{
private $id;
private $software;
private $version;
}
The Version entity stores specific shop-versions, for example Magento 1.2 or OXID eShop 4.7 - so a entry for a version-entity should be reusable.
Every time the version for a Shop is changed, i want to store this change to have a historical view for the version-changes.
How can i do that with symfony2 and doctrine2? I have tried many-to-many mappings, but i cant figure out the right way using the correct mapping.
Thanks for your help!
There's a few things you have to set properly in order for this to work.
First, you need to tell Doctrine that $versions is related to Version:
class Shop
{
private $id;
private $url;
/**
* #ORM\ManyToMany(targetEntity="Version", cascade={"persist"})
* #ORM\JoinTable(name="shop_version",
* joinColumns={#ORM\JoinColumn(name="shop_id", referencedColumnName="id")},
* inverseJoinColumns={#ORM\JoinColumn(name="version_id", referencedColumnName="id")}
* )
*/
private $versions;
}
Since it's a ManyToMany relationship (documentation), $versions will be treated like an ArrayCollection by Symfony. Thus, you need to create methods to handle it accordingly.
Constructor
public function __construct()
{
$this->versions = new ArrayCollection();
}
Getter
public function getVersions()
{
return $this->versions;
}
Adder
public function addVersion(Version $version)
{
$this->versions[] = $version;
}
Remover
public function removeVersion(Version $version)
{
$this->versions->removeElement($version);
}
That's it. Don't forget to add the use statment for ArrayCollection!
use Doctrine\Common\Collections\ArrayCollection;
In your case instead of reinventing the wheel i would recommend Doctrine2 extension: EntityAudit that allows full versioning of entities and their associations. Usage:
$auditReader = $this->container->get("simplethings_entityaudit.reader");
// find entity state at a particular revision
$articleAudit = $auditReader->find('SimpleThings\EntityAudit\Tests\ArticleAudit', $id = 1, $rev = 10);
// find Revision History of an audited entity
$revisions = $auditReader->findRevisions('SimpleThings\EntityAudit\Tests\ArticleAudit', $id = 1);
// find Changed Entities at a specific revision
$changedEntities = $auditReader->findEntitiesChangedAtRevision( 10 );
and more on: https://github.com/simplethings/EntityAudit
Another available package for entity versioning is https://github.com/madmis/ActivityLogBundle. This package includes a revision control system that saves each state of your desired entities and properties.
To enable logging, add the following annotation to your entity class
#Gedmo\Loggable(logEntryClass="ActivityLogBundle\Entity\LogEntry")
Make sure to import the annotation
use Gedmo\Mapping\Annotation as Gedmo;
Add the following annotations to the properties where you want to log changes of
#Gedmo\Versioned
The package offers methods to easily retrieve logentries for an entity
public function getLogEntriesQuery($entity)
This will return log entries with the following methods
$logEntry->getVersion() //returns entities revision version
$logEntry->getOldData() //returns data state before updating
$logEntry->getData() //returns data state after updating
$logEntry->getLoggedAt() //returns when de log was created
In order to retrieve logEntries for a given timeframe you can extend the querybuilder that's returned from the following method, which is also available in the LogEntryRepository:
public function getLogEntriesQueryBuilder($entity)

Make another entity from entity by Doctrine

I have two tables, user and userAttr.
'user' and 'userAttr' are tied as onebyone.
I would like to insert a row in userAttr when row is inserted user.
So this is my idea.
Make new data row of userAttr In prePersist() method in user entity.
in Acme/UserBundle/Entity/User.php
class User extends BaseUser implements ParticipantInterface
{
public function prePersist()
{
$userAttr = new userAttr();
$userAttr->setUser($this);
$userAttr->setEnabled(true);
$this->setUserAttr($userAttr);
$em = $this->getDoctrine()->getManager();
$em->persist($userAttr);
$em->flush();
but it shows error like this.
Fatal error: Call to undefined method Acme\UserBundle\Entity\User::getDoctrine() in
There are two quesions.
1.Is my basic idea correct?
2.How can I get the instance of doctrine in entity class?
Generally you got a little fallacy in there. Once the entity manager persists a entry it will also persist the related one-to-one connection. So if you persist $userAttr - which is one-to-one connected to your instance of User - it will persist User before it should get persisted. Causing double writings in the database. You can avoid this by adjusting your prePersist() to
public function prePersist()
{
$userAttr = new userAttr();
$userAttr->setUser($this);
$userAttr->setEnabled(true);
$this->setUserAttr($userAttr);
}
This avoids finding a way to get a instance of the entity manager too.
I'll answer your second question first.
How can I get the instance of doctrine in entity class?
You can't and you shouldn't. Your entity class is just a model, it has no knowledge of Doctrine, Symfony or the Entity Manager. Persistence will be handled at a higher level.
Is my basic idea correct?
No. As I said in the previous point, persistence shouldn't be a worry at this level. Here, you're just defining the properties and relations of your model.
I imagine your entity looks somewhat like this:
class User extends BaseUser implements ParticipantInterface
{
/**
* #var integer
*
* #ORM\Column(name="id", type="integer")
* #ORM\Id
* #ORM\GeneratedValue(strategy="AUTO")
*/
private $id;
// ...
/**
* #ORM\OneToOne(targetEntity="UserAttr", cascade={"persist"})
*/
private $userAttr;
public function setUserAttr(UserAttr $userAttr = null)
{
$this->userAttr = $userAttr;
return $this;
}
public function getUserAttr()
{
return $this->technician;
}
}
Note the cascade={"persist"} option in the relation with UserAttr. This is what tells Doctrine that it should insert that into the database, too.
Further reading
Doctrine: One-To-One, Unidirectional
Doctrine: Transitive persistence / Cascade Operations
Symfony: Persisting Objects to the Database

symfony2 create doctrine collection from query

Not sure if this is possible or not but im looking to create a doctrine collection from a query. The idea is to populate the collection with some pre-set values so i can update the database think of it like an import/generate users from an old system into a new one. Im struggling with the repository bit.
Entity
// Portal\UserBundle\Entity\User.php
namespace Portal\UserBundle\Entity;
use Doctrine\ORM\Mapping AS ORM;
/**
* #ORM\Entity
*/
class User
{
/**
* #ORM\Id
* #ORM\Column(type="integer")
*/
private $id;
/**
* #ORM\Column(type="string", length=255, nullable=false)
*/
private $fistname;
/**
* Get id
*
* #return integer
*/
public function getId()
{
return $this->id;
}
// etc...
}
Repository
namespace Portal\UserBundle\Entity\Repository;
use Doctrine\ORM\EntityRepository;
class UserRepository extends EntityRepository
{
public function getGenerateNewUsers()
{
// acts as an import from an old user table
$sql = " SELECT firstname, surname, other FROM old_user_table ";
$userCollection = .... not sure how I link query?
return $userCollection;
}
}
Calling it inside the controller
With the above I intend to be able to fetch the newly generated users loop over them and have access to my entity methods objects etc.
class SetupController extends Controller
{
public function indexAction(){
$repository = this->getDoctrine()->getRepository('UserBundle:User');
$newUsers = $repository->getGenerateUsers();
// I can now have access to the users with something like
foreach($newUsers as $user){
$user->setFirstName('testing');
$user->save();
}
}
}
It's usually the case with imports like this that your legacy table doesn't directly map to your new one (in terms of field names, constraints, etc), and may not even be in the same DBMS, so really the best option is a slightly manual approach. Execute your SQL query against your legacy database in your favourite old-fashioned way to get your users as simple arrays, then loop through them and create entities:
//Set up an empty collection
$collection = new ArrayCollection();
/*Assuming PDO where you have set up and executed a PDO statement $pdoStatement,
but mysql_fetch_assoc or whatever is appropriate for your DBMS would work equally
well. $oldUser should just be a plain associative array*/
while($oldUser = $pdoStatement->fetch(PDO::FETCH_ASSOC)){
//Initialise an empty User entity
$newUser = new User();
//Set the properties on the entity using the values from your array
$newUser->setFirstName($oldUser['firstname']);
//etc
//Add your user to the collection
$collection->add($newUser);
}
return $collection
I notice you're thinking of calling save() on your User objects in your controller, but it doesn't generally work that way in Doctrine as your entities will be plain objects which don't inherit from anything and don't have any special methods. The way to save the entity to your new database is to grab the entity manager and call its persist method.
In your controller:
$entityManager = $this->get('Doctrine')->getManager();
foreach($users as $user){
//Manipulate the user here if you need to
//Then persist it
$entityManager->persist($user);
}
As an aside - if you wanted to get a collection of entities by executing a query against your new database that's a slightly different problem to which there's a much more elegant solution. Doctrine Query Language allows you to query your database in a SQL-like way while using the language of your objects. With DQL, the results of your queries will by default be hydrated into Doctrine entites.
Hogan mentions DQL. Here is what that would look like, but you'd have to make sure your old database was wired up. The result is a collection of entities, off which you could use method calls to store part or all of the data as you see fit.
namespace Portal\UserBundle\Entity\Repository;
use Doctrine\ORM\EntityRepository;
class UserRepository extends EntityRepository
{
public function getGenerateNewUsers()
{
$qb = $this->getEntityManager()
->getRepository('Bundle:Old_User')->createQueryBuilder('o');
$query = $qb->getQuery();
$results = $query->getResult();
return $results;
}
}

Resources