From 435c5c567f2c9bedc636b376b51e6e0dc40333e3 Mon Sep 17 00:00:00 2001 From: Ben Roberts Date: Sat, 25 May 2024 06:36:40 +0200 Subject: [PATCH] Auto-generated types for standard int/string/uuid objects Not sure if this should become part of the main package. The API still needs work, and there is currently no interface for the string/int/uuid objects to enforce the correct methods. --- config/services.php | 15 ++ src/Attribute/DoctrineType.php | 16 +++ src/HeadsnetDoctrineToolsBundle.php | 20 +++ src/Types/CandidateType.php | 30 ++++ src/Types/DoctrineTypesCompilerPass.php | 136 ++++++++++++++++++ .../AbstractIntegerMappingType.php | 45 ++++++ .../AbstractStringMappingType.php | 45 ++++++ .../StandardTypes/AbstractUuidMappingType.php | 49 +++++++ .../StandardTypes/IntegerMappingPrototype.php | 20 +++ src/Types/StandardTypes/MappingPrototype.php | 13 ++ .../StandardTypes/StringMappingPrototype.php | 20 +++ .../StandardTypes/UuidMappingPrototype.php | 20 +++ tests/Fixtures/config.yaml | 1 + 13 files changed, 430 insertions(+) create mode 100644 config/services.php create mode 100644 src/Attribute/DoctrineType.php create mode 100644 src/Types/CandidateType.php create mode 100644 src/Types/DoctrineTypesCompilerPass.php create mode 100644 src/Types/StandardTypes/AbstractIntegerMappingType.php create mode 100644 src/Types/StandardTypes/AbstractStringMappingType.php create mode 100644 src/Types/StandardTypes/AbstractUuidMappingType.php create mode 100644 src/Types/StandardTypes/IntegerMappingPrototype.php create mode 100644 src/Types/StandardTypes/MappingPrototype.php create mode 100644 src/Types/StandardTypes/StringMappingPrototype.php create mode 100644 src/Types/StandardTypes/UuidMappingPrototype.php diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..d223ef7 --- /dev/null +++ b/config/services.php @@ -0,0 +1,15 @@ +services() + ->defaults() + ->autowire() + ->autoconfigure() + ; + + $services->load('Headsnet\\DoctrineToolsBundle\\', '../src/*'); +}; diff --git a/src/Attribute/DoctrineType.php b/src/Attribute/DoctrineType.php new file mode 100644 index 0000000..5b5aa45 --- /dev/null +++ b/src/Attribute/DoctrineType.php @@ -0,0 +1,16 @@ +rootNode() ->children() + ->scalarNode('root_namespace')->cannotBeEmpty()->end() + ->arrayNode('preset_types') + ->canBeDisabled() + ->children() + ->arrayNode('scan_dirs') + ->defaultValue(['src/'])->scalarPrototype()->end() + ->end() + ->end() + ->end() // End preset_types ->arrayNode('custom_types') ->children() ->arrayNode('scan_dirs') @@ -33,13 +43,19 @@ public function configure(DefinitionConfigurator $definition): void /** * @param array{ + * root_namespace: string, + * preset_types: array{scan_dirs: array}, * custom_types: array{scan_dirs: array}, * carbon_types: array{enabled: boolean, replace: boolean} * } $config */ public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void { + $container->import('../config/services.php'); + $container->parameters() + ->set('headsnet_doctrine_tools.root_namespace', $config['root_namespace']) + ->set('headsnet_doctrine_tools.preset_types.scan_dirs', $config['preset_types']['scan_dirs']) ->set('headsnet_doctrine_tools.custom_types.scan_dirs', $config['custom_types']['scan_dirs']) ->set('headsnet_doctrine_tools.carbon_types.enabled', $config['carbon_types']['enabled']) ->set('headsnet_doctrine_tools.carbon_types.replace', $config['carbon_types']['replace']) @@ -50,6 +66,10 @@ public function build(ContainerBuilder $container): void { parent::build($container); + $container->addCompilerPass( + new DoctrineTypesCompilerPass() + ); + $container->addCompilerPass( new DoctrineTypeMappingsCompilerPass() ); diff --git a/src/Types/CandidateType.php b/src/Types/CandidateType.php new file mode 100644 index 0000000..bb04756 --- /dev/null +++ b/src/Types/CandidateType.php @@ -0,0 +1,30 @@ +baseTypeClass = $baseTypeClass; + } + + public function getBaseTypeClass(): string + { + return $this->baseTypeClass; + } +} diff --git a/src/Types/DoctrineTypesCompilerPass.php b/src/Types/DoctrineTypesCompilerPass.php new file mode 100644 index 0000000..8231419 --- /dev/null +++ b/src/Types/DoctrineTypesCompilerPass.php @@ -0,0 +1,136 @@ +hasParameter(self::TYPE_DEFINITION_PARAMETER)) { + return; + } + + /** @var array $typeDefinitions */ + $typeDefinitions = $container->getParameter(self::TYPE_DEFINITION_PARAMETER); + /** @var array $scanDirs */ + $scanDirs = $container->getParameter('headsnet_doctrine_tools.preset_types.scan_dirs'); + $this->rootNamespace = $container->getParameter('headsnet_doctrine_tools.root_namespace'); // @phpstan-ignore-line + + $objectsToRegister = $this->findObjectsToRegister($scanDirs); + + $prototypeTypes = array_keys($container->findTaggedServiceIds(MappingPrototype::TAG)); + + foreach ($objectsToRegister as $candidate) { + // Do not add the type if it's been manually defined already + if (array_key_exists($candidate->typeName, $typeDefinitions)) { + continue; + } + + /** @var MappingPrototype $prototypeType */ + foreach ($prototypeTypes as $prototypeType) { + if ($prototypeType::supports($candidate->baseType)) { + $candidate->setBaseTypeClass( + $prototypeType::mappedBy() + ); + } + } + + if (!$candidate->getBaseTypeClass()) { + throw new \RuntimeException('Unsupported base type for Doctrine!'); + } + + $this->writeClassToFile($candidate); + + $typeDefinitions[$candidate->typeName] = [ + 'class' => sprintf( + '%s\_generated\HeadsnetDoctrineTools\Types\\%s', + $this->rootNamespace, + $candidate->typeClass + ), + ]; + } + + $container->setParameter(self::TYPE_DEFINITION_PARAMETER, $typeDefinitions); + } + + /** + * @param array $scanDirs + * + * @return iterable + */ + private function findObjectsToRegister(array $scanDirs): iterable + { + $classNames = ConstructFinder::locatedIn(...$scanDirs)->findClassNames(); + + foreach ($classNames as $className) { + $reflection = new ReflectionClass($className); + + // Skip any abstract parent types + if ($reflection->isAbstract()) { + continue; + } + + // Only register types that have the #[DoctrineType] attribute + if ($reflection->getAttributes(DoctrineType::class)) { + $attribute = $reflection->getAttributes(DoctrineType::class)[0]; + $attributeArgs = $attribute->getArguments(); + + yield new CandidateType( + typeName: $attributeArgs['name'], + typeClass: $reflection->getShortName() . 'Type', + baseType: $attributeArgs['type'], + objectClass: $className + ); + } + } + } + + private function generateClass(CandidateType $candidate): string + { + return <<rootNamespace\_generated\HeadsnetDoctrineTools\Types; + +class $candidate->typeClass extends \\{$candidate->getBaseTypeClass()} { + public function getName(): string + { + return '$candidate->typeName'; + } + + public function getClass(): string + { + return '$candidate->objectClass'; + } +} +PHP; + } + + private function writeClassToFile(CandidateType $candidate): void + { + $classCode = $this->generateClass($candidate); + + $filePath = sprintf('src/_generated/HeadsnetDoctrineTools/Types/%s.php', $candidate->typeClass); + + if (!is_dir(dirname($filePath))) { + mkdir(dirname($filePath), 0777, true); + } + + file_put_contents($filePath, $classCode); + } +} diff --git a/src/Types/StandardTypes/AbstractIntegerMappingType.php b/src/Types/StandardTypes/AbstractIntegerMappingType.php new file mode 100644 index 0000000..dc10d57 --- /dev/null +++ b/src/Types/StandardTypes/AbstractIntegerMappingType.php @@ -0,0 +1,45 @@ +getIntegerTypeDeclarationSQL($column); + } + + /** + * @param int|null $value + */ + public function convertToPHPValue($value, AbstractPlatform $platform): ?object + { + if ($value === null) { + return null; + } + + $class = $this->getClass(); + + return $class::create($value); + } + + /** + * @param object|null $value + */ + public function convertToDatabaseValue($value, AbstractPlatform $platform): ?int + { + if ($value === null) { + return null; + } + + return $value->asInteger(); // @phpstan-ignore-line + } + + abstract public function getName(): string; + + abstract public function getClass(): string; +} diff --git a/src/Types/StandardTypes/AbstractStringMappingType.php b/src/Types/StandardTypes/AbstractStringMappingType.php new file mode 100644 index 0000000..ebedd6d --- /dev/null +++ b/src/Types/StandardTypes/AbstractStringMappingType.php @@ -0,0 +1,45 @@ +getStringTypeDeclarationSQL($column); + } + + /** + * @param string|null $value + */ + public function convertToPHPValue($value, AbstractPlatform $platform): ?object + { + if ($value === null) { + return null; + } + + $class = $this->getClass(); + + return $class::create($value); + } + + /** + * @param object|null $value + */ + public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string + { + if ($value === null) { + return null; + } + + return $value->asString(); // @phpstan-ignore-line + } + + abstract public function getName(): string; + + abstract public function getClass(): string; +} diff --git a/src/Types/StandardTypes/AbstractUuidMappingType.php b/src/Types/StandardTypes/AbstractUuidMappingType.php new file mode 100644 index 0000000..3d6489f --- /dev/null +++ b/src/Types/StandardTypes/AbstractUuidMappingType.php @@ -0,0 +1,49 @@ +getGuidTypeDeclarationSQL($column); + } + + /** + * @param string|null $value + */ + public function convertToPHPValue($value, AbstractPlatform $platform): ?object + { + if ($value === null) { + return null; + } + + $class = $this->getClass(); + + return $class::fromString($value); + } + + /** + * @param object|string|null $value + */ + public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string + { + if ($value === null) { + return null; + } + + if (is_string($value)) { + return $value; + } + + return $value->asString(); // @phpstan-ignore-line + } + + abstract public function getName(): string; + + abstract public function getClass(): string; +} diff --git a/src/Types/StandardTypes/IntegerMappingPrototype.php b/src/Types/StandardTypes/IntegerMappingPrototype.php new file mode 100644 index 0000000..dfdbeeb --- /dev/null +++ b/src/Types/StandardTypes/IntegerMappingPrototype.php @@ -0,0 +1,20 @@ +