Skip to content

Commit 32e9484

Browse files
Maxcastelsoyuka
andauthored
feat: support relations on filters (#7711)
Co-authored-by: soyuka <soyuka@users.noreply.github.com>
1 parent 64b46b2 commit 32e9484

35 files changed

Lines changed: 2659 additions & 77 deletions

src/Doctrine/Orm/Filter/ExactFilter.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace ApiPlatform\Doctrine\Orm\Filter;
1515

1616
use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait;
17+
use ApiPlatform\Doctrine\Orm\NestedPropertyHelperTrait;
1718
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
1819
use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait;
1920
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
@@ -27,6 +28,7 @@
2728
final class ExactFilter implements FilterInterface, OpenApiParameterFilterInterface
2829
{
2930
use BackwardCompatibleFilterDescriptionTrait;
31+
use NestedPropertyHelperTrait;
3032
use OpenApiFilterTrait;
3133

3234
public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
@@ -42,6 +44,8 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q
4244
$alias = $queryBuilder->getRootAliases()[0];
4345
$parameterName = $queryNameGenerator->generateParameterName($property);
4446

47+
[$alias, $property] = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $parameter);
48+
4549
if (\is_array($value)) {
4650
$queryBuilder
4751
->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s.%s IN (:%s)', $alias, $property, $parameterName));

src/Doctrine/Orm/Filter/FreeTextQueryFilter.php

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use ApiPlatform\Doctrine\Common\Filter\LoggerAwareTrait;
1818
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
1919
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait;
20+
use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper;
2021
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
2122
use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait;
2223
use ApiPlatform\Metadata\Operation;
@@ -51,16 +52,47 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q
5152
$qb->resetDQLPart('where');
5253
$qb->setParameters(new ArrayCollection());
5354
foreach ($this->properties ?? $parameter->getProperties() ?? [] as $property) {
55+
$subParameter = $parameter->withProperty($property);
56+
57+
$nestedPropertiesInfo = $parameter->getExtraProperties()['nested_properties_info'] ?? [];
58+
if (isset($nestedPropertiesInfo[$property])) {
59+
$subParameter = $subParameter->withExtraProperties([
60+
...$subParameter->getExtraProperties(),
61+
'nested_property_info' => $nestedPropertiesInfo[$property],
62+
]);
63+
}
64+
5465
$this->filter->apply(
5566
$qb,
5667
$queryNameGenerator,
5768
$resourceClass,
5869
$operation,
59-
['parameter' => $parameter->withProperty($property)] + $context
70+
['parameter' => $subParameter] + $context
6071
);
6172
}
6273

6374
$queryBuilder->andWhere($qb->getDQLPart('where'));
75+
76+
foreach ($qb->getDQLPart('join') as $joins) {
77+
foreach ($joins as $join) {
78+
$joinString = $join->getJoin();
79+
if (str_contains($joinString, '.')) {
80+
[$parentAlias, $association] = explode('.', $joinString, 2);
81+
QueryBuilderHelper::addJoinOnce(
82+
$queryBuilder,
83+
$queryNameGenerator,
84+
$parentAlias,
85+
$association,
86+
$join->getJoinType(),
87+
$join->getConditionType(),
88+
$join->getCondition(),
89+
null,
90+
$join->getAlias()
91+
);
92+
}
93+
}
94+
}
95+
6496
$parameters = $queryBuilder->getParameters();
6597

6698
foreach ($qb->getParameters() as $p) {

src/Doctrine/Orm/Filter/PartialSearchFilter.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace ApiPlatform\Doctrine\Orm\Filter;
1515

1616
use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait;
17+
use ApiPlatform\Doctrine\Orm\NestedPropertyHelperTrait;
1718
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
1819
use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait;
1920
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
@@ -27,6 +28,7 @@
2728
final class PartialSearchFilter implements FilterInterface, OpenApiParameterFilterInterface
2829
{
2930
use BackwardCompatibleFilterDescriptionTrait;
31+
use NestedPropertyHelperTrait;
3032
use OpenApiFilterTrait;
3133

3234
public function __construct(private readonly bool $caseSensitive = false)
@@ -43,6 +45,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q
4345

4446
$property = $parameter->getProperty();
4547
$alias = $queryBuilder->getRootAliases()[0];
48+
[$alias, $property] = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $parameter);
4649
$field = $alias.'.'.$property;
4750
$values = $parameter->getValue();
4851

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Doctrine\Orm;
15+
16+
use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper;
17+
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
18+
use ApiPlatform\Metadata\Parameter;
19+
use Doctrine\ORM\QueryBuilder;
20+
21+
/**
22+
* Helper trait for handling nested properties.
23+
*
24+
* @author Kévin Dunglas <dunglas@gmail.com>
25+
*/
26+
trait NestedPropertyHelperTrait
27+
{
28+
/**
29+
* Adds the necessary join for a nested property.
30+
*
31+
* @return array An array where the first element is the join $alias of the leaf entity,
32+
* the second element is the leaf property
33+
*/
34+
protected function addJoinsForNestedProperty(
35+
string $property,
36+
string $alias,
37+
QueryBuilder $queryBuilder,
38+
QueryNameGeneratorInterface $queryNameGenerator,
39+
Parameter $parameter,
40+
): array {
41+
$extraProperties = $parameter->getExtraProperties();
42+
$nestedInfo = $extraProperties['nested_property_info'] ?? null;
43+
44+
if (!$nestedInfo) {
45+
return [$alias, $property];
46+
}
47+
48+
foreach ($nestedInfo['converted_relation_segments'] as $association) {
49+
$alias = QueryBuilderHelper::addJoinOnce(
50+
$queryBuilder,
51+
$queryNameGenerator,
52+
$alias,
53+
$association
54+
);
55+
}
56+
57+
return [$alias, $nestedInfo['leaf_property']];
58+
}
59+
}

src/Laravel/ApiPlatformProvider.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,11 @@ public function register(): void
379379
});
380380
}
381381

