Skip to content

Doctrine filters: Allow nested properties (across relations) #242

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Nov 12, 2015
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
## 2.0.0

* Use string values for `Dunglas\ApiBundle\Doctrine\Orm\Filter\DateFilter` null-management constants
* Support nested properties in Doctrine filters
* Add method to avoid naming collision of DQL join alias and bound parameter name

## 1.0.0 beta 3

Expand Down
72 changes: 63 additions & 9 deletions Doctrine/Orm/Extension/PaginationExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@

namespace Dunglas\ApiBundle\Doctrine\Orm\Extension;

use Doctrine\Common\Persistence\ManagerRegistry;
use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrineOrmPaginator;
use Dunglas\ApiBundle\Api\ResourceInterface;
use Dunglas\ApiBundle\Doctrine\Orm\Paginator;
use Dunglas\ApiBundle\Doctrine\Orm\QueryResultExtensionInterface;
use Dunglas\ApiBundle\Doctrine\Orm\Util\QueryChecker;
use Symfony\Component\HttpFoundation\Request;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrineOrmPaginator;
use Symfony\Component\HttpFoundation\RequestStack;

/**
Expand All @@ -27,16 +29,23 @@
*/
class PaginationExtension implements QueryResultExtensionInterface
{
/**
* @var ManagerRegistry
*/
private $managerRegistry;

/**
* @var RequestStack
*/
private $requestStack;

/**
* @param RequestStack $requestStack
* @param ManagerRegistry $managerRegistry
* @param RequestStack $requestStack
*/
public function __construct(RequestStack $requestStack)
public function __construct(ManagerRegistry $managerRegistry, RequestStack $requestStack)
{
$this->managerRegistry = $managerRegistry;
$this->requestStack = $requestStack;
}

Expand All @@ -54,8 +63,7 @@ public function applyToCollection(ResourceInterface $resource, QueryBuilder $que

$queryBuilder
->setFirstResult(($this->getPage($resource, $request) - 1) * $itemsPerPage)
->setMaxResults($itemsPerPage)
;
->setMaxResults($itemsPerPage);
}

/**
Expand All @@ -65,7 +73,9 @@ public function applyToCollection(ResourceInterface $resource, QueryBuilder $que
*/
public function supportsResult(ResourceInterface $resource)
{
return $this->isPaginationEnabled($resource, $this->requestStack->getCurrentRequest());
$request = $this->requestStack->getCurrentRequest();

return $request !== null && $this->isPaginationEnabled($resource, $request);
}

/**
Expand All @@ -74,8 +84,8 @@ public function supportsResult(ResourceInterface $resource)
public function getResult(QueryBuilder $queryBuilder)
{
$doctrineOrmPaginator = new DoctrineOrmPaginator($queryBuilder);
// Disable output walkers by default (performance)
$doctrineOrmPaginator->setUseOutputWalkers(false);

$doctrineOrmPaginator->setUseOutputWalkers($this->useOutputWalkers($queryBuilder));

return new Paginator($doctrineOrmPaginator);
}
Expand Down Expand Up @@ -123,10 +133,54 @@ private function getPage(ResourceInterface $resource, Request $request)
private function getItemsPerPage(ResourceInterface $resource, Request $request)
{
if ($resource->isClientAllowedToChangeItemsPerPage()
&& $itemsPerPage = $request->get($resource->getItemsPerPageParameter())) {
&& $itemsPerPage = $request->get($resource->getItemsPerPageParameter())
) {
return (float) $itemsPerPage;
}

return $resource->getItemsPerPageByDefault();
}

/**
* Determines whether output walkers should be used.
*
* @param QueryBuilder $queryBuilder
*
* @return bool
*/
private function useOutputWalkers(QueryBuilder $queryBuilder)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally this should belong in Doctrine ORM?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At least in an other place than here yep :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the problem to have it here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just thought it could be useful in Doctrine ORM (of course, that's subject to the opinion of Doctrine maintainers).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dunglas I too don't get why this should be in another place, when it's specific to Doctrine pagination. But then again I'm not sure I can understand very well the other strong opinions of @sroze

{
/*
* "Cannot count query that uses a HAVING clause. Use the output walkers for pagination"
*
* @see https://github.com/doctrine/doctrine2/blob/900b55d16afdcdeb5100d435a7166d3a425b9873/lib/Doctrine/ORM/Tools/Pagination/CountWalker.php#L50
*/
if (QueryChecker::hasHavingClause($queryBuilder)) {
return true;
}

/*
* "Paginating an entity with foreign key as identifier only works when using the Output Walkers. Call Paginator#setUseOutputWalkers(true) before iterating the paginator."
*
* @see https://github.com/doctrine/doctrine2/blob/900b55d16afdcdeb5100d435a7166d3a425b9873/lib/Doctrine/ORM/Tools/Pagination/LimitSubqueryWalker.php#L87
*/
if (QueryChecker::hasRootEntityWithForeignKeyIdentifier($queryBuilder, $this->managerRegistry)) {
return true;
}

/*
* "Cannot select distinct identifiers from query with LIMIT and ORDER BY on a column from a fetch joined to-many association. Use output walkers."
*
* @see https://github.com/doctrine/doctrine2/blob/900b55d16afdcdeb5100d435a7166d3a425b9873/lib/Doctrine/ORM/Tools/Pagination/LimitSubqueryWalker.php#L149
*/
if (
QueryChecker::hasMaxResults($queryBuilder)
&& QueryChecker::hasOrderByOnToManyJoin($queryBuilder, $this->managerRegistry)
) {
return true;
}

// Disable output walkers by default (performance)
return false;
}
}
106 changes: 104 additions & 2 deletions Doctrine/Orm/Filter/AbstractFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Doctrine\Common\Persistence\ManagerRegistry;
use Doctrine\Common\Persistence\Mapping\ClassMetadata;
use Dunglas\ApiBundle\Api\ResourceInterface;
use Dunglas\ApiBundle\Util\RequestParser;
use Symfony\Component\HttpFoundation\Request;

/**
Expand Down Expand Up @@ -61,15 +62,102 @@ protected function getClassMetadata(ResourceInterface $resource)
}

/**
* Is the given property enabled?
* Determines whether the given property is enabled.
*
* @param string $property
*
* @return bool
*/
protected function isPropertyEnabled($property)
{
return null === $this->properties || array_key_exists($property, $this->properties);
if (null === $this->properties) {
// to ensure sanity, nested properties must still be explicitly enabled
return !$this->isPropertyNested($property);
}

return array_key_exists($property, $this->properties);
}

/**
* Determines whether the given property is mapped.
*
* @param string $property
* @param ResourceInterface $resource
* @param bool $allowAssociation
*
* @return bool
*/
protected function isPropertyMapped($property, ResourceInterface $resource, $allowAssociation = false)
{
if ($this->isPropertyNested($property)) {
$propertyParts = $this->splitPropertyParts($property);
$metadata = $this->getNestedMetadata($resource, $propertyParts['associations']);
$property = $propertyParts['field'];
} else {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same idea here, please prevent usage of else when possible :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO if-else is perfectly appropriate for branching (which is exactly what's happening here). I'd agree with you if it's checking of a precondition or otherwise bailing out early.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that here the else will still be used but the code can be simplified:

protected function isPropertyMapped($property, ResourceInterface $resource, $allowAssociation = false)
{
    if ($this->isPropertyNested($property)) {
        $propertyParts = $this->splitPropertyParts($property);
        $metadata = $this->getNestedMetadata($resource, $propertyParts['associations']);
        $property = $propertyParts['field'];
    } else {
        $metadata = $this->getClassMetadata($resource);
    }

    return $metadata->hasField($property) || ($allowAssociation && $metadata->hasAssociation($property));
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! :)

$metadata = $this->getClassMetadata($resource);
}

return $metadata->hasField($property) || ($allowAssociation && $metadata->hasAssociation($property));
}

/**
* Determines whether the given property is nested.
*
* @param string $property
*
* @return bool
*/
protected function isPropertyNested($property)
{
return false !== strpos($property, '.');
}

/**
* Gets nested class metadata for the given resource.
*
* @param ResourceInterface $resource
* @param string[] $associations
*
* @return ClassMetadata
*/
protected function getNestedMetadata(ResourceInterface $resource, array $associations)
{
$metadata = $this->getClassMetadata($resource);

foreach ($associations as $association) {
if ($metadata->hasAssociation($association)) {
$associationClass = $metadata->getAssociationTargetClass($association);

$metadata = $this
->managerRegistry
->getManagerForClass($associationClass)
->getClassMetadata($associationClass)
;
}
}

return $metadata;
}

/**
* Splits the given property into parts.
*
* Returns an array with the following keys:
* - associations: array of associations according to nesting order
* - field: string holding the actual field (leaf node)
*
* @param string $property
*
* @return array
*/
protected function splitPropertyParts($property)
{
$parts = explode('.', $property);

return [
'associations' => array_slice($parts, 0, -1),
'field' => end($parts),
];
}

/**
Expand All @@ -81,6 +169,20 @@ protected function isPropertyEnabled($property)
*/
protected function extractProperties(Request $request)
{
$needsFixing = false;

if (null !== $this->properties) {
foreach ($this->properties as $property => $value) {
if ($this->isPropertyNested($property) && $request->query->has(str_replace('.', '_', $property))) {
$needsFixing = true;
}
}
}

if ($needsFixing) {
$request = RequestParser::parseAndDuplicateRequest($request);
}

return $request->query->all();
}
}
Loading