Re-internalize the Doctrine entity normalizer library.

This commit is contained in:
Buster Neece 2022-11-13 18:36:13 -06:00
parent cb3495fe80
commit 52e8b53d24
No known key found for this signature in database
GPG Key ID: F1D2E64A0005E80E
12 changed files with 410 additions and 69 deletions

View File

@ -26,7 +26,6 @@
"ext-xml": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"azuracast/doctrine-entity-normalizer": "dev-main",
"azuracast/nowplaying": "dev-main",
"bacon/bacon-qr-code": "^2.0",
"beberlei/doctrineextensions": "^1.2",

62
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "000dc28cca8c41491fbef907f30c94c0",
"content-hash": "c918d27e7d839c9c2c2d09238952c9be",
"packages": [
{
"name": "aws/aws-crt-php",
@ -150,65 +150,6 @@
},
"time": "2022-11-04T19:25:00+00:00"
},
{
"name": "azuracast/doctrine-entity-normalizer",
"version": "dev-main",
"source": {
"type": "git",
"url": "https://github.com/AzuraCast/doctrine-entity-normalizer.git",
"reference": "4cfa9d03f049e38991bb2dddaf291c2a6390bf22"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/AzuraCast/doctrine-entity-normalizer/zipball/4cfa9d03f049e38991bb2dddaf291c2a6390bf22",
"reference": "4cfa9d03f049e38991bb2dddaf291c2a6390bf22",
"shasum": ""
},
"require": {
"doctrine/collections": ">1",
"doctrine/inflector": "^2",
"doctrine/orm": "^2",
"doctrine/persistence": "^2|^3",
"php": ">=8.0",
"symfony/serializer": "^5|^6"
},
"require-dev": {
"php-parallel-lint/php-console-highlighter": "^1",
"php-parallel-lint/php-parallel-lint": "^1.3",
"phpstan/phpstan": "^1",
"roave/security-advisories": "dev-master"
},
"default-branch": true,
"type": "library",
"autoload": {
"psr-4": {
"Azura\\Normalizer\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Buster 'Silver Eagle' Neece",
"email": "buster@busterneece.com",
"homepage": "https://dashdev.net/"
}
],
"description": "An implementation of the Symfony Serializer with custom support for Doctrine 2 ORM entities.",
"support": {
"issues": "https://github.com/AzuraCast/doctrine-entity-normalizer/issues",
"source": "https://github.com/AzuraCast/doctrine-entity-normalizer/tree/main"
},
"funding": [
{
"url": "https://github.com/SlvrEagle23",
"type": "github"
}
],
"time": "2022-06-12T14:29:46+00:00"
},
{
"name": "azuracast/nowplaying",
"version": "dev-main",
@ -13776,7 +13717,6 @@
"aliases": [],
"minimum-stability": "dev",
"stability-flags": {
"azuracast/doctrine-entity-normalizer": 20,
"azuracast/nowplaying": 20,
"lstrojny/fxmlrpc": 20,
"php-di/php-di": 20,

View File

@ -285,7 +285,7 @@ return [
$normalizers = [
new Symfony\Component\Serializer\Normalizer\JsonSerializableNormalizer(),
new Azura\Normalizer\DoctrineEntityNormalizer($em, $classMetaFactory),
new App\Normalizer\DoctrineEntityNormalizer($em, $classMetaFactory),
new Symfony\Component\Serializer\Normalizer\ObjectNormalizer($classMetaFactory),
];
$encoders = [

View File

@ -6,8 +6,8 @@ namespace App\Entity;
use App\Entity\Interfaces\EntityGroupsInterface;
use App\Entity\Interfaces\IdentifiableEntityInterface;
use App\Normalizer\Attributes\DeepNormalize;
use App\Security\SplitToken;
use Azura\Normalizer\Attributes\DeepNormalize;
use Doctrine\ORM\Mapping as ORM;
use Stringable;
use Symfony\Component\Serializer\Annotation as Serializer;

View File

@ -9,12 +9,12 @@ use App\Entity\Enums\StorageLocationTypes;
use App\Entity\Interfaces\EntityGroupsInterface;
use App\Entity\Interfaces\IdentifiableEntityInterface;
use App\Environment;
use App\Normalizer\Attributes\DeepNormalize;
use App\Radio\Enums\BackendAdapters;
use App\Radio\Enums\FrontendAdapters;
use App\Utilities\File;
use App\Utilities\Urls;
use App\Validator\Constraints as AppAssert;
use Azura\Normalizer\Attributes\DeepNormalize;
use DateTimeZone;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;

View File

@ -6,9 +6,9 @@ namespace App\Entity;
use App\Media\Metadata;
use App\Media\MetadataInterface;
use App\Normalizer\Attributes\DeepNormalize;
use App\OpenApi;
use App\Utilities\Time;
use Azura\Normalizer\Attributes\DeepNormalize;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

View File

@ -8,8 +8,8 @@ use App\Entity\Enums\PlaylistOrders;
use App\Entity\Enums\PlaylistRemoteTypes;
use App\Entity\Enums\PlaylistSources;
use App\Entity\Enums\PlaylistTypes;
use App\Normalizer\Attributes\DeepNormalize;
use App\Utilities\File;
use Azura\Normalizer\Attributes\DeepNormalize;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

View File

@ -4,9 +4,9 @@ declare(strict_types=1);
namespace App\Entity;
use App\Normalizer\Attributes\DeepNormalize;
use App\OpenApi;
use App\Validator\Constraints\UniqueEntity;
use Azura\Normalizer\Attributes\DeepNormalize;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

View File

@ -8,10 +8,10 @@ use App\Auth;
use App\Entity\Interfaces\EntityGroupsInterface;
use App\Entity\Interfaces\IdentifiableEntityInterface;
use App\Enums\SupportedThemes;
use App\Normalizer\Attributes\DeepNormalize;
use App\OpenApi;
use App\Utilities\Strings;
use App\Validator\Constraints\UniqueEntity;
use Azura\Normalizer\Attributes\DeepNormalize;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Normalizer\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD)]
final class DeepNormalize
{
private bool $deepNormalize;
public function __construct(bool $value)
{
$this->deepNormalize = $value;
}
public function getDeepNormalize(): bool
{
return $this->deepNormalize;
}
}

View File

@ -0,0 +1,370 @@
<?php
namespace App\Normalizer;
use App\Normalizer\Attributes\DeepNormalize;
use App\Normalizer\Exception\NoGetterAvailableException;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Util\ClassUtils;
use Doctrine\Inflector\Inflector;
use Doctrine\Inflector\InflectorFactory;
use Doctrine\ORM\EntityManagerInterface;
use ReflectionClass;
use ReflectionProperty;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use function is_array;
final class DoctrineEntityNormalizer extends AbstractNormalizer implements NormalizerAwareInterface
{
use NormalizerAwareTrait;
public const NORMALIZE_TO_IDENTIFIERS = 'form_mode';
public const CLASS_METADATA = 'class_metadata';
public const ASSOCIATION_MAPPINGS = 'association_mappings';
private readonly Inflector $inflector;
public function __construct(
private readonly EntityManagerInterface $em,
ClassMetadataFactoryInterface $classMetadataFactory = null,
NameConverterInterface $nameConverter = null,
array $defaultContext = []
) {
$defaultContext[self::ALLOW_EXTRA_ATTRIBUTES] = false;
parent::__construct($classMetadataFactory, $nameConverter, $defaultContext);
$this->inflector = InflectorFactory::create()->build();
}
/**
* Replicates the "toArray" functionality previously present in Doctrine 1.
*/
public function normalize(mixed $object, ?string $format = null, array $context = []): array
{
if (!is_object($object)) {
throw new \InvalidArgumentException('Cannot normalize non-object.');
}
if ($this->isCircularReference($object, $context)) {
return $this->handleCircularReference($object, $format, $context);
}
$context[self::CLASS_METADATA] = $this->em->getClassMetadata($object::class);
$props = $this->getAllowedAttributes($object, $context);
$return_arr = [];
if ($props) {
foreach ($props as $property) {
$attribute = $property->getName();
try {
$value = $this->getAttributeValue($object, $attribute, $format, $context);
/** @var callable|null $callback */
$callback = $context[self::CALLBACKS][$attribute]
?? $this->defaultContext[self::CALLBACKS][$attribute]
?? null;
if ($callback) {
$value = $callback($value, $object, $attribute, $format, $context);
}
$return_arr[$attribute] = $value;
} catch (NoGetterAvailableException) {
}
}
}
return $return_arr;
}
/**
* Replicates the "fromArray" functionality previously present in Doctrine 1.
*
* @template T as object
* @param mixed $data
* @param class-string<T> $type
* @param string|null $format
* @param array $context
* @return T
*/
public function denormalize(mixed $data, string $type, string $format = null, array $context = []): object
{
/** @var T $object */
$object = $this->instantiateObject($data, $type, $context, new ReflectionClass($type), false, $format);
$type = get_class($object);
$context[self::CLASS_METADATA] = $this->em->getMetadataFactory()->getMetadataFor($type);
$context[self::ASSOCIATION_MAPPINGS] = [];
if ($context[self::CLASS_METADATA]->associationMappings) {
foreach ($context[self::CLASS_METADATA]->associationMappings as $mapping_name => $mapping_info) {
$entity = $mapping_info['targetEntity'];
if (isset($mapping_info['joinTable'])) {
$context[self::ASSOCIATION_MAPPINGS][$mapping_info['fieldName']] = [
'type' => 'many',
'entity' => $entity,
'is_owning_side' => ($mapping_info['isOwningSide'] == 1),
];
} elseif (isset($mapping_info['joinColumns'])) {
foreach ($mapping_info['joinColumns'] as $col) {
$col_name = $col['name'];
$col_name = $context[self::CLASS_METADATA]->fieldNames[$col_name] ?? $col_name;
$context[self::ASSOCIATION_MAPPINGS][$mapping_name] = [
'name' => $col_name,
'type' => 'one',
'entity' => $entity,
];
}
}
}
}
foreach ((array)$data as $attribute => $value) {
/** @var callable|null $callback */
$callback = $context[self::CALLBACKS][$attribute]
?? $this->defaultContext[self::CALLBACKS][$attribute]
?? null;
if ($callback) {
$value = $callback($value, $object, $attribute, $format, $context);
}
$this->setAttributeValue($object, $attribute, $value, $format, $context);
}
return $object;
}
/**
* @inheritdoc
*/
public function supportsNormalization($data, string $format = null): bool
{
return $this->isEntity($data);
}
/**
* @inheritdoc
*/
public function supportsDenormalization($data, $type, string $format = null): bool
{
return $this->isEntity($type);
}
/**
* @param object|class-string $classOrObject
* @param array $context
* @param bool $attributesAsString
*
*/
protected function getAllowedAttributes(
$classOrObject,
array $context,
bool $attributesAsString = false
): array|false {
$meta = $this->classMetadataFactory?->getMetadataFor($classOrObject)?->getAttributesMetadata();
if (null === $meta) {
throw new \RuntimeException('Class metadata factory not specified.');
}
$props_raw = (new ReflectionClass($classOrObject))->getProperties(
ReflectionProperty::IS_PUBLIC | ReflectionProperty::IS_PROTECTED
);
$props = [];
foreach ($props_raw as $prop_raw) {
$props[$prop_raw->getName()] = $prop_raw;
}
$props = array_intersect_key($meta, $props);
$tmpGroups = $context[self::GROUPS] ?? $this->defaultContext[self::GROUPS] ?? null;
$groups = (is_array($tmpGroups) || is_scalar($tmpGroups)) ? (array)$tmpGroups : false;
$allowedAttributes = [];
foreach ($props as $attributeMetadata) {
$name = $attributeMetadata->getName();
if (
(false === $groups || array_intersect($attributeMetadata->getGroups(), $groups)) &&
$this->isAllowedAttribute($classOrObject, $name, null, $context)
) {
$allowedAttributes[] = $attributesAsString ? $name : $attributeMetadata;
}
}
return $allowedAttributes;
}
private function getAttributeValue(
object $object,
string $prop_name,
string $format = null,
array $context = []
): mixed {
$form_mode = $context[self::NORMALIZE_TO_IDENTIFIERS] ?? false;
if (isset($context[self::CLASS_METADATA]->associationMappings[$prop_name])) {
$deepNormalizeAttrs = (new ReflectionClass($object))->getProperty($prop_name)->getAttributes(
DeepNormalize::class
);
if (!empty($deepNormalizeAttrs)) {
/** @var DeepNormalize $deepNormalize */
$deepNormalize = current($deepNormalizeAttrs)->newInstance();
$deep = $deepNormalize->getDeepNormalize();
} else {
$deep = false;
}
if (!$deep) {
throw new NoGetterAvailableException(
sprintf(
'Deep normalization disabled for property %s.',
$prop_name
)
);
}
$prop_val = $this->getProperty($object, $prop_name);
if ($prop_val instanceof Collection) {
$return_val = [];
if (count($prop_val) > 0) {
/** @var object $val_obj */
foreach ($prop_val as $val_obj) {
if ($form_mode) {
$id_field = $this->em->getClassMetadata($val_obj::class)->identifier;
if ($id_field && count($id_field) === 1) {
$return_val[] = $this->getProperty($val_obj, $id_field[0]);
}
} else {
$return_val[] = $this->normalizer->normalize($val_obj, $format, $context);
}
}
}
return $return_val;
}
return $this->normalizer->normalize($prop_val, $format, $context);
}
$value = $this->getProperty($object, $prop_name);
if ($value instanceof Collection) {
$value = $value->toArray();
}
return $value;
}
private function getProperty(object $entity, string $key): mixed
{
// Default to "getStatus", "getConfig", etc...
$getter_method = $this->getMethodName($key, 'get');
if (method_exists($entity, $getter_method)) {
return $entity->{$getter_method}();
}
// but also allow "isEnabled" instead of "getIsEnabled"
$raw_method = $this->getMethodName($key);
if (method_exists($entity, $raw_method)) {
return $entity->{$raw_method}();
}
throw new NoGetterAvailableException(sprintf('No getter is available for property %s.', $key));
}
/**
* Converts "getvar_name_blah" to "getVarNameBlah".
*/
private function getMethodName(string $var, string $prefix = ''): string
{
return $this->inflector->camelize(($prefix ? $prefix . '_' : '') . $var);
}
/**
* @param object $object
* @param string $field
* @param mixed $value
* @param string|null $format
* @param array $context
*/
private function setAttributeValue(
object $object,
string $field,
mixed $value,
?string $format = null,
array $context = []
): void {
if (isset($context[self::ASSOCIATION_MAPPINGS][$field])) {
// Handle a mapping to another entity.
$mapping = $context[self::ASSOCIATION_MAPPINGS][$field];
if ('one' === $mapping['type']) {
if (empty($value)) {
$this->setProperty($object, $field, null);
} else {
/** @var class-string $entity */
$entity = $mapping['entity'];
if (($field_item = $this->em->find($entity, $value)) instanceof $entity) {
$this->setProperty($object, $field, $field_item);
}
}
} elseif ($mapping['is_owning_side']) {
$collection = $this->getProperty($object, $field);
if ($collection instanceof Collection) {
$collection->clear();
if ($value) {
foreach ((array)$value as $field_id) {
/** @var class-string $entity */
$entity = $mapping['entity'];
$field_item = $this->em->find($entity, $field_id);
if ($field_item instanceof $entity) {
$collection->add($field_item);
}
}
}
}
}
} else {
$this->setProperty($object, $field, $value);
}
}
private function setProperty(object $entity, string $key, mixed $value): void
{
$method_name = $this->getMethodName($key, 'set');
if (!method_exists($entity, $method_name)) {
return;
}
$entity->$method_name($value);
}
private function isEntity(mixed $class): bool
{
if (is_object($class)) {
$class = ClassUtils::getClass($class);
}
if (!is_string($class) || !class_exists($class)) {
return false;
}
return !$this->em->getMetadataFactory()->isTransient($class);
}
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Normalizer\Exception;
final class NoGetterAvailableException extends \Exception
{
}