382+
// Parameter metadata factory with Laravel Eloquent support
383+
$this->app->extend(ResourceMetadataCollectionFactoryInterface::class, static function (ResourceMetadataCollectionFactoryInterface $inner, Application $app) {
384+
return new Metadata\Resource\Factory\ParameterResourceMetadataCollectionFactory($inner, $app->make(ModelMetadata::class), new \Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter());
385+
});
386+
382387
$this->app->singleton(OperationMetadataFactory::class, static function (Application $app) {
383388
return new OperationMetadataFactory($app->make(ResourceNameCollectionFactoryInterface::class), $app->make(ResourceMetadataCollectionFactoryInterface::class));
384389
});

src/Laravel/Eloquent/Filter/EqualsFilter.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
final class EqualsFilter implements FilterInterface
2121
{
22+
use NestedPropertyTrait;
2223
use QueryPropertyTrait;
2324

2425
/**
@@ -27,6 +28,11 @@ final class EqualsFilter implements FilterInterface
2728
*/
2829
public function apply(Builder $builder, mixed $values, Parameter $parameter, array $context = []): Builder
2930
{
30-
return $builder->{$context['whereClause'] ?? 'where'}($this->getQueryProperty($parameter), $values);
31+
return $this->applyWithNestedProperty(
32+
$builder,
33+
$parameter,
34+
static fn (Builder $query, string $property, string $whereClause) => $query->{$whereClause}($property, $values),
35+
$context['whereClause'] ?? 'where'
36+
);
3137
}
3238
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Laravel\Eloquent\Filter;
15+
16+
use ApiPlatform\Metadata\Parameter;
17+
use Illuminate\Database\Eloquent\Builder;
18+
19+
/**
20+
* @internal
21+
*/
22+
trait NestedPropertyTrait
23+
{
24+
/**
25+
* Applies a filter condition supporting nested properties via relationships.
26+
*
27+
* @param Builder<\Illuminate\Database\Eloquent\Model> $builder
28+
* @param callable $condition Callback receiving ($query, $property) to apply the actual filter condition
29+
*
30+
* @return Builder<\Illuminate\Database\Eloquent\Model>
31+
*/
32+
private function applyWithNestedProperty(
33+
Builder $builder,
34+
Parameter $parameter,
35+
callable $condition,
36+
string $whereClause = 'where',
37+
): Builder {
38+
$nestedInfo = $parameter->getExtraProperties()['nested_property_info'] ?? null;
39+
40+
if (!$nestedInfo) {
41+
// No nested property, use simple where clause
42+
$property = $this->getQueryProperty($parameter);
43+
44+
return $condition($builder, $property, $whereClause);
45+
}
46+
47+
// Handle nested property using whereHas
48+
// For Laravel Eloquent, use the original relation names (camelCase method names)
49+
// not the converted names (snake_case database columns)
50+
$relationSegments = $nestedInfo['relation_segments'];
51+
$leafProperty = $nestedInfo['leaf_property'];
52+
53+
if (0 === \count($relationSegments)) {
54+
// Edge case: no relations, just a property
55+
return $condition($builder, $leafProperty, $whereClause);
56+
}
57+
58+
// Build nested whereHas callbacks from innermost to outermost
59+
// For product.name: whereHas('product', fn($q) => $q->where('name', ...))
60+
// For product.variations.code: whereHas('product', fn($q) => $q->whereHas('variations', fn($q2) => $q2->where('code', ...)))
61+
62+
$callback = static function ($query) use ($leafProperty, $condition, $whereClause) {
63+
return $condition($query, $leafProperty, $whereClause);
64+
};
65+
66+
// Build the chain from the end to the beginning
67+
for ($i = \count($relationSegments) - 1; $i > 0; --$i) {
68+
$relation = $relationSegments[$i];
69+
$previousCallback = $callback;
70+
$callback = static function ($query) use ($relation, $previousCallback) {
71+
return $query->whereHas($relation, $previousCallback);
72+
};
73+
}
74+
75+
// Apply the outermost whereHas
76+
return $builder->whereHas($relationSegments[0], $callback);
77+
}
78+
}

src/Laravel/Eloquent/Filter/PartialSearchFilter.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
final class PartialSearchFilter implements FilterInterface
2121
{
22+
use NestedPropertyTrait;
2223
use QueryPropertyTrait;
2324

2425
/**
@@ -27,6 +28,11 @@ final class PartialSearchFilter implements FilterInterface
2728
*/
2829
public function apply(Builder $builder, mixed $values, Parameter $parameter, array $context = []): Builder
2930
{
30-
return $builder->{$context['whereClause'] ?? 'where'}($this->getQueryProperty($parameter), 'like', '%'.$values.'%');
31+
return $this->applyWithNestedProperty(
32+
$builder,
33+
$parameter,
34+
static fn (Builder $query, string $property, string $whereClause) => $query->{$whereClause}($property, 'like', '%'.$values.'%'),
35+
$context['whereClause'] ?? 'where'
36+
);
3137
}
3238
}

src/Laravel/Eloquent/Metadata/ModelMetadata.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,31 @@ private function attributeIsHidden(string $attribute, Model $model): bool
315315
return false;
316316
}
317317

318+
/**
319+
* Gets the related model class for a given relationship property.
320+
*
321+
* @param class-string<Model> $modelClass The current model class
322+
* @param string $property The property/relationship name
323+
*
324+
* @return class-string<Model>|null The related model class, or null if not a relationship
325+
*/
326+
public function getRelatedModelClass(string $modelClass, string $property): ?string
327+
{
328+
if (!class_exists($modelClass)) {
329+
return null;
330+
}
331+
332+
$relations = $this->getRelations(new $modelClass());
333+
334+
foreach ($relations as $relation) {
335+
if ($relation['method_name'] === $property || $relation['name'] === $property) {
336+
return $relation['related'];
337+
}
338+
}
339+
340+
return null;
341+
}
342+
318343
/**
319344
* Determines if the given attribute is unique.
320345
*

0 commit comments

Comments
 (0)