I have the following issue:
Doctrine is suppose to update entities that were changed. The problem is that for some reason (maybe legacy 32 bit systems?) the bigint data type is treated as string (as you can see below - this is the bigint type class in doctrine, there are also multiple other conversions to string in doctrine code).
<?php
namespace Doctrine\DBAL\Types;
use Doctrine\DBAL\ParameterType;
use Doctrine\DBAL\Platforms\AbstractPlatform;
/**
* Type that maps a database BIGINT to a PHP string.
*/
class BigIntType extends Type implements PhpIntegerMappingType
{
/**
* {#inheritdoc}
*/
public function getName()
{
return Type::BIGINT;
}
/**
* {#inheritdoc}
*/
public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform)
{
return $platform->getBigIntTypeDeclarationSQL($fieldDeclaration);
}
/**
* {#inheritdoc}
*/
public function getBindingType()
{
return ParameterType::STRING;
}
/**
* {#inheritdoc}
*/
public function convertToPHPValue($value, AbstractPlatform $platform)
{
return $value === null ? null : (string) $value;
}
}
This results in updating data that should not be updated because the unit of work checker compares data as strict (as it should) resulting in differences (code below).
// skip if value haven't changed
if ($orgValue === $actualValue) { // $orgValue = "3829784117300", $actualValue = 3829784117300
continue;
}
The end result for this is that the following code:
$product = $productRepo->find($id);
$product->setOwnerId($ownerId); // $ownerId = 3829784117300
$this->em->flush();
Is generating a query that does... nothing basically except stressing db (in my case I have a few tens of millions of those per day). The solution for the particular case above...
$product = $productRepo->find($id);
if ((int)$product->getOwnerId() !== $ownerId) {
$product->setOwnerId($ownerId); // $ownerId = 3829784117300
}
$this->em->flush();
Simple right? But... what do you do when you have 2 bigints? Ok... 2 conditions... not a big deal. But what if they are... 90? Ok... we can use reflection go through entity properties and check all.
But... what if somewhere in the relation chain there is another entity that needs to be checked? And the complete solution is that we have to recursively check every attribute of the entity and its children and check for bigints.
But... isn't that what the doctrine unit of work is for? Why do I need to reparse the whole entity and check for something that is already checked for just because bigint is treated as string (resulting in basically duplicating a huge chunk of doctrine code)?
And now the question... how to go around this (bare in mind that I'm not asking for a particular solution for a simple case, I'm asking for a general solution that can be applied to a big code base that is supposed to be mentained for years to come - as you can see above I have solutions but I'm not ok with half jobs and fragile code unless there is really no other way)? I'm looking for maybe a setting that I missed that makes doctrine treat integers like integers and not as strings... stuff like that.
One solution would be to override Doctrine's implementation of bigint type with your own. First, create a class identical to Doctrine's BigIntType, except replacing the cast to string with a cast to int:
class MyBigIntType extends Type
{
/**
* {#inheritdoc}
*/
public function getName()
{
return Type::BIGINT;
}
/**
* {#inheritdoc}
*/
public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform)
{
return $platform->getBigIntTypeDeclarationSQL($fieldDeclaration);
}
/**
* {#inheritdoc}
*/
public function getBindingType()
{
return \PDO::PARAM_STR;
}
/**
* {#inheritdoc}
*/
public function convertToPHPValue($value, AbstractPlatform $platform)
{
return (null === $value) ? null : (int)$value;
}
}
Then, register the type in config.yml or doctrine.yaml:
doctrine:
dbal:
types:
bigint: MyBigIntType
I tested this with Symfony 2.8, and this caused to use MyBigIntType for all fields that were of type bigint. Should work with later Symfony versions as well.
Related
I have created my first advanced field value converter correctly. However, I would like to do some some processing based on the column definition of properties.
Entity example:
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Location
{
#[ORM\Column(type: "customString", length: 10)]
public ?string $myString;
}
Type converter snippet:
use Doctrine\DBAL\Types\Type;
use Doctrine\DBAL\Platforms\AbstractPlatform;
class CustomStringType extends Type
{
/* other methods */
public function convertToPHPValue($value, AbstractPlatform $platform)
{
return $value;
}
public function convertToDabaseValue($value, AbstractPlatform $platform)
{
/* I don`t know how to get the '10' value here */
return $value !== null ? substr($value, 0, 10) : null;
}
}
So if I input $entity->myString = 'My very very long string', when retrieving this value from database, I expect it to be equal to 'My very ve', a 10 length string.
More background:
Read into using getters and setters but that is not an option in this case. Possibly using a custom hydrator would be, but I don't know how to properly do it.
TLDR:
Would like to truncate, model wise, some string properties on my application. I don't know how to do it without re-declaring it across all my entites getters/setters or their logic classes.
I am using Symfony 5.4 and API-Platform 2.6 and would like to filter the returns based on user roles.
It is about data created by different institutions. Each of these institutions is allowed to see all their own data, but not the data of the other institutions.
But there is also a role (I call it administrator in the following) that is allowed to see all data, but in anonymized form. Here some fields are missing like for example the name. For data protection reasons it is necessary that the data is already filtered.
Now I am looking for the best way to implement this.
It would be nice if the routes do not have to provide the institution ID, but they are automatically added internally and respected on the server side.
For the administrator role I don't see a really good solution yet.
I am open for solutions, as well as alternatives.
Also please excuse my bad English.
I see many questions in one question here ^^
Identify the institution of the connected user
You could add a relation user->institution, that's a simple solution and you'll be able to retrieve the user's institution from the connected user.
From now how do you know if a user is part of an institution?
Filter the data per institution
To illustrate let's imagine you have a Product with a getter for each property:
id
name
user
institution
You could create a ApiPlatform extension, there is a good example that is similar to your usecase.
Example
class FilterByInstitutionExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
public function __construct(private Security $security)
{
}
/**
* {#inheritdoc}
*/
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
{
$this->addWhere($queryBuilder, $resourceClass, $queryNameGenerator);
}
/**
* {#inheritdoc}
*/
public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, string $operationName = null, array $context = [])
{
$this->addWhere($queryBuilder, $resourceClass, $queryNameGenerator);
}
private function addWhere(QueryBuilder $queryBuilder, string $resourceClass, QueryNameGeneratorInterface $queryNameGenerator)
{
$toFilterClasses = [Product::class];
if (!in_array($resourceClass, $toFilterClasses)) {
return;
}
// user isn't connected or anonymous
// UserRoles::ADMIN is pseudo code, replace by whatever your admin role is
if (null === $user = $this->security->getUser() || $this->security->isGranted(UserRoles::ADMIN)) {
return;
}
$institution = $user->getInstitution();
$rootAlias = $queryBuilder->getRootAliases()[0];
$queryBuilder->andWhere(sprintf('%s.institution = :institution', $rootAlias));
$queryBuilder->setParameter('institution', $institution);
}
}
Remove user-sensitive data for admin
In Symfony when you want to modify returned data from the server you generally use denormalizer, you should really check the documentation is pretty well explained. Basically, you want to create a denormalizer for each of your apiplatform resources that contains sensitive user data.
Your denormalizer could look like this. Of course, you'll need to tweak it, it's pseudo code right now :)
class AnonymizedUserDataNormalizer implements NormalizerInterface
{
public function __construct(private NormalizerInterface $normalizer, private Security $security)
{
}
public function normalize($entity, string $format = null, array $context = [])
{
$data = $this->normalizer->normalize($entity, $format, $context);
if (!$this->security->isGranted(UserRoles::ADMIN)) {
return $data;
}
if (isset($data['user'])) {
unset($data['user']['firstName'], $data['user']['lastName']);
}
return $data;
}
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof Product;
}
}
Then you need to register your service because you're using an ApiPlatform service.
App\Serializer\Normalizer\AnonymizedUserDataNormalizer:
arguments:
$normalizer: '#api_platform.serializer.normalizer.item'
All of this is not the only way to go, it's just a proposal to put you on the road. Feel free to ask if something is not clear :)
I use the last version of EasyAdmin and my add and remove functions are ignored when I submit the form:
Ambiance entity:
/**
* #ORM\OneToMany(targetEntity="Vehicule", mappedBy="ambiance")
*/
protected Collection $vehicules;
public function __construct()
{
$this->vehicules = new ArrayCollection();
}
public function addVehicule(Vehicule $vehicule): self
{
if (!$this->vehicules->contains($vehicule)) {
$this->vehicules[] = $vehicule;
$vehicule->setAmbiance($this);
}
return $this;
}
public function removeVehicule(Vehicule $vehicule): void
{
if (!$this->vehicules->contains($vehicule)) {
return;
}
$this->vehicules->removeElement($vehicule);
}
public function getVehicules()
{
return $this->vehicules;
}
public function setVehicules($vehicules): void
{
$this->vehicules = $vehicules;
}
Yet my Doctrine mapping is valid..
My EasyAdmin form in AmbianceCrudController.php:
'vehicules' => AssociationField::new('vehicules', 'Véhicules'),
It generates a multiple select2 but when I add vehicles and submit my form, no data is inserted.
Replace
AssociationField::new('vehicules')
by
AssociationField::new('vehicules', 'Véhicules')->setFormTypeOption('by_reference', false)
I'm strugling with the same problem, but I found already bits of the puzzle that could be helpful:
in my experience, it seems that the owning side of the association (the one that has the foreign key in the database) results in an edit form that persists the change.
the other side ignores the change completely
the setters and getters, and addABC and removeABC methods seem indeed to be skipped for reasons that are unclear to me as well.
I hope you can find a solution with the hint that the crudcontroller other side in the relationship, I think Vehicules in your case.
I've got an entity called Logs that has a ManyToOne relation to an HourlyRates entity. Both Logs and HourlyRates have date properties. When adding a log with a specific date, an hourlyRate is assigned to it if the log-date fits within the rate's time range. I'm using the Doctrine Extensions Bundle, so the data in each entity can be soft-deleted.
What needs to be done:
After soft-deleting an HourlyRate the related Log has to be updated, so that the nearest existing past HourlyRate takes the place of the deleted one.
I tried to use preSoftDelete, postSoftDelete, preRemove and postRemove methods inside an HourlyRate entity listener. The code was being executed and the setters were working properly, but the database hasn't been updated in any of said cases. An "EntityNotFoundException" was being thrown everytime.
My second approach was to use the preRemove event along with setting the cascade option to "all" by using annotations in the HourlyRate class. As a result, soft-deleting an hourlyRate caused soft-deleting of the related log.
The Log entity:
class Log
{
use SoftDeleteableEntity;
/**
* #ORM\ManyToOne(targetEntity="App\Entity\HourlyRate", inversedBy="logs")
* #ORM\JoinColumn(nullable=false)
*/
private $hourlyRate;
public function setHourlyRate(?HourlyRate $hourlyRate): self
{
$this->hourlyRate = $hourlyRate;
return $this;
}
}
The HourlyRate entity:
class HourlyRate
{
use SoftDeleteableEntity;
//other code
/**
* #ORM\OneToMany(targetEntity="App\Entity\Log", mappedBy="hourlyRate", cascade={"all"})
*/
private $logs;
}
The HourlyRate entity listener:
class HourlyRateEntityListener
{
public function preRemove(HourlyRate $hourlyRate, LifecycleEventArgs $args)
{
$entityManager = $args->getObjectManager();
/** #var HourlyRateRepository $HRrepo */
$HRrepo = $entityManager->getRepository(HourlyRate::class);
foreach ($hourlyRate->getLogs() as $log)
{
$rate = $HRrepo->findHourlyRateByDate($log->getDate(), $log->getUser(), $hourlyRate);
$log->setHourlyRate($rate);
}
}
}
The repository method:
class HourlyRateRepository extends ServiceEntityRepository
{
public function findHourlyRateByDate(?\DateTimeInterface $datetime, User $user, ?HourlyRate $ignore = null): ?HourlyRate
{
$qb = $this->createQueryBuilder('hr')
->where('hr.date <= :hr_date')
->andWhere('hr.user = :user')
->orderBy('hr.date', 'DESC')
->setMaxResults(1)
->setParameters(array('hr_date' => $datetime, 'user' => $user));
//ignore the "deleted" hourlyRate
if($ignore){
$qb->andWhere('hr.id != :ignored')
->setParameter('ignored', $ignore->getId());
}
return $qb->getQuery()
->getOneOrNullResult()
;
}
}
Thank you in advance for any of your help.
EDIT:
Okay so after a whole week of trials and errors i finally managed to achieve the result I wanted.
I removed the One-To-Many relation between the hourlyRates and the logs from the entities, but left the $hourlyRate property inside the Log class. Then I got rid of the HourlyRateEntityListener and the preRemove() method from the LogEntityListener. Instead, I implemented the postLoad() method:
class LogEntityListener
{
public function postLoad(Log $log, LifeCycleEventArgs $args)
{
$entityManager = $args->getObjectManager();
$HRrepo = $entityManager->getRepository(HourlyRate::class);
/** #var HourlyRateRepository $HRrepo */
$rate = $HRrepo->findHourlyRateByDate($log->getDate(), $log->getUser());
$log->setHourlyRate($rate);
}
}
This approach allows me to set the proper hourlyRate for each log without involving the database. Idk if this solution is acceptable though.
What I'm trying to do is to append the comments of each article to the articles object, but the problem is that I need to request different number of comments each time.
and for some reason I need to use mutators for that, because some times I request 50 articles and I don't want to loop through the result and append the comments.
So is it possible to do something like the following and how to pass the extra argument.
This the Model:
class Article extends Model
{
protected $appends = ['user', 'comments', 'media'];
public function getCommentsAttribute($data, $maxNumberOfComments = 0)
{
// I need to set maxNumberOfComments
return $this->comments()->paginate($maxNumberOfComments);
}
}
Here is the controller:
class PostsController extends Controller
{
public function index()
{
//This will automatically append the comments to each article but I
//have no control over the number of comments
$posts = Post::user()->paginate(10);
return $posts;
}
}
What I don't want to do is:
class PostsController extends Controller
{
public function index()
{
$articles = Post::user()->all();
$number = 5;
User::find(1)->articles()->map(function(Article $article) {
$article['comments'] = $article->getCommnets($number);
return $article;
});
return Response::json($articles);
}
}
Is there a better way to do it? because I use this a lot and it does not seams right.
Judging from the Laravel source code, no – it's not possible to pass an extra argument to this magic accessor method.
The easiest solution is just to add another, extra method in your class that does accept any parameters you wish – and you can use that method instead of magic property.
Eg. simply rename your getCommentsAttribute() to getComments() and fire ->getComments() instead of ->comments in your view, and you are good to go.
I just set a public property on the model. At the accessing point, I update that property to my desired value. Then, in the attribute method, I read the desired arguments from that property. So, putting all of that together,
// Model.php
public $arg1= true;
public function getAmazingAttribute () {
if ($this->arg1 === false)
$this->relation()->where('col', 5);
else $this->relation()->where('col', 15);
}
// ModelController.php
$instance->arg1 = false;
$instance->append('amazing');
It is been a while for this question, but maybe someone will need it too.
Here is my way
{
/**
* #var string|null
*/
protected ?string $filter = null;
/**
* #return UserSettings[]|null
*/
public function getSettingsAttribute(): ?array
{
return services()->tenants()->settings($this)->getAll();
}
/**
* #return FeatureProperty[]|null
*/
public function getFeaturePropertiesAttribute(): ?array
{
return services()->tenants()->featureProperty($this)->getListByIds($this->filter);
}
/**
* #param string|null $filter
* #return Tenant
*/
public function filter(string $filter = null): Model
{
$this->filter = $filter;
return $this;
}
Accessor is using some service to get values. Service accepts parameters, in my case string, that will be compared with featureProperty->name
Magic happens when you return $this in filter method.
Regular way to call accessor would be:
$model->feature_properties
Extended way:
$model->filter('name')->feature_properties
Since filter argument can be null, we can have accessor like this:
$filter = null
$model->filter($filter)->feature_properties
In case you would like to play with it a little more you can think about overriding models getAttribute or magic __call methods implementing filter in manner which will be similar to laravel scopes
I know its an old question, but there is another option, but maybe not the best:
$articles = Post::user()->all();
$number = 5;
$articles->map(function($a) use($number){
$a->commentsLimit = $number;
return $a;
});
And then in getCommentsAttribute():
return $this->comments()->paginate($this->commentsLimit);