diff --git a/src/Extension/Cryptography/CryptographyMetadataEnricher.php b/src/Extension/Cryptography/CryptographyMetadataEnricher.php index 6357d5a4..4cd94e44 100644 --- a/src/Extension/Cryptography/CryptographyMetadataEnricher.php +++ b/src/Extension/Cryptography/CryptographyMetadataEnricher.php @@ -17,12 +17,14 @@ final class CryptographyMetadataEnricher implements MetadataEnricher public function enrich(ClassMetadata $classMetadata): void { $subjectIdMapping = []; + $subjectIdProperties = []; + $sensitiveProperties = []; foreach ($classMetadata->properties as $property) { $isSubjectId = false; $attributeReflectionList = $property->reflection->getAttributes(DataSubjectId::class); - if ($attributeReflectionList) { + if ($attributeReflectionList !== []) { $subjectIdIdentifier = $attributeReflectionList[0]->newInstance()->name; if (array_key_exists($subjectIdIdentifier, $subjectIdMapping)) { @@ -35,6 +37,7 @@ public function enrich(ClassMetadata $classMetadata): void } $subjectIdMapping[$subjectIdIdentifier] = $property->fieldName; + $subjectIdProperties[$subjectIdIdentifier] = $property; $isSubjectId = true; } @@ -50,13 +53,17 @@ public function enrich(ClassMetadata $classMetadata): void } $property->extras[SensitiveDataInfo::class] = $sensitiveDataInfo; + $sensitiveProperties[] = $property; } - if ($subjectIdMapping === []) { - return; + if ($sensitiveProperties !== []) { + $classMetadata->extras[SensitiveDataInfo::class . '::properties'] = $sensitiveProperties; } - $classMetadata->extras[SubjectIdFieldMapping::class] = new SubjectIdFieldMapping($subjectIdMapping); + if ($subjectIdMapping !== []) { + $classMetadata->extras[SubjectIdFieldMapping::class] = new SubjectIdFieldMapping($subjectIdMapping); + $classMetadata->extras[SubjectIdFieldMapping::class . '::properties'] = $subjectIdProperties; + } } private function sensitiveDataInfo(ReflectionProperty $reflectionProperty): SensitiveDataInfo|null diff --git a/src/Extension/Cryptography/CryptographyMiddleware.php b/src/Extension/Cryptography/CryptographyMiddleware.php index af40f893..7153b32f 100644 --- a/src/Extension/Cryptography/CryptographyMiddleware.php +++ b/src/Extension/Cryptography/CryptographyMiddleware.php @@ -36,39 +36,48 @@ public function __construct( */ public function hydrate(ClassMetadata $metadata, array $data, array $context, Stack $stack): object { + /** @var list|null $properties */ + $properties = $metadata->extras[SensitiveDataInfo::class . '::properties'] ?? null; + + if ($properties === null) { + return $stack->next()->hydrate($metadata, $data, $context, $stack); + } + $context[SubjectIds::class] = $subjectIds = $this->resolveSubjectIds($metadata, $data, $context); + $cryptographer = $this->cryptographer; - foreach ($metadata->properties as $propertyMetadata) { - $info = $propertyMetadata->extras[SensitiveDataInfo::class] ?? null; + foreach ($properties as $propertyMetadata) { + $fieldName = $propertyMetadata->fieldName; - if (!$info instanceof SensitiveDataInfo) { + if (!isset($data[$fieldName])) { continue; } - $value = $data[$propertyMetadata->fieldName] ?? null; + $value = $data[$fieldName]; - if ($value === null) { + if (!$cryptographer->supports($value)) { continue; } - if (!$this->cryptographer->supports($value)) { - continue; - } + $info = $propertyMetadata->extras[SensitiveDataInfo::class]; + assert($info instanceof SensitiveDataInfo); $subjectId = $subjectIds->get($info->subjectIdName); try { - $data[$propertyMetadata->fieldName] = $this->cryptographer->decrypt($subjectId, $value); + $data[$fieldName] = $cryptographer->decrypt($subjectId, $value); } catch (DecryptionFailed | CipherKeyNotExists) { $fallback = $info->fallback instanceof Closure ? ($info->fallback)($subjectId) : $info->fallback; - if ($propertyMetadata->normalizer) { - $fallback = $propertyMetadata->normalizer->normalize($fallback, $context); + $normalizer = $propertyMetadata->normalizer; + + if ($normalizer !== null) { + $fallback = $normalizer->normalize($fallback, $context); } - $data[$propertyMetadata->fieldName] = $fallback; + $data[$fieldName] = $fallback; } } diff --git a/src/Metadata/ClassMetadata.php b/src/Metadata/ClassMetadata.php index 24b176d8..44a82e44 100644 --- a/src/Metadata/ClassMetadata.php +++ b/src/Metadata/ClassMetadata.php @@ -5,6 +5,7 @@ namespace Patchlevel\Hydrator\Metadata; use ReflectionClass; +use ReflectionParameter; /** * @phpstan-type serialized array{ @@ -21,7 +22,16 @@ final class ClassMetadata public readonly string $className; /** @var array */ - public readonly array $properties; + public array $properties; + + /** @var list */ + public array $propertiesWithNormalizer; + + /** @var list */ + public array $propertiesWithoutNormalizer; + + /** @var array|null */ + private array|null $promotedConstructorDefaults = null; /** * @param ReflectionClass $reflection @@ -36,13 +46,29 @@ public function __construct( ) { $this->className = $reflection->getName(); + $this->updateProperties($properties); + } + + /** @param list $properties */ + public function updateProperties(array $properties): void + { $map = []; + $withNormalizer = []; + $withoutNormalizer = []; foreach ($properties as $property) { $map[$property->propertyName] = $property; + + if ($property->normalizer !== null) { + $withNormalizer[] = $property; + } else { + $withoutNormalizer[] = $property; + } } $this->properties = $map; + $this->propertiesWithNormalizer = $withNormalizer; + $this->propertiesWithoutNormalizer = $withoutNormalizer; } public function propertyForField(string $name): PropertyMetadata @@ -62,12 +88,38 @@ public function newInstance(): object return $this->reflection->newInstanceWithoutConstructor(); } + /** @return array */ + public function promotedConstructorDefaults(): array + { + if ($this->promotedConstructorDefaults !== null) { + return $this->promotedConstructorDefaults; + } + + $constructor = $this->reflection->getConstructor(); + + if (!$constructor) { + return $this->promotedConstructorDefaults = []; + } + + $result = []; + + foreach ($constructor->getParameters() as $parameter) { + if (!$parameter->isPromoted() || !$parameter->isDefaultValueAvailable()) { + continue; + } + + $result[$parameter->getName()] = $parameter; + } + + return $this->promotedConstructorDefaults = $result; + } + /** @return serialized */ public function __serialize(): array { return [ 'className' => $this->className, - 'properties' => $this->properties, + 'properties' => array_values($this->properties), 'lazy' => $this->lazy, 'extras' => $this->extras, ]; @@ -77,7 +129,25 @@ public function __serialize(): array public function __unserialize(array $data): void { $this->reflection = new ReflectionClass($data['className']); - $this->properties = $data['properties']; + + $map = []; + $withNormalizer = []; + $withoutNormalizer = []; + + foreach ($data['properties'] as $property) { + $map[$property->propertyName] = $property; + + if ($property->normalizer !== null) { + $withNormalizer[] = $property; + } else { + $withoutNormalizer[] = $property; + } + } + + $this->className = $data['className']; + $this->properties = $map; + $this->propertiesWithNormalizer = $withNormalizer; + $this->propertiesWithoutNormalizer = $withoutNormalizer; $this->lazy = $data['lazy']; $this->extras = $data['extras']; } diff --git a/src/Metadata/EnrichingMetadataFactory.php b/src/Metadata/EnrichingMetadataFactory.php index 15312914..87dc46a4 100644 --- a/src/Metadata/EnrichingMetadataFactory.php +++ b/src/Metadata/EnrichingMetadataFactory.php @@ -17,8 +17,14 @@ public function metadata(string $class): ClassMetadata { $metadata = $this->factory->metadata($class); + $enriched = false; foreach ($this->enrichers as $enricher) { $enricher->enrich($metadata); + $enriched = true; + } + + if ($enriched) { + $metadata->updateProperties(array_values($metadata->properties)); } return $metadata; diff --git a/src/MetadataHydrator.php b/src/MetadataHydrator.php index 24920f58..51fd7572 100644 --- a/src/MetadataHydrator.php +++ b/src/MetadataHydrator.php @@ -23,12 +23,15 @@ final class MetadataHydrator implements Hydrator /** @var array */ private array $classMetadata = []; + private readonly Stack $stack; + /** @param list $middlewares */ public function __construct( private readonly MetadataFactory $metadataFactory = new AttributeMetadataFactory(), private readonly array $middlewares = [new TransformMiddleware()], private readonly bool $defaultLazy = false, ) { + $this->stack = new Stack($this->middlewares); } /** @@ -48,23 +51,21 @@ public function hydrate(string $class, array $data, array $context = []): object throw new ClassNotSupported($class, $e); } - if (PHP_VERSION_ID < 80400) { - $stack = new Stack($this->middlewares); + $stack = clone $this->stack; + if (PHP_VERSION_ID < 80400) { return $stack->next()->hydrate($metadata, $data, $context, $stack); } $lazy = $metadata->lazy ?? $this->defaultLazy; if (!$lazy) { - $stack = new Stack($this->middlewares); - return $stack->next()->hydrate($metadata, $data, $context, $stack); } return (new ReflectionClass($class))->newLazyProxy( function () use ($metadata, $data, $context): object { - $stack = new Stack($this->middlewares); + $stack = clone $this->stack; return $stack->next()->hydrate($metadata, $data, $context, $stack); }, @@ -79,7 +80,7 @@ function () use ($metadata, $data, $context): object { public function extract(object $object, array $context = []): array { $metadata = $this->metadata($object::class); - $stack = new Stack($this->middlewares); + $stack = clone $this->stack; return $stack->next()->extract($metadata, $object, $context, $stack); } @@ -93,18 +94,18 @@ public function extract(object $object, array $context = []): array */ public function metadata(string $class): ClassMetadata { - if (array_key_exists($class, $this->classMetadata)) { + if (isset($this->classMetadata[$class])) { return $this->classMetadata[$class]; } $this->classMetadata[$class] = $metadata = $this->metadataFactory->metadata($class); foreach ($metadata->properties as $property) { - if (!($property->normalizer instanceof HydratorAwareNormalizer)) { - continue; - } + $normalizer = $property->normalizer; - $property->normalizer->setHydrator($this); + if ($normalizer instanceof HydratorAwareNormalizer) { + $normalizer->setHydrator($this); + } } return $metadata; diff --git a/src/Middleware/Stack.php b/src/Middleware/Stack.php index 7a47f627..3cba48df 100644 --- a/src/Middleware/Stack.php +++ b/src/Middleware/Stack.php @@ -16,14 +16,6 @@ public function __construct( public function next(): Middleware { - $next = $this->middlewares[$this->index] ?? null; - - if ($next === null) { - throw new NoMoreMiddleware(); - } - - $this->index++; - - return $next; + return $this->middlewares[$this->index++] ?? throw new NoMoreMiddleware(); } } diff --git a/src/Middleware/TransformMiddleware.php b/src/Middleware/TransformMiddleware.php index d9464c2a..23ab0892 100644 --- a/src/Middleware/TransformMiddleware.php +++ b/src/Middleware/TransformMiddleware.php @@ -9,7 +9,6 @@ use Patchlevel\Hydrator\Metadata\ClassMetadata; use Patchlevel\Hydrator\NormalizationFailure; use Patchlevel\Hydrator\TypeMismatch; -use ReflectionParameter; use Throwable; use TypeError; @@ -35,48 +34,79 @@ public function hydrate(ClassMetadata $metadata, array $data, array $context, St { $object = $metadata->newInstance(); - $constructorParameters = null; + foreach ($metadata->propertiesWithoutNormalizer as $propertyMetadata) { + $fieldName = $propertyMetadata->fieldName; - foreach ($metadata->properties as $propertyMetadata) { - if (!array_key_exists($propertyMetadata->fieldName, $data)) { + if (!isset($data[$fieldName]) && !array_key_exists($fieldName, $data)) { if (!$propertyMetadata->reflection->isPromoted()) { - continue; + goto next_without_normalizer; } - if ($constructorParameters === null) { - $constructorParameters = $this->promotedConstructorParametersWithDefaultValue($metadata); - } + $constructorParameters = $metadata->promotedConstructorDefaults(); - if (!array_key_exists($propertyMetadata->propertyName, $constructorParameters)) { - continue; + if (!isset($constructorParameters[$propertyMetadata->propertyName])) { + goto next_without_normalizer; } - $propertyMetadata->setValue( + $propertyMetadata->reflection->setValue( $object, $constructorParameters[$propertyMetadata->propertyName]->getDefaultValue(), ); - continue; + goto next_without_normalizer; } - if ($propertyMetadata->normalizer) { - try { - /** @psalm-suppress MixedAssignment */ - $value = $propertyMetadata->normalizer->denormalize($data[$propertyMetadata->fieldName], $context); - } catch (Throwable $e) { - throw new DenormalizationFailure( - $metadata->className, - $propertyMetadata->propertyName, - $propertyMetadata->normalizer::class, - $e, - ); + try { + $propertyMetadata->reflection->setValue($object, $data[$fieldName]); + } catch (TypeError $e) { + throw new TypeMismatch( + $metadata->className, + $propertyMetadata->propertyName, + $e, + ); + } + + next_without_normalizer: + } + + foreach ($metadata->propertiesWithNormalizer as $propertyMetadata) { + $fieldName = $propertyMetadata->fieldName; + + if (!isset($data[$fieldName]) && !array_key_exists($fieldName, $data)) { + if (!$propertyMetadata->reflection->isPromoted()) { + goto next_with_normalizer; } - } else { - $value = $data[$propertyMetadata->fieldName]; + + $constructorParameters = $metadata->promotedConstructorDefaults(); + + if (!isset($constructorParameters[$propertyMetadata->propertyName])) { + goto next_with_normalizer; + } + + $propertyMetadata->reflection->setValue( + $object, + $constructorParameters[$propertyMetadata->propertyName]->getDefaultValue(), + ); + + goto next_with_normalizer; } + $normalizer = $propertyMetadata->normalizer; + try { - $propertyMetadata->setValue($object, $value); + /** @psalm-suppress MixedAssignment */ + $value = $normalizer->denormalize($data[$fieldName], $context); + } catch (Throwable $e) { + throw new DenormalizationFailure( + $metadata->className, + $propertyMetadata->propertyName, + $normalizer::class, + $e, + ); + } + + try { + $propertyMetadata->reflection->setValue($object, $value); } catch (TypeError $e) { throw new TypeMismatch( $metadata->className, @@ -84,6 +114,8 @@ public function hydrate(ClassMetadata $metadata, array $data, array $context, St $e, ); } + + next_with_normalizer: } return $object; @@ -98,7 +130,7 @@ public function extract(ClassMetadata $metadata, object $object, array $context, { $objectId = spl_object_id($object); - if (array_key_exists($objectId, $this->callStack)) { + if (isset($this->callStack[$objectId])) { $references = array_values($this->callStack); $references[] = $object::class; @@ -110,26 +142,30 @@ public function extract(ClassMetadata $metadata, object $object, array $context, try { $data = []; - foreach ($metadata->properties as $propertyMetadata) { - if ($propertyMetadata->normalizer) { - try { - /** @psalm-suppress MixedAssignment */ - $data[$propertyMetadata->fieldName] = $propertyMetadata->normalizer->normalize( - $propertyMetadata->getValue($object), - $context, - ); - } catch (CircularReference $e) { + foreach ($metadata->propertiesWithoutNormalizer as $propertyMetadata) { + $data[$propertyMetadata->fieldName] = $propertyMetadata->reflection->getValue($object); + } + + foreach ($metadata->propertiesWithNormalizer as $propertyMetadata) { + $normalizer = $propertyMetadata->normalizer; + + try { + /** @psalm-suppress MixedAssignment */ + $data[$propertyMetadata->fieldName] = $normalizer->normalize( + $propertyMetadata->reflection->getValue($object), + $context, + ); + } catch (Throwable $e) { + if ($e instanceof CircularReference) { throw $e; - } catch (Throwable $e) { - throw new NormalizationFailure( - $object::class, - $propertyMetadata->propertyName, - $propertyMetadata->normalizer::class, - $e, - ); } - } else { - $data[$propertyMetadata->fieldName] = $propertyMetadata->getValue($object); + + throw new NormalizationFailure( + $object::class, + $propertyMetadata->propertyName, + $normalizer::class, + $e, + ); } } } finally { @@ -138,31 +174,4 @@ public function extract(ClassMetadata $metadata, object $object, array $context, return $data; } - - /** @return array */ - private function promotedConstructorParametersWithDefaultValue(ClassMetadata $metadata): array - { - $constructor = $metadata->reflection->getConstructor(); - - if (!$constructor) { - return []; - } - - $parameters = $constructor->getParameters(); - $result = []; - - foreach ($parameters as $parameter) { - if (!$parameter->isPromoted()) { - continue; - } - - if (!$parameter->isDefaultValueAvailable()) { - continue; - } - - $result[$parameter->getName()] = $parameter; - } - - return $result; - } } diff --git a/src/Normalizer/ArrayNormalizer.php b/src/Normalizer/ArrayNormalizer.php index f6a106c2..b2e92196 100644 --- a/src/Normalizer/ArrayNormalizer.php +++ b/src/Normalizer/ArrayNormalizer.php @@ -35,8 +35,10 @@ public function normalize(mixed $value, array $context): array|null throw InvalidArgument::withWrongType('array|null', $value); } + $normalizer = $this->normalizer; + foreach ($value as &$item) { - $item = $this->normalizer->normalize($item, $context); + $item = $normalizer->normalize($item, $context); } return $value; @@ -57,8 +59,10 @@ public function denormalize(mixed $value, array $context): array|null throw InvalidArgument::withWrongType('array|null', $value); } + $normalizer = $this->normalizer; + foreach ($value as &$item) { - $item = $this->normalizer->denormalize($item, $context); + $item = $normalizer->denormalize($item, $context); } return $value; diff --git a/src/Normalizer/EnumNormalizer.php b/src/Normalizer/EnumNormalizer.php index cf806d4f..96eb7b7a 100644 --- a/src/Normalizer/EnumNormalizer.php +++ b/src/Normalizer/EnumNormalizer.php @@ -30,7 +30,11 @@ public function normalize(mixed $value, array $context): mixed return null; } - $enum = $this->getEnum(); + $enum = $this->enum; + + if ($enum === null) { + throw InvalidType::missingType(); + } if (!$value instanceof $enum) { throw InvalidArgument::withWrongType($enum . '|null', $value); @@ -50,7 +54,11 @@ public function denormalize(mixed $value, array $context): BackedEnum|null throw InvalidArgument::withWrongType('string|int|null', $value); } - $enum = $this->getEnum(); + $enum = $this->enum; + + if ($enum === null) { + throw InvalidType::missingType(); + } try { return $enum::from($value); diff --git a/src/Normalizer/ObjectNormalizer.php b/src/Normalizer/ObjectNormalizer.php index b7f0b878..52e82324 100644 --- a/src/Normalizer/ObjectNormalizer.php +++ b/src/Normalizer/ObjectNormalizer.php @@ -32,41 +32,53 @@ public function __construct( */ public function normalize(mixed $value, array $context): array|null { - if (!$this->hydrator) { - throw new MissingHydrator(); - } - if ($value === null) { return null; } - $className = $this->getClassName(); + $hydrator = $this->hydrator; + + if ($hydrator === null) { + throw new MissingHydrator(); + } + + $className = $this->className; + + if ($className === null) { + throw InvalidType::missingType(); + } if (!$value instanceof $className) { throw InvalidArgument::withWrongType($className . '|null', $value); } - return $this->hydrator->extract($value, $context); + return $hydrator->extract($value, $context); } /** @param array $context */ public function denormalize(mixed $value, array $context): object|null { - if (!$this->hydrator) { - throw new MissingHydrator(); - } - if ($value === null) { return null; } + $hydrator = $this->hydrator; + + if ($hydrator === null) { + throw new MissingHydrator(); + } + if (!is_array($value)) { throw InvalidArgument::withWrongType('array|null', $value); } - $className = $this->getClassName(); + $className = $this->className; + + if ($className === null) { + throw InvalidType::missingType(); + } - return $this->hydrator->hydrate($className, $value, $context); + return $hydrator->hydrate($className, $value, $context); } public function setHydrator(Hydrator $hydrator): void