How do I use QueryBuilder with andWhere for json - symfony

please I am just new to querybuilder and precisely I don't know how to work with json object in a where clause. I would appreciate your swift assistance.
"post_id": 1
"post": ""
"author": [{"id": 2, "email": "example#example.com"}, {"id": 3, "email": "example2#example.com"}]
"post_id": 2
"post": ""
"author": [{"id": 4, "email": "example#example.com"}, {"id": 9, "email": "example2#example.com"}]
I want to query the table Post where the author->id is current_user. I have tried all I can. I have installed composer require scienta/doctrine-json-functions
When I ran it, I got
"An exception occurred while executing a query: SQLSTATE[42883]: Undefined function: 7 ERROR: operator does not exist: json = unknown\nLINE 1: ... w0_ WHERE json_extract_path(w0_.author, $1) = $2 ORDER...\n ^\nHINT: No operator matches the given name and argument type(s). You might need to add explicit type casts.",
I am out of ideas.
private function addWhere(QueryBuilder $queryBuilder, string $resourceClass): void
{
if (WorkshopSession::class !== $resourceClass || $this->security->isGranted('ROLE_ADMIN') || null === $user = $this->security->getUser()) {
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
$queryBuilder->andWhere("JSON_EXTRACT_PATH(o.author, :id) = :current_user");
$queryBuilder->setParameter('id', 'o.id');
$queryBuilder->setParameter('current_user', $user->getId());
}
I am using api-platform and using doctrine extensions. Database Server - postgreSQL

First thing you need to do, is to switch option to jsonb that would help you traverse the json tree easily without putting yourself in a box.
select * from post where author #> '[{"id": "2"}]';
Running this in your traditional sql should work, then convert this to DQL. This may be tricky as there is no '#>' in DQL but you can take advantage of custom DQL User Defined Function. I recommend doing this instead of installing a third party library that would add a layer of abstraction and complexity with versioning issue (deprecation).
As you can see below, we make use of FunctionNode, SqlWalker, Lexer and Parser.
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\Query\AST\Node;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\Lexer;
use function sprintf;
class JsonContains extends FunctionNode
{
/** #var Node */
/** #psalm-suppress all */
private $expr1;
/** #var Node */
/** #psalm-suppress all */
private $expr2;
public function parse(Parser $parser) : void
{
$parser->match(Lexer::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS);
$this->expr1 = $parser->StringPrimary();
$parser->match(Lexer::T_COMMA);
$this->expr2 = $parser->StringPrimary();
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
}
public function getSql(SqlWalker $sqlWalker) : string
{
return sprintf(
'(%s #> %s)',
$this->expr1->dispatch($sqlWalker),
$this->expr2->dispatch($sqlWalker)
);
}
}
One you have defined this, you can register it. Symfony 5 up suggests to use doctrine.yaml. You can register it as follows
dql:
string_functions:
JSON_CONTAINS: App\Doctrine\Extension\Functions\DQL\UDF\JsonContains
Then in your extensions, you can simply use it.
$queryBuilder->andWhere("JSON_CONTAINS(".$rootAlias .".author, :current_user) = true");
$queryBuilder->setParameter('current_user', json_encode([['id' => $user->getId()]]));
This may definitely help others going through it.
Cheers!

Related

How to configure the Symfony serializer to link relational fields when deserialising?

Objective:
I'm importing a bunch of JSON files data into the database. Keeping the id fields the same as in the json files and link the relational id's to existing rows.
Problem:
When deserialising relational fields, the serialiser is inserting new empty records rather than linking them to existing rows.
Context:
I'm deserialising the files into respective entity objects.
Let's focus on one called Region.json which has an entity called Region and has a ManyToOne relation to Country.
Here is a snippet from Region.json the fields are the same as the entity properties.
[
{
"id": 1,
"name": "Aera",
"code": AR",
"country": 1, // relational field
"isActive": true,
},
{
"id": 2,
"name": "Mauw",
"code": "MW",
"country": 8, // relational field
"isActive": true,
}
]
The deserialisation process is as follows:
public function getDeserializeData(): mixed
{
$classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader()));
$normalizers = [new ObjectNormalizer( classMetadataFactory: $classMetadataFactory,propertyTypeExtractor: new ReflectionExtractor()), new GetSetMethodNormalizer(), new ArrayDenormalizer()];
$encoders = [new JsonEncoder(), new XmlEncoder(), new CsvEncoder()];
$serializer = new Serializer(
normalizers: $normalizers,
encoders: $encoders
);
return $serializer->deserialize(
$this->staticDataFile->getContents(),
$this->getEntityNamespace() . '[]',
$this->staticDataFile->getExtension()
);
}
I'm using the ReflectionExtractor because are you can see the json data files have pre-defined ids and this can not be changed.
If I try to change the generated value strategy from 'IDENTITY' to 'NONE' I get the following error:
Entity of type App\Entity\Country is missing an assigned ID for field 'id'. The identifier generation strategy for this
entity requires the ID field to be populated before EntityManager#persist() is called. If you want automatically genera
ted identifiers instead you need to adjust the metadata mapping accordingly.
You will likely need a custom (De-)Normalizer for this, designed for each specific entity, e.g. for Region. Then you know, which fields contain associated data like country and how to search for that data. Your normalizer will take the id from the input, get the country from the database and add it in place of the number. It could look roughly like this:
class RegionDenormalizer implements DenormalizerInterface
{
public function __construct(
private CountryRepository $countryRepository,
) {}
public functionsupportsDenormalization(mixed $data, string $type, string $format = null /* , array $context = [] */)
{
return $type === Region::class;
}
public function denormalize(mixed $data, string $type, string $format = null, array $context = [])
{
$country = $this->countryRepository->find($data[’country’];
if (!$country instanceof Country)
{
// throw an Exception probably
}
$region = $context[AbstractNormalizer::OBJECT_TO_POPULATE];
$region->setCountry($country);
// Probably also set the other fields
}
}
You can also use $context to prevent your Denormalizer from being called twice, replace the id with the country in data and then use the original ObjectNormalizer. This is a bit more complicated, but I prefer this:
class RegionDenormalizer implements DenormalizerInterface, DenormalizerAwareInterface
{
use DenormalizerAwareTrait;
public function __construct(
private CountryRepository $countryRepository,
) {}
public functionsupportsDenormalization(mixed $data, string $type, string $format = null /* , array $context = [] */)
{
return $type === Region::class
&& !in_array($data[‘id’], $context[‘visited_regions’] ?? []);
}
public function denormalize(mixed $data, string $type, string $format = null, array $context = [])
{
$innerContext = $context;
$innerContext[‘visited_regions’][] = $data[‘id’];
$country = $this->countryRepository->find($data[’country’];
if (!$country instanceof Country)
{
// throw an Exception probably
}
$innerContext = $context;
// By setting this inner context, we prevent this listener from being called again for this region
$innerContext[‘visited_regions’][] = $data[‘id’];
// By replacing the country in data, we now have the expected country instead of the id or a new entity
$data[‘country’] = $country;
return $this->denormalizer->denormalize($data, $type, $innerContext);
}
}
I prefer this, because I don’t have to care about how to deserialize the region itself, only about replacing the country-id with the actual instance, but handling the context is more difficult.
Note: the single quotes in the code samples are wrong, because I am typing this on an iPad. You will have to replace them.

Conditionally displaying specific routes on OpenAPI (aka Swagger) documentation generated by API-Platform

I wish to limit the routes displayed by the API-Platform generated OpenAPI documentation based on each route's security attribute and the logged on user's roles (i.e. only ROLE_ADMIN can see the OpenApi documentation).
A similar question was earlier asked and this answer partially answered it but not completely:
This isn't supported out of the box (but it would be a nice
contribution). What you can do is to decorate the
DocumentationNormalizer to unset() the paths you don't want to appear
in the OpenAPI documentation.
More information:
https://api-platform.com/docs/core/swagger/#overriding-the-openapi-specification
It appears that DocumentationNormalizer is depreciated and that one should decorate OpenApiFactoryInterface instead.
Attempting to implement, I configured config/services.yaml to decorate OpenApiFactory as shown below.
The first issue is I am unable to "unset() the paths you don't want to appear". The paths exist within the \ApiPlatform\Core\OpenApi\Model\Paths property of \ApiPlatform\Core\OpenApi\OpenApi, but there is only the ability to add additional paths to Paths and not remove them. I've come up with a solution which is shown in the below code which creates new objects and only adds back properties if they do not require admin access, but I suspect that doing so is not the "right way" to do this. Also, I just realized while it removed the documentation from SwaggerUI's routes, it did not remove it from the Schema displayed below the routes.
The second issue is how to determine which paths to display, and I temporarily hardcoded them in my getRemovedPaths() method. First, I will need to add a logon form to the SwaggerUi page so that we know the user's role which is fairly straightforward. Next, however, I will need to obtain the security attributes associated with each route so that I could determine whether a given route should be displayed, however, I have no idea how to do so. I expected the necessary data to be in each ApiPlatform\Core\OpenApi\Model\PathItem, however, there does not appear to be any methods to retrieve it and the properties are private. I also attempted to access the information by using \App\Kernel::getContainer()->get('router'), but was not successful locating the route security attributes.
In summary, how should one prevent routes from being displayed by the API-Platform generated OpenAPI documentation if the user does not have authority to access the route?
config/services.yaml
services:
App\OpenApi\OpenApiFactory:
decorates: 'api_platform.openapi.factory'
arguments: [ '#App\OpenApi\OpenApiFactory.inner' ]
autoconfigure: false
App/OpenApi/OpenApiFactory
<?php
namespace App\OpenApi;
use ApiPlatform\Core\OpenApi\Factory\OpenApiFactoryInterface;
use ApiPlatform\Core\OpenApi\OpenApi;
use ApiPlatform\Core\OpenApi\Model\Paths;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use App\Kernel;
class OpenApiFactory implements OpenApiFactoryInterface {
private $decorated, $tokenStorage, $kernel;
public function __construct(OpenApiFactoryInterface $decorated, TokenStorageInterface $tokenStorage, Kernel $kernel)
{
$this->decorated = $decorated;
$this->tokenStorage = $tokenStorage;
$this->kernel = $kernel;
}
public function __invoke(array $context = []): OpenApi
{
//$this->debug($context);
$openApi = $this->decorated->__invoke($context);
$removedPaths = $this->getRemovedPaths();
$paths = new Paths;
$pathArray = $openApi->getPaths()->getPaths();
foreach($openApi->getPaths()->getPaths() as $path=>$pathItem) {
if(!isset($removedPaths[$path])) {
// No restrictions
$paths->addPath($path, $pathItem);
}
elseif($removedPaths[$path]!=='*') {
// Remove one or more operation
foreach($removedPaths[$path] as $operation) {
$method = 'with'.ucFirst($operation);
$pathItem = $pathItem->$method(null);
}
$paths->addPath($path, $pathItem);
}
// else don't add this route to the documentation
}
$openApiTest = $openApi->withPaths($paths);
return $openApi->withPaths($paths);
}
private function getRemovedPaths():array
{
/*
Instead of hardcoding removed paths, remove all paths which $user does not have access to based on the route's security attributes and the user's credentials.
This hack returns an array with the path as the key, and either "*" to remove all operations or an array to remove specific operations.
*/
$user = $this->tokenStorage->getToken()->getUser();
return [
'/guids'=>'*', // Remove all operations
'/guids/{guid}'=>'*', // Remove all operations
'/accounts'=>['post'], // Remove only post operation
'/accounts/{uuid}'=>['delete'], // Remove only delete operation
];
}
private function debug(array $context = [])
{
$this->display($context, '$context');
$openApi = $this->decorated->__invoke($context);
$this->displayGetters($openApi);
$pathObject = $openApi->getPaths();
$this->displayGetters($pathObject, null, ['getPath', 'getPaths']);
$pathsArray = $pathObject->getPaths();
$this->display($pathsArray, '$openApi->getPaths()->getPaths()', true);
$pathItem = $pathsArray['/accounts'];
$this->displayGetters($pathItem);
$getGet = $pathItem->getGet();
$this->displayGetters($getGet, '$pathItem->getGet()', ['getResponses']);
$this->display($getGet->getTags(), '$getGet->getTags()');
$this->display($getGet->getParameters(), '$getGet->getParameters()');
$this->display($getGet->getSecurity(), '$getGet->getSecurity()');
$this->display($getGet->getExtensionProperties(), '$getGet->getExtensionProperties()');
$this->displayGetters($this->kernel, null, ['getBundles', 'getBundle']);
$container = $this->kernel->getContainer();
$this->displayGetters($container, null, ['getRemovedIds', 'getParameter', 'get', 'getServiceIds']);
$router = $container->get('router');
$this->displayGetters($router, null, ['getOption']);
$routeCollection = $router->getRouteCollection();
$this->displayGetters($routeCollection, null, ['get']);
$this->displayGetters($this, '$this');
$this->displayGetters($this->decorated, '$this->decorated');
$components = $openApi->getComponents ();
$this->displayGetters($components, null, []);
}
private function displayGetters($obj, ?string $notes=null, array $exclude=[])
{
echo('-----------------------------------------------------------'.PHP_EOL);
if($notes) {
echo($notes.PHP_EOL);
}
echo(get_class($obj).PHP_EOL);
echo('get_object_vars'.PHP_EOL);
print_r(array_keys(get_object_vars($obj)));
echo('get_class_methods'.PHP_EOL);
print_r(get_class_methods($obj));
foreach(get_class_methods($obj) as $method) {
if(substr($method, 0, 3)==='get') {
if(!in_array($method, $exclude)) {
$rs = $obj->$method();
$type = gettype($rs);
switch($type) {
case 'object':
printf('type: %s path: %s method: %s'.PHP_EOL, $type, $method, get_class($rs));
print_r(get_class_methods($rs));
break;
case 'array':
printf('type: %s method: %s'.PHP_EOL, $type, $method);
print_r($rs);
break;
default:
printf('type: %s method: %s, value: %s'.PHP_EOL, $type, $method, $rs);
}
}
else {
echo('Exclude method: '.$method.PHP_EOL);
}
}
}
}
private function display($rs, string $notes, bool $keysOnly = false)
{
echo('-----------------------------------------------------------'.PHP_EOL);
echo($notes.PHP_EOL);
print_r($keysOnly?array_keys($rs):$rs);
}
}

Api platform aliasing filters for nested resources

I'm currently using API Platform and its default SearchFilter and it works as intended.
However, filtering on a deep relationship between resources can be heavy by its quite long query string in the url. (I have got multiple entities like this.)
For instance I want to search every books listed in the stores of a specific country :
{url}/books?department.store.city.country.name=italy
Is there any way to edit the #ApiFilter(SearchFilter::class, properties={}) in order to get simply at the end ?
{url}/books?country_filter=italy
Thanks !
Thank you for your advices,
After some (hours of) researches, I came to the conclusion to extend the SearchFilter when creating my personnal CountryFilter :
In my entity class :
/*
* #ApiFilter(CountryFilter::class, properties={
* "country_filter": "department.store.city.country.name",
* })
*/
In my App\Filter\CountryFilter.php :
<?php
namespace App\Filter;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractContextAwareFilter;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use Doctrine\ORM\QueryBuilder;
final class CountryFilter extends SearchFilter
{
protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
{
foreach($this->properties as $alias => $propertyName){
if($alias == $property){
$property = $propertyName;
break;
}
}
/*
if (
null === $value ||
!$this->isPropertyEnabled($property, $resourceClass) ||
!$this->isPropertyMapped($property, $resourceClass, true)
) {
return;
}
*/
// The rest of the SearchFilter.php copy/pasted code ...
}
public function getDescription(string $resourceClass): array
{
// ....
}
}
You can make your one custom api filter and add your own logic in it.Call it country_filter and pass one value only, after that a custom query will search in database and return the rows. To make one you have to extend the AbstractFilter class and after that you have to add this filter it in your entity. A good tutorial from official site is here and the next chapter here

what is the correct way to pass not services to the constructor of a custom normalize in symfony

I'm creating my own Normalizer, based on the tutorial on the Symfony documentation page https://symfony.com/doc/current/serializer/custom_normalizer.html, which I find incomplete because it tells you how to create it but not apply it, that's the first point.
Then based on my little experience in Symfony I'm trying to guess how to pass data to the normalizer to be the proper calculations, the data I'm trying to pass are not services, which can be a String or a Request object, but none of this data allows me, really I need to understand or I need to refactor to get what I want?
I put my normalizer code to understand well what I am looking for.
Normalizer:
<?php
namespace App\Serializer;
use App\Entity\Task;
use App\Traits\TaskControl;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
class TaskNormalizer implements NormalizerInterface
{
use TaskControl;
private $normalizer;
private $rangeDate;
public function __construct(ObjectNormalizer $normalizer, $rangeDate )
{
$this->normalizer = $normalizer;
$this->rangeDate = $rangeDate;
}
public function normalize($task, $format = null, array $context = [])
{
$data = $this->normalizer->normalize($task, $format, $context);
dd($this->rangeDate);
$data['totalWork'] = $this->timeTask($task,$this->rangeDate);
return $data;
}
public function supportsNormalization($task, $format = null, array $context = []): bool
{
return $task instanceof Task;
}
}
Applying the normalizer:
Passing the variable $rangeDate that is dynamic from object Request.
$rangeDate = $request->request->get('range','all');
$serializer = new Serializer([new TaskNormalizer($normalizer,$rangeDate)]);
$data = $serializer->normalize($attendances, null, ['attributes' => $attributes]);
and this is the error I get:
Cannot autowire service "App\Serializer\TaskNormalizer": argument "$rangeDate" of method "__construct()" has no type-hint, you should configure its value explicitly.
Why would you pass your range date as constructor argument?
Normalizer is a service dependency, rangeDate is a dynamic value.
You can pass it as an argument for the method normalize instead either as a new argument, or in the context array:
$rangeDate = $request->request->get('range','all');
$serializer = new Serializer([new TaskNormalizer($normalizer)]);
$data = $serializer->normalize($attendances, null, ['attributes' => $attributes, 'rangeDate' => $rangeDate]);
You'll have t odeclare your service explicitely... something like this should do the trick:
## services.yml
App\Serializer\TaskNormalizer :
arguments:
$normalizer: '#serializer.normalizer.object' ## check the alias ...
$rangeDate: '%range_date%'
Keep in mind that it is better depend on interface than class, for the sake of dependency inversion principle. So you should think about changing the constructor to :
## your class
public function __construct(NormalizerInterface $normalizer, $rangeDate )
{
$this->normalizer = $normalizer;
$this->rangeDate = $rangeDate;
}

how to access annotation of an property(class,mappedBy,inversedBy)

Good morning,
Is it exist an function where I pass an entity and the propertyName and return me the mappedBy,inversedBy and absoluteClassName of an Entity.
The goal is to use the __call to create automatic getteur/setteur and addFucntion bidirectionnal.
I don't want to use generates Entities I want all getteur,setteur and add Function use __call.
But i can"t do an addBirectionnal if i don't know if the relation is many to many or one to many and if i don't know the name of the mappedBy.
my code:
public function __get($p){
return $this->$p;
}
public function __set($p,$v){
$this->$p = $v;
return $this;
}
public function __call($name,$arguments){
if(substr($name,1,3)=='et')
$name2 = substr(3);
if($name[0] == 'g'){
return $this->$name2;
}else{//substr($name,0,1) == 's'
$this->$name2 = $arguments[0];
/*for a one to one*/
/*$mappedByName= getmappedByOrInversedBy(get_class($name),$name2);
if($mappedByName){
$this->$name->$mappedByName = $this;/
}*/
return $this;
}
}
}
I need getmappedByOrInversedBy, thanks.
edit: I try this
public function test(){
$str = "AppBundle\Entity\Group";
$mapping = new \Doctrine\ORM\Mapping\ClassMetadataInfo($str);
$d = $mapping->getAssociationMappedByTargetField('trad');
var_dump($d);
return $this->render('default/index.html.twig', array(
'base_dir' => realpath($this->getParameter('kernel.root_dir').'/..'),
));
}
class Group
{
...
/**
* #ORM\OneToOne(targetEntity="Traduction",inversedBy="grp")
*/
protected $trad;
}
Result : Undefined index: trad
The ClassMetadataInfo is what you are looking for.
Creates an instance with the entityName :
$mapping = new \Doctrine\ORM\Mapping\ClassMetadataInfo($entityNamespaceOrAlias);
Then, get the informations you want :
Get all association names: $mapping->getAssociationNames();
Get the join column of an association:
$mapping->getSingleAssociationJoinColumnName($fieldName);
Get the mappedBy column of an association:
$mapping->getAssociationMappedByTargetField($fieldName);
...
Look at the class to know which method you can access.
Hopes it's what you expect.
EDIT
As you can access the EntityManager (i.e. from a controller), use :
$em = $this->getDoctrine()->getManager();
$metadata = $em->getClassMetadata('AppBundle:Group');
To be sure there is no problem with your entity namespace, try :
print $metadata->getTableName();
To retrieve the associations of the entity, use :
$metadata->getAssociationNames();
And to get the mapping informations of an existing association, use :
$metadata->getAssociationMapping($fieldName);
And to get all the association mappings of your entity, use:
$metadata->getAssociationMappings();

Resources