Skip to content

Commit d640d10

Browse files
aaa2000soyuka
andauthored
feat(doctrine): uuid filter (#7628)
Co-authored-by: soyuka <soyuka@users.noreply.github.com>
1 parent 14b754a commit d640d10

24 files changed

Lines changed: 1669 additions & 3 deletions

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"conflict": {
3636
"doctrine/common": "<3.2.2",
3737
"doctrine/dbal": "<2.10",
38-
"doctrine/orm": "<2.14.0",
38+
"doctrine/orm": "<2.14.0 || 3.0.0",
3939
"doctrine/mongodb-odm": "<2.4",
4040
"doctrine/persistence": "<1.3",
4141
"symfony/framework-bundle": "6.4.6 || 7.0.6",
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
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\Filter;
15+
16+
use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface;
17+
use ApiPlatform\Doctrine\Common\Filter\LoggerAwareTrait;
18+
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
19+
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait;
20+
use ApiPlatform\Doctrine\Common\PropertyHelperTrait;
21+
use ApiPlatform\Doctrine\Orm\PropertyHelperTrait as OrmPropertyHelperTrait;
22+
use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper;
23+
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
24+
use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait;
25+
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
26+
use ApiPlatform\Metadata\JsonSchemaFilterInterface;
27+
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
28+
use ApiPlatform\Metadata\Operation;
29+
use ApiPlatform\Metadata\Parameter;
30+
use ApiPlatform\Metadata\QueryParameter;
31+
use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter;
32+
use Doctrine\DBAL\ArrayParameterType;
33+
use Doctrine\DBAL\ParameterType;
34+
use Doctrine\DBAL\Types\ConversionException;
35+
use Doctrine\DBAL\Types\Type;
36+
use Doctrine\ORM\Query\Expr\Join;
37+
use Doctrine\ORM\QueryBuilder;
38+
39+
/**
40+
* @internal
41+
*/
42+
class AbstractUuidFilter implements FilterInterface, ManagerRegistryAwareInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface, LoggerAwareInterface
43+
{
44+
use BackwardCompatibleFilterDescriptionTrait;
45+
use LoggerAwareTrait;
46+
use ManagerRegistryAwareTrait;
47+
use OrmPropertyHelperTrait;
48+
use PropertyHelperTrait;
49+
50+
private const UUID_SCHEMA = [
51+
'type' => 'string',
52+
'format' => 'uuid',
53+
];
54+
55+
public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
56+
{
57+
$parameter = $context['parameter'] ?? null;
58+
if (!$parameter) {
59+
return;
60+
}
61+
62+
$this->filterProperty($parameter->getProperty(), $parameter->getValue(), $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context);
63+
}
64+
65+
private function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
66+
{
67+
$alias = $queryBuilder->getRootAliases()[0];
68+
$field = $property;
69+
70+
$associations = [];
71+
if ($this->isPropertyNested($property, $resourceClass)) {
72+
[$alias, $field, $associations] = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $resourceClass, Join::INNER_JOIN);
73+
}
74+
75+
$metadata = $this->getNestedMetadata($resourceClass, $associations);
76+
77+
if ($metadata->hasField($field)) {
78+
$value = $this->convertValuesToTheDatabaseRepresentation($queryBuilder, $this->getDoctrineFieldType($property, $resourceClass), $value);
79+
$this->addWhere($queryBuilder, $queryNameGenerator, $alias, $field, $value);
80+
81+
return;
82+
}
83+
84+
// metadata doesn't have the field, nor an association on the field
85+
if (!$metadata->hasAssociation($field)) {
86+
$this->logger->notice('Tried to filter on a non-existent field or association', [
87+
'field' => $field,
88+
'resource_class' => $resourceClass,
89+
'exception' => new InvalidArgumentException(\sprintf('Property "%s" does not exist in resource "%s".', $field, $resourceClass)),
90+
]);
91+
92+
return;
93+
}
94+
95+
// association, let's fetch the entity (or reference to it) if we can so we can make sure we get its orm id
96+
$associationResourceClass = $metadata->getAssociationTargetClass($field);
97+
$associationMetadata = $this->getClassMetadata($associationResourceClass);
98+
$associationFieldIdentifier = $associationMetadata->getIdentifierFieldNames()[0];
99+
$doctrineTypeField = $this->getDoctrineFieldType($associationFieldIdentifier, $associationResourceClass);
100+
101+
$associationAlias = $alias;
102+
$associationField = $field;
103+
104+
if ($metadata->isCollectionValuedAssociation($associationField) || $metadata->isAssociationInverseSide($field)) {
105+
$associationAlias = QueryBuilderHelper::addJoinOnce($queryBuilder, $queryNameGenerator, $alias, $associationField);
106+
$associationField = $associationFieldIdentifier;
107+
}
108+
109+
$value = $this->convertValuesToTheDatabaseRepresentation($queryBuilder, $doctrineTypeField, $value);
110+
$this->addWhere($queryBuilder, $queryNameGenerator, $associationAlias, $associationField, $value);
111+
}
112+
113+
/**
114+
* Converts value to their database representation.
115+
*/
116+
private function convertValuesToTheDatabaseRepresentation(QueryBuilder $queryBuilder, ?string $doctrineFieldType, mixed $value): mixed
117+
{
118+
if (null === $doctrineFieldType || !Type::hasType($doctrineFieldType)) {
119+
throw new InvalidArgumentException(\sprintf('The Doctrine type "%s" is not valid or not registered.', $doctrineFieldType));
120+
}
121+
122+
$doctrineType = Type::getType($doctrineFieldType);
123+
$platform = $queryBuilder->getEntityManager()->getConnection()->getDatabasePlatform();
124+
125+
$convertValue = static function (mixed $value) use ($doctrineType, $platform) {
126+
try {
127+
return $doctrineType->convertToDatabaseValue($value, $platform);
128+
} catch (ConversionException $e) {
129+
throw new InvalidArgumentException(\sprintf('The value "%s" could not be converted to database representation.', $value), previous: $e);
130+
}
131+
};
132+
133+
if (\is_array($value)) {
134+
return array_map($convertValue, $value);
135+
}
136+
137+
return $convertValue($value);
138+
}
139+
140+
/**
141+
* Adds where clause.
142+
*/
143+
private function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, mixed $value): void
144+
{
145+
$valueParameter = ':'.$queryNameGenerator->generateParameterName($field);
146+
$aliasedField = \sprintf('%s.%s', $alias, $field);
147+
148+
if (!\is_array($value)) {
149+
$queryBuilder
150+
->andWhere($queryBuilder->expr()->eq($aliasedField, $valueParameter))
151+
->setParameter($valueParameter, $value, $this->getDoctrineParameterType());
152+
153+
return;
154+
}
155+
156+
$queryBuilder
157+
->andWhere($queryBuilder->expr()->in($aliasedField, $valueParameter))
158+
->setParameter($valueParameter, $value, $this->getDoctrineArrayParameterType());
159+
}
160+
161+
protected function getDoctrineParameterType(): ?ParameterType
162+
{
163+
return null;
164+
}
165+
166+
protected function getDoctrineArrayParameterType(): ?ArrayParameterType
167+
{
168+
return null;
169+
}
170+
171+
public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null
172+
{
173+
$in = $parameter instanceof QueryParameter ? 'query' : 'header';
174+
$schema = $parameter->getSchema();
175+
$isArraySchema = 'array' === ($schema['type'] ?? null);
176+
$hasNonArrayType = isset($schema['type']) && 'array' !== $schema['type'];
177+
178+
// Get filter's base schema
179+
$baseSchema = self::UUID_SCHEMA;
180+
$arraySchema = ['type' => 'array', 'items' => $baseSchema];
181+
182+
if ($isArraySchema) {
183+
return new OpenApiParameter(
184+
name: $parameter->getKey().'[]',
185+
in: $in,
186+
schema: $arraySchema,
187+
style: 'deepObject',
188+
explode: true,
189+
);
190+
}
191+
192+
if ($hasNonArrayType) {
193+
return new OpenApiParameter(
194+
name: $parameter->getKey(),
195+
in: $in,
196+
schema: $baseSchema,
197+
);
198+
}
199+
200+
// oneOf or no specific type constraint - return both with explicit schemas
201+
return [
202+
new OpenApiParameter(
203+
name: $parameter->getKey(),
204+
in: $in,
205+
schema: $baseSchema,
206+
),
207+
new OpenApiParameter(
208+
name: $parameter->getKey().'[]',
209+
in: $in,
210+
schema: $arraySchema,
211+
style: 'deepObject',
212+
explode: true,
213+
),
214+
];
215+
}
216+
217+
public function getSchema(Parameter $parameter): array
218+
{
219+
if (false === $parameter->getCastToArray()) {
220+
return self::UUID_SCHEMA;
221+
}
222+
223+
return [
224+
'oneOf' => [
225+
self::UUID_SCHEMA,
226+
[
227+
'type' => 'array',
228+
'items' => self::UUID_SCHEMA,
229+
],
230+
],
231+
];
232+
}
233+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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\Filter;
15+
16+
use ApiPlatform\Metadata\Parameter;
17+
use ApiPlatform\Metadata\QueryParameter;
18+
use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter;
19+
20+
final class UlidFilter extends AbstractUuidFilter
21+
{
22+
private const ULID_SCHEMA = [
23+
'type' => 'string',
24+
'format' => 'ulid',
25+
];
26+
27+
public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array
28+
{
29+
$in = $parameter instanceof QueryParameter ? 'query' : 'header';
30+
$schema = $parameter->getSchema();
31+
$isArraySchema = 'array' === ($schema['type'] ?? null);
32+
$hasNonArrayType = isset($schema['type']) && 'array' !== $schema['type'];
33+
34+
$baseSchema = self::ULID_SCHEMA;
35+
$arraySchema = ['type' => 'array', 'items' => $baseSchema];
36+
37+
if ($isArraySchema) {
38+
return new OpenApiParameter(
39+
name: $parameter->getKey().'[]',
40+
in: $in,
41+
schema: $arraySchema,
42+
style: 'deepObject',
43+
explode: true,
44+
);
45+
}
46+
47+
if ($hasNonArrayType) {
48+
return new OpenApiParameter(
49+
name: $parameter->getKey(),
50+
in: $in,
51+
schema: $baseSchema,
52+
);
53+
}
54+
55+
// oneOf or no specific type constraint - return both with explicit schemas
56+
return [
57+
new OpenApiParameter(
58+
name: $parameter->getKey(),
59+
in: $in,
60+
schema: $baseSchema,
61+
),
62+
new OpenApiParameter(
63+
name: $parameter->getKey().'[]',
64+
in: $in,
65+
schema: $arraySchema,
66+
style: 'deepObject',
67+
explode: true,
68+
),
69+
];
70+
}
71+
72+
public function getSchema(Parameter $parameter): array
73+
{
74+
if (false === $parameter->getCastToArray()) {
75+
return self::ULID_SCHEMA;
76+
}
77+
78+
return [
79+
'oneOf' => [
80+
self::ULID_SCHEMA,
81+
[
82+
'type' => 'array',
83+
'items' => self::ULID_SCHEMA,
84+
],
85+
],
86+
];
87+
}
88+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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\Filter;
15+
16+
use Composer\InstalledVersions;
17+
use Composer\Semver\VersionParser;
18+
use Doctrine\DBAL\ArrayParameterType;
19+
use Doctrine\DBAL\ParameterType;
20+
21+
final class UuidBinaryFilter extends AbstractUuidFilter
22+
{
23+
public function __construct()
24+
{
25+
if (!InstalledVersions::satisfies(new VersionParser(), 'doctrine/orm', '^3.0.1')) {
26+
// @see https://github.com/doctrine/orm/pull/11287
27+
throw new \LogicException('The "doctrine/orm" package version 3.0.1 or higher is required to use the UuidBinaryFilter. Please upgrade your dependencies.');
28+
}
29+
}
30+
31+
protected function getDoctrineParameterType(): ParameterType
32+
{
33+
return ParameterType::BINARY;
34+
}
35+
36+
protected function getDoctrineArrayParameterType(): ArrayParameterType
37+
{
38+
return ArrayParameterType::BINARY;
39+
}
40+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
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\Filter;
15+
16+
final class UuidFilter extends AbstractUuidFilter
17+
{
18+
}

0 commit comments

Comments
 (0)