Skip to content

Commit 9dbcb75

Browse files
committed
Refactor to use a custom TypeExtractor.
1 parent 71db5d1 commit 9dbcb75

File tree

4 files changed

+273
-137
lines changed

4 files changed

+273
-137
lines changed

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
"require-dev": {
3737
"php-parallel-lint/php-console-highlighter": "^1",
3838
"php-parallel-lint/php-parallel-lint": "^1.3",
39-
"phpstan/phpstan": "^1",
39+
"phpstan/phpstan": "^2",
4040
"roave/security-advisories": "dev-master"
4141
},
4242
"config": {

src/DoctrineEntityNormalizer.php

Lines changed: 70 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -2,48 +2,45 @@
22

33
namespace Azura\Normalizer;
44

5+
use ArrayObject;
56
use Azura\Normalizer\Attributes\DeepNormalize;
67
use Azura\Normalizer\Exception\NoGetterAvailableException;
7-
use ArrayObject;
8+
use Azura\Normalizer\TypeExtractor\EntityTypeExtractor;
89
use Doctrine\Common\Collections\Collection;
9-
use Doctrine\Inflector\Inflector;
10-
use Doctrine\Inflector\InflectorFactory;
1110
use Doctrine\ORM\EntityManagerInterface;
1211
use Doctrine\ORM\Proxy\DefaultProxyClassNameResolver;
1312
use InvalidArgumentException;
1413
use ReflectionClass;
1514
use ReflectionException;
1615
use ReflectionProperty;
17-
use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
1816
use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface;
1917
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
2018
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
2119
use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
2220

2321
final class DoctrineEntityNormalizer extends AbstractObjectNormalizer
2422
{
25-
private const CLASS_METADATA = 'class_metadata';
26-
private const ASSOCIATION_MAPPINGS = 'association_mappings';
23+
public const CLASS_METADATA = 'class_metadata';
24+
public const ASSOCIATION_MAPPINGS = 'association_mappings';
2725

2826
public const NORMALIZE_TO_IDENTIFIERS = 'form_mode';
2927

30-
private readonly Inflector $inflector;
28+
private EntityTypeExtractor $typeExtractor;
3129

3230
public function __construct(
3331
private readonly EntityManagerInterface $em,
3432
?ClassMetadataFactoryInterface $classMetadataFactory = null,
35-
?PropertyTypeExtractorInterface $propertyTypeExtractor = null,
3633
array $defaultContext = []
3734
) {
3835
$defaultContext[AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES] = true;
3936

37+
$this->typeExtractor = new EntityTypeExtractor();
38+
4039
parent::__construct(
4140
classMetadataFactory: $classMetadataFactory,
42-
propertyTypeExtractor: $propertyTypeExtractor,
41+
propertyTypeExtractor: $this->typeExtractor,
4342
defaultContext: $defaultContext
4443
);
45-
46-
$this->inflector = InflectorFactory::create()->build();
4744
}
4845

4946
/**
@@ -153,7 +150,7 @@ protected function getAllowedAttributes(
153150

154151
protected function extractAttributes(object $object, ?string $format = null, array $context = []): array
155152
{
156-
$rawProps = (new ReflectionClass($object))->getProperties(
153+
$rawProps = new ReflectionClass($object)->getProperties(
157154
ReflectionProperty::IS_PUBLIC | ReflectionProperty::IS_PROTECTED
158155
);
159156

@@ -186,39 +183,34 @@ protected function isAllowedAttribute(
186183
return false;
187184
}
188185

189-
$reflectionClass = new ReflectionClass($classOrObject);
190-
if (!$reflectionClass->hasProperty($attribute)) {
191-
return false;
192-
}
186+
$class = \is_object($classOrObject) ? $classOrObject::class : $classOrObject;
193187

194188
if (isset($context[self::CLASS_METADATA]->associationMappings[$attribute])) {
195-
if (!$this->supportsDeepNormalization($reflectionClass, $attribute)) {
189+
if (!$this->supportsDeepNormalization($class, $attribute)) {
196190
return false;
197191
}
198192
}
199193

200-
return $this->hasGetter($reflectionClass, $attribute);
194+
return $this->hasGetter($class, $attribute);
201195
}
202196

203197
/**
204-
* @param ReflectionClass<object> $reflectionClass
198+
* @param class-string $className
205199
* @param string $attribute
206-
* @return bool
200+
* @return bool Whether a getter exists that can return for this property.
207201
*/
208-
private function hasGetter(ReflectionClass $reflectionClass, string $attribute): bool
202+
private function hasGetter(string $className, string $attribute): bool
209203
{
210-
if ($reflectionClass->hasProperty($attribute) && $reflectionClass->getProperty($attribute)->isPublic()) {
204+
if (null !== $this->typeExtractor->getAccessorMethod($className, $attribute)) {
211205
return true;
212206
}
213207

214-
// Default to "getStatus", "getConfig", etc...
215-
$getterMethod = $this->getMethodName($attribute, 'get');
216-
if ($reflectionClass->hasMethod($getterMethod)) {
217-
return true;
218-
}
208+
try {
209+
$reflProp = new ReflectionProperty($className, $attribute);
210+
return $reflProp->isPublic();
211+
} catch (ReflectionException) {}
219212

220-
$rawMethod = $this->getMethodName($attribute);
221-
return $reflectionClass->hasMethod($rawMethod);
213+
return false;
222214
}
223215

224216
protected function getAttributeValue(
@@ -230,7 +222,7 @@ protected function getAttributeValue(
230222
$formMode = $context[self::NORMALIZE_TO_IDENTIFIERS] ?? false;
231223

232224
if (isset($context[self::CLASS_METADATA]->associationMappings[$attribute])) {
233-
if (!$this->supportsDeepNormalization(new ReflectionClass($object), $attribute)) {
225+
if (!$this->supportsDeepNormalization($object::class, $attribute)) {
234226
throw new NoGetterAvailableException(
235227
sprintf(
236228
'Deep normalization disabled for property %s.',
@@ -241,6 +233,8 @@ protected function getAttributeValue(
241233
}
242234

243235
$value = $this->getProperty($object, $attribute);
236+
237+
// Special handling for Doctrine "many-to-x" relationships (Collections)
244238
if ($value instanceof Collection) {
245239
if ($formMode) {
246240
$value = array_filter(array_map(
@@ -261,81 +255,72 @@ function(object $valObj) {
261255
}
262256

263257
/**
264-
* @param ReflectionClass<object> $reflectionClass
258+
* @param class-string $className
265259
* @param string $attribute
266260
* @return bool
267-
* @throws ReflectionException
268261
*/
269-
private function supportsDeepNormalization(ReflectionClass $reflectionClass, string $attribute): bool
262+
private function supportsDeepNormalization(string $className, string $attribute): bool
270263
{
271-
$deepNormalizeAttrs = $reflectionClass->getProperty($attribute)->getAttributes(
272-
DeepNormalize::class
273-
);
264+
try {
265+
$reflProp = new ReflectionProperty($className, $attribute);
266+
$deepNormalizeAttrs = $reflProp->getAttributes(DeepNormalize::class);
274267

275-
if (empty($deepNormalizeAttrs)) {
268+
if (empty($deepNormalizeAttrs)) {
269+
return false;
270+
}
271+
272+
/** @var DeepNormalize $deepNormalize */
273+
$deepNormalize = current($deepNormalizeAttrs)->newInstance();
274+
return $deepNormalize->getDeepNormalize();
275+
} catch (\ReflectionException) {
276276
return false;
277277
}
278-
279-
/** @var DeepNormalize $deepNormalize */
280-
$deepNormalize = current($deepNormalizeAttrs)->newInstance();
281-
return $deepNormalize->getDeepNormalize();
282278
}
283279

284280
private function getProperty(object $entity, string $key): mixed
285281
{
286-
// Public item hook.
287-
if (property_exists($entity, $key)) {
288-
$reflProp = new ReflectionProperty($entity, $key);
282+
if (null !== $accessor = $this->typeExtractor->getAccessorMethod($entity::class, $key)) {
283+
[$method, $prefix] = $accessor;
284+
return $method->invoke($entity);
285+
}
286+
287+
try {
288+
$reflProp = new ReflectionProperty($entity::class, $key);
289289
if ($reflProp->isPublic()) {
290290
return $reflProp->getValue($entity);
291291
}
292-
}
293-
294-
// Default to "getStatus", "getConfig", etc...
295-
$getterMethod = $this->getMethodName($key, 'get');
296-
if (method_exists($entity, $getterMethod)) {
297-
return $entity->{$getterMethod}();
298-
}
299-
300-
// but also allow "isEnabled" instead of "getIsEnabled"
301-
$rawMethod = $this->getMethodName($key);
302-
if (method_exists($entity, $rawMethod)) {
303-
return $entity->{$rawMethod}();
304-
}
292+
} catch (ReflectionException) {}
305293

306294
throw new NoGetterAvailableException(sprintf('No getter is available for property %s.', $key));
307295
}
308296

309-
/**
310-
* Converts "getvar_name_blah" to "getVarNameBlah".
311-
*/
312-
private function getMethodName(string $var, string $prefix = ''): string
313-
{
314-
return $this->inflector->camelize(($prefix ? $prefix . '_' : '') . $var);
315-
}
316-
317297
protected function setAttributeValue(
318298
object $object,
319299
string $attribute,
320300
mixed $value,
321301
?string $format = null,
322302
array $context = []
323303
): void {
304+
// Special handling for Doctrine entity relationship fields.
324305
if (isset($context[self::ASSOCIATION_MAPPINGS][$attribute])) {
325-
// Handle a mapping to another entity.
326306
$mapping = $context[self::ASSOCIATION_MAPPINGS][$attribute];
327307

328308
if ('one' === $mapping['type']) {
309+
// Allow passing either a related object or simply its ID to a "one-to-x" relationship.
310+
311+
/** @var class-string $entity */
312+
$entity = $mapping['entity'];
313+
329314
if (empty($value)) {
330315
$this->setProperty($object, $attribute, null);
331-
} else {
332-
/** @var class-string $entity */
333-
$entity = $mapping['entity'];
334-
if (($fieldItem = $this->em->find($entity, $value)) instanceof $entity) {
335-
$this->setProperty($object, $attribute, $fieldItem);
336-
}
316+
} else if ($value instanceof $entity) {
317+
$this->setProperty($object, $attribute, $value);
318+
} else if (($fieldItem = $this->em->find($entity, $value)) instanceof $entity) {
319+
$this->setProperty($object, $attribute, $fieldItem);
337320
}
338321
} elseif ($mapping['is_owning_side']) {
322+
// Convert an array of entities or identifiers to a Doctrine collection for "many-to-x" relationships.
323+
339324
$collection = $this->getProperty($object, $attribute);
340325

341326
if ($collection instanceof Collection) {
@@ -355,81 +340,30 @@ protected function setAttributeValue(
355340
}
356341
}
357342
} else {
358-
$this->setStandardValue($object, $attribute, $value);
359-
}
360-
}
361-
362-
private function setStandardValue(
363-
object $object,
364-
string $attribute,
365-
mixed $value,
366-
?string $format = null,
367-
array $context = []
368-
): void {
369-
$reflClass = new ReflectionClass($object);
370-
371-
if ($reflClass->hasProperty($attribute)) {
372-
$reflProp = $reflClass->getProperty($attribute);
373-
374-
if ($reflProp->isPublic() && !$reflProp->isProtectedSet() && !$reflProp->isPrivateSet()) {
375-
$propType = $reflProp->getSettableType();
376-
if (null === $value) {
377-
if ($propType->allowsNull()) {
378-
$reflProp->setValue($object, null);
379-
return;
380-
}
381-
} else {
382-
$reflProp->setValue($object, $value);
383-
return;
384-
}
385-
}
386-
}
387-
388-
$methodName = $this->getMethodName($attribute, 'set');
389-
if ($reflClass->hasMethod($methodName)) {
390-
// If setter parameter is a special class, normalize to it.
391-
$methodParams = $reflClass->getMethod($methodName)->getParameters();
392-
$parameter = $methodParams[0];
393-
394-
if (null === $value) {
395-
if ($parameter->allowsNull()) {
396-
$object->$methodName(null);
397-
return;
398-
}
399-
} else {
400-
$object->$methodName(
401-
$this->denormalizeParameter(
402-
$reflClass,
403-
$parameter,
404-
$attribute,
405-
$value,
406-
$this->createChildContext($context, $attribute, $format),
407-
$format
408-
)
409-
);
410-
return;
411-
}
343+
$this->setProperty($object, $attribute, $value);
412344
}
413345
}
414346

415347
private function setProperty(
416348
object $entity,
417-
string $attribute,
349+
string $key,
418350
mixed $value
419351
): void {
420-
// Public item hook.
421-
if (property_exists($entity, $attribute)) {
422-
$reflProp = new ReflectionProperty($entity, $attribute);
352+
// Prefer setter if it exists.
353+
if (null !== $mutator = $this->typeExtractor->getMutatorMethod($entity::class, $key)) {
354+
[$method, $prefix] = $mutator;
355+
$method->invoke($entity, $value);
356+
return;
357+
}
358+
359+
// Try directly setting on the property.
360+
try {
361+
$reflProp = new ReflectionProperty($entity::class, $key);
423362
if ($reflProp->isPublic() && !$reflProp->isProtectedSet() && !$reflProp->isPrivateSet()) {
424363
$reflProp->setValue($entity, $value);
425364
return;
426365
}
427-
}
428-
429-
$methodName = $this->getMethodName($attribute, 'set');
430-
if (method_exists($entity, $methodName)) {
431-
$entity->$methodName($value);
432-
}
366+
} catch (ReflectionException) {}
433367
}
434368

435369
private function isEntity(mixed $class): bool
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
namespace Azura\Normalizer\TypeExtractor;
3+
4+
use Symfony\Component\TypeInfo\Exception\UnsupportedException;
5+
use Symfony\Component\TypeInfo\Type;
6+
use Symfony\Component\TypeInfo\TypeContext\TypeContext;
7+
use Symfony\Component\TypeInfo\TypeContext\TypeContextFactory;
8+
use Symfony\Component\TypeInfo\TypeResolver\ReflectionTypeResolver;
9+
use Symfony\Component\TypeInfo\TypeResolver\TypeResolverInterface;
10+
11+
final readonly class EntityPropertyTypeResolver implements TypeResolverInterface
12+
{
13+
public function __construct(
14+
private ReflectionTypeResolver $reflectionTypeResolver,
15+
private TypeContextFactory $typeContextFactory,
16+
) {
17+
}
18+
19+
public function resolve(mixed $subject, ?TypeContext $typeContext = null): Type
20+
{
21+
if (!$subject instanceof \ReflectionProperty) {
22+
throw new UnsupportedException(\sprintf('Expected subject to be a "ReflectionProperty", "%s" given.', get_debug_type($subject)), $subject);
23+
}
24+
25+
$typeContext ??= $this->typeContextFactory->createFromReflection($subject);
26+
27+
try {
28+
return $this->reflectionTypeResolver->resolve($subject->getSettableType(), $typeContext);
29+
} catch (UnsupportedException $e) {
30+
$path = \sprintf('%s::$%s', $subject->getDeclaringClass()->getName(), $subject->getName());
31+
32+
throw new UnsupportedException(\sprintf('Cannot resolve type for "%s".', $path), $subject, previous: $e);
33+
}
34+
}
35+
}

0 commit comments

Comments
 (0)