From 6146de970cb6c2f2653bada655c56a62def59b22 Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Mon, 21 Apr 2025 17:10:28 +0200 Subject: [PATCH 1/6] Improved annotations, annotation reader, reflection, code generator and unit tests --- src/Annotation/Annotation.php | 57 +---- src/Annotation/AnnotationReader.php | 203 ++++++++------- src/Annotation/BuildAnnotation.php | 25 ++ src/Annotation/MethodExecutionAnnotation.php | 25 ++ src/ClassBuilder/BuildHandler.php | 4 +- .../{BuildContext.php => BuildOutput.php} | 2 +- src/ClassBuilder/ClassBuilder.php | 9 +- src/Code/MethodCodeGenerator.php | 114 +++++++++ src/Collection/Kmap.php | 9 +- src/Container/Container.php | 33 ++- .../Builder/MethodExecutionBuildHandler.php | 26 +- .../Builder/MethodExecutionProxy.php | 20 +- .../MethodExecutionContext.php | 15 +- src/Reflection/ReflectionUtils.php | 237 ++++-------------- .../Handler/RepositoryBuildHandler.php | 200 +++++++++------ src/Repository/Repository.php | 5 +- src/Storage/Entity/Column/Column.php | 3 +- src/Storage/Entity/Entity.php | 3 +- src/Storage/Entity/EntityMetadataService.php | 29 ++- src/Storage/MysqlPersistenceStrategy.php | 17 +- .../Annotation/AnnotationReaderTest.php | 204 ++++++++++----- .../Axpecto/ClassBuilder/BuildOutputTest.php | 2 +- .../Axpecto/ClassBuilder/ClassBuilderTest.php | 24 +- .../Axpecto/Code/MethodCodeGeneratorTest.php | 102 ++++++++ .../MethodExecutionBuildHandlerTest.php | 111 ++++---- .../Builder/MethodExecutionProxyTest.php | 27 +- .../MethodExecutionContextTest.php | 13 +- .../Reflection/ReflectionUtilsTest.php | 169 +++++++++++++ .../Handler/RepositoryBuildHandlerTest.php | 134 ++++++---- 29 files changed, 1143 insertions(+), 679 deletions(-) create mode 100644 src/Annotation/BuildAnnotation.php create mode 100644 src/Annotation/MethodExecutionAnnotation.php rename src/ClassBuilder/{BuildContext.php => BuildOutput.php} (99%) create mode 100644 src/Code/MethodCodeGenerator.php create mode 100644 tests/Axpecto/Code/MethodCodeGeneratorTest.php create mode 100644 tests/Axpecto/Reflection/ReflectionUtilsTest.php diff --git a/src/Annotation/Annotation.php b/src/Annotation/Annotation.php index 4fba013..18dd381 100644 --- a/src/Annotation/Annotation.php +++ b/src/Annotation/Annotation.php @@ -3,36 +3,21 @@ namespace Axpecto\Annotation; use Attribute; -use Axpecto\ClassBuilder\BuildHandler; -use Axpecto\MethodExecution\MethodExecutionHandler; /** * Class Annotation * - * Represents an abstract base class for annotations in an Aspect-Oriented Programming (AOP) system. + * Represents a base class for annotations in an Aspect-Oriented Programming (AOP) system. * Annotations can have associated handler classes that define how they are processed during * method execution or build phases. This class provides mechanisms to retrieve those handlers and * associate annotations with specific classes and methods. * * @package Axpecto\Aop + * + * @TODO Refactor this and possibly create a hierarchy of annotations with Annotation -> BuildAnnotation -> MethodExecutionAnnotation. */ #[Attribute] class Annotation { - - /** - * The handler for processing the method execution annotation. - * - * @var MethodExecutionHandler|null - */ - protected ?MethodExecutionHandler $methodExecutionHandler = null; - - /** - * The builder for the annotation, used during the build phase. - * - * @var BuildHandler|null - */ - protected ?BuildHandler $builder = null; - /** * The class associated with the annotation. * @@ -47,42 +32,6 @@ class Annotation { */ protected ?string $annotatedMethod = null; - /** - * Gets the BuildHandler for this annotation, if available. - * - * @return BuildHandler|null The builder for the annotation, or null if not set. - */ - public function getBuilder(): ?BuildHandler { - return $this->builder; - } - - /** - * Checks if this annotation is meant for the build phase. - * - * @return bool True if the annotation is used for building, false otherwise. - */ - public function isBuildAnnotation(): bool { - return $this->builder instanceof BuildHandler; - } - - /** - * Gets the MethodExecutionHandler for this annotation, if available. - * - * @return MethodExecutionHandler|null The handler for method execution, or null if not set. - */ - public function getMethodExecutionHandler(): ?MethodExecutionHandler { - return $this->methodExecutionHandler; - } - - /** - * Checks if this annotation is meant for method execution interception. - * - * @return bool True if it is a method execution annotation, false otherwise. - */ - public function isMethodExecutionAnnotation(): bool { - return $this->methodExecutionHandler instanceof MethodExecutionHandler; - } - /** * Sets the class name that this annotation is associated with. * diff --git a/src/Annotation/AnnotationReader.php b/src/Annotation/AnnotationReader.php index f505ae2..059c73b 100644 --- a/src/Annotation/AnnotationReader.php +++ b/src/Annotation/AnnotationReader.php @@ -1,146 +1,171 @@ $class The fully qualified class name. - * @param string $method The method name. - * @param string $annotationClass The annotation class to filter. + * @template T + * @param class-string $class + * @param class-string $annotationClass * - * @return Klist A list of annotations for the method. - * @throws ReflectionException|Exception + * @return Klist + * @throws ReflectionException */ - public function getMethodAnnotations( string $class, string $method, string $annotationClass = Annotation::class ): Klist { - return $this->mapAttributesToAnnotations( - attributes: $this->reflect->getMethodAttributes( $class, $method ), - annotationClass: $annotationClass - ); + public function getClassAnnotations( + string $class, + string $annotationClass, + ): Klist { + $raw = $this->reflection->getClassAttributes( $class ); + + return $this + ->filterAndInject( $raw, $annotationClass ) + ->map( fn( Annotation $ann ): Annotation => $ann->setAnnotatedClass( $class ) ); } /** - * Fetches build-related annotations for a method. + * Fetch all annotations of a given type on a method. * - * @param class-string $class The fully qualified class name. - * @param string $method The method name. - * @param string $annotationClass The annotation class to filter. + * @template T + * @param class-string $class + * @param string $method + * @param class-string $annotationClass * - * @return Klist A list of build annotations for the method. + * @return Klist * @throws ReflectionException */ - public function getMethodExecutionAnnotations( string $class, string $method, string $annotationClass = Annotation::class ): Klist { - return $this->getMethodAnnotations( $class, $method, $annotationClass ) - ->filter( fn( Annotation $annotation ) => $annotation->isMethodExecutionAnnotation() ) - ->map( fn( Annotation $annotation ) => $annotation->setAnnotatedClass( $class )->setAnnotatedMethod( $method ) ); + public function getMethodAnnotations( + string $class, + string $method, + string $annotationClass, + ): Klist { + $raw = $this->reflection->getMethodAttributes( $class, $method ); + + return $this + ->filterAndInject( $raw, $annotationClass ) + ->map( fn( Annotation $ann ): Annotation => $ann + ->setAnnotatedClass( $class ) + ->setAnnotatedMethod( $method ) + ); } /** - * Fetches build-related annotations for a method. + * Fetch both class‑level and method‑level annotations of a given type. * - * @param class-string $class The fully qualified class name. - * @param string $method The method name. - * @param string $annotationClass The annotation class to filter. + * @template T + * @param class-string $class + * @param class-string $annotationClass * - * @return Klist A list of build annotations for the method. + * @return Klist * @throws ReflectionException */ - public function getMethodBuildAnnotations( string $class, string $method, string $annotationClass = Annotation::class ): Klist { - return $this->getMethodAnnotations( $class, $method, $annotationClass ) - ->filter( fn( Annotation $annotation ) => $annotation->isBuildAnnotation() ) - ->map( fn( Annotation $annotation ) => $annotation->setAnnotatedClass( $class )->setAnnotatedMethod( $method ) ); + public function getAllAnnotations( + string $class, + string $annotationClass = Annotation::class + ): Klist { + $classAnns = $this->getClassAnnotations( $class, $annotationClass ); + $methodAnns = $this->reflection + ->getAnnotatedMethods( $class, $annotationClass ) + ->map( fn( \ReflectionMethod $m ) => $this->getMethodAnnotations( $class, $m->getName(), $annotationClass ) + ) + ->flatten(); + + return $classAnns->merge( $methodAnns ); } /** - * Fetches all build-related annotations for a class, including its methods. + * Fetch all annotations of a given type on one of a method’s parameters. * - * @param class-string $class The fully qualified class name. - * @param string $annotationClass The annotation class to filter. + * @template T + * @param class-string $class + * @param string $method + * @param string $parameterName + * @param class-string $annotationClass * - * @return Klist A list of all build annotations for the class. + * @return Klist * @throws ReflectionException */ - public function getAllBuildAnnotations( string $class, string $annotationClass = Annotation::class ): Klist { - return $this->reflect - ->getAnnotatedMethods( $class ) - ->map( fn( ReflectionMethod $method ) => $this->getMethodBuildAnnotations( $class, $method->getName(), $annotationClass ) ) - ->flatten() - ->merge( $this->getClassBuildAnnotations( $class, $annotationClass ) ); - } + public function getParameterAnnotations( + string $class, + string $method, + string $parameterName, + string $annotationClass, + ): Klist { + $parameter = listFrom( $this->reflection->getClassMethod( $class, $method )->getParameters() ) + ->filter( fn( ReflectionParameter $p ) => $p->getName() === $parameterName ) + ->firstOrNull(); - /** - * Fetches annotations for a class. - * - * @param class-string $class The fully qualified class name. - * @param string $annotationClass The annotation class to filter. - * - * @return Klist A list of annotations for the class. - * @throws ReflectionException|Exception - */ - public function getClassAnnotations( string $class, string $annotationClass = Annotation::class ): Klist { - return $this->mapAttributesToAnnotations( - attributes: $this->reflect->getClassAttributes( $class ), - annotationClass: $annotationClass - ); + if ( ! $parameter ) { + return emptyList(); + } + + return listFrom( $parameter->getAttributes() ) + ->map( fn( ReflectionAttribute $p ) => $p->newInstance() ) + ->maybe( fn( Klist $attributes ) => $this->filterAndInject( $attributes, $annotationClass ) ) + ->foreach( fn( Annotation $ann ) => $ann->setAnnotatedClass( $class )->setAnnotatedMethod( $method ) ); } /** - * Fetches build-related annotations for a class. + * Fetch a single annotation of a given type on a property. * - * @param class-string $class The fully qualified class name. - * @param string $annotationClass The annotation class to filter. + * @template T + * @param class-string $class + * @param string $property + * @param class-string $annotationClass * - * @return Klist A list of build annotations for the class. - * @throws ReflectionException|Exception + * @return A|null + * @throws ReflectionException */ - public function getClassBuildAnnotations( string $class, string $annotationClass = Annotation::class ): Klist { - return $this->getClassAnnotations( $class, $annotationClass ) - ->filter( fn( Annotation $annotation ) => $annotation->isBuildAnnotation() ) - ->map( fn( Annotation $annotation ) => $annotation->setAnnotatedClass( $class ) ); + public function getPropertyAnnotation( + string $class, + string $property, + string $annotationClass = Annotation::class + ): mixed { + $attributes = $this->reflection + ->getReflectionClass( $class ) + ->getProperty( $property ) + ->getAttributes(); + + return listFrom( $attributes ) + ->map( fn( ReflectionAttribute $a ) => $a->newInstance() ) + ->maybe( fn( Klist $attributes ) => $this->filterAndInject( $attributes, $annotationClass ) ) + ->firstOrNull() + ?->setAnnotatedClass( $class ); } /** - * Maps attributes to annotations and applies property injection. - * - * @param Klist $attributes A list of attributes. - * @param string $annotationClass The annotation class to filter. + * @template T of Annotation + * @param Klist $instances + * @param class-string $annotationClass * - * @return Klist A list of filtered and injected annotations. - * @throws Exception + * @return Klist */ - private function mapAttributesToAnnotations( Klist $attributes, string $annotationClass ): Klist { - return $attributes - ->filter( fn( $annotation ) => $annotation instanceof $annotationClass ) - ->foreach( fn( $annotation ) => $this->container->applyPropertyInjection( $annotation ) ); + private function filterAndInject( Klist $instances, string $annotationClass ): Klist { + return $instances + ->filter( fn( $i ) => is_a( $i, $annotationClass, true ) ) + ->foreach( fn( Annotation $ann ) => $this->container->applyPropertyInjection( $ann ) ); } } diff --git a/src/Annotation/BuildAnnotation.php b/src/Annotation/BuildAnnotation.php new file mode 100644 index 0000000..07a5b72 --- /dev/null +++ b/src/Annotation/BuildAnnotation.php @@ -0,0 +1,25 @@ +builder; + } +} \ No newline at end of file diff --git a/src/Annotation/MethodExecutionAnnotation.php b/src/Annotation/MethodExecutionAnnotation.php new file mode 100644 index 0000000..bbdf0c3 --- /dev/null +++ b/src/Annotation/MethodExecutionAnnotation.php @@ -0,0 +1,25 @@ +methodExecutionHandler; + } +} \ No newline at end of file diff --git a/src/ClassBuilder/BuildHandler.php b/src/ClassBuilder/BuildHandler.php index d7bf8d7..7df1620 100644 --- a/src/ClassBuilder/BuildHandler.php +++ b/src/ClassBuilder/BuildHandler.php @@ -18,9 +18,9 @@ interface BuildHandler { * This method allows the handler to modify the build output by interacting with the build chain * and using information from the provided annotation and current build state. * - * @param BuildContext $context The current build output, which can be augmented by the handler. + * @param BuildOutput $buildOutput The current build output, which can be augmented by the handler. * * @return void The modified or updated build output. */ - public function intercept( Annotation $annotation, BuildContext $context ): void; + public function intercept( Annotation $annotation, BuildOutput $buildOutput ): void; } diff --git a/src/ClassBuilder/BuildContext.php b/src/ClassBuilder/BuildOutput.php similarity index 99% rename from src/ClassBuilder/BuildContext.php rename to src/ClassBuilder/BuildOutput.php index ee806cb..f8dc1cc 100644 --- a/src/ClassBuilder/BuildContext.php +++ b/src/ClassBuilder/BuildOutput.php @@ -21,7 +21,7 @@ * * @package Axpecto\Aop\Build */ -class BuildContext { +class BuildOutput { /** * Constructor for the BuildOutput class. diff --git a/src/ClassBuilder/ClassBuilder.php b/src/ClassBuilder/ClassBuilder.php index ee36271..bb1fbc7 100644 --- a/src/ClassBuilder/ClassBuilder.php +++ b/src/ClassBuilder/ClassBuilder.php @@ -2,6 +2,7 @@ namespace Axpecto\ClassBuilder; +use Axpecto\Annotation\BuildAnnotation; use Axpecto\Container\Exception\ClassAlreadyBuiltException; use Axpecto\Reflection\ReflectionUtils; use ReflectionException; @@ -44,10 +45,10 @@ public function build( string $class ): string { } // Get all the Build annotations for the class and its methods - $buildAnnotations = $this->reader->getAllBuildAnnotations( $class ); + $buildAnnotations = $this->reader->getAllAnnotations( $class, BuildAnnotation::class ); // Create and proceed with the build chain - $context = new BuildContext( $class ); + $context = new BuildOutput( $class ); $buildAnnotations->foreach( fn( Annotation $a ) => $a->getBuilder()?->intercept( $a, $context ) ); // If the build output is empty, return the original class @@ -72,12 +73,12 @@ public function build( string $class ): string { * It also evaluates the generated class code dynamically using `eval`. * * @param string $class The original class name to be proxied. - * @param BuildContext $buildOutput The output from the build process, containing properties and methods. + * @param BuildOutput $buildOutput The output from the build process, containing properties and methods. * * @return string The name of the generated proxy class. * @throws ReflectionException */ - private function generateProxyClass( string $class, BuildContext $buildOutput ): string { + private function generateProxyClass( string $class, BuildOutput $buildOutput ): string { // Generate a unique proxy class name by replacing backslashes in the class name. $proxiedClassName = str_replace( "\\", '_', $class ) . 'Proxy'; diff --git a/src/Code/MethodCodeGenerator.php b/src/Code/MethodCodeGenerator.php new file mode 100644 index 0000000..02e1975 --- /dev/null +++ b/src/Code/MethodCodeGenerator.php @@ -0,0 +1,114 @@ +reflectionUtils->getClassMethod( $class, $method ); + + if ( $rMethod->isPrivate() || ! $rMethod->isAbstract() ) { + throw new Exception( "Can't implement non-abstract or private method $class::{$method}()" ); + } + + $visibility = $rMethod->isPublic() ? 'public' : 'protected'; + + $arguments = listFrom( $rMethod->getParameters() ) + ->map( $this->mapParameterToCode( ... ) ) + ->join( ', ' ); + + $return = $rMethod->hasReturnType() + ? ': ' . $this->mapReturnTypeToCode( $rMethod->getReturnType() ) + : ''; + + return "$visibility function {$method}($arguments)$return"; + } + + /** + * Convert a ReflectionParameter to a PHP argument definition string. + * + * @param ReflectionParameter $parameter + * + * @return string + */ + public function mapParameterToCode( ReflectionParameter $parameter ): string { + $code = ''; + + if ( $parameter->hasType() ) { + $code .= $this->mapReturnTypeToCode( $parameter->getType() ) . ' '; + } + + if ( $parameter->isPassedByReference() ) { + $code .= '&'; + } + + if ( $parameter->isVariadic() ) { + $code .= '...'; + } + + $code .= '$' . $parameter->getName(); + + if ( $parameter->isDefaultValueAvailable() && ! $parameter->isVariadic() ) { + $default = var_export( $parameter->getDefaultValue(), true ); + $code .= " = $default"; + } + + return $code; + } + + /** + * Convert a ReflectionType (return or parameter) to a PHP type declaration. + * + * @param ReflectionType|null $type + * + * @return string + */ + public function mapReturnTypeToCode( ?ReflectionType $type ): string { + if ( ! $type ) { + return ''; + } + + if ( $type instanceof ReflectionNamedType ) { + $nullable = $type->allowsNull() && $type->getName() !== 'mixed' ? '?' : ''; + + return $nullable . $type->getName(); + } + + if ( $type instanceof ReflectionUnionType || $type instanceof ReflectionIntersectionType ) { + $separator = $type instanceof ReflectionUnionType ? '|' : '&'; + + return listFrom( $type->getTypes() ) + ->map( fn( ReflectionType $t ) => $this->mapReturnTypeToCode( $t ) ) + ->join( $separator ); + } + + return ''; + } +} diff --git a/src/Collection/Kmap.php b/src/Collection/Kmap.php index 635d7fe..bc85c1e 100644 --- a/src/Collection/Kmap.php +++ b/src/Collection/Kmap.php @@ -177,12 +177,9 @@ public function toArray(): array { */ #[Override] public function filter( Closure $predicate ): static { - $filtered = []; - foreach ( $this->array as $key => $value ) { - if ( $predicate( $key, $value ) ) { - $filtered[ $key ] = $value; - } - } + $filtered = array_filter( $this->array, function ( $value, $key ) use ( $predicate ) { + return $predicate( $key, $value ); + }, ARRAY_FILTER_USE_BOTH ); if ( $this->mutable ) { $this->array = $filtered; diff --git a/src/Container/Container.php b/src/Container/Container.php index 8ba9abc..550bab4 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -40,14 +40,21 @@ class Container { */ private ReflectionUtils $reflect; + /** + * Reads annotations and injects dependencies. + * + * @var AnnotationReader The annotation reader instance. + */ + private AnnotationReader $annotationReader; + /** * Container constructor. * * @psalm-suppress PossiblyUnusedMethod * - * @param array $values Stores constant values (like configs). - * @param array $bindings Maps interfaces or abstract classes to concrete implementations. - * @param array $instances Stores class instances (usually singletons). + * @param array $values Stores constant values (like configs). + * @param array $bindings Maps interfaces or abstract classes to concrete implementations. + * @param array $instances Stores class instances (usually singletons). * @param array $autoWiring Tracks classes currently being autowired to prevent circular references. */ public function __construct( @@ -59,15 +66,15 @@ public function __construct( $this->reflect = new ReflectionUtils(); $this->instances[ ReflectionUtils::class ] = $this->reflect; - $annotationReader = new AnnotationReader( + $this->annotationReader = new AnnotationReader( container: $this, - reflect: $this->reflect, + reflection: $this->reflect, ); - $this->instances[ AnnotationReader::class ] = $annotationReader; + $this->instances[ AnnotationReader::class ] = $this->annotationReader; $this->classBuilder = new ClassBuilder( reflect: $this->reflect, - reader: $annotationReader, + reader: $this->annotationReader, ); $this->instances[ ClassBuilder::class ] = $this->classBuilder; $this->instances[ self::class ] = $this; @@ -76,7 +83,7 @@ public function __construct( /** * Adds a class instance to the container. * - * @param string $class The class name. + * @param string $class The class name. * @param object $instance The instance of the class. */ public function addClassInstance( string $class, object $instance ): void { @@ -88,8 +95,8 @@ public function addClassInstance( string $class, object $instance ): void { * * @psalm-suppress PossiblyUnusedMethod * - * @param string $name The name of the value. - * @param mixed $value The value to add. + * @param string $name The name of the value. + * @param mixed $value The value to add. */ public function addValue( string $name, mixed $value ): void { $this->values[ $this->getValueKey( $name ) ] = $value; @@ -99,7 +106,7 @@ public function addValue( string $name, mixed $value ): void { * Binds an interface or class to a specific implementation. * * @param string $classOrInterface The class or interface name. - * @param string $class The class name to bind. + * @param string $class The class name to bind. */ public function bind( string $classOrInterface, string $class ): void { $this->bindings[ $classOrInterface ] = $class; @@ -158,13 +165,13 @@ public function applyPropertyInjection( object $instance ): void { foreach ( $propertiesToInject as $property ) { /** @var Inject $annotation */ - $annotation = $this->reflect->getPropertyAnnotated( $instance::class, $property->name, with: Inject::class ); + $annotation = $this->annotationReader->getPropertyAnnotation( $instance::class, $property->name, Inject::class ); if ( ! empty( $annotation->args ) ) { $type = $property->type; if ( ! is_string( $type ) || ! class_exists( $type ) ) { - throw new RuntimeException("Cannot instantiate property {$property->name}: missing or invalid type."); + throw new RuntimeException( "Cannot instantiate property {$property->name}: missing or invalid type." ); } $value = new $type( ...$annotation->args ); diff --git a/src/MethodExecution/Builder/MethodExecutionBuildHandler.php b/src/MethodExecution/Builder/MethodExecutionBuildHandler.php index 0fe5c07..26419ec 100644 --- a/src/MethodExecution/Builder/MethodExecutionBuildHandler.php +++ b/src/MethodExecution/Builder/MethodExecutionBuildHandler.php @@ -3,9 +3,9 @@ namespace Axpecto\MethodExecution\Builder; use Axpecto\Annotation\Annotation; -use Axpecto\ClassBuilder\BuildContext; +use Axpecto\ClassBuilder\BuildOutput; use Axpecto\ClassBuilder\BuildHandler; -use Axpecto\Reflection\ReflectionUtils; +use Axpecto\Code\MethodCodeGenerator; use Exception; use Override; use ReflectionException; @@ -18,42 +18,40 @@ * It intercepts the build chain and adds method signature and interception logic to the BuildOutput. */ class MethodExecutionBuildHandler implements BuildHandler { - const PROXY_PROPERTY_NAME = 'proxy'; + const string PROXY_PROPERTY_NAME = 'proxy'; /** * MethodExecutionBuildHandler constructor. * - * @param ReflectionUtils $reflect Reflection utility for analyzing classes and methods. + * @param MethodCodeGenerator $code */ public function __construct( - protected readonly ReflectionUtils $reflect, + protected readonly MethodCodeGenerator $code, ) { } /** * Intercepts a build chain, adding method interception logic to the output. * - * @param Annotation $annotation The annotation being processed. - * @param BuildContext $context The current build context to modify. + * @param Annotation $annotation The annotation being processed. + * @param BuildOutput $buildOutput The current build context to modify. * * @throws ReflectionException If reflection on the method or class fails. * @throws Exception */ #[Override] - public function intercept( Annotation $annotation, BuildContext $context ): void { + public function intercept( Annotation $annotation, BuildOutput $buildOutput ): void { $class = $annotation->getAnnotatedClass(); $method = $annotation->getAnnotatedMethod(); // Define properties and add them as injectable dependencies. - $context->injectProperty( self::PROXY_PROPERTY_NAME, MethodExecutionProxy::class ); + $buildOutput->injectProperty( self::PROXY_PROPERTY_NAME, MethodExecutionProxy::class ); // Generate the method signature and implementation using reflection. - $methodSignature = $this->reflect->getMethodDefinitionString( $class, $method ); - $returnStatement = $this->reflect->getReturnType( $class, $method ) !== 'void' ? 'return ' : ''; - $implementation = $returnStatement . "\$this->" . self::PROXY_PROPERTY_NAME - . "->handle('$class', '$method', parent::$method(...), func_get_args());"; + $methodSignature = $this->code->implementMethodSignature( $class, $method ); + $implementation = "return \$this->" . self::PROXY_PROPERTY_NAME . "->handle('$class', '$method', parent::$method(...), func_get_args());"; // Add the method and proxy property to the context output. - $context->addMethod( $method, $methodSignature, $implementation ); + $buildOutput->addMethod( $method, $methodSignature, $implementation ); } } diff --git a/src/MethodExecution/Builder/MethodExecutionProxy.php b/src/MethodExecution/Builder/MethodExecutionProxy.php index 279c993..78467f2 100644 --- a/src/MethodExecution/Builder/MethodExecutionProxy.php +++ b/src/MethodExecution/Builder/MethodExecutionProxy.php @@ -2,7 +2,9 @@ namespace Axpecto\MethodExecution\Builder; +use Axpecto\Annotation\Annotation; use Axpecto\Annotation\AnnotationReader; +use Axpecto\Annotation\MethodExecutionAnnotation; use Axpecto\MethodExecution\MethodExecutionContext; use Axpecto\Reflection\ReflectionUtils; use Closure; @@ -20,8 +22,8 @@ class MethodExecutionProxy { * * @psalm-suppress PossiblyUnusedMethod * - * @param ReflectionUtils $reflect The reflection utility instance for handling class/method reflection. - * @param AnnotationReader $reader Reads annotations for the given class and method. + * @param ReflectionUtils $reflect The reflection utility instance for handling class/method reflection. + * @param AnnotationReader $reader Reads annotations for the given class and method. */ public function __construct( private readonly ReflectionUtils $reflect, @@ -37,10 +39,10 @@ public function __construct( * * @psalm-suppress PossiblyUnusedMethod * - * @param string $class The fully qualified class name. - * @param string $method The method name to intercept. + * @param string $class The fully qualified class name. + * @param string $method The method name to intercept. * @param Closure $methodCall The original method's closure to call. - * @param array $arguments The arguments passed to the method. + * @param array $arguments The arguments passed to the method. * * @return mixed The result of the method call or modified behavior based on annotations. * @throws ReflectionException @@ -52,18 +54,18 @@ public function handle( array $arguments, ): mixed { // Get method annotations - $annotations = $this->reader->getMethodExecutionAnnotations( $class, $method ); + $annotations = $this->reader->getMethodAnnotations( $class, $method, MethodExecutionAnnotation::class ); // Resolve method arguments using reflection $mappedArguments = $this->reflect->mapValuesToArguments( $class, $method, $arguments ); // Create and initialize the method execution context $context = new MethodExecutionContext( - className: $class, + className: $class, methodName: $method, methodCall: $methodCall, - arguments: $mappedArguments, - queue: $annotations, + arguments: $mappedArguments, + queue: $annotations, ); // Delegate the execution to the context, which will handle proceeding through annotations diff --git a/src/MethodExecution/MethodExecutionContext.php b/src/MethodExecution/MethodExecutionContext.php index 243f1cf..5bd3aef 100644 --- a/src/MethodExecution/MethodExecutionContext.php +++ b/src/MethodExecution/MethodExecutionContext.php @@ -3,6 +3,7 @@ namespace Axpecto\MethodExecution; use Axpecto\Annotation\Annotation; +use Axpecto\Annotation\MethodExecutionAnnotation; use Axpecto\Collection\Klist; use Closure; @@ -19,11 +20,11 @@ class MethodExecutionContext { /** * MethodExecutionContext constructor. * - * @param string $className The fully qualified class name. - * @param string $methodName The method name. + * @param string $className The fully qualified class name. + * @param string $methodName The method name. * @param Closure $methodCall The closure representing the method execution. - * @param array $arguments Arguments to pass to the method. - * @param Klist $queue Queue of annotations to process. + * @param array $arguments Arguments to pass to the method. + * @param Klist $queue Queue of annotations to process. */ public function __construct( public string $className, @@ -50,8 +51,8 @@ public function getAnnotation(): ?Annotation { * * @psalm-suppress PossiblyUnusedMethod * - * @param string $name The argument name. - * @param mixed $value The argument value. + * @param string $name The argument name. + * @param mixed $value The argument value. */ public function addArgument( string $name, mixed $value ): void { $this->arguments[ $name ] = $value; @@ -67,7 +68,7 @@ public function proceed(): mixed { $this->queue->next(); $this->currentAnnotation = $annotation; - if ( ! $annotation instanceof Annotation ) { + if ( ! $annotation instanceof MethodExecutionAnnotation ) { // No more annotations, execute the actual method. return ( $this->methodCall )( ...$this->arguments ); } diff --git a/src/Reflection/ReflectionUtils.php b/src/Reflection/ReflectionUtils.php index b9bb682..04d387a 100644 --- a/src/Reflection/ReflectionUtils.php +++ b/src/Reflection/ReflectionUtils.php @@ -5,7 +5,6 @@ use Attribute; use Axpecto\Annotation\Annotation; use Axpecto\Collection\Klist; -use Axpecto\Collection\Kmap; use Axpecto\Reflection\Dto\Argument; use ReflectionAttribute; use ReflectionClass; @@ -30,46 +29,52 @@ class ReflectionUtils { private array $reflectionClasses = []; /** - * Generates a method definition string with visibility, name, arguments, and return type. + * Returns a ReflectionClass instance, using caching for optimization. * - * @param string $class - * @param string $method + * @param class-string $class * - * @return string + * @return ReflectionClass * @throws ReflectionException */ - public function getMethodDefinitionString( string $class, string $method ): string { - $reflectionMethod = $this->getReflectionClass( $class )->getMethod( $method ); - - $visibility = $reflectionMethod->isProtected() ? 'protected' : 'public'; - $argumentListString = listFrom( $reflectionMethod->getParameters() ) - ->map( function ( ReflectionParameter $arg ) { - $definition = ( $arg->hasType() ? $arg->getType() . ' ' : '' ) . ( $arg->isVariadic() ? '...' : '' ) . "\${$arg->getName()}"; - if ( $arg->isDefaultValueAvailable() ) { - $definition .= " = " . var_export( $arg->getDefaultValue(), true ); - } - - return $definition; - } ) - ->join( ',' ); + public function getReflectionClass( string $class ): ReflectionClass { + return $this->reflectionClasses[ $class ] ??= new ReflectionClass( $class ); + } - $returnType = $reflectionMethod->hasReturnType() ? ': ' . $reflectionMethod->getReturnType() : ''; + /** + * @return Klist + * @throws ReflectionException + */ + public function getClassAttributes( string $class ): Klist { + return listFrom( $this->getReflectionClass( $class )->getAttributes() ) + ->map( fn( ReflectionAttribute $attribute ) => $attribute->newInstance() ); + } - return "$visibility function {$reflectionMethod->getName()}($argumentListString)$returnType"; + /** + * Returns a list of attributes for a method. + * + * @return Klist + * @throws ReflectionException + */ + public function getMethodAttributes( string $class, string $method ): Klist { + return listFrom( $this->getReflectionClass( $class )->getMethod( $method )->getAttributes() ) + ->map( fn( ReflectionAttribute $attribute ) => $attribute->newInstance() ); } /** * Fetches methods annotated with a specific annotation class. * * @param class-string $class - * @param string $with + * @param class-string $with * * @return Klist * @throws ReflectionException */ public function getAnnotatedMethods( string $class, string $with = Annotation::class ): Klist { - return $this->getMethods( $class ) - ->filter( fn( ReflectionMethod $method ) => $this->methodHasAnnotations( $method, $with ) ); + return listFrom( $this->getReflectionClass( $class )->getMethods() ) + ->filter( fn( ReflectionMethod $m ) => listFrom( $m->getAttributes() ) + ->filter( fn( ReflectionAttribute $attribute ) => $attribute->getName() === $with ) + ->isNotEmpty() + ); } /** @@ -102,60 +107,15 @@ public function getAbstractMethods( string $class ): Klist { * Fetches annotations for a class. * * @param class-string $class - * @param string $annotationClass + * @param string $annotationClass * * @return Klist * @throws ReflectionException */ public function getClassAnnotations( string $class, string $annotationClass = Annotation::class ): Klist { return $this->getAnnotations( - attributes: $this->getAttributes( $class ), - target: Attribute::TARGET_CLASS, - annotationClass: $annotationClass - ); - } - - /** - * Fetches annotations for a parameter. - * - * @param class-string $class The class name. - * @param string $method The method name (use "__construct" for constructor). - * @param string $parameterName The parameter name. - * @param string $annotationClass The annotation class to filter by. - * - * @return Klist - * @throws ReflectionException - */ - public function getParamAnnotations( - string $class, - string $method, - string $parameterName, - string $annotationClass = Annotation::class - ): Klist { - // Get the reflection of the specified method. - $reflectionMethod = $this->getReflectionClass( $class )->getMethod( $method ); - - // Find the parameter by name. - $reflectionParameter = null; - foreach ( $reflectionMethod->getParameters() as $parameter ) { - if ( $parameter->getName() === $parameterName ) { - $reflectionParameter = $parameter; - break; - } - } - - if ( $reflectionParameter === null ) { - throw new ReflectionException( "Parameter {$parameterName} not found in method {$method} of class {$class}" ); - } - - // Get attributes from the parameter. - $attributes = listFrom( $reflectionParameter->getAttributes() ); - - // Filter the annotations by target. - // For parameters, the target is Attribute::TARGET_PARAMETER. - return $this->getAnnotations( - attributes: $attributes, - target: Attribute::TARGET_PARAMETER, + attributes: $this->getAttributes( $class ), + target: Attribute::TARGET_CLASS, annotationClass: $annotationClass ); } @@ -193,7 +153,7 @@ public function getAnnotatedProperties( string $class, string $annotationClass = * * @param object $instance * @param string $property - * @param mixed $value + * @param mixed $value * * @return void * @throws ReflectionException @@ -208,14 +168,14 @@ public function setPropertyValue( object $instance, string $property, mixed $val /** * Gets a map of method arguments and their values. * - * @param string $class - * @param string $method + * @param string $class + * @param string $method * @param array $arguments * * @return array * @throws ReflectionException */ - public function mapValuesToArguments( string $class, string $method, array $arguments ) { + public function mapValuesToArguments( string $class, string $method, array $arguments ): array { $parameters = $this->getReflectionClass( $class ) ->getMethod( $method ) ->getParameters(); @@ -231,59 +191,6 @@ public function mapValuesToArguments( string $class, string $method, array $argu return array_combine( $parameters->toArray(), $arguments ); } - /** - * Gets default argument values for a method. - * - * @psalm-suppress PossiblyUnusedMethod - * - * @param string $class - * @param string $method - * - * @return Kmap - * @throws ReflectionException - */ - public function getMethodArgumentsDefaults( string $class, string $method ): Kmap { - $parameters = $this->getReflectionClass( $class )->getMethod( $method )->getParameters(); - if ( count( $parameters ) === 0 ) { - return emptyMap(); - } - - return listFrom( $parameters ) - ->mapOf( fn( ReflectionParameter $value ) => [ $value->getName() => $value->isDefaultValueAvailable() ? $value->getDefaultValue() : null, ] ) - ->filterNotNull(); - } - - /** - * Fetches an annotation on a property. - * - * @param string $class - * @param string $property - * @param string $with - * - * @return Annotation|null - * @throws ReflectionException - */ - public function getPropertyAnnotated( string $class, string $property, string $with = Annotation::class ): ?Annotation { - return listFrom( $this->getReflectionClass( $class )->getProperty( $property )->getAttributes() ) - ->filter( fn( ReflectionAttribute $attribute ) => $attribute->getName() === $with ) - ->firstOrNull()?->newInstance(); - } - - /** - * Gets the return type of a method. - * - * @param string $class - * @param string $method - * - * @return string|null - * @throws ReflectionException - */ - public function getReturnType( string $class, string $method ): ?string { - $method = $this->getReflectionClass( $class )->getMethod( $method ); - - return $method->hasReturnType() ? $method->getReturnType()->getName() : null; - } - /** * Checks if the supplied class is an interface. * @@ -296,27 +203,6 @@ public function isInterface( $class ): bool { return $this->getReflectionClass( $class )->isInterface(); } - /** - * Returns a list of attributes for a method. - * - * @return Klist - * @throws ReflectionException - */ - public function getMethodAttributes( string $class, string $method ): Klist { - return listFrom( $this->getReflectionClass( $class )->getMethod( $method )->getAttributes() ) - ->map( fn( ReflectionAttribute $attribute ) => $attribute->newInstance() ); - } - - /** - * @return Klist - * @throws ReflectionException - */ - public function getClassAttributes( string $class ): Klist { - return listFrom( $this->getReflectionClass( $class )->getAttributes() ) - ->filter( fn( ReflectionAttribute $attribute ) => class_exists( $attribute->getName() ) ) - ->map( fn( ReflectionAttribute $attribute ) => $attribute->newInstance() ); - } - /** * Converts a ReflectionProperty or ReflectionParameter into an Argument DTO. * @@ -330,7 +216,7 @@ private function reflectionToArgument( ReflectionProperty|ReflectionParameter $r $name = $reflection->getName(); $type = $reflection->getType()?->getName() ?? 'mixed'; $nullable = $reflection->getType()?->allowsNull() ?? false; - $default = null; + $default = null; if ( $reflection instanceof ReflectionParameter ) { if ( $reflection->isDefaultValueAvailable() ) { @@ -343,8 +229,8 @@ private function reflectionToArgument( ReflectionProperty|ReflectionParameter $r } return new Argument( - name: $name, - type: $type, + name: $name, + type: $type, nullable: $nullable, default: $default ); @@ -354,40 +240,24 @@ private function reflectionToArgument( ReflectionProperty|ReflectionParameter $r * Filters properties that are annotated with a specific annotation. * * @param ReflectionProperty $property - * @param string $annotationClass + * @param string $annotationClass * * @return bool */ private function filterAnnotatedProperties( ReflectionProperty $property, string $annotationClass ): bool { return $this->getAnnotations( - attributes: listFrom( $property->getAttributes() ), - target: Attribute::TARGET_PROPERTY, + attributes: listFrom( $property->getAttributes() ), + target: Attribute::TARGET_PROPERTY, annotationClass: $annotationClass )->isNotEmpty(); } - /** - * Checks if a method is annotated with a specific attribute. - * - * @param ReflectionMethod $method - * @param string $annotationClass - * - * @return bool - */ - private function methodHasAnnotations( ReflectionMethod $method, string $annotationClass = Annotation::class ): bool { - return $this->getAnnotations( - attributes: listFrom( $method->getAttributes() ), - target: Attribute::TARGET_METHOD, - annotationClass: $annotationClass, - )->isNotEmpty(); - } - /** * Fetches annotations based on the target. * * @param Klist $attributes - * @param string|null $target - * @param string $annotationClass + * @param string|null $target + * @param string $annotationClass * * @return Klist */ @@ -398,18 +268,6 @@ private function getAnnotations( Klist $attributes, ?string $target, string $ann ->filter( fn( $annotation ) => $annotation instanceof $annotationClass ); } - /** - * Returns a ReflectionClass instance, using caching for optimization. - * - * @param class-string $class - * - * @return ReflectionClass - * @throws ReflectionException - */ - private function getReflectionClass( string $class ): ReflectionClass { - return $this->reflectionClasses[ $class ] ??= new ReflectionClass( $class ); - } - /** * Wrapper for getting attributes from a class as a Klist. * @@ -421,4 +279,11 @@ private function getReflectionClass( string $class ): ReflectionClass { private function getAttributes( string $class ): Klist { return listFrom( $this->getReflectionClass( $class )->getAttributes() ); } + + /** + * @throws ReflectionException + */ + public function getClassMethod( string $class, string $method ): ReflectionMethod { + return $this->getReflectionClass( $class )->getMethod( $method ); + } } \ No newline at end of file diff --git a/src/Repository/Handler/RepositoryBuildHandler.php b/src/Repository/Handler/RepositoryBuildHandler.php index 0501038..59ca3a0 100644 --- a/src/Repository/Handler/RepositoryBuildHandler.php +++ b/src/Repository/Handler/RepositoryBuildHandler.php @@ -3,9 +3,12 @@ namespace Axpecto\Repository\Handler; use Axpecto\Annotation\Annotation; -use Axpecto\ClassBuilder\BuildContext; +use Axpecto\Annotation\AnnotationReader; use Axpecto\ClassBuilder\BuildHandler; +use Axpecto\ClassBuilder\BuildOutput; +use Axpecto\Code\MethodCodeGenerator; use Axpecto\Collection\Klist; +use Axpecto\Collection\Kmap; use Axpecto\Reflection\ReflectionUtils; use Axpecto\Repository\Mapper\ArrayToEntityMapper; use Axpecto\Repository\Repository; @@ -21,118 +24,159 @@ use ReflectionParameter; readonly class RepositoryBuildHandler implements BuildHandler { + private const MAPPER_PROP = 'mapper'; + private const STORAGE_PROP = 'storage'; - /** - * @psalm-suppress PossiblyUnusedMethod - * - * @param ReflectionUtils $reflectUtils - * @param RepositoryMethodNameParser $nameParser - * @param EntityMetadataService $metadataService - */ public function __construct( - private ReflectionUtils $reflectUtils, - private RepositoryMethodNameParser $nameParser, + private ReflectionUtils $reflectionUtils, + private MethodCodeGenerator $codeGenerator, + private RepositoryMethodNameParser $methodNameParser, private EntityMetadataService $metadataService, + private AnnotationReader $annotationReader, ) { } /** * @throws ReflectionException + * @throws Exception */ #[Override] - public function intercept( Annotation $annotation, BuildContext $context ): void { - if ( ! $annotation instanceof Repository || $annotation->getAnnotatedMethod() !== null ) { - throw new InvalidArgumentException( 'Invalid annotation type or method.' ); + public function intercept( Annotation $annotation, BuildOutput $buildOutput ): void { + $repository = $this->ensureRepositoryAnnotation( $annotation ); + $entityAttr = $this->fetchEntityMetadata( $repository ); + + // If the entity is not annotated, nothing to build + // @TODO Maybe throw an exception here? + if ( $entityAttr === null ) { + return; } - /** @var EntityAttribute $entityAnnotation */ - $entityAnnotation = $this->reflectUtils - ->getClassAnnotations( $annotation->entityClass, EntityAttribute::class ) - ->firstOrNull(); + $this->reflectionUtils + ->getAbstractMethods( $repository->getAnnotatedClass() ) + ->foreach( fn( ReflectionMethod $method ) => $this->buildRepositoryMethod( $method, $buildOutput, $repository, $entityAttr ) ); + } - if ( ! $entityAnnotation ) { - return; + private function ensureRepositoryAnnotation( Annotation $ann ): Repository { + if ( ! $ann instanceof Repository || $ann->getAnnotatedMethod() !== null ) { + throw new InvalidArgumentException( 'Invalid @Repository annotation usage.' ); } - $this->reflectUtils - ->getAbstractMethods( $annotation->getAnnotatedClass() ) - ->foreach( fn( ReflectionMethod $m ) => $this->implementAbstractMethod( $m, $context, $annotation, $entityAnnotation ) ); + return $ann; } /** - * Implements an abstract method by generating code that creates a Criteria, - * adds conditions based on parsed method parts (using the mapped field name if defined), - * and returns the storage call result. - * - * @param ReflectionMethod $method - * @param BuildContext $output - * @param Repository $repositoryAnnotation - * @param EntityAttribute $entityAnnotation + * @throws ReflectionException + */ + private function fetchEntityMetadata( Repository $repo ): ?EntityAttribute { + return $this->annotationReader + ->getClassAnnotations( $repo->entityClass, EntityAttribute::class ) + ->firstOrNull(); + } + + /** + * Builds a single repository method: injects deps, validates params, generates code. * * @throws Exception - * - * @TODO Re-implement this whole method. */ - protected function implementAbstractMethod( + private function buildRepositoryMethod( ReflectionMethod $method, - BuildContext $output, - Repository $repositoryAnnotation, - EntityAttribute $entityAnnotation + BuildOutput $buildOutput, + Repository $repository, + EntityAttribute $entityAttr ): void { - $entityClass = $repositoryAnnotation->entityClass; + $entityClass = $repository->entityClass; - // Inject dependencies: the mapper and the storage. - $mapperReference = $output->injectProperty( 'mapper', ArrayToEntityMapper::class ); - $storageReference = $output->injectProperty( 'storage', $entityAnnotation->storage ); + $this->injectDependencies( $buildOutput, $entityAttr ); + $fieldMap = $this->buildFieldToColumnMap( $entityClass ); + $parts = $this->methodNameParser->parse( $method->getName() ); - // Build an associative map of field names to their database field names. - $fields = $this->metadataService - ->getFields( $entityClass ) - ->mapOf( fn( EntityField $field ) => [ $field->name => $field->persistenceMapping ] ); + $this->assertArgumentCountMatches( $method, $parts ); - // Parse the method name into parts (ParsedMethodPart instances). - $methodParts = $this->nameParser->parse( $method->getName() ); + $body = $this->generateCriteriaBody( $method, $parts, $fieldMap, $entityClass ); + $signature = $this->codeGenerator->implementMethodSignature( $method->class, $method->getName() ); - // Calculate the expected argument count from the parsed method parts. - $expectedCount = $methodParts->reduce( fn( $count, ParsedMethodPart $part ) => $count + $part->operator->argumentCount(), 0 ); - - // Compare with the declared parameter count of the method. - $declaredParameters = listFrom( $method->getParameters() ); - $declaredCount = $declaredParameters->count(); - if ( $declaredCount !== $expectedCount ) { - throw new Exception( "Method {$method->getName()} declares $declaredCount arguments, but parsed conditions require $expectedCount." ); - } - - // Create a list of parameter names for the method. - $paramNames = $declaredParameters->map( fn( ReflectionParameter $p ) => $p->name ); + $buildOutput->addMethod( + name: $method->getName(), + signature: $signature, + implementation: $body, + ); + } - // Filter out the method parts that have a field defined and map with the database field names. - $mappedMethodParts = $methodParts - ->filter( fn( ParsedMethodPart $part ) => $part->field ) - ->map( fn( ParsedMethodPart $part ) => $part->copy( field: $fields[ $part->field ] ?? throw new Exception( "Unknown field: $part->field" ) ) ); + /** + * @throws Exception + */ + private function injectDependencies( BuildOutput $out, EntityAttribute $entityAttr ): void { + $out->injectProperty( self::MAPPER_PROP, ArrayToEntityMapper::class ); + $out->injectProperty( self::STORAGE_PROP, $entityAttr->storage ); + } - $code = $mappedMethodParts->reduce( - fn( $carry, ParsedMethodPart $part ) => $carry . $this->mapMethodPartToCode( $part, $paramNames ), - initial: "\$criteria = new \\Axpecto\\Storage\\Criteria\\Criteria();\n" - ); + /** + * @throws ReflectionException + */ + private function buildFieldToColumnMap( string $entityClass ): Kmap { + return $this->metadataService + ->getFields( $entityClass ) + ->mapOf( fn( EntityField $f ) => [ $f->name => $f->persistenceMapping ] ); + } - // Generate the final storage call. - $code .= "\t\treturn \$this->{$storageReference}->findAllByCriteria(\$criteria, '{$entityClass}')\n"; - $code .= "\t\t ->map(fn(\$item) => \$this->{$mapperReference}->map('{$entityClass}', \$item));"; + /** + * @throws Exception + */ + private function assertArgumentCountMatches( ReflectionMethod $method, Klist $parts ): void { + $expected = $parts->reduce( fn( int $count, ParsedMethodPart $part ) => $count + $part->operator->argumentCount(), 0 ); + $actual = count( $method->getParameters() ); + + if ( $expected !== $actual ) { + throw new Exception( + sprintf( + '%s declares %d args, but parsed conditions require %d.', + $method->getName(), + $actual, + $expected + ) + ); + } + } - $output->addMethod( - name: $method->getName(), - signature: $this->reflectUtils->getMethodDefinitionString( $method->class, $method->getName() ), - implementation: $code, - ); + /** + * Generates the method body that builds the Criteria and returns the mapped results. + * + * @throws Exception + */ + private function generateCriteriaBody( + ReflectionMethod $method, + Klist $parts, + Kmap $fieldMap, + string $entityClass + ): string { + // Initialize criteria + $code = "\$criteria = new \\Axpecto\\Storage\\Criteria\\Criteria();\n"; + + // Extract parameter names in declaration order + $paramNames = listFrom( $method->getParameters() ) + ->map( fn( ReflectionParameter $p ) => $p->getName() ); + + // For each part, map field name, then append condition code + $code = $parts + ->filter( fn( ParsedMethodPart $p ) => (bool) $p->field ) + ->map( fn( ParsedMethodPart $p ) => $p->copy( field: $fieldMap[ $p->field ] ?? throw new Exception( "Unknown field {$p->field}" ) ) ) + ->reduce( fn( string $carry, ParsedMethodPart $part ) => $carry . $this->mapConditionCode( $part, $paramNames ), $code ); + + // Append the final storage call + $code .= "\t\treturn \$this->" . self::STORAGE_PROP . + "->findAllByCriteria(\$criteria, '$entityClass')\n"; + $code .= "\t\t ->map(fn(\$item) => \$this->" . + self::MAPPER_PROP . "->map('$entityClass', \$item));"; + + return $code; } - private function mapMethodPartToCode( ParsedMethodPart $part, Klist $params ): string { + private function mapConditionCode( ParsedMethodPart $part, Klist $params ): string { return match ( $part->operator ) { - Operator::BETWEEN => "\t\t\$criteria->addCondition('$part->field', [\${$params->nextAndGet()}, \${$params->nextAndGet()}], \\Axpecto\\Storage\\Criteria\\Operator::BETWEEN, \\Axpecto\\Storage\\Criteria\\LogicOperator::{$part->logicOperator->name});\n", + Operator::BETWEEN => "\t\t\$criteria->addCondition('{$part->field}', [\${$params->nextAndGet()}, \${$params->nextAndGet()}], \\Axpecto\\Storage\\Criteria\\Operator::BETWEEN, \\Axpecto\\Storage\\Criteria\\LogicOperator::{$part->logicOperator->name});\n", Operator::IS_NULL, - Operator::IS_NOT_NULL => "\t\t\$criteria->addCondition('$part->field', null, \\Axpecto\\Storage\\Criteria\\Operator::{$part->operator->name}, \\Axpecto\\Storage\\Criteria\\LogicOperator::{$part->logicOperator->name});\n", - default => "\t\t\$criteria->addCondition('$part->field', \${$params->nextAndGet()}, \\Axpecto\\Storage\\Criteria\\Operator::{$part->operator->name}, \\Axpecto\\Storage\\Criteria\\LogicOperator::{$part->logicOperator->name});\n", + Operator::IS_NOT_NULL => "\t\t\$criteria->addCondition('{$part->field}', null, \\Axpecto\\Storage\\Criteria\\Operator::{$part->operator->name}, \\Axpecto\\Storage\\Criteria\\LogicOperator::{$part->logicOperator->name});\n", + default => "\t\t\$criteria->addCondition('{$part->field}', \${$params->nextAndGet()}, \\Axpecto\\Storage\\Criteria\\Operator::{$part->operator->name}, \\Axpecto\\Storage\\Criteria\\LogicOperator::{$part->logicOperator->name});\n", }; } } diff --git a/src/Repository/Repository.php b/src/Repository/Repository.php index ce16e64..ae875c1 100644 --- a/src/Repository/Repository.php +++ b/src/Repository/Repository.php @@ -6,6 +6,7 @@ use Attribute; use Axpecto\Annotation\Annotation; +use Axpecto\Annotation\BuildAnnotation; use Axpecto\ClassBuilder\BuildHandler; use Axpecto\Container\Annotation\Inject; use Axpecto\Repository\Handler\RepositoryBuildHandler; @@ -19,8 +20,8 @@ * * @template T of EntityAttribute */ -#[Attribute( Attribute::TARGET_CLASS )] -class Repository extends Annotation { +#[Attribute] +class Repository extends BuildAnnotation { /** * The build handler instance responsible for processing the repository. * diff --git a/src/Storage/Entity/Column/Column.php b/src/Storage/Entity/Column/Column.php index 0e8d1d4..16a3c96 100644 --- a/src/Storage/Entity/Column/Column.php +++ b/src/Storage/Entity/Column/Column.php @@ -3,13 +3,14 @@ namespace Axpecto\Storage\Entity\Column; use Attribute; +use Axpecto\Annotation\Annotation; use Axpecto\Storage\Entity\EntityField; /** * @psalm-suppress UnusedProperty */ #[Attribute( Attribute::TARGET_PARAMETER )] -class Column { +class Column extends Annotation { const CURRENT_TIMESTAMP = 'CURRENT_TIMESTAMP'; public function __construct( diff --git a/src/Storage/Entity/Entity.php b/src/Storage/Entity/Entity.php index ebcf988..38e0d5a 100644 --- a/src/Storage/Entity/Entity.php +++ b/src/Storage/Entity/Entity.php @@ -3,10 +3,11 @@ namespace Axpecto\Storage\Entity; use Attribute; +use Axpecto\Annotation\Annotation; use Axpecto\Storage\Criteria\CriteriaPersistenceStrategy; #[Attribute( Attribute::TARGET_CLASS )] -class Entity { +class Entity extends Annotation { /** * Constructor. diff --git a/src/Storage/Entity/EntityMetadataService.php b/src/Storage/Entity/EntityMetadataService.php index 8f103b9..f0325fd 100644 --- a/src/Storage/Entity/EntityMetadataService.php +++ b/src/Storage/Entity/EntityMetadataService.php @@ -2,6 +2,7 @@ namespace Axpecto\Storage\Entity; +use Axpecto\Annotation\AnnotationReader; use Axpecto\Collection\Klist; use Axpecto\Reflection\Dto\Argument; use Axpecto\Reflection\ReflectionUtils; @@ -11,15 +12,17 @@ class EntityMetadataService { - private const CONSTRUCTOR_METHOD = '__construct'; + private const string CONSTRUCTOR_METHOD = '__construct'; /** * @psalm-suppress PossiblyUnusedMethod * * @param ReflectionUtils $reflectionUtils + * @param AnnotationReader $annotationReader */ public function __construct( private readonly ReflectionUtils $reflectionUtils, + private readonly AnnotationReader $annotationReader, ) { } @@ -44,7 +47,7 @@ public function getFields( string $entityClass ): Klist { * @throws Exception */ public function getEntity( string $entityClass ): Entity { - $entityAnnotation = $this->reflectionUtils + $entityAnnotation = $this->annotationReader ->getClassAnnotations( $entityClass, Entity::class ) ->firstOrNull(); @@ -60,7 +63,7 @@ public function getEntity( string $entityClass ): Entity { */ private function mapArgumentToEntityField( Argument $argument, string $entity ): EntityField { /* @var Column $column */ - $column = $this->reflectionUtils->getParamAnnotations( + $column = $this->annotationReader->getParameterAnnotations( $entity, self::CONSTRUCTOR_METHOD, $argument->name, @@ -68,17 +71,17 @@ private function mapArgumentToEntityField( Argument $argument, string $entity ): )->firstOrNull(); return new EntityField( - name: $argument->name, - type: $column?->type ?? $argument->type, - nullable: $column?->isNullable ?? $argument->nullable, - entityClass: $entity, - default: $column?->defaultValue ?? $argument->default ?? EntityField::NO_DEFAULT_VALUE_SPECIFIED, + name: $argument->name, + type: $column?->type ?? $argument->type, + nullable: $column?->isNullable ?? $argument->nullable, + entityClass: $entity, + default: $column?->defaultValue ?? $argument->default ?? EntityField::NO_DEFAULT_VALUE_SPECIFIED, persistenceMapping: $column?->name ?? $argument->name, - isAutoIncrement: $column?->autoIncrement ?? false, - isPrimary: $column?->isPrimary ?? false, - isUnique: $column?->isUnique ?? false, - isIndexed: $column?->isIndexed ?? false, - onUpdate: $column?->onUpdate ?? false, + isAutoIncrement: $column?->autoIncrement ?? false, + isPrimary: $column?->isPrimary ?? false, + isUnique: $column?->isUnique ?? false, + isIndexed: $column?->isIndexed ?? false, + onUpdate: $column?->onUpdate ?? false, ); } } \ No newline at end of file diff --git a/src/Storage/MysqlPersistenceStrategy.php b/src/Storage/MysqlPersistenceStrategy.php index 7df0fb6..50107d3 100644 --- a/src/Storage/MysqlPersistenceStrategy.php +++ b/src/Storage/MysqlPersistenceStrategy.php @@ -2,10 +2,9 @@ namespace Axpecto\Storage; +use Axpecto\Annotation\AnnotationReader; use Axpecto\Collection\Klist; -use Axpecto\Reflection\ReflectionUtils; use Axpecto\Storage\Connection\Connection; -use Axpecto\Storage\Criteria\Condition; use Axpecto\Storage\Criteria\Criteria; use Axpecto\Storage\Criteria\CriteriaPersistenceStrategy; use Axpecto\Storage\Criteria\Operator; @@ -16,11 +15,11 @@ /** * @psalm-suppress UnusedClass This class is used by the build system or clients. */ -class MysqlPersistenceStrategy implements CriteriaPersistenceStrategy { +readonly class MysqlPersistenceStrategy implements CriteriaPersistenceStrategy { public function __construct( - private readonly Connection $conn, - private readonly ReflectionUtils $reflect + private Connection $conn, + private AnnotationReader $annotationReader, ) { } @@ -35,7 +34,7 @@ public function __construct( * @throws Exception */ private function getEntityMetadata( string $entityClass ): EntityAttribute { - $entityAnnotation = $this->reflect + $entityAnnotation = $this->annotationReader ->getClassAnnotations( $entityClass, EntityAttribute::class ) ->firstOrNull(); if ( ! $entityAnnotation ) { @@ -102,7 +101,7 @@ public function save( object $entity ): bool { * @TODO Refactor this whole method. Implement Klist reduce. * * @param Criteria $criteria - * @param string $entityClass Fully qualified entity class name. + * @param string $entityClass Fully qualified entity class name. * * @return Klist * @throws Exception @@ -186,7 +185,7 @@ public function findAllByCriteria( Criteria $criteria, string $entityClass ): Kl * @psalm-suppress PossiblyUnusedMethod Class used by generated Repository implementations. * * @template T - * @param Criteria $criteria + * @param Criteria $criteria * @param class-string $entityClass Fully qualified entity class name. * * @return T|null Returns an instance of T or null if not found. @@ -204,7 +203,7 @@ public function findOneByCriteria( Criteria $criteria, string $entityClass ): ?o * * @psalm-suppress PossiblyUnusedMethod Class used by generated Repository implementations. * - * @param int $id + * @param int $id * @param string $entityClass Fully qualified entity class name. * * @return bool diff --git a/tests/Axpecto/Annotation/AnnotationReaderTest.php b/tests/Axpecto/Annotation/AnnotationReaderTest.php index 53740a4..a28da46 100644 --- a/tests/Axpecto/Annotation/AnnotationReaderTest.php +++ b/tests/Axpecto/Annotation/AnnotationReaderTest.php @@ -1,90 +1,164 @@ [ - 'annotationClass' => Annotation::class, - 'list' => listOf(), - 'expected' => listOf(), - 'injectionCount' => 0, - ], - 'Annotations are filtered when they are not of the expected type' => [ - 'annotationClass' => $c::class, - 'list' => listOf( $a, $a, $a, $b, $b, $c, $b ), - 'expected' => listOf( $c ), - 'injectionCount' => 1, - ], - 'Annotation are not filtered if they are of the expected type' => [ - 'annotationClass' => $a::class, - 'list' => listOf( $a, $a, $a ), - 'expected' => listOf( $a, $a, $a ), - 'injectionCount' => 3, - ], - ]; + protected function setUp(): void + { + $this->reflect = $this->createMock(ReflectionUtils::class); + $this->container = $this->createMock(Container::class); + $this->reader = new AnnotationReader($this->container, $this->reflect); } - protected function setUp(): void { - // Mock the Container and ReflectionUtils dependencies - $this->container = $this->createMock( Container::class ); - $this->reflectionUtils = $this->createMock( ReflectionUtils::class ); + public function testGetClassAnnotationsFiltersByTypeAndInjects(): void + { + $class = stdClass::class; + $good = $this->createMock(DummyAnnotation::class); + $bad = $this->createMock(OtherAnnotation::class); + + // reflect returns both + $this->reflect + ->method('getClassAttributes') + ->with($class) + ->willReturn(listOf($good, $bad)); + + // container should inject only the good one + $this->container + ->expects($this->once()) + ->method('applyPropertyInjection') + ->with($good); + + // stub setAnnotatedClass + $good + ->expects($this->once()) + ->method('setAnnotatedClass') + ->with($class) + ->willReturn($good); - // Initialize AnnotationReader with mocked dependencies - $this->annotationReader = new AnnotationReader( $this->container, $this->reflectionUtils ); + $out = $this->reader->getClassAnnotations($class, DummyAnnotation::class); + + $this->assertInstanceOf(Klist::class, $out); + $this->assertCount(1, $out); + $this->assertSame($good, $out->firstOrNull()); } - /** - * @dataProvider methodDataProvider - */ - public function testGetMethodAnnotations( $annotationClass, $list, $expected, $injectionCount ): void { - // Mock the getMethodAttributes call to return the mock attributes - $this->reflectionUtils - ->expects( $this->once() ) - ->method( 'getMethodAttributes' ) - ->willReturn( $list ); + public function testGetMethodAnnotationsAddsClassAndMethod(): void + { + $class = stdClass::class; + $method = 'foo'; + $ann = $this->createMock(DummyAnnotation::class); + + $this->reflect + ->method('getMethodAttributes') + ->with($class, $method) + ->willReturn(listOf($ann)); $this->container - ->expects( $this->exactly( $injectionCount ) ) - ->method( 'applyPropertyInjection' ); + ->expects($this->once()) + ->method('applyPropertyInjection') + ->with($ann); + + $ann + ->expects($this->once()) + ->method('setAnnotatedClass') + ->with($class) + ->willReturnSelf(); + $ann + ->expects($this->once()) + ->method('setAnnotatedMethod') + ->with($method) + ->willReturnSelf(); - // Call the method - $actual = $this->annotationReader->getMethodAnnotations( 'AnyClass', 'AnyMethod', $annotationClass ); + $out = $this->reader->getMethodAnnotations($class, $method, DummyAnnotation::class); - // Assert the filtered list of annotations is returned - $this->assertEquals( $expected, $actual ); + $this->assertCount(1, $out); + $this->assertSame($ann, $out->firstOrNull()); } - /** - * @dataProvider methodDataProvider - */ - public function testGetClassAnnotations( $annotationClass, $list, $expected, $injectionCount ): void { - // Mock the getMethodAttributes call to return the mock attributes - $this->reflectionUtils - ->expects( $this->once() ) - ->method( 'getClassAttributes' ) - ->willReturn( $list ); + public function testGetAllAnnotationsMergesClassAndMethods(): void + { + $class = stdClass::class; + // class ann + $c1 = $this->createMock(DummyAnnotation::class); + $this->reflect + ->method('getClassAttributes') + ->with($class) + ->willReturn(listOf($c1)); + + $c1 + ->method('setAnnotatedClass') + ->willReturnSelf(); $this->container - ->expects( $this->exactly( $injectionCount ) ) - ->method( 'applyPropertyInjection' ); + ->method('applyPropertyInjection') + ->willReturnCallback(fn($a) => $a); + + // method list + $m1 = $this->createMock(DummyAnnotation::class); + $method = new ReflectionMethod(TestSubject::class, 'bar'); - // Call the method - $actual = $this->annotationReader->getClassAnnotations( 'AnyClass', $annotationClass ); + $this->reflect + ->method('getAnnotatedMethods') + ->with($class, DummyAnnotation::class) + ->willReturn(listOf($method)); - // Assert the filtered list of annotations is returned - $this->assertEquals( $expected, $actual ); + // when reading that method + $this->reflect + ->method('getMethodAttributes') + ->with($class, 'bar') + ->willReturn(listOf($m1)); + + $m1 + ->method('setAnnotatedClass') + ->with($class) + ->willReturnSelf(); + $m1 + ->method('setAnnotatedMethod') + ->with('bar') + ->willReturnSelf(); + + $out = $this->reader->getAllAnnotations($class, DummyAnnotation::class); + + // should contain both c1 and m1 + $this->assertCount(2, $out); + $this->assertEqualsCanonicalizing([$c1, $m1], $out->toArray()); } } + + +/** + * Dummy attribute class for testing. + */ +#[\Attribute(\Attribute::TARGET_ALL)] +class DummyAnnotation extends Annotation +{ + public function __construct() {} +} + +/** Another annotation, should be filtered out. */ +#[\Attribute(\Attribute::TARGET_ALL)] +class OtherAnnotation extends Annotation +{ + public function __construct() {} +} + +/** A dummy class with one method for testGetAllAnnotations. */ +class TestSubject +{ + #[DummyAnnotation] + public function bar(): void {} +} diff --git a/tests/Axpecto/ClassBuilder/BuildOutputTest.php b/tests/Axpecto/ClassBuilder/BuildOutputTest.php index cb29336..4bb4479 100644 --- a/tests/Axpecto/ClassBuilder/BuildOutputTest.php +++ b/tests/Axpecto/ClassBuilder/BuildOutputTest.php @@ -16,7 +16,7 @@ protected function setUp(): void { $this->propertiesMock = $this->createMock( Kmap::class ); // Instantiate BuildOutput with the mocks - $this->buildOutput = new BuildContext( 'AnyClass', $this->methodsMock, $this->propertiesMock ); + $this->buildOutput = new BuildOutput( 'AnyClass', $this->methodsMock, $this->propertiesMock ); } public function testAddMethod(): void { diff --git a/tests/Axpecto/ClassBuilder/ClassBuilderTest.php b/tests/Axpecto/ClassBuilder/ClassBuilderTest.php index 686fa0d..4daced3 100644 --- a/tests/Axpecto/ClassBuilder/ClassBuilderTest.php +++ b/tests/Axpecto/ClassBuilder/ClassBuilderTest.php @@ -4,7 +4,9 @@ use Axpecto\Annotation\Annotation; use Axpecto\Annotation\AnnotationReader; -use Axpecto\ClassBuilder\BuildContext; +use Axpecto\Annotation\BuildAnnotation; +use Axpecto\ClassBuilder\BuildHandler; +use Axpecto\ClassBuilder\BuildOutput; use Axpecto\ClassBuilder\ClassBuilder; use Axpecto\Collection\Klist; use Axpecto\Collection\Kmap; @@ -33,8 +35,8 @@ public function testBuildReturnsOriginalClassWhenNoAnnotations(): void { // Mock the reader to return no annotations $this->annotationReaderMock ->expects( $this->once() ) - ->method( 'getAllBuildAnnotations' ) - ->with( $class ) + ->method( 'getAllAnnotations' ) + ->with( $class, BuildAnnotation::class ) ->willReturn( emptyList() ); // Execute the build method @@ -64,8 +66,8 @@ public function testBuildGeneratesProxyClass(): void { $property = '#[Inject] private MethodExecutionProxy $proxy;'; // Mock the reader to return an annotation with a builder - $annotationMock = $this->createMock( Annotation::class ); - $builderMock = $this->createMock( \Axpecto\ClassBuilder\BuildHandler::class ); + $annotationMock = $this->createMock( BuildAnnotation::class ); + $builderMock = $this->createMock( BuildHandler::class ); $annotationMock ->method( 'getBuilder' ) ->willReturn( $builderMock ); @@ -74,16 +76,10 @@ public function testBuildGeneratesProxyClass(): void { $annotations = new Klist( [ $annotationMock ] ); $this->annotationReaderMock ->expects( $this->once() ) - ->method( 'getAllBuildAnnotations' ) - ->with( $class ) + ->method( 'getAllAnnotations' ) + ->with( $class, BuildAnnotation::class ) ->willReturn( $annotations ); - // Mock the reflection utility to provide method signature and implementation - $this->reflectionUtilsMock - ->method( 'getMethodDefinitionString' ) - ->with( $class, 'testMethod' ) - ->willReturn( $methodSignature ); - // Expect the builder to be called and add a method/property $builderMock ->expects( $this->once() ) @@ -102,7 +98,7 @@ public function testBuildGeneratesProxyClass(): void { public function testGenerateProxyClass(): void { $class = SampleClass::class; - $buildOutput = new BuildContext( + $buildOutput = new BuildOutput( $class, new Kmap( [ 'testMethod' => 'public function testMethod() {}' ] ), new Kmap( [ 'proxy' => '#[Inject] private MethodExecutionProxy $proxy;' ] ) diff --git a/tests/Axpecto/Code/MethodCodeGeneratorTest.php b/tests/Axpecto/Code/MethodCodeGeneratorTest.php new file mode 100644 index 0000000..cac4542 --- /dev/null +++ b/tests/Axpecto/Code/MethodCodeGeneratorTest.php @@ -0,0 +1,102 @@ +reflectionUtils = $this->createMock( ReflectionUtils::class ); + $this->generator = new MethodCodeGenerator( $this->reflectionUtils ); + } + + public function testThrowsWhenNotAbstractOrIsPrivate(): void { + // Prepare a ReflectionMethod for a concrete (non-abstract) public method + $rMethod = new ReflectionMethod( TestClassConcrete::class, 'concreteMethod' ); + $this->reflectionUtils + ->expects( $this->once() ) + ->method( 'getClassMethod' ) + ->with( TestClassConcrete::class, 'concreteMethod' ) + ->willReturn( $rMethod ); + + $this->expectException( Exception::class ); + $this->expectExceptionMessage( "Can't implement non-abstract or private method " . TestClassConcrete::class . '::concreteMethod()' ); + + $this->generator->implementMethodSignature( TestClassConcrete::class, 'concreteMethod' ); + } + + public function testGeneratesSignatureForPublicAbstractMethodWithTypes(): void { + // Use the real ReflectionMethod on our abstract test class + $rMethod = new ReflectionMethod( TestAbstract::class, 'foo' ); + $this->reflectionUtils + ->expects( $this->once() ) + ->method( 'getClassMethod' ) + ->with( TestAbstract::class, 'foo' ) + ->willReturn( $rMethod ); + + $sig = $this->generator->implementMethodSignature( TestAbstract::class, 'foo' ); + + // Expected: public function foo(int &$x = 5, string ...$rest): string|int, for some reason the return type is reversed on core. + $this->assertSame( + 'public function foo(int &$x = 5, string ...$rest): string|int', + $sig + ); + } + + public function testGeneratesSignatureForProtectedAbstractWithNullableReturnAndDefault(): void { + $rMethod = new ReflectionMethod( TestAbstract::class, 'bar' ); + $this->reflectionUtils + ->expects( $this->once() ) + ->method( 'getClassMethod' ) + ->with( TestAbstract::class, 'bar' ) + ->willReturn( $rMethod ); + + $sig = $this->generator->implementMethodSignature( TestAbstract::class, 'bar' ); + + // Expected: protected function bar(?string $name = 'default'): ?bool + $this->assertSame( + "protected function bar(?string \$name = 'default'): ?bool", + $sig + ); + } +} + +/** + * A concrete class with a non-abstract method for negative testing. + */ +class TestClassConcrete { + public function concreteMethod(): void { + } +} + +/** + * An abstract class containing the methods to be generated. + */ +abstract class TestAbstract { + /** + * Abstract public method with: + * - typed, by-reference, defaulted parameter + * - variadic parameter + * - union return type + */ + public abstract function foo( + int &$x = 5, + string ...$rest + ): int|string; + + /** + * Abstract protected method with: + * - nullable return type + * - defaulted nullable parameter + */ + protected abstract function bar( + ?string $name = 'default' + ): ?bool; +} diff --git a/tests/Axpecto/MethodExecution/Builder/MethodExecutionBuildHandlerTest.php b/tests/Axpecto/MethodExecution/Builder/MethodExecutionBuildHandlerTest.php index 72bf720..39e4c76 100644 --- a/tests/Axpecto/MethodExecution/Builder/MethodExecutionBuildHandlerTest.php +++ b/tests/Axpecto/MethodExecution/Builder/MethodExecutionBuildHandlerTest.php @@ -3,70 +3,85 @@ namespace Axpecto\MethodExecution\Builder; use Axpecto\Annotation\Annotation; -use Axpecto\ClassBuilder\BuildContext; -use Axpecto\Reflection\ReflectionUtils; +use Axpecto\ClassBuilder\BuildOutput; +use Axpecto\Code\MethodCodeGenerator; +use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\TestCase; +use ReflectionException; /** * Unit test for the MethodExecutionBuildHandler class. */ class MethodExecutionBuildHandlerTest extends TestCase { - private ReflectionUtils $reflectionUtilsMock; - private BuildContext $buildContextMock; - private Annotation $annotationMock; - private MethodExecutionBuildHandler $methodExecutionBuildHandler; + private MethodCodeGenerator $codeGen; + private MethodExecutionBuildHandler $handler; + /** + * @throws Exception + */ protected function setUp(): void { - // Create mock objects for dependencies - $this->reflectionUtilsMock = $this->createMock( ReflectionUtils::class ); - $this->buildContextMock = $this->createMock( BuildContext::class ); - $this->annotationMock = $this->createMock( Annotation::class ); - - // Instantiate MethodExecutionBuildHandler with mocked dependencies - $this->methodExecutionBuildHandler = new MethodExecutionBuildHandler( $this->reflectionUtilsMock ); + // mock the code generator + $this->codeGen = $this->createMock( MethodCodeGenerator::class ); + $this->handler = new MethodExecutionBuildHandler( $this->codeGen ); } - public function testInterceptAddsMethodToContext(): void { - $class = 'TestClass'; - $method = 'testMethod'; - $signature = 'public function testMethod()'; - $implementation = "return \$this->proxy->handle('TestClass', 'testMethod', parent::testMethod(...), func_get_args());"; - - // Set up the annotation to return the class and method - $this->annotationMock->method( 'getAnnotatedClass' )->willReturn( $class ); - $this->annotationMock->method( 'getAnnotatedMethod' )->willReturn( $method ); + /** + * @throws ReflectionException + * @throws Exception + */ + public function testInterceptAddsProxyPropertyAndMethod(): void { + // Dummy target class + method + $className = DummyClass::class; + $methodName = 'sayHello'; - // Mock the reflection utility to return the method signature and implementation - $this->reflectionUtilsMock->method( 'getMethodDefinitionString' )->with( $class, $method )->willReturn( $signature ); - $this->reflectionUtilsMock->method( 'getReturnType' )->with( $class, $method )->willReturn( 'string' ); // Non-void return type + // 1) Prepare a fake annotation + $annotation = $this->createMock( Annotation::class ); + $annotation + ->method( 'getAnnotatedClass' ) + ->willReturn( $className ); + $annotation + ->method( 'getAnnotatedMethod' ) + ->willReturn( $methodName ); - // Expect the method and property to be added to the context - $this->buildContextMock->expects( $this->once() )->method( 'addMethod' )->with( $method, $signature, $implementation ); - $this->buildContextMock->expects( $this->once() )->method( 'injectProperty' )->with( 'proxy', MethodExecutionProxy::class ); - - // Execute the intercept method - $this->methodExecutionBuildHandler->intercept( $this->annotationMock, $this->buildContextMock ); - } + // 2) Stub the code generator to return a known signature + $this->codeGen + ->expects( $this->once() ) + ->method( 'implementMethodSignature' ) + ->with( $className, $methodName ) + ->willReturn( 'public function sayHello(string $who)' ); - public function testInterceptHandlesVoidMethods(): void { - $class = 'TestClass'; - $method = 'voidMethod'; - $signature = 'public function voidMethod()'; - $implementation = "\$this->proxy->handle('TestClass', 'voidMethod', parent::voidMethod(...), func_get_args());"; + // 3) Create a fresh BuildContext for our DummyClass + $context = new BuildOutput( $className ); - // Set up the annotation to return the class and method - $this->annotationMock->method( 'getAnnotatedClass' )->willReturn( $class ); - $this->annotationMock->method( 'getAnnotatedMethod' )->willReturn( $method ); + // 4) Invoke the handler + $this->handler->intercept( $annotation, $context ); - // Mock the reflection utility to return the method signature and void return type - $this->reflectionUtilsMock->method( 'getMethodDefinitionString' )->with( $class, $method )->willReturn( $signature ); - $this->reflectionUtilsMock->method( 'getReturnType' )->with( $class, $method )->willReturn( 'void' ); // Void return type + // 5a) Assert the proxy property was injected + $this->assertTrue( + $context->properties->offsetExists( MethodExecutionProxy::class ), + 'Expected a "proxy" property in BuildContext::properties' + ); - // Expect the method and property to be added to the context - $this->buildContextMock->expects( $this->once() )->method( 'addMethod' )->with( $method, $signature, $implementation ); - $this->buildContextMock->expects( $this->once() )->method( 'injectProperty' )->with( 'proxy', MethodExecutionProxy::class ); + // 5b) Assert the method was added + $this->assertTrue( + $context->methods->offsetExists( $methodName ), + "Expected method \"$methodName\" in BuildContext::methods" + ); - // Execute the intercept method - $this->methodExecutionBuildHandler->intercept( $this->annotationMock, $this->buildContextMock ); + // 5c) Inspect the generated code for the correct handle(...) call + $methodCode = $context->methods->toArray()[ $methodName ]; + $this->assertStringContainsString( + "return \$this->proxy->handle('$className', '$methodName', parent::$methodName(...), func_get_args())", + $methodCode + ); } } + +/** + * Dummy class used in the test. + */ +class DummyClass { + public function sayHello( string $who ): string { + return "Hello, $who"; + } +} \ No newline at end of file diff --git a/tests/Axpecto/MethodExecution/Builder/MethodExecutionProxyTest.php b/tests/Axpecto/MethodExecution/Builder/MethodExecutionProxyTest.php index 2c02518..9d259fb 100644 --- a/tests/Axpecto/MethodExecution/Builder/MethodExecutionProxyTest.php +++ b/tests/Axpecto/MethodExecution/Builder/MethodExecutionProxyTest.php @@ -4,6 +4,8 @@ use Axpecto\Annotation\Annotation; use Axpecto\Annotation\AnnotationReader; +use Axpecto\Annotation\MethodExecutionAnnotation; +use Axpecto\Collection\Klist; use Axpecto\MethodExecution\MethodExecutionContext; use Axpecto\MethodExecution\MethodExecutionHandler; use Axpecto\Reflection\ReflectionUtils; @@ -36,9 +38,9 @@ public function testHandleExecutesMethodWithNoAnnotations(): void { // Mock empty annotations (no annotations are defined for the method) $this->annotationReaderMock ->expects( $this->once() ) - ->method( 'getMethodExecutionAnnotations' ) - ->with( $class, $method ) - ->willReturn( new \Axpecto\Collection\Klist() ); + ->method( 'getMethodAnnotations' ) + ->with( $class, $method, MethodExecutionAnnotation::class ) + ->willReturn( new Klist() ); // Mock reflection to map the method arguments correctly $this->reflectionUtilsMock @@ -65,7 +67,7 @@ public function testHandleExecutesWithAnnotations(): void { }; // Mock an annotation and its handler - $annotationMock = $this->createMock( Annotation::class ); + $annotationMock = $this->createMock( MethodExecutionAnnotation::class ); $handlerMock = $this->createMock( MethodExecutionHandler::class ); $annotationMock ->expects( $this->once() ) @@ -73,11 +75,11 @@ public function testHandleExecutesWithAnnotations(): void { ->willReturn( $handlerMock ); // Mock annotations (single annotation for the method) - $annotations = new \Axpecto\Collection\Klist( [ $annotationMock ] ); + $annotations = new Klist( [ $annotationMock ] ); $this->annotationReaderMock ->expects( $this->once() ) - ->method( 'getMethodExecutionAnnotations' ) - ->with( $class, $method ) + ->method( 'getMethodAnnotations' ) + ->with( $class, $method, MethodExecutionAnnotation::class ) ->willReturn( $annotations ); // Mock reflection to map the method arguments correctly @@ -111,8 +113,8 @@ public function testHandleHandlesMultipleAnnotations(): void { }; // Mock two annotations and their handlers - $annotationMock1 = $this->createMock( Annotation::class ); - $annotationMock2 = $this->createMock( Annotation::class ); + $annotationMock1 = $this->createMock( MethodExecutionAnnotation::class ); + $annotationMock2 = $this->createMock( MethodExecutionAnnotation::class ); $handlerMock1 = $this->createMock( MethodExecutionHandler::class ); $handlerMock2 = $this->createMock( MethodExecutionHandler::class ); @@ -121,17 +123,18 @@ public function testHandleHandlesMultipleAnnotations(): void { ->expects( $this->once() ) ->method( 'getMethodExecutionHandler' ) ->willReturn( $handlerMock1 ); + $annotationMock2 ->expects( $this->once() ) ->method( 'getMethodExecutionHandler' ) ->willReturn( $handlerMock2 ); // Mock annotations (two annotations for the method) - $annotations = new \Axpecto\Collection\Klist( [ $annotationMock1, $annotationMock2 ] ); + $annotations = new Klist( [ $annotationMock1, $annotationMock2 ] ); $this->annotationReaderMock ->expects( $this->once() ) - ->method( 'getMethodExecutionAnnotations' ) - ->with( $class, $method ) + ->method( 'getMethodAnnotations' ) + ->with( $class, $method, MethodExecutionAnnotation::class ) ->willReturn( $annotations ); // Mock reflection to map the method arguments correctly diff --git a/tests/Axpecto/MethodExecution/MethodExecutionContextTest.php b/tests/Axpecto/MethodExecution/MethodExecutionContextTest.php index 40f893a..6022e29 100644 --- a/tests/Axpecto/MethodExecution/MethodExecutionContextTest.php +++ b/tests/Axpecto/MethodExecution/MethodExecutionContextTest.php @@ -3,7 +3,9 @@ namespace Axpecto\MethodExecution; use Axpecto\Annotation\Annotation; +use Axpecto\Annotation\MethodExecutionAnnotation; use Axpecto\Collection\Klist; +use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\TestCase; class MethodExecutionContextTest extends TestCase { @@ -31,6 +33,9 @@ className: 'TestClass', $this->assertEquals( 3, $result ); } + /** + * @throws Exception + */ public function testProceedWithAnnotationsInterception() { // Mock a method call that shouldn't be executed because of interception $methodCall = function () { @@ -46,8 +51,10 @@ public function testProceedWithAnnotationsInterception() { ->willReturn( 'interceptedResult' ); // Create a mock annotation with the handler - $annotationMock = $this->createMock( Annotation::class ); - $annotationMock->method( 'getMethodExecutionHandler' )->willReturn( $handlerMock ); + $annotationMock = $this->createMock( MethodExecutionAnnotation::class ); + $annotationMock->expects( $this->once() ) + ->method( 'getMethodExecutionHandler' ) + ->willReturn( $handlerMock ); // Create a Klist with one annotation $queue = new Klist( [ $annotationMock ] ); @@ -99,7 +106,7 @@ public function testProceedSkipsAnnotationWithoutHandler() { }; // Create an annotation without a handler - $annotationMock = $this->createMock( Annotation::class ); + $annotationMock = $this->createMock( MethodExecutionAnnotation::class ); $annotationMock->method( 'getMethodExecutionHandler' )->willReturn( null ); // Create a Klist with the mock annotation diff --git a/tests/Axpecto/Reflection/ReflectionUtilsTest.php b/tests/Axpecto/Reflection/ReflectionUtilsTest.php new file mode 100644 index 0000000..837def1 --- /dev/null +++ b/tests/Axpecto/Reflection/ReflectionUtilsTest.php @@ -0,0 +1,169 @@ +utils = new ReflectionUtils(); + } + + public function testGetReflectionClassCaching(): void { + $r1 = $this->utils->getReflectionClass( DummyClass::class ); + $r2 = $this->utils->getReflectionClass( DummyClass::class ); + $this->assertSame( $r1, $r2 ); + } + + public function testGetClassAttributes(): void { + $attrs = $this->utils->getClassAttributes( DummyClass::class ); + $this->assertInstanceOf( Klist::class, $attrs ); + $list = $attrs->toArray(); + $this->assertCount( 1, $list ); + $this->assertInstanceOf( TestAttribute::class, $list[0] ); + } + + public function testGetMethodAttributes(): void { + $attrs = $this->utils->getMethodAttributes( DummyClass::class, 'annotatedMethod' ); + $this->assertCount( 1, $attrs->toArray() ); + $this->assertInstanceOf( TestAttribute::class, $attrs->toArray()[0] ); + } + + public function testGetAnnotatedMethods(): void { + $methods = $this->utils->getAnnotatedMethods( DummyClass::class, TestAttribute::class ); + $names = array_map( fn( ReflectionMethod $m ) => $m->getName(), $methods->toArray() ); + $this->assertContains( 'annotatedMethod', $names ); + $this->assertNotContains( 'nonAnnotatedMethod', $names ); + } + + public function testGetMethodsFiltersConstructorPrivateAndFinal(): void { + $methods = $this->utils->getMethods( DummyClass::class ); + $names = array_map( fn( ReflectionMethod $m ) => $m->getName(), $methods->toArray() ); + + $this->assertContains( 'annotatedMethod', $names ); + $this->assertContains( 'nonAnnotatedMethod', $names ); + $this->assertContains( 'methodWithParams', $names ); + + $this->assertNotContains( 'privateMethod', $names ); + $this->assertNotContains( 'finalMethod', $names ); + $this->assertNotContains( '__construct', $names ); + } + + public function testGetAbstractMethods(): void { + $methods = $this->utils->getAbstractMethods( DummyAbstract::class ); + $names = array_map( fn( ReflectionMethod $m ) => $m->getName(), $methods->toArray() ); + $this->assertContains( 'doSomething', $names ); + $this->assertNotContains( 'concrete', $names ); + } + + public function testGetConstructorArguments(): void { + $args = $this->utils->getConstructorArguments( DummyCtor::class ); + $this->assertInstanceOf( Klist::class, $args ); + $array = $args->toArray(); + $this->assertCount( 2, $array ); + + /** @var Argument $first */ + $first = $array[0]; + $this->assertEquals( 'a', $first->name ); + $this->assertEquals( 'int', $first->type ); + $this->assertFalse( $first->nullable ); + $this->assertNull( $first->default ); + + /** @var Argument $second */ + $second = $array[1]; + $this->assertEquals( 'b', $second->name ); + $this->assertEquals( 'string', $second->type ); + $this->assertTrue( $second->nullable || $second->default !== null ); + $this->assertEquals( 'hi', $second->default ); + } + + public function testGetAnnotatedProperties(): void { + $props = $this->utils->getAnnotatedProperties( DummyPropClass::class, TestAttribute::class ); + $this->assertInstanceOf( Klist::class, $props ); + $arr = $props->toArray(); + $this->assertCount( 1, $arr ); + $this->assertEquals( 'foo', $arr[0]->name ); + } + + public function testSetPropertyValue(): void { + $obj = new DummyPropClass(); + $this->utils->setPropertyValue( $obj, 'foo', 42 ); + + $rp = new ReflectionProperty( DummyPropClass::class, 'foo' ); + $rp->setAccessible( true ); + $this->assertSame( 42, $rp->getValue( $obj ) ); + } + + public function testMapValuesToArguments(): void { + $map = $this->utils->mapValuesToArguments( DummyClass::class, 'methodWithParams', [ 1 ] ); + $this->assertEquals( [ 'a' => 1, 'b' => null ], $map ); + } + + public function testIsInterface(): void { + $this->assertTrue( $this->utils->isInterface( DummyInterface::class ) ); + $this->assertFalse( $this->utils->isInterface( DummyClass::class ) ); + } + + public function testGetClassMethod(): void { + $rm = $this->utils->getClassMethod( DummyClass::class, 'nonAnnotatedMethod' ); + $this->assertInstanceOf( ReflectionMethod::class, $rm ); + $this->assertEquals( 'nonAnnotatedMethod', $rm->getName() ); + } +} diff --git a/tests/Axpecto/Repository/Handler/RepositoryBuildHandlerTest.php b/tests/Axpecto/Repository/Handler/RepositoryBuildHandlerTest.php index 066d439..32021c6 100644 --- a/tests/Axpecto/Repository/Handler/RepositoryBuildHandlerTest.php +++ b/tests/Axpecto/Repository/Handler/RepositoryBuildHandlerTest.php @@ -3,82 +3,122 @@ namespace Axpecto\Repository\Handler; use Axpecto\Annotation\Annotation; -use Axpecto\ClassBuilder\BuildContext; +use Axpecto\Annotation\AnnotationReader; +use Axpecto\ClassBuilder\BuildOutput; +use Axpecto\Code\MethodCodeGenerator; use Axpecto\Reflection\ReflectionUtils; +use Axpecto\Repository\Mapper\ArrayToEntityMapper; use Axpecto\Repository\Repository; use Axpecto\Storage\Criteria\LogicOperator; use Axpecto\Storage\Criteria\Operator; use Axpecto\Storage\Entity\Entity; use Axpecto\Storage\Entity\EntityField; use Axpecto\Storage\Entity\EntityMetadataService; +use InvalidArgumentException; +use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\TestCase; use ReflectionMethod; class RepositoryBuildHandlerTest extends TestCase { + private ReflectionUtils $reflect; + private MethodCodeGenerator $codeGen; + private RepositoryMethodNameParser $parser; + private EntityMetadataService $metadata; + private RepositoryBuildHandler $handler; + private AnnotationReader $annotationReader; + + /** + * @throws Exception + */ + protected function setUp(): void { + $this->reflect = $this->createMock( ReflectionUtils::class ); + $this->codeGen = $this->createMock( MethodCodeGenerator::class ); + $this->parser = $this->createMock( RepositoryMethodNameParser::class ); + $this->metadata = $this->createMock( EntityMetadataService::class ); + $this->annotationReader = $this->createMock( AnnotationReader::class ); + + $this->handler = new RepositoryBuildHandler( + $this->reflect, + $this->codeGen, + $this->parser, + $this->metadata, + $this->annotationReader, + ); + } public function testInterceptSkipsIfInvalidAnnotation(): void { - $reflect = $this->createMock( ReflectionUtils::class ); - $parser = $this->createMock( RepositoryMethodNameParser::class ); - $metadata = $this->createMock( EntityMetadataService::class ); - - $handler = new RepositoryBuildHandler( $reflect, $parser, $metadata ); - - $this->expectException( \InvalidArgumentException::class ); + $this->expectException( InvalidArgumentException::class ); $annotation = $this->createMock( Annotation::class ); - $context = $this->createMock( BuildContext::class ); + $context = $this->createMock( BuildOutput::class ); - $handler->intercept( $annotation, $context ); + $this->handler->intercept( $annotation, $context ); } public function testInterceptGeneratesMethod(): void { - $reflect = $this->createMock( ReflectionUtils::class ); - $parser = $this->createMock( RepositoryMethodNameParser::class ); - $metadata = $this->createMock( EntityMetadataService::class ); - - $handler = new RepositoryBuildHandler( $reflect, $parser, $metadata ); - - // Mocks + // 1) Prepare a valid @Repository annotation $repositoryAnnotation = new Repository( entityClass: DummyEntity::class ); $repositoryAnnotation->setAnnotatedClass( DummyRepository::class ); + // 2) Stub fetching the Entity metadata $entityAnnotation = new Entity( storage: DummyStorage::class, table: 'dummy' ); + $this->annotationReader + ->method( 'getClassAnnotations' ) + ->willReturn( listOf( $entityAnnotation ) ); - $reflect->method( 'getClassAnnotations' ) - ->willReturn( listOf( $entityAnnotation ) ); - + // 3) Stub the abstract methods on the repository interface $method = new ReflectionMethod( DummyRepository::class, 'findByIdAndName' ); - - $reflect->method( 'getAbstractMethods' ) - ->willReturn( listOf( $method ) ); - - $reflect->method( 'getMethodDefinitionString' ) - ->willReturn( 'public function findByIdAndName($id, $name)' ); - - $parser->method( 'parse' )->willReturn( - listOf( - new ParsedMethodPart( Prefix::GET_BY, LogicOperator::AND, 'id', Operator::EQUALS ), - new ParsedMethodPart( Prefix::GET_BY, LogicOperator::AND, 'name', Operator::EQUALS ) - ) - ); - - $metadata->method( 'getFields' )->willReturn( - listOf( - new EntityField( 'id', 'string', false, DummyEntity::class, persistenceMapping: 'id' ), - new EntityField( 'name', 'string', false, DummyEntity::class, persistenceMapping: 'name_mapping' ), - ) - ); - - $context = new BuildContext( DummyRepository::class ); - - $handler->intercept( $repositoryAnnotation, $context ); - + $this->reflect + ->method( 'getAbstractMethods' ) + ->willReturn( listOf( $method ) ); + + // 4) Stub the name parser to produce two conditions (id, name) + $this->parser + ->method( 'parse' ) + ->with( 'findByIdAndName' ) + ->willReturn( listOf( + new ParsedMethodPart( prefix: Prefix::FIND_BY, logicOperator: LogicOperator::AND, field: 'id', operator: Operator::EQUALS ), + new ParsedMethodPart( prefix: Prefix::FIND_BY, logicOperator: LogicOperator::AND, field: 'name', operator: Operator::EQUALS ) + ) ); + + // 5) Stub the metadata service to map fields to storage columns + $this->metadata + ->method( 'getFields' ) + ->with( DummyEntity::class ) + ->willReturn( listOf( + new EntityField( name: 'id', type: 'int', nullable: false, entityClass: DummyEntity::class, default: false, persistenceMapping: 'id_field' ), + new EntityField( name: 'name', type: 'string', nullable: false, entityClass: DummyEntity::class, default: false, persistenceMapping: 'name_mapping' ) + ) ); + + // 6) Stub the code generator to return a dummy signature + $this->codeGen + ->expects( $this->once() ) + ->method( 'implementMethodSignature' ) + ->with( DummyRepository::class, 'findByIdAndName' ) + ->willReturn( 'public function findByIdAndName($id, $name)' ); + + // 7) Create a fresh BuildOutput and run intercept() + $context = new BuildOutput( DummyRepository::class ); + $this->handler->intercept( $repositoryAnnotation, $context ); + + // 8) Assertions: + + // a) Dependencies were injected + $this->assertTrue( $context->properties->offsetExists( ArrayToEntityMapper::class ) ); + $this->assertTrue( $context->properties->offsetExists( DummyStorage::class ) ); + + // b) Method was added $this->assertTrue( $context->methods->offsetExists( 'findByIdAndName' ) ); - $this->assertStringContainsString( 'addCondition', $context->methods['findByIdAndName'] ); - $this->assertStringContainsString( 'name_mapping', $context->methods['findByIdAndName'] ); + + // c) Generated body contains both column names and addCondition call + $body = $context->methods['findByIdAndName']; + $this->assertStringContainsString( 'addCondition', $body ); + $this->assertStringContainsString( 'id_field', $body ); + $this->assertStringContainsString( 'name_mapping', $body ); } } + // Dummy classes for the test interface DummyRepository { From 8b2ee8dde58a66f367e000943ebf0e787c461b19 Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Mon, 21 Apr 2025 17:18:51 +0200 Subject: [PATCH 2/6] Fixed Psalm problems --- src/ClassBuilder/BuildHandler.php | 4 ++-- src/ClassBuilder/ClassBuilder.php | 9 ++++----- .../Builder/MethodExecutionBuildHandler.php | 12 ++++++------ src/Reflection/ReflectionUtils.php | 17 ----------------- .../Handler/RepositoryBuildHandler.php | 16 ++++++++++++++-- 5 files changed, 26 insertions(+), 32 deletions(-) diff --git a/src/ClassBuilder/BuildHandler.php b/src/ClassBuilder/BuildHandler.php index 7df1620..c7dcca5 100644 --- a/src/ClassBuilder/BuildHandler.php +++ b/src/ClassBuilder/BuildHandler.php @@ -2,7 +2,7 @@ namespace Axpecto\ClassBuilder; -use Axpecto\Annotation\Annotation; +use Axpecto\Annotation\BuildAnnotation; /** * Interface BuildHandler @@ -22,5 +22,5 @@ interface BuildHandler { * * @return void The modified or updated build output. */ - public function intercept( Annotation $annotation, BuildOutput $buildOutput ): void; + public function intercept( BuildAnnotation $annotation, BuildOutput $buildOutput ): void; } diff --git a/src/ClassBuilder/ClassBuilder.php b/src/ClassBuilder/ClassBuilder.php index bb1fbc7..a1af7e7 100644 --- a/src/ClassBuilder/ClassBuilder.php +++ b/src/ClassBuilder/ClassBuilder.php @@ -2,12 +2,11 @@ namespace Axpecto\ClassBuilder; +use Axpecto\Annotation\AnnotationReader; use Axpecto\Annotation\BuildAnnotation; use Axpecto\Container\Exception\ClassAlreadyBuiltException; use Axpecto\Reflection\ReflectionUtils; use ReflectionException; -use Axpecto\Annotation\Annotation; -use Axpecto\Annotation\AnnotationReader; /** * Class ClassBuilder @@ -19,7 +18,7 @@ class ClassBuilder { /** - * @param ReflectionUtils $reflect Utility for handling reflection of classes, methods, and properties. + * @param ReflectionUtils $reflect Utility for handling reflection of classes, methods, and properties. * @param array $builtClasses Stores already built classes to avoid duplication. */ public function __construct( @@ -49,7 +48,7 @@ public function build( string $class ): string { // Create and proceed with the build chain $context = new BuildOutput( $class ); - $buildAnnotations->foreach( fn( Annotation $a ) => $a->getBuilder()?->intercept( $a, $context ) ); + $buildAnnotations->foreach( fn( BuildAnnotation $a ) => $a->getBuilder()?->intercept( $a, $context ) ); // If the build output is empty, return the original class if ( $context->isEmpty() ) { @@ -72,7 +71,7 @@ public function build( string $class ): string { * This method constructs the class declaration and body, including properties and methods as defined by the build output. * It also evaluates the generated class code dynamically using `eval`. * - * @param string $class The original class name to be proxied. + * @param string $class The original class name to be proxied. * @param BuildOutput $buildOutput The output from the build process, containing properties and methods. * * @return string The name of the generated proxy class. diff --git a/src/MethodExecution/Builder/MethodExecutionBuildHandler.php b/src/MethodExecution/Builder/MethodExecutionBuildHandler.php index 26419ec..d5e4531 100644 --- a/src/MethodExecution/Builder/MethodExecutionBuildHandler.php +++ b/src/MethodExecution/Builder/MethodExecutionBuildHandler.php @@ -2,11 +2,10 @@ namespace Axpecto\MethodExecution\Builder; -use Axpecto\Annotation\Annotation; -use Axpecto\ClassBuilder\BuildOutput; +use Axpecto\Annotation\BuildAnnotation; use Axpecto\ClassBuilder\BuildHandler; +use Axpecto\ClassBuilder\BuildOutput; use Axpecto\Code\MethodCodeGenerator; -use Exception; use Override; use ReflectionException; @@ -33,14 +32,15 @@ public function __construct( /** * Intercepts a build chain, adding method interception logic to the output. * - * @param Annotation $annotation The annotation being processed. + * @psalm-suppress PossiblyUnusedMethod + * + * @param BuildAnnotation $annotation The annotation being processed. * @param BuildOutput $buildOutput The current build context to modify. * * @throws ReflectionException If reflection on the method or class fails. - * @throws Exception */ #[Override] - public function intercept( Annotation $annotation, BuildOutput $buildOutput ): void { + public function intercept( BuildAnnotation $annotation, BuildOutput $buildOutput ): void { $class = $annotation->getAnnotatedClass(); $method = $annotation->getAnnotatedMethod(); diff --git a/src/Reflection/ReflectionUtils.php b/src/Reflection/ReflectionUtils.php index 04d387a..e207687 100644 --- a/src/Reflection/ReflectionUtils.php +++ b/src/Reflection/ReflectionUtils.php @@ -103,23 +103,6 @@ public function getAbstractMethods( string $class ): Klist { ->filter( fn( ReflectionMethod $method ) => $method->isAbstract() ); } - /** - * Fetches annotations for a class. - * - * @param class-string $class - * @param string $annotationClass - * - * @return Klist - * @throws ReflectionException - */ - public function getClassAnnotations( string $class, string $annotationClass = Annotation::class ): Klist { - return $this->getAnnotations( - attributes: $this->getAttributes( $class ), - target: Attribute::TARGET_CLASS, - annotationClass: $annotationClass - ); - } - /** * Fetches the constructor arguments of a class as Arguments. * diff --git a/src/Repository/Handler/RepositoryBuildHandler.php b/src/Repository/Handler/RepositoryBuildHandler.php index 59ca3a0..afafaee 100644 --- a/src/Repository/Handler/RepositoryBuildHandler.php +++ b/src/Repository/Handler/RepositoryBuildHandler.php @@ -4,6 +4,7 @@ use Axpecto\Annotation\Annotation; use Axpecto\Annotation\AnnotationReader; +use Axpecto\Annotation\BuildAnnotation; use Axpecto\ClassBuilder\BuildHandler; use Axpecto\ClassBuilder\BuildOutput; use Axpecto\Code\MethodCodeGenerator; @@ -27,6 +28,15 @@ private const MAPPER_PROP = 'mapper'; private const STORAGE_PROP = 'storage'; + /** + * @psalm-suppress PossiblyUnusedMethod + * + * @param ReflectionUtils $reflectionUtils + * @param MethodCodeGenerator $codeGenerator + * @param RepositoryMethodNameParser $methodNameParser + * @param EntityMetadataService $metadataService + * @param AnnotationReader $annotationReader + */ public function __construct( private ReflectionUtils $reflectionUtils, private MethodCodeGenerator $codeGenerator, @@ -37,11 +47,13 @@ public function __construct( } /** + * @psalm-suppress PossiblyUnusedMethod + * * @throws ReflectionException * @throws Exception */ #[Override] - public function intercept( Annotation $annotation, BuildOutput $buildOutput ): void { + public function intercept( BuildAnnotation $annotation, BuildOutput $buildOutput ): void { $repository = $this->ensureRepositoryAnnotation( $annotation ); $entityAttr = $this->fetchEntityMetadata( $repository ); @@ -56,7 +68,7 @@ public function intercept( Annotation $annotation, BuildOutput $buildOutput ): v ->foreach( fn( ReflectionMethod $method ) => $this->buildRepositoryMethod( $method, $buildOutput, $repository, $entityAttr ) ); } - private function ensureRepositoryAnnotation( Annotation $ann ): Repository { + private function ensureRepositoryAnnotation( BuildAnnotation $ann ): Repository { if ( ! $ann instanceof Repository || $ann->getAnnotatedMethod() !== null ) { throw new InvalidArgumentException( 'Invalid @Repository annotation usage.' ); } From c252719f8882d84f50e8c0db7ca18a65ca8b7ecc Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Mon, 21 Apr 2025 17:23:47 +0200 Subject: [PATCH 3/6] Fixed Psalm and unit tests --- src/Code/MethodCodeGenerator.php | 5 +++++ src/Reflection/ReflectionUtils.php | 12 ------------ .../Builder/MethodExecutionBuildHandlerTest.php | 4 ++-- .../Handler/RepositoryBuildHandlerTest.php | 4 ++-- 4 files changed, 9 insertions(+), 16 deletions(-) diff --git a/src/Code/MethodCodeGenerator.php b/src/Code/MethodCodeGenerator.php index 02e1975..cbd8ae9 100644 --- a/src/Code/MethodCodeGenerator.php +++ b/src/Code/MethodCodeGenerator.php @@ -16,6 +16,11 @@ */ class MethodCodeGenerator { + /** + * @psalm-suppress PossiblyUnusedMethod + * + * @param ReflectionUtils $reflectionUtils + */ public function __construct( private readonly ReflectionUtils $reflectionUtils, ) { diff --git a/src/Reflection/ReflectionUtils.php b/src/Reflection/ReflectionUtils.php index e207687..0107df1 100644 --- a/src/Reflection/ReflectionUtils.php +++ b/src/Reflection/ReflectionUtils.php @@ -251,18 +251,6 @@ private function getAnnotations( Klist $attributes, ?string $target, string $ann ->filter( fn( $annotation ) => $annotation instanceof $annotationClass ); } - /** - * Wrapper for getting attributes from a class as a Klist. - * - * @param string $class - * - * @return Klist - * @throws ReflectionException - */ - private function getAttributes( string $class ): Klist { - return listFrom( $this->getReflectionClass( $class )->getAttributes() ); - } - /** * @throws ReflectionException */ diff --git a/tests/Axpecto/MethodExecution/Builder/MethodExecutionBuildHandlerTest.php b/tests/Axpecto/MethodExecution/Builder/MethodExecutionBuildHandlerTest.php index 39e4c76..54fc1c2 100644 --- a/tests/Axpecto/MethodExecution/Builder/MethodExecutionBuildHandlerTest.php +++ b/tests/Axpecto/MethodExecution/Builder/MethodExecutionBuildHandlerTest.php @@ -2,7 +2,7 @@ namespace Axpecto\MethodExecution\Builder; -use Axpecto\Annotation\Annotation; +use Axpecto\Annotation\BuildAnnotation; use Axpecto\ClassBuilder\BuildOutput; use Axpecto\Code\MethodCodeGenerator; use PHPUnit\Framework\MockObject\Exception; @@ -35,7 +35,7 @@ public function testInterceptAddsProxyPropertyAndMethod(): void { $methodName = 'sayHello'; // 1) Prepare a fake annotation - $annotation = $this->createMock( Annotation::class ); + $annotation = $this->createMock( BuildAnnotation::class ); $annotation ->method( 'getAnnotatedClass' ) ->willReturn( $className ); diff --git a/tests/Axpecto/Repository/Handler/RepositoryBuildHandlerTest.php b/tests/Axpecto/Repository/Handler/RepositoryBuildHandlerTest.php index 32021c6..8f98e7d 100644 --- a/tests/Axpecto/Repository/Handler/RepositoryBuildHandlerTest.php +++ b/tests/Axpecto/Repository/Handler/RepositoryBuildHandlerTest.php @@ -2,8 +2,8 @@ namespace Axpecto\Repository\Handler; -use Axpecto\Annotation\Annotation; use Axpecto\Annotation\AnnotationReader; +use Axpecto\Annotation\BuildAnnotation; use Axpecto\ClassBuilder\BuildOutput; use Axpecto\Code\MethodCodeGenerator; use Axpecto\Reflection\ReflectionUtils; @@ -49,7 +49,7 @@ protected function setUp(): void { public function testInterceptSkipsIfInvalidAnnotation(): void { $this->expectException( InvalidArgumentException::class ); - $annotation = $this->createMock( Annotation::class ); + $annotation = $this->createMock( BuildAnnotation::class ); $context = $this->createMock( BuildOutput::class ); $this->handler->intercept( $annotation, $context ); From c7b10c87ba197028e1603d60cc0ceda31a177409 Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Mon, 21 Apr 2025 17:30:14 +0200 Subject: [PATCH 4/6] Added more tests and fixed Psalm issue --- src/ClassBuilder/BuildOutput.php | 2 + .../Annotation/AnnotationReaderTest.php | 159 +++++++++++++----- 2 files changed, 118 insertions(+), 43 deletions(-) diff --git a/src/ClassBuilder/BuildOutput.php b/src/ClassBuilder/BuildOutput.php index f8dc1cc..a916008 100644 --- a/src/ClassBuilder/BuildOutput.php +++ b/src/ClassBuilder/BuildOutput.php @@ -69,6 +69,8 @@ public function addProperty( string $name, string $implementation ): void { /** * Inject a property into the output. * + * @psalm-suppress PossiblyUnusedReturnValue + * * @param string $name * @param string $class * diff --git a/tests/Axpecto/Annotation/AnnotationReaderTest.php b/tests/Axpecto/Annotation/AnnotationReaderTest.php index a28da46..e1d9029 100644 --- a/tests/Axpecto/Annotation/AnnotationReaderTest.php +++ b/tests/Axpecto/Annotation/AnnotationReaderTest.php @@ -9,13 +9,18 @@ use Axpecto\Reflection\ReflectionUtils; use PHPUnit\Framework\TestCase; use ReflectionMethod; +use ReflectionClass; +use ReflectionException; use stdClass; +use Attribute; +use ReflectionParameter; +use ReflectionProperty; class AnnotationReaderTest extends TestCase { - private ReflectionUtils $reflect; - private Container $container; - private AnnotationReader $reader; + private ReflectionUtils $reflect; + private Container $container; + private AnnotationReader $reader; protected function setUp(): void { @@ -30,24 +35,23 @@ public function testGetClassAnnotationsFiltersByTypeAndInjects(): void $good = $this->createMock(DummyAnnotation::class); $bad = $this->createMock(OtherAnnotation::class); - // reflect returns both $this->reflect + ->expects($this->once()) ->method('getClassAttributes') ->with($class) ->willReturn(listOf($good, $bad)); - // container should inject only the good one + // only $good should be injected $this->container ->expects($this->once()) ->method('applyPropertyInjection') ->with($good); - // stub setAnnotatedClass $good ->expects($this->once()) ->method('setAnnotatedClass') ->with($class) - ->willReturn($good); + ->willReturnSelf(); $out = $this->reader->getClassAnnotations($class, DummyAnnotation::class); @@ -58,11 +62,12 @@ public function testGetClassAnnotationsFiltersByTypeAndInjects(): void public function testGetMethodAnnotationsAddsClassAndMethod(): void { - $class = stdClass::class; - $method = 'foo'; - $ann = $this->createMock(DummyAnnotation::class); + $class = stdClass::class; + $method = 'foo'; + $ann = $this->createMock(DummyAnnotation::class); $this->reflect + ->expects($this->once()) ->method('getMethodAttributes') ->with($class, $method) ->willReturn(listOf($ann)); @@ -92,71 +97,139 @@ public function testGetMethodAnnotationsAddsClassAndMethod(): void public function testGetAllAnnotationsMergesClassAndMethods(): void { $class = stdClass::class; + $c1 = $this->createMock(DummyAnnotation::class); + $m1 = $this->createMock(DummyAnnotation::class); - // class ann - $c1 = $this->createMock(DummyAnnotation::class); + // class‐level $this->reflect ->method('getClassAttributes') ->with($class) ->willReturn(listOf($c1)); - - $c1 - ->method('setAnnotatedClass') - ->willReturnSelf(); - $this->container - ->method('applyPropertyInjection') - ->willReturnCallback(fn($a) => $a); + $c1->method('setAnnotatedClass')->willReturnSelf(); + $this->container->method('applyPropertyInjection')->willReturnCallback(fn($a) => $a); // method list - $m1 = $this->createMock(DummyAnnotation::class); - $method = new ReflectionMethod(TestSubject::class, 'bar'); - + $methodRef = new ReflectionMethod(TestSubject::class, 'bar'); $this->reflect ->method('getAnnotatedMethods') ->with($class, DummyAnnotation::class) - ->willReturn(listOf($method)); + ->willReturn(listOf($methodRef)); - // when reading that method + // per‐method attributes $this->reflect ->method('getMethodAttributes') ->with($class, 'bar') ->willReturn(listOf($m1)); - - $m1 - ->method('setAnnotatedClass') - ->with($class) - ->willReturnSelf(); - $m1 - ->method('setAnnotatedMethod') - ->with('bar') - ->willReturnSelf(); + $m1->method('setAnnotatedClass')->willReturnSelf(); + $m1->method('setAnnotatedMethod')->willReturnSelf(); $out = $this->reader->getAllAnnotations($class, DummyAnnotation::class); - // should contain both c1 and m1 $this->assertCount(2, $out); $this->assertEqualsCanonicalizing([$c1, $m1], $out->toArray()); } + + public function testGetParameterAnnotationsReturnsOnlyMatching(): void + { + // use a real ReflectionMethod on ParamSubject + $this->reflect + ->expects($this->once()) + ->method('getClassMethod') + ->with(ParamSubject::class, 'foo') + ->willReturn(new ReflectionMethod(ParamSubject::class, 'foo')); + + $paramAnn = $this->reader->getParameterAnnotations( + ParamSubject::class, + 'foo', + 'x', + ParamAnnotation::class + ); + + $this->assertCount(1, $paramAnn); + $this->assertInstanceOf(ParamAnnotation::class, $paramAnn->firstOrNull()); + } + + public function testGetParameterAnnotationsEmptyWhenNoSuchParam(): void + { + $this->reflect + ->method('getClassMethod') + ->willReturn(new ReflectionMethod(ParamSubject::class, 'foo')); + + $empty = $this->reader->getParameterAnnotations( + ParamSubject::class, + 'foo', + 'no_such', + ParamAnnotation::class + ); + + $this->assertInstanceOf(Klist::class, $empty); + $this->assertCount(0, $empty); + } + + public function testGetPropertyAnnotationReturnsFirst(): void + { + // stub ReflectionClass so getProperty returns real reflection + $reflectionClass = new ReflectionClass(PropSubject::class); + $this->reflect + ->expects($this->once()) + ->method('getReflectionClass') + ->with(PropSubject::class) + ->willReturn($reflectionClass); + + $ann = $this->reader->getPropertyAnnotation( + PropSubject::class, + 'foo', + PropAnnotation::class + ); + + $this->assertInstanceOf(PropAnnotation::class, $ann); + $this->assertSame(PropSubject::class, $ann->getAnnotatedClass()); + } } -/** - * Dummy attribute class for testing. - */ -#[\Attribute(\Attribute::TARGET_ALL)] -class DummyAnnotation extends Annotation +//-------------------------------------------------------- +// Dummy attributes and subjects for testing parameters & props +//-------------------------------------------------------- + +#[Attribute(Attribute::TARGET_PARAMETER)] +class ParamAnnotation extends Annotation { public function __construct() {} } -/** Another annotation, should be filtered out. */ -#[\Attribute(\Attribute::TARGET_ALL)] -class OtherAnnotation extends Annotation +class ParamSubject +{ + public function foo( + #[ParamAnnotation] + $x, + $y + ) {} +} + +#[Attribute(Attribute::TARGET_PROPERTY)] +class PropAnnotation extends Annotation { public function __construct() {} } -/** A dummy class with one method for testGetAllAnnotations. */ +class PropSubject +{ + #[PropAnnotation] + public string $foo = ''; +} + + +//-------------------------------------------------------- +// Re‑use earlier DummyAnnotation, OtherAnnotation, TestSubject +//-------------------------------------------------------- + +#[Attribute(Attribute::TARGET_ALL)] +class DummyAnnotation extends Annotation { public function __construct() {} } + +#[Attribute(Attribute::TARGET_ALL)] +class OtherAnnotation extends Annotation { public function __construct() {} } + class TestSubject { #[DummyAnnotation] From d4e1f0368fc7c76c96cd4698aa475d08c0aa5cad Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Mon, 21 Apr 2025 18:29:03 +0200 Subject: [PATCH 5/6] Added tests --- composer.json | 4 +- src/Storage/Entity/EntityField.php | 2 +- src/Storage/Entity/EntityMetadataService.php | 2 +- src/Storage/MysqlPersistenceStrategy.php | 8 +- .../Entity/EntityMetadataServiceTest.php | 141 ++++++++++ .../Storage/MysqlPersistenceStrategyTest.php | 247 ++++++++++++++++++ 6 files changed, 397 insertions(+), 7 deletions(-) create mode 100644 tests/Axpecto/Storage/Entity/EntityMetadataServiceTest.php create mode 100644 tests/Axpecto/Storage/MysqlPersistenceStrategyTest.php diff --git a/composer.json b/composer.json index 8125984..3191926 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,8 @@ { "name": "rcrdortiz/axpecto", - "description": "PHP meta framework for building modern, AI oriented, aspect orientated development frameworks.", + "description": "PHP meta‑framework for modern, AI‑augmented, aspect‑oriented development.", "type": "library", + "license": "MIT", "version": "1.0.2", "require": { "php": ">=8.3", @@ -20,7 +21,6 @@ "src/functions.php" ] }, - "license": "GNU", "authors": [ { "name": "Richard Ortiz", diff --git a/src/Storage/Entity/EntityField.php b/src/Storage/Entity/EntityField.php index a217f0e..640110e 100644 --- a/src/Storage/Entity/EntityField.php +++ b/src/Storage/Entity/EntityField.php @@ -19,7 +19,7 @@ public function __construct( public readonly bool $isPrimary = false, public readonly bool $isUnique = false, public readonly bool $isIndexed = false, - public readonly bool $onUpdate = false, + public readonly ?string $onUpdate = null, ) { } } \ No newline at end of file diff --git a/src/Storage/Entity/EntityMetadataService.php b/src/Storage/Entity/EntityMetadataService.php index f0325fd..34a0aea 100644 --- a/src/Storage/Entity/EntityMetadataService.php +++ b/src/Storage/Entity/EntityMetadataService.php @@ -81,7 +81,7 @@ private function mapArgumentToEntityField( Argument $argument, string $entity ): isPrimary: $column?->isPrimary ?? false, isUnique: $column?->isUnique ?? false, isIndexed: $column?->isIndexed ?? false, - onUpdate: $column?->onUpdate ?? false, + onUpdate: $column?->onUpdate ?? null, ); } } \ No newline at end of file diff --git a/src/Storage/MysqlPersistenceStrategy.php b/src/Storage/MysqlPersistenceStrategy.php index 50107d3..e150bee 100644 --- a/src/Storage/MysqlPersistenceStrategy.php +++ b/src/Storage/MysqlPersistenceStrategy.php @@ -15,11 +15,11 @@ /** * @psalm-suppress UnusedClass This class is used by the build system or clients. */ -readonly class MysqlPersistenceStrategy implements CriteriaPersistenceStrategy { +class MysqlPersistenceStrategy implements CriteriaPersistenceStrategy { public function __construct( - private Connection $conn, - private AnnotationReader $annotationReader, + private readonly Connection $conn, + private readonly AnnotationReader $annotationReader, ) { } @@ -56,6 +56,8 @@ private function getEntityMetadata( string $entityClass ): EntityAttribute { * * @return bool True on success, false on failure. * @throws Exception + * + * @TODO Refactor this method to use the id field from the column metadata instead of the Entity. */ #[Override] public function save( object $entity ): bool { diff --git a/tests/Axpecto/Storage/Entity/EntityMetadataServiceTest.php b/tests/Axpecto/Storage/Entity/EntityMetadataServiceTest.php new file mode 100644 index 0000000..e1be0c5 --- /dev/null +++ b/tests/Axpecto/Storage/Entity/EntityMetadataServiceTest.php @@ -0,0 +1,141 @@ +reflect = $this->createMock( ReflectionUtils::class ); + $this->reader = $this->createMock( AnnotationReader::class ); + $this->svc = new EntityMetadataService( $this->reflect, $this->reader ); + } + + /** + * @throws \ReflectionException + */ + public function testGetFieldsWithAndWithoutColumnOverrides(): void { + $entityClass = DummyEntity::class; + + // Simulate two constructor arguments: foo and bar + $argFoo = new Argument( name: 'foo', type: 'string', nullable: false, default: null ); + $argBar = new Argument( name: 'bar', type: 'int', nullable: true, default: 42 ); + + $this->reflect + ->expects( $this->once() ) + ->method( 'getConstructorArguments' ) + ->with( $entityClass ) + ->willReturn( listOf( $argFoo, $argBar ) ); + + // Prepare a Column override for "foo" + $column = new Column( + name: 'col_foo', + isPrimary: true, + isUnique: true, + isIndexed: true, + autoIncrement: true, + isNullable: true, + type: 'custom_type', + defaultValue: 'def', + onUpdate: 'now()' + ); + + // Stub getParameterAnnotations: foo→[$column], bar→[] + $this->reader + ->expects( $this->exactly( 2 ) ) + ->method( 'getParameterAnnotations' ) + ->willReturnCallback( function ( + string $cls, + string $method, + string $paramName, + string $annotationClass + ) use ( $column ): Klist { + // only foo gets the override + return $paramName === 'foo' + ? listOf( $column ) + : emptyList(); + } ); + + $fields = $this->svc->getFields( $entityClass ); + $arr = $fields->toArray(); + + // "foo" should reflect the Column override + /** @var EntityField $fFoo */ + $fFoo = $arr[0]; + $this->assertSame( 'foo', $fFoo->name ); + $this->assertSame( 'custom_type', $fFoo->type ); + $this->assertTrue( $fFoo->nullable ); + $this->assertSame( 'def', $fFoo->default ); + $this->assertSame( 'col_foo', $fFoo->persistenceMapping ); + $this->assertTrue( $fFoo->isAutoIncrement ); + $this->assertTrue( $fFoo->isPrimary ); + $this->assertTrue( $fFoo->isUnique ); + $this->assertTrue( $fFoo->isIndexed ); + $this->assertSame( 'now()', $fFoo->onUpdate ); + + // "bar" should fall back to the Argument defaults + /** @var EntityField $fBar */ + $fBar = $arr[1]; + $this->assertSame( 'bar', $fBar->name ); + $this->assertSame( 'int', $fBar->type ); + $this->assertTrue( $fBar->nullable ); + $this->assertEquals( 42, $fBar->default ); + $this->assertSame( 'bar', $fBar->persistenceMapping ); + $this->assertFalse( $fBar->isAutoIncrement ); + $this->assertFalse( $fBar->isPrimary ); + $this->assertFalse( $fBar->isUnique ); + $this->assertFalse( $fBar->isIndexed ); + $this->assertNull( $fBar->onUpdate ); + } + + public function testGetEntityReturnsAnnotation(): void { + $entityClass = DummyEntity::class; + $entityAnno = new EntityAttribute( storage: DummyStorage::class, table: 'tbl' ); + + $this->reader + ->expects( $this->once() ) + ->method( 'getClassAnnotations' ) + ->with( $entityClass, EntityAttribute::class ) + ->willReturn( listOf( $entityAnno ) ); + + $this->assertSame( $entityAnno, $this->svc->getEntity( $entityClass ) ); + } + + public function testGetEntityMissingThrows(): void { + $entityClass = DummyEntity::class; + + $this->reader + ->expects( $this->once() ) + ->method( 'getClassAnnotations' ) + ->with( $entityClass, EntityAttribute::class ) + ->willReturn( emptyList() ); + + $this->expectException( Exception::class ); + $this->expectExceptionMessage( "Entity annotation missing on class $entityClass" ); + + $this->svc->getEntity( $entityClass ); + } +} + +// Dummy types for test + +class DummyEntity { + public function __construct( string $foo, ?int $bar = 42 ) { + } +} + +class DummyStorage { +} diff --git a/tests/Axpecto/Storage/MysqlPersistenceStrategyTest.php b/tests/Axpecto/Storage/MysqlPersistenceStrategyTest.php new file mode 100644 index 0000000..2889582 --- /dev/null +++ b/tests/Axpecto/Storage/MysqlPersistenceStrategyTest.php @@ -0,0 +1,247 @@ +conn = $this->createMock( Connection::class ); + $this->reader = $this->createMock( AnnotationReader::class ); + $this->strategy = new MysqlPersistenceStrategy( $this->conn, $this->reader ); + } + + public function testGetEntityMetadataThrowsWhenNoAnnotation(): void { + $this->reader + ->expects( $this->once() ) + ->method( 'getClassAnnotations' ) + ->with( FakeEntity::class, EntityAttribute::class ) + ->willReturn( emptyList() ); + + $this->expectException( Exception::class ); + $this->expectExceptionMessage( "Entity annotation missing on class " . FakeEntity::class ); + // invoke via save() so that getEntityMetadata is called: + $this->strategy->save( new FakeEntity() ); + } + + /** + * @throws \PHPUnit\Framework\MockObject\Exception + */ + public function testSaveDoesInsertWhenNoId(): void { + $entity = new FakeEntity(); + $entity->foo = 'bar'; + // id starts null + $entity->id = null; + + $attr = new EntityAttribute( + storage: AnyStorage::class, + table: 'my_table', + idField: 'id', + ); + + $this->reader + ->method( 'getClassAnnotations' ) + ->willReturn( listOf( $attr ) ); + + $stmt = $this->createMock( PDOStatement::class ); + $stmt->expects( $this->once() ) + ->method( 'execute' ) + ->with( [ null, 'bar' ] ) // <-- swapped order here + ->willReturn( true ); + + $this->conn + ->expects( $this->once() ) + ->method( 'prepare' ) + ->with( "INSERT INTO my_table (id, foo) VALUES (?, ?)" ) + ->willReturn( $stmt ); + + $this->conn + ->method( 'lastInsertId' ) + ->willReturn( '42' ); + + $result = $this->strategy->save( $entity ); + + $this->assertTrue( $result ); + $this->assertSame( 42, $entity->id ); + } + + public function testSaveDoesUpdateWhenIdPresent(): void { + $entity = new FakeEntity(); + $entity->foo = 'baz'; + $entity->id = 99; + + $attr = new EntityAttribute( + storage: AnyStorage::class, + table: 'other', + idField: 'id', + ); + + $this->reader + ->method( 'getClassAnnotations' ) + ->willReturn( listOf( $attr ) ); + + $stmt = $this->createMock( PDOStatement::class ); + $stmt->expects( $this->once() ) + ->method( 'execute' ) + ->with( [ 'baz', 99 ] ) + ->willReturn( true ); + $this->conn + ->expects( $this->once() ) + ->method( 'prepare' ) + ->with( "UPDATE other SET foo = ? WHERE id = ?" ) + ->willReturn( $stmt ); + + $this->assertTrue( $this->strategy->save( $entity ) ); + } + + /** + * @throws \PHPUnit\Framework\MockObject\Exception + * @throws Exception + */ + public function testFindAllByCriteriaBuildsCorrectSqlAndParams(): void { + $criteria = new Criteria() + ->addCondition( 'a', 1, Operator::EQUALS ) + ->addCondition( 'b', [ 2, 3 ], Operator::BETWEEN, null ) + ->addCondition( 'c', [ 'x', 'y' ], Operator::IN ); + $entityClass = FakeEntity::class; + + $attr = new EntityAttribute( + storage: AnyStorage::class, + table: 'tbl', + idField: 'id', + ); + + $this->reader + ->method( 'getClassAnnotations' ) + ->willReturn( listOf( $attr ) ); + + $stmt = $this->createMock( PDOStatement::class ); + // capture the SQL and params + $this->conn + ->expects( $this->once() ) + ->method( 'prepare' ) + ->with( $this->stringContains( 'SELECT * FROM tbl WHERE a = ? AND b BETWEEN ? AND ? AND c IN (?, ?)' ) ) + ->willReturn( $stmt ); + + $stmt->expects( $this->once() ) + ->method( 'execute' ) + ->with( [ 1, 2, 3, 'x', 'y' ] ); + + $stmt->method( 'fetchAll' ) + ->willReturn( [ [ 'foo' => 123 ] ] ); + + $out = $this->strategy->findAllByCriteria( $criteria, $entityClass ); + $this->assertInstanceOf( Klist::class, $out ); + $this->assertSame( [ [ 'foo' => 123 ] ], $out->toArray() ); + } + + public function testFindOneByCriteria(): void { + $criteria = new Criteria(); + $entityClass = FakeEntity::class; + + // Prepare two dummy objects + $first = new \stdClass; + $second = new \stdClass; + + $spy = $this->getMockBuilder( MysqlPersistenceStrategy::class ) + ->setConstructorArgs( [ $this->conn, $this->reader ] ) + ->onlyMethods( [ 'findAllByCriteria' ] ) + ->getMock(); + + $spy->expects( $this->once() ) + ->method( 'findAllByCriteria' ) + ->with( $criteria, $entityClass ) + ->willReturn( listOf( $first, $second ) ); + + $this->assertSame( $first, $spy->findOneByCriteria( $criteria, $entityClass ) ); + } + + /** + * @throws \PHPUnit\Framework\MockObject\Exception + */ + public function testDeleteBuildsCorrectSql(): void { + $attr = new EntityAttribute( + storage: AnyStorage::class, + table: 't', + idField: 'pk', + ); + + $this->reader + ->method( 'getClassAnnotations' ) + ->willReturn( listOf( $attr ) ); + + $stmt = $this->createMock( PDOStatement::class ); + $stmt->expects( $this->once() ) + ->method( 'execute' ) + ->with( [ 55 ] ) + ->willReturn( true ); + + $this->conn + ->expects( $this->once() ) + ->method( 'prepare' ) + ->with( "DELETE FROM t WHERE pk = ?" ) + ->willReturn( $stmt ); + + $this->assertTrue( $this->strategy->delete( 55, FakeEntity::class ) ); + } + + /** + * @dataProvider operatorProvider + */ + public function testMapOperator( Operator $op, string $expected ): void { + $r = new \ReflectionMethod( MysqlPersistenceStrategy::class, 'mapOperator' ); + $r->setAccessible( true ); + $this->assertSame( $expected, $r->invoke( $this->strategy, $op ) ); + } + + public static function operatorProvider(): array { + return [ + [ Operator::GREATER_THAN_EQUAL, '>=' ], + [ Operator::GREATER_THAN, '>' ], + [ Operator::AFTER, '>' ], + [ Operator::LESS_THAN_EQUAL, '<=' ], + [ Operator::LESS_THAN, '<' ], + [ Operator::BEFORE, '<' ], + [ Operator::BETWEEN, 'BETWEEN' ], + [ Operator::IN, 'IN' ], + [ Operator::NOT_IN, 'NOT IN' ], + [ Operator::IS_NULL, 'IS NULL' ], + [ Operator::IS_NOT_NULL, 'IS NOT NULL' ], + [ Operator::LIKE, 'LIKE' ], + [ Operator::NOT_LIKE, 'NOT LIKE' ], + [ Operator::CONTAINS, 'LIKE' ], + [ Operator::STARTING_WITH, 'LIKE' ], + [ Operator::ENDING_WITH, 'LIKE' ], + [ Operator::EQUALS, '=' ], + ]; + } +} + +/** + * A fake entity class used only for these tests. + */ +class FakeEntity { + public ?int $id = null; + public string $foo; +} + +class AnyStorage { +} +// Dummy class to satisfy the EntityAttribute storage parameter.} From 222d3576f65d39001623f4d01c85ca36c75643b6 Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Mon, 21 Apr 2025 18:30:17 +0200 Subject: [PATCH 6/6] Fixed test --- tests/Axpecto/Storage/MysqlPersistenceStrategyTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Axpecto/Storage/MysqlPersistenceStrategyTest.php b/tests/Axpecto/Storage/MysqlPersistenceStrategyTest.php index 2889582..6249fd6 100644 --- a/tests/Axpecto/Storage/MysqlPersistenceStrategyTest.php +++ b/tests/Axpecto/Storage/MysqlPersistenceStrategyTest.php @@ -116,7 +116,7 @@ public function testSaveDoesUpdateWhenIdPresent(): void { * @throws Exception */ public function testFindAllByCriteriaBuildsCorrectSqlAndParams(): void { - $criteria = new Criteria() + $criteria = ( new Criteria() ) ->addCondition( 'a', 1, Operator::EQUALS ) ->addCondition( 'b', [ 2, 3 ], Operator::BETWEEN, null ) ->addCondition( 'c', [ 'x', 'y' ], Operator::IN );