diff --git a/composer.json b/composer.json index f9bccf4..886eab9 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,14 @@ { "name": "rcrdortiz/axpecto", "description": "PHP meta‑framework for modern, AI‑augmented, aspect‑oriented development.", + "keywords": [ + "php", + "aop", + "dependency-injection", + "collections", + "meta-framework", + "ai" + ], "type": "library", "version": "1.0.4", "license": "MIT", diff --git a/src/Annotation/Annotation.php b/src/Annotation/Annotation.php index 18dd381..50eb897 100644 --- a/src/Annotation/Annotation.php +++ b/src/Annotation/Annotation.php @@ -14,7 +14,6 @@ * * @package Axpecto\Aop * - * @TODO Refactor this and possibly create a hierarchy of annotations with Annotation -> BuildAnnotation -> MethodExecutionAnnotation. */ #[Attribute] class Annotation { diff --git a/src/Annotation/AnnotationReader.php b/src/Annotation/AnnotationReader.php index 059c73b..e045675 100644 --- a/src/Annotation/AnnotationReader.php +++ b/src/Annotation/AnnotationReader.php @@ -5,167 +5,171 @@ namespace Axpecto\Annotation; use Axpecto\Collection\Klist; -use Axpecto\Container\Container; use Axpecto\Reflection\ReflectionUtils; use ReflectionAttribute; use ReflectionException; +use ReflectionMethod; use ReflectionParameter; /** - * Reads PHP8 attributes and turns them into AOP-style Annotation instances, - * filtering by class vs. method targets and injecting their properties via DI. + * AnnotationReader + * + * Reads PHP 8 attributes and turns them into Annotation instances, + * filtering by class, and annotating each instance + * with its declaring class and/or method name. * * @template A of Annotation * @psalm-consistent-constructor */ class AnnotationReader { + /** + * @param ReflectionUtils $reflection + * Used to fetch native PHP ReflectionAttribute instances. + */ public function __construct( - private readonly Container $container, private readonly ReflectionUtils $reflection ) { } /** - * Fetch all annotations of a given type on a class. + * Fetch all annotations of the given type on a class. * - * @template T - * @param class-string $class - * @param class-string $annotationClass + * @template T of Annotation + * @param class-string $class + * @param class-string $annotationClass + * + * @return Klist A list of instantiated annotations, each with its + * ->setAnnotatedClass($class) already applied. * - * @return Klist * @throws ReflectionException */ public function getClassAnnotations( string $class, - string $annotationClass, + string $annotationClass ): Klist { - $raw = $this->reflection->getClassAttributes( $class ); - - return $this - ->filterAndInject( $raw, $annotationClass ) - ->map( fn( Annotation $ann ): Annotation => $ann->setAnnotatedClass( $class ) ); + return $this->reflection + ->getClassAttributes( $class ) + ->filter( fn( Annotation $ann ) => $ann instanceof $annotationClass ) + ->map( fn( Annotation $ann ) => $ann->setAnnotatedClass( $class ) ); } /** - * Fetch all annotations of a given type on a method. + * Fetch all annotations of the given type on a specific method. * - * @template T - * @param class-string $class + * @template T of Annotation + * @param class-string $class * @param string $method - * @param class-string $annotationClass + * @param class-string $annotationClass + * + * @return Klist A list of instantiated annotations, each with + * ->setAnnotatedClass($class) + * and ->setAnnotatedMethod($method) applied. * - * @return Klist * @throws ReflectionException */ public function getMethodAnnotations( string $class, string $method, - string $annotationClass, + string $annotationClass ): Klist { - $raw = $this->reflection->getMethodAttributes( $class, $method ); - - return $this - ->filterAndInject( $raw, $annotationClass ) - ->map( fn( Annotation $ann ): Annotation => $ann + return $this->reflection + ->getMethodAttributes( $class, $method ) + ->filter( fn( Annotation $ann ) => $ann instanceof $annotationClass ) + ->map( fn( Annotation $ann ) => $ann ->setAnnotatedClass( $class ) ->setAnnotatedMethod( $method ) ); } /** - * Fetch both class‑level and method‑level annotations of a given type. + * Fetch both class-level and method-level annotations of a given type. * - * @template T - * @param class-string $class - * @param class-string $annotationClass + * @template T of Annotation + * @param class-string $class + * @param class-string $annotationClass + * + * @return Klist All matching annotations on the class itself + * and on any of its methods. * - * @return Klist * @throws ReflectionException */ public function getAllAnnotations( string $class, - string $annotationClass = Annotation::class + string $annotationClass, ): Klist { - $classAnns = $this->getClassAnnotations( $class, $annotationClass ); + $classAnns = $this->getClassAnnotations( $class, $annotationClass ); + $methodAnns = $this->reflection ->getAnnotatedMethods( $class, $annotationClass ) - ->map( fn( \ReflectionMethod $m ) => $this->getMethodAnnotations( $class, $m->getName(), $annotationClass ) - ) + ->map( fn( ReflectionMethod $m ) => $this->getMethodAnnotations( $class, $m->getName(), $annotationClass ) ) ->flatten(); return $classAnns->merge( $methodAnns ); } /** - * Fetch all annotations of a given type on one of a method’s parameters. + * Fetch all annotations of the given type on a single method parameter. * - * @template T - * @param class-string $class + * @template T of Annotation + * @param class-string $class * @param string $method * @param string $parameterName - * @param class-string $annotationClass + * @param class-string $annotationClass + * + * @return Klist A list (possibly empty) of annotations on that parameter, + * each with ->setAnnotatedClass() and ->setAnnotatedMethod(). * - * @return Klist * @throws ReflectionException */ public function getParameterAnnotations( string $class, string $method, string $parameterName, - string $annotationClass, + string $annotationClass ): Klist { - $parameter = listFrom( $this->reflection->getClassMethod( $class, $method )->getParameters() ) + $param = listFrom( $this->reflection->getClassMethod( $class, $method )->getParameters() ) ->filter( fn( ReflectionParameter $p ) => $p->getName() === $parameterName ) ->firstOrNull(); - if ( ! $parameter ) { + if ( $param === null ) { 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 ) ); + return listFrom( $param->getAttributes() ) + ->map( fn( ReflectionAttribute $attr ) => $attr->newInstance() ) + ->filter( fn( $inst ) => $inst instanceof $annotationClass ) + ->map( fn( Annotation $ann ) => $ann + ->setAnnotatedClass( $class ) + ->setAnnotatedMethod( $method ) + ); } /** - * Fetch a single annotation of a given type on a property. + * Fetch exactly one annotation of the given type on a class property. * - * @template T - * @param class-string $class + * @template T of Annotation + * @param class-string $class * @param string $property - * @param class-string $annotationClass + * @param class-string $annotationClass + * + * @return T The first matching annotation, or null if none. * - * @return A|null * @throws ReflectionException */ public function getPropertyAnnotation( string $class, string $property, string $annotationClass = Annotation::class - ): mixed { - $attributes = $this->reflection + ): ?Annotation { + $attrs = $this->reflection ->getReflectionClass( $class ) ->getProperty( $property ) ->getAttributes(); - return listFrom( $attributes ) - ->map( fn( ReflectionAttribute $a ) => $a->newInstance() ) - ->maybe( fn( Klist $attributes ) => $this->filterAndInject( $attributes, $annotationClass ) ) + return listFrom( $attrs ) + ->map( fn( ReflectionAttribute $attr ) => $attr->newInstance() ) + ->filter( fn( $inst ) => $inst instanceof $annotationClass ) ->firstOrNull() ?->setAnnotatedClass( $class ); } - - /** - * @template T of Annotation - * @param Klist $instances - * @param class-string $annotationClass - * - * @return Klist - */ - 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/AnnotationService.php b/src/Annotation/AnnotationService.php new file mode 100644 index 0000000..5c178b5 --- /dev/null +++ b/src/Annotation/AnnotationService.php @@ -0,0 +1,125 @@ + $class + * @param class-string $annotationClass + * + * @return Klist + * @throws ReflectionException + */ + public function getClassAnnotations( + string $class, + string $annotationClass, + ): Klist { + return $this->reader->getClassAnnotations( $class, $annotationClass ) + ->foreach( fn( Annotation $a ) => $this->dependencyResolver->applyPropertyInjection( $a ) ); + } + + /** + * Fetch all annotations of a given type on a method. + * + * @template T + * @param class-string $class + * @param string $method + * @param class-string $annotationClass + * + * @return Klist + * @throws ReflectionException + */ + public function getMethodAnnotations( + string $class, + string $method, + string $annotationClass, + ): Klist { + return $this->reader->getMethodAnnotations( $class, $method, $annotationClass ) + ->foreach( fn( Annotation $a ) => $this->dependencyResolver->applyPropertyInjection( $a ) ); + } + + /** + * Fetch both class‑level and method‑level annotations of a given type. + * + * @template T + * @param class-string $class + * @param class-string $annotationClass + * + * @return Klist + * @throws ReflectionException + */ + public function getAllAnnotations( + string $class, + string $annotationClass = Annotation::class + ): Klist { + return $this->reader->getAllAnnotations( $class, $annotationClass ) + ->foreach( fn( Annotation $a ) => $this->dependencyResolver->applyPropertyInjection( $a ) ); + } + + /** + * Fetch all annotations of a given type on one of a method’s parameters. + * + * @template T + * @param class-string $class + * @param string $method + * @param string $parameterName + * @param class-string $annotationClass + * + * @return Klist + * @throws ReflectionException + */ + public function getParameterAnnotations( + string $class, + string $method, + string $parameterName, + string $annotationClass, + ): Klist { + return $this->reader->getParameterAnnotations( $class, $method, $parameterName, $annotationClass ) + ->foreach( fn( Annotation $a ) => $this->dependencyResolver->applyPropertyInjection( $a ) ); + } + + /** + * Fetch a single annotation of a given type on a property. + * + * @template T + * @param class-string $class + * @param string $property + * @param class-string $annotationClass + * + * @return A|null + * @throws ReflectionException + */ + public function getPropertyAnnotation( + string $class, + string $property, + string $annotationClass = Annotation::class + ): ?Annotation { + $annotation = $this->reader->getPropertyAnnotation( $class, $property, $annotationClass ); + if ( $annotation !== null ) { + $this->dependencyResolver->applyPropertyInjection( $annotation ); + } + + return $annotation; + } +} \ No newline at end of file diff --git a/src/Annotation/MethodExecutionAnnotation.php b/src/Annotation/MethodExecutionAnnotation.php index bbdf0c3..a266ac6 100644 --- a/src/Annotation/MethodExecutionAnnotation.php +++ b/src/Annotation/MethodExecutionAnnotation.php @@ -3,10 +3,16 @@ namespace Axpecto\Annotation; use Attribute; +use Axpecto\ClassBuilder\BuildHandler; +use Axpecto\Container\Annotation\Inject; +use Axpecto\MethodExecution\Builder\MethodExecutionBuildHandler; use Axpecto\MethodExecution\MethodExecutionHandler; #[Attribute] class MethodExecutionAnnotation extends BuildAnnotation { + #[Inject( class: MethodExecutionBuildHandler::class )] + protected ?BuildHandler $builder = null; + /** * The handler for processing the method execution annotation. * diff --git a/src/Cache/Annotation/Cache.php b/src/Cache/Annotation/Cache.php new file mode 100644 index 0000000..fc0fbec --- /dev/null +++ b/src/Cache/Annotation/Cache.php @@ -0,0 +1,23 @@ +getAnnotation( Cache::class ); + + $this->cacheService->enableTelemetry( $annotation->telemetry ); + + $cacheKey = $this->getCacheKey( $annotation, $context ); + + return $this->cacheService->runCached( + $cacheKey, + $annotation->group, + $annotation->ttl, + fn() => $context->proceed(), + ); + } + + private function getCacheKey( Cache $ann, MethodExecutionContext $context ): string { + return $ann->key ?? $context->className . '::' . $context->methodName . '(' . md5( serialize( $context->arguments ) ) . ')'; + } +} \ No newline at end of file diff --git a/src/Cache/CacheService.php b/src/Cache/CacheService.php new file mode 100644 index 0000000..1eb8b16 --- /dev/null +++ b/src/Cache/CacheService.php @@ -0,0 +1,22 @@ + [ + * 'cacheKey' => ['value' => mixed, 'expiresAt' => DateTimeImmutable|null], + * … + * ], + * … + * ] + * + * @var array> + */ + private array $store = []; + private bool $telemetryEnabled = false; + + public function __construct( + private readonly TelemetryService $telemetryService, + ) {} + + /** + * @inheritDoc + */ + #[Override] + public function runCached( + string $cacheKey, + ?string $group, + ?int $expiration, + Closure $lambda + ): mixed { + $group = $group ?? self::DEFAULT_GROUP; + + // initialize group if needed + if ( ! isset( $this->store[ $group ] ) ) { + $this->store[ $group ] = []; + } + + // return cached if still valid + if ( isset( $this->store[ $group ][ $cacheKey ] ) ) { + $entry = $this->store[ $group ][ $cacheKey ]; + if ( $entry['expiresAt'] === null || $entry['expiresAt'] > new DateTimeImmutable() ) { + $this->recordEvent( hit: true, group: $group, cacheKey: $cacheKey, expires: $entry['expiresAt']?->format( 'c' ) ?? '' ); + return $entry['value']; + } + // expired + unset( $this->store[ $group ][ $cacheKey ] ); + } + + // compute and store + $value = $lambda(); + $expiresAt = $expiration !== null + ? ( new DateTimeImmutable() )->modify( "+{$expiration} seconds" ) + : null; + + $this->store[ $group ][ $cacheKey ] = [ + 'value' => $value, + 'expiresAt' => $expiresAt, + ]; + + $this->recordEvent( hit: false, group: $group, cacheKey: $cacheKey, expires: $expiresAt?->format( 'c' ) ?? '' ); + + return $value; + } + + /** + * Delete a cached entry. + * + * @param string $cacheKey + * @param string|null $group Optional group name (will default to "__default"). + */ + public function delete( string $cacheKey, ?string $group = null ): void { + $group = $group ?? self::DEFAULT_GROUP; + if ( isset( $this->store[ $group ][ $cacheKey ] ) ) { + unset( $this->store[ $group ][ $cacheKey ] ); + } + } + + /** + * Clear an entire group of cache entries (or all if group is null). + * + * @param string|null $group + */ + public function clear( ?string $group = null ): void { + if ( $group === null ) { + $this->store = []; + } else { + unset( $this->store[ $group ] ); + } + } + + public function enableTelemetry( bool $enable ): void { + $this->telemetryEnabled = $enable; + } + + private function recordEvent( bool $hit, string $group, string $cacheKey, string $expires ): void { + $this->telemetryEnabled && $this->telemetryService->recordEvent( + 'in_memory.cache.' . ($hit ? 'hit' : 'miss') . ' => ' . $cacheKey, + [ + 'group' => $group, + 'cacheKey' => $cacheKey, + 'expiresAt' => $expires, + ] + ); + } +} diff --git a/src/ClassBuilder/BuildOutput.php b/src/ClassBuilder/BuildOutput.php index a916008..e50c6ee 100644 --- a/src/ClassBuilder/BuildOutput.php +++ b/src/ClassBuilder/BuildOutput.php @@ -2,6 +2,8 @@ namespace Axpecto\ClassBuilder; +use Axpecto\Annotation\Annotation; +use Axpecto\Collection\Klist; use Axpecto\Collection\Kmap; use Axpecto\Container\Annotation\Inject; use Exception; @@ -25,7 +27,7 @@ class BuildOutput { /** * Constructor for the BuildOutput class. - ** + * * @param Kmap $methods List of methods in the output. * @param Kmap $properties List of class properties in the output. */ @@ -34,6 +36,8 @@ public function __construct( public readonly Kmap $methods = new Kmap( mutable: true ), public readonly Kmap $properties = new Kmap( mutable: true ), public readonly Kmap $traits = new Kmap( mutable: true ), + // @TODO I might change this. + private array $methodAnnotations = [], ) { } @@ -52,6 +56,21 @@ public function addMethod( string $name, string $signature, string $implementati $this->methods->add( $name, "$signature {\n\t\t$implementation\n\t}\n" ); } + /** + * @template T of Annotation + * @param string $methodName + * @param class-string $annotation + * + * @throws Exception + */ + public function annotateMethod( string $methodName, string $annotation ): void { + $this->methodAnnotations[$methodName][] = '#[' . $annotation . ']'; + } + + public function getMethodAnnotations( string $methodName ): Klist { + return listFrom( $this->methodAnnotations[$methodName] ?? [] ); + } + /** * Add a property to the output. * Modifies the internal state directly. @@ -80,7 +99,7 @@ public function addProperty( string $name, string $implementation ): void { public function injectProperty( string $name, string $class ): string { $this->addProperty( name: $class, - implementation: "#[" . Inject::class . "] private $class \$$name;", + implementation: "#[" . Inject::class . "] protected $class \$$name;", ); return $name; diff --git a/src/ClassBuilder/ClassBuilder.php b/src/ClassBuilder/ClassBuilder.php index a1af7e7..d1c6e40 100644 --- a/src/ClassBuilder/ClassBuilder.php +++ b/src/ClassBuilder/ClassBuilder.php @@ -2,10 +2,12 @@ namespace Axpecto\ClassBuilder; -use Axpecto\Annotation\AnnotationReader; +use Axpecto\Annotation\Annotation; +use Axpecto\Annotation\AnnotationService; use Axpecto\Annotation\BuildAnnotation; use Axpecto\Container\Exception\ClassAlreadyBuiltException; use Axpecto\Reflection\ReflectionUtils; +use Exception; use ReflectionException; /** @@ -23,7 +25,7 @@ class ClassBuilder { */ public function __construct( private readonly ReflectionUtils $reflect, - private readonly AnnotationReader $reader, + private readonly AnnotationService $annotationService, private array $builtClasses = [], ) { } @@ -36,30 +38,45 @@ public function __construct( * @return string The name of the proxied class. * @throws ReflectionException * @throws ClassAlreadyBuiltException + * @throws Exception */ - public function build( string $class ): string { + public function build( string $class, int $pass = 0, ?string $extends = null ): string { + // “current” is the class we’re actually building this pass + $current = $extends ?? $class; + // Check if the class has already been built - if ( isset( $this->builtClasses[ $class ] ) ) { - throw new ClassAlreadyBuiltException( $class ); + if ( isset( $this->builtClasses[ $current ] ) ) { + throw new ClassAlreadyBuiltException( $current ); } // Get all the Build annotations for the class and its methods - $buildAnnotations = $this->reader->getAllAnnotations( $class, BuildAnnotation::class ); + $buildAnnotations = $this->annotationService->getAllAnnotations( $current, BuildAnnotation::class ); // Create and proceed with the build chain - $context = new BuildOutput( $class ); + $context = new BuildOutput( $current ); $buildAnnotations->foreach( fn( BuildAnnotation $a ) => $a->getBuilder()?->intercept( $a, $context ) ); + // Annotate the methods with the existing annotations, we exclude BuildAnnotations. + $context->methods->foreach( fn( string $methodName ) => $this->annotationService->getMethodAnnotations( $current, $methodName, Annotation::class ) + ->filter( fn( Annotation $a ) => ! $a instanceof BuildAnnotation ) + ->foreach( fn( Annotation $a ) => $context->annotateMethod( $methodName, $a::class ) ) + ); + // If the build output is empty, return the original class if ( $context->isEmpty() ) { - return $class; + return $current; } // Generate and evaluate the proxy class - $proxiedClass = $this->generateProxyClass( $class, $context ); + $proxiedClass = $this->buildClass( $class, $context, $pass, $extends ); // Cache the built class - $this->builtClasses[ $class ] = $proxiedClass; + $this->builtClasses[ $current ] = $proxiedClass; + + // Check if the class has any Build annotations and trigger a new build pass. + if ( $this->annotationService->getAllAnnotations( $proxiedClass, BuildAnnotation::class )->isNotEmpty() ) { + return $this->build( $current, ++$pass, $proxiedClass ); + } // Return the proxy class name return $proxiedClass; @@ -77,9 +94,9 @@ public function build( string $class ): string { * @return string The name of the generated proxy class. * @throws ReflectionException */ - private function generateProxyClass( string $class, BuildOutput $buildOutput ): string { + private function buildClass( string $class, BuildOutput $buildOutput, int $pass, ?string $extends = null ): string { // Generate a unique proxy class name by replacing backslashes in the class name. - $proxiedClassName = str_replace( "\\", '_', $class ) . 'Proxy'; + $proxiedClassName = str_replace( "\\", '_', $class ) ."__x$pass"; // Define whether the proxy class extends or implements the original class. $inheritanceType = $this->reflect->isInterface( $class ) ? 'implements' : 'extends'; @@ -89,19 +106,25 @@ private function generateProxyClass( string $class, BuildOutput $buildOutput ): $traits = "\tuse " . $buildOutput->traits->join( "," ) . ';'; } + $methods = $buildOutput->methods->map( fn( $method, $code ) => $buildOutput->getMethodAnnotations( $method )->join() . $code ); + // Construct the full class definition. $proxiedClass = sprintf( - "\nclass %s %s %s {\n%s\n%s\n%s\n}", + "\nclass %s %s %s {\n%s\n%s\n\n%s\n}", $proxiedClassName, $inheritanceType, - $class, + $extends ?? $class, $traits, "\t" . $buildOutput->properties->join( "\n\t" ), - "\t" . $buildOutput->methods->join( "\n\t" ), + "\t" . $methods->join( "\n\t" ), ); /* @TODO Replace with a component that allows for different behaviors besides eval. */ // Dynamically evaluate the class definition using eval. + if ( defined( "DEBUG_CLASS_BUILD_OUTPUT" ) && DEBUG_CLASS_BUILD_OUTPUT ) { + var_dump( $proxiedClass ); + } + eval( $proxiedClass ); return $proxiedClassName; diff --git a/src/Code/AnnotationCodeGenerator.php b/src/Code/AnnotationCodeGenerator.php new file mode 100644 index 0000000..504f39d --- /dev/null +++ b/src/Code/AnnotationCodeGenerator.php @@ -0,0 +1,107 @@ +reflect->getReflectionClass( $annotation::class ); + + $ctor = $refClass->getConstructor(); + $params = $ctor ? $ctor->getParameters() : []; + + $parts = []; + foreach ( $params as $param ) { + $name = $param->getName(); + // read the public property of the same name + $value = $annotation->$name; + + // skip if matches default + if ( $param->isDefaultValueAvailable() && + $param->getDefaultValue() === $value + ) { + continue; + } + + if ( $param->isVariadic() ) { + $parts[] = '...' . $this->serializeValue( $value ); + } else { + $parts[] = $name . ': ' . $this->serializeValue( $value ); + } + } + + return $annotation::class . '(' . implode( ', ', $parts ) . ')'; + } + + /** + * Recursively serialize PHP values into valid PHP code. + * + * @param mixed $v + * + * @return string + * @throws InvalidArgumentException|ReflectionException + */ + private function serializeValue( mixed $v ): string { + if ( is_array( $v ) ) { + $items = []; + foreach ( $v as $k => $e ) { + if ( is_string( $k ) ) { + $items[] = var_export( $k, true ) . ' => ' . $this->serializeValue( $e ); + } else { + $items[] = $this->serializeValue( $e ); + } + } + + return '[' . implode( ', ', $items ) . ']'; + } + + if ( is_string( $v ) || is_int( $v ) || is_float( $v ) || is_bool( $v ) || is_null( $v ) ) { + if ( is_string( $v ) || is_null( $v ) ) { + return var_export( $v, true ); + } + + return $v === true ? 'true' : ( $v === false ? 'false' : (string) $v ); + } + + // nested attribute? + if ( is_object( $v ) ) { + $nestedRef = new ReflectionClass( $v ); + if ( $nestedRef->isAttribute() ) { + // strip leading "#[" and trailing "]" from nested serialization + $raw = $this->serializeAnnotation( $v ); + + return substr( $raw, 2, - 1 ); + } + } + + throw new InvalidArgumentException( 'Cannot serialize value of type ' . get_debug_type( $v ) ); + } +} diff --git a/src/Code/MethodCodeGenerator.php b/src/Code/MethodCodeGenerator.php index cbd8ae9..90523f3 100644 --- a/src/Code/MethodCodeGenerator.php +++ b/src/Code/MethodCodeGenerator.php @@ -39,10 +39,6 @@ public function __construct( public function implementMethodSignature( string $class, string $method ): string { $rMethod = $this->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() ) diff --git a/src/Collection/Klist.php b/src/Collection/Klist.php index 5b61a11..57ac44d 100644 --- a/src/Collection/Klist.php +++ b/src/Collection/Klist.php @@ -3,281 +3,1014 @@ namespace Axpecto\Collection; use Closure; +use Exception; use Override; /** + * A list collection with lazy pipeline evaluation. + * + * Intermediate operations (filter, map, flatMap, etc.) are deferred — they + * append to an internal pipeline and return a new Klist without processing data. + * + * Terminal operations (toArray, firstOrNull, count, etc.) trigger evaluation. + * Short-circuit terminals (firstOrNull, any) avoid full materialization when + * the pipeline consists only of fuseable operations (filter, map, mapNotNull). + * * @template TValue * @implements CollectionInterface */ class Klist implements CollectionInterface { + /** Pipeline step types that can be fused into a single per-element pass. */ + private const FUSEABLE = [ 'filter', 'map', 'mapNotNull', 'onEach' ]; + + /** Sentinel object identity — used to mark filtered-out elements in the pipeline. */ + private static ?object $FILTERED = null; + + /** @var array|null Cached result of pipeline evaluation. */ + private ?array $materialized = null; + + /** Iterator position within the materialized array. */ + private int $iteratorIndex = 0; + /** - * @var Kmap + * @param array $source Raw source data. + * @param bool $mutable Whether mutation is allowed. + * @param array $pipeline Deferred operation steps (internal). */ - private Kmap $internalMap; + public function __construct( + private readonly array $source = [], + private readonly bool $mutable = false, + private readonly array $pipeline = [], + ) { + // Mutable lists are always eagerly materialized — no lazy deferral. + if ( $mutable ) { + $this->materialized = array_values( $source ); + } + } + + private static function filtered(): object { + return self::$FILTERED ??= new \stdClass(); + } + + // =============================================================== + // Intermediate operations — lazy, return a new Klist + // =============================================================== /** - * @param array $array - * @param bool $mutable + * Keep only elements matching the predicate. + * + * @param Closure(TValue): bool $predicate + * + * @return static + */ + #[Override] + public function filter( Closure $predicate ): static { + return $this->pipe( 'filter', $predicate ); + } + + #[Override] + public function filterNotNull(): static { + return $this->pipe( 'filter', fn( $v ) => $v !== null ); + } + + /** + * Transform each element. + * + * @template TOut + * @param Closure(TValue): TOut $transform + * + * @return Klist + */ + #[Override] + public function map( Closure $transform ): static { + return $this->pipe( 'map', $transform ); + } + + /** + * Transform elements, dropping null results. + * + * @template TOut + * @param Closure(TValue): (TOut|null) $transform + * + * @return Klist + */ + public function mapNotNull( Closure $transform ): static { + return $this->pipe( 'mapNotNull', $transform ); + } + + /** + * Transform each element to an iterable and flatten the results. + * + * @template TOut + * @param Closure(TValue): iterable $transform + * + * @return Klist + */ + public function flatMap( Closure $transform ): static { + return $this->pipe( 'flatMap', $transform ); + } + + /** + * Flatten one level of nesting. + * + * @return static + */ + #[Override] + public function flatten(): static { + return $this->pipe( 'flatten', null ); + } + + /** + * Take the first $n elements. + */ + public function take( int $n ): static { + return $this->pipe( 'take', $n ); + } + + /** + * Skip the first $n elements. + */ + public function drop( int $n ): static { + return $this->pipe( 'drop', $n ); + } + + /** + * Remove duplicate values (by strict equality). + */ + public function distinct(): static { + return $this->pipe( 'distinct', null ); + } + + /** + * Remove duplicates by a key selector. + * + * @param Closure(TValue): mixed $selector + */ + public function distinctBy( Closure $selector ): static { + return $this->pipe( 'distinctBy', $selector ); + } + + /** + * Sort ascending by a selector. + * + * @param Closure(TValue): mixed $selector + */ + public function sortedBy( Closure $selector ): static { + return $this->pipe( 'sortedBy', $selector ); + } + + /** + * Sort descending by a selector. + * + * @param Closure(TValue): mixed $selector + */ + public function sortedByDescending( Closure $selector ): static { + return $this->pipe( 'sortedByDescending', $selector ); + } + + /** + * Reverse element order. */ - public function __construct( array $array = [], private readonly bool $mutable = false ) { - $this->internalMap = new Kmap( $array, $this->mutable ); + public function reversed(): static { + return $this->pipe( 'reversed', null ); } + /** + * Side-effect per element, lazy — fires during materialization. + * + * @param Closure(TValue): void $action + */ + public function onEach( Closure $action ): static { + return $this->pipe( 'onEach', $action ); + } + + /** + * Split into chunks of $size. + * + * @return Klist> + */ + public function chunked( int $size ): static { + return $this->pipe( 'chunked', $size ); + } + + // =============================================================== + // Terminal operations — force evaluation + // =============================================================== + /** * @return array */ #[Override] public function toArray(): array { - return $this->internalMap->toArray(); + return $this->materialize(); + } + + #[Override] + public function count(): int { + return count( $this->materialize() ); } #[Override] public function isEmpty(): bool { - return $this->internalMap->isEmpty(); + // Fast path: no pipeline + if ( empty( $this->pipeline ) ) { + return empty( $this->materialized ?? $this->source ); + } + // Already materialized + if ( $this->materialized !== null ) { + return empty( $this->materialized ); + } + // Short-circuit for fuseable pipelines + if ( $this->isFullyFuseable() ) { + $filtered = self::filtered(); + foreach ( $this->source as $item ) { + if ( $this->applyFuseableChain( $item ) !== $filtered ) { + return false; + } + } + + return true; + } + + return empty( $this->materialize() ); } #[Override] public function isNotEmpty(): bool { - return $this->internalMap->isNotEmpty(); + return ! $this->isEmpty(); } /** - * @param Closure(TValue):bool $predicate - * - * @return static + * @return TValue|null */ #[Override] - public function filter( Closure $predicate ): static { - $map = $this->internalMap->filter( fn( $key, $value ) => $predicate( $value ) ); - $map->resetKeys(); + public function firstOrNull(): mixed { + if ( $this->materialized !== null ) { + return $this->materialized[0] ?? null; + } + if ( empty( $this->pipeline ) ) { + return $this->source[0] ?? null; + } + // Short-circuit for fuseable pipelines + if ( $this->isFullyFuseable() ) { + $filtered = self::filtered(); + foreach ( $this->source as $item ) { + $result = $this->applyFuseableChain( $item ); + if ( $result !== $filtered ) { + return $result; + } + } + + return null; + } + + return $this->materialize()[0] ?? null; + } - return $this->mutable ? $this : new static( $map->toArray(), $this->mutable ); + /** + * @return TValue + * @throws Exception + */ + public function first(): mixed { + $data = $this->materialize(); + if ( empty( $data ) ) { + throw new Exception( "List is empty" ); + } + + return $data[0]; } /** - * @return static + * @return TValue|null */ - #[Override] - public function filterNotNull(): static { - $map = $this->internalMap->filterNotNull(); + public function lastOrNull(): mixed { + $data = $this->materialize(); - return $this->mutable ? $this : new static( $map->toArray(), $this->mutable ); + return empty( $data ) ? null : $data[ count( $data ) - 1 ]; } /** - * @template TOut - * @param Closure(TValue):TOut $transform - * - * @return Klist + * @return TValue + * @throws Exception */ - #[Override] - public function map( Closure $transform ): static { - $map = $this->internalMap->map( fn( $key, $value ) => [ $key => $transform( $value ) ] ); + public function last(): mixed { + $data = $this->materialize(); + if ( empty( $data ) ) { + throw new Exception( "List is empty" ); + } + + return $data[ count( $data ) - 1 ]; + } + + /** + * @return TValue + * @throws Exception if the list does not contain exactly one element. + */ + public function single(): mixed { + $data = $this->materialize(); + if ( count( $data ) !== 1 ) { + throw new Exception( "List does not contain exactly one element" ); + } - return $this->mutable ? $this : new Klist( $map->toArray(), $this->mutable ); + return $data[0]; } /** - * @param Closure(TValue):bool $predicate + * @return TValue|null null if the list does not contain exactly one element. + */ + public function singleOrNull(): mixed { + $data = $this->materialize(); + + return count( $data ) === 1 ? $data[0] : null; + } + + /** + * True if any element matches. + * + * @param Closure(TValue): bool $predicate */ #[Override] public function any( Closure $predicate ): bool { - return $this->internalMap->any( $predicate ); + // Short-circuit for fuseable pipelines + if ( $this->materialized === null && $this->isFullyFuseable() ) { + $filtered = self::filtered(); + foreach ( $this->source as $item ) { + $result = $this->applyFuseableChain( $item ); + if ( $result !== $filtered && $predicate( $result ) ) { + return true; + } + } + + return false; + } + + foreach ( $this->materialize() as $item ) { + if ( $predicate( $item ) ) { + return true; + } + } + + return false; } /** - * @param Closure(TValue):bool $predicate + * True if all elements match. + * + * @param Closure(TValue): bool $predicate */ #[Override] public function all( Closure $predicate ): bool { - return $this->internalMap->all( $predicate ); + foreach ( $this->materialize() as $item ) { + if ( ! $predicate( $item ) ) { + return false; + } + } + + return true; } /** - * @param array $array + * True if no element matches. * - * @return static + * @param Closure(TValue): bool $predicate + */ + public function none( Closure $predicate ): bool { + return ! $this->any( $predicate ); + } + + /** + * @param Closure(mixed, TValue): mixed $transform + * @param mixed|null $initial + * + * @return mixed */ #[Override] - public function mergeArray( array $array ): static { - $map = $this->internalMap->mergeArray( $array ); + public function reduce( Closure $transform, mixed $initial = null ): mixed { + $carry = $initial; + foreach ( $this->materialize() as $item ) { + $carry = $transform( $carry, $item ); + } - return $this->mutable ? $this : new static( $map->toArray(), $this->mutable ); + return $carry; } /** - * @param CollectionInterface $collection + * Kotlin-style fold (initial value first). * - * @return static + * @param mixed $initial + * @param Closure(mixed, TValue): mixed $operation + * + * @return mixed */ - #[Override] - public function merge( CollectionInterface $collection ): static { - return $this->mergeArray( $collection->toArray() ); + public function fold( mixed $initial, Closure $operation ): mixed { + return $this->reduce( $operation, $initial ); + } + + /** + * @param Closure(TValue): int|float $selector + */ + public function sumOf( Closure $selector ): int|float { + $sum = 0; + foreach ( $this->materialize() as $item ) { + $sum += $selector( $item ); + } + + return $sum; + } + + /** + * @param Closure(TValue): mixed $selector + * + * @return TValue|null + */ + public function minByOrNull( Closure $selector ): mixed { + $minItem = null; + $minSel = null; + foreach ( $this->materialize() as $item ) { + $sel = $selector( $item ); + if ( $minSel === null || $sel < $minSel ) { + $minSel = $sel; + $minItem = $item; + } + } + + return $minItem; + } + + /** + * @param Closure(TValue): mixed $selector + * + * @return TValue|null + */ + public function maxByOrNull( Closure $selector ): mixed { + $maxItem = null; + $maxSel = null; + foreach ( $this->materialize() as $item ) { + $sel = $selector( $item ); + if ( $maxSel === null || $sel > $maxSel ) { + $maxSel = $sel; + $maxItem = $item; + } + } + + return $maxItem; + } + + /** + * @return int -1 if not found. + */ + public function indexOf( mixed $element ): int { + $index = array_search( $element, $this->materialize(), true ); + + return $index === false ? - 1 : $index; + } + + /** + * @param Closure(TValue): bool $predicate + * + * @return int -1 if not found. + */ + public function indexOfFirst( Closure $predicate ): int { + foreach ( $this->materialize() as $index => $item ) { + if ( $predicate( $item ) ) { + return $index; + } + } + + return - 1; + } + + public function contains( mixed $element ): bool { + return in_array( $element, $this->materialize(), true ); } #[Override] - public function join( string $separator ): string { - return $this->internalMap->join( $separator ); + public function join( string $separator = '' ): string { + return implode( $separator, $this->materialize() ); } /** - * Executes the closure only if the list is not empty. + * Execute a side-effect for each element (terminal). * - * @param Closure(Klist):void $predicate + * @param Closure(TValue): void $transform * * @return static */ #[Override] - public function maybe( Closure $predicate ): static { - count( $this ) > 0 && $predicate( $this ); + public function foreach( Closure $transform ): static { + foreach ( $this->materialize() as $item ) { + $transform( $item ); + } return $this; } /** + * Split elements into [matching, non-matching] pair. + * + * @param Closure(TValue): bool $predicate + * + * @return array{Klist, Klist} + */ + public function partition( Closure $predicate ): array { + $matching = []; + $nonMatching = []; + foreach ( $this->materialize() as $item ) { + if ( $predicate( $item ) ) { + $matching[] = $item; + } else { + $nonMatching[] = $item; + } + } + + return [ new static( $matching ), new static( $nonMatching ) ]; + } + + /** + * Group elements by a key selector. + * + * @param Closure(TValue): array-key $keySelector + * + * @return Kmap> + */ + public function groupBy( Closure $keySelector ): Kmap { + $groups = []; + foreach ( $this->materialize() as $item ) { + $key = $keySelector( $item ); + $groups[ $key ][] = $item; + } + + return new Kmap( array_map( fn( $group ) => new static( $group ), $groups ) ); + } + + /** + * Transform each element to a key-value pair and collect as a Kmap. + * The closure should return a single-entry associative array: [$key => $value]. + * + * @template TMapKey of array-key + * @template TMapValue + * @param Closure(TValue): array $transform + * + * @return Kmap + */ + public function associate( Closure $transform ): Kmap { + $result = []; + foreach ( $this->materialize() as $item ) { + $entry = $transform( $item ); + $result[ key( $entry ) ] = current( $entry ); + } + + return new Kmap( $result ); + } + + /** + * Key each element by a selector, value is the element itself. + * + * @param Closure(TValue): array-key $keySelector + * + * @return Kmap + */ + public function associateBy( Closure $keySelector ): Kmap { + $result = []; + foreach ( $this->materialize() as $item ) { + $result[ $keySelector( $item ) ] = $item; + } + + return new Kmap( $result ); + } + + /** + * Backward-compatible alias for associate(). + * + * @template TMap + * @param Closure(TValue): TMap $transform + * + * @return Kmap + */ + public function mapOf( Closure $transform ): Kmap { + return $this->associate( $transform ); + } + + /** + * Pair elements from this list with another iterable. + * + * @template TOther + * @param iterable $other + * + * @return Klist + */ + public function zip( iterable $other ): static { + $data = $this->materialize(); + $otherArray = $other instanceof CollectionInterface + ? $other->toArray() + : ( is_array( $other ) ? $other : iterator_to_array( $other ) ); + $result = []; + $count = min( count( $data ), count( $otherArray ) ); + for ( $i = 0; $i < $count; $i++ ) { + $result[] = [ $data[ $i ], $otherArray[ $i ] ]; + } + + return new static( $result ); + } + + // =============================================================== + // Merge / conversion + // =============================================================== + + /** + * @param array $array + * * @return static */ #[Override] - public function toMutable(): static { - return $this->mutable ? $this : new static( $this->internalMap->toArray(), true ); + public function mergeArray( array $array ): static { + $data = array_merge( $this->materialize(), $array ); + if ( $this->mutable ) { + $this->materialized = $data; + + return $this; + } + + return new static( $data ); } /** + * @param CollectionInterface $collection + * * @return static */ + #[Override] + public function merge( CollectionInterface $collection ): static { + return $this->mergeArray( $collection->toArray() ); + } + + #[Override] + public function toMutable(): static { + return $this->mutable ? $this : new static( $this->materialize(), true ); + } + #[Override] public function toImmutable(): static { - return ! $this->mutable ? $this : new static( $this->internalMap->toArray(), false ); + return ! $this->mutable ? $this : new static( $this->materialize(), false ); } /** + * @param Closure(Klist): void $predicate + * * @return static */ #[Override] - public function flatten(): static { - $map = $this->internalMap->flatten(); + public function maybe( Closure $predicate ): static { + if ( $this->isNotEmpty() ) { + $predicate( $this ); + } - return $this->mutable ? $this : new static( $map->toArray(), $this->mutable ); + return $this; + } + + // =============================================================== + // Mutation (mutable only) + // =============================================================== + + /** + * Append a value. + * + * @param TValue $value + * + * @throws Exception + */ + public function add( mixed $value ): void { + if ( ! $this->mutable ) { + throw new Exception( "Cannot modify immutable list" ); + } + $this->materialized[] = $value; } + // =============================================================== + // Iterator + // =============================================================== + #[Override] public function current(): mixed { - return $this->internalMap->current(); + $data = $this->materialize(); + + return $data[ $this->iteratorIndex ] ?? null; } #[Override] public function next(): void { - $this->internalMap->next(); + $this->iteratorIndex++; } /** - * Returns the current item and advances the internal pointer. + * Returns the current item and advances the pointer. * * @return TValue|null */ #[Override] public function nextAndGet(): mixed { - return $this->internalMap->nextAndGet(); + $current = $this->current(); + $this->next(); + + return $current; } #[Override] public function key(): mixed { - return $this->internalMap->key(); + return $this->valid() ? $this->iteratorIndex : null; } #[Override] public function valid(): bool { - return $this->internalMap->valid(); + return $this->iteratorIndex < count( $this->materialize() ); } #[Override] public function rewind(): void { - $this->internalMap->rewind(); + $this->iteratorIndex = 0; } + // =============================================================== + // ArrayAccess + // =============================================================== + #[Override] public function offsetExists( mixed $offset ): bool { - return $this->internalMap->offsetExists( $offset ); + return isset( $this->materialize()[ $offset ] ); } #[Override] public function offsetGet( mixed $offset ): mixed { - return $this->internalMap->offsetGet( $offset ); + return $this->materialize()[ $offset ]; } + /** + * @throws Exception + */ #[Override] public function offsetSet( mixed $offset, mixed $value ): void { - $this->internalMap->offsetSet( $offset, $value ); + if ( ! $this->mutable ) { + throw new Exception( "Cannot modify immutable list" ); + } + if ( $offset === null ) { + $this->materialized[] = $value; + } else { + $this->materialized[ $offset ] = $value; + } } + /** + * @throws Exception + */ #[Override] public function offsetUnset( mixed $offset ): void { - $this->internalMap->offsetUnset( $offset ); + if ( ! $this->mutable ) { + throw new Exception( "Cannot modify immutable list" ); + } + unset( $this->materialized[ $offset ] ); + $this->materialized = array_values( $this->materialized ); } - #[Override] - public function count(): int { - return $this->internalMap->count(); - } + // =============================================================== + // Countable & JsonSerializable (via terminal) + // =============================================================== #[Override] public function jsonSerialize(): mixed { - return $this->internalMap->jsonSerialize(); + return $this->materialize(); } + // =============================================================== + // Pipeline internals + // =============================================================== + /** - * Add a value to the end of the list. - * - * @param TValue $value - * - * @throws \Exception + * Append a step to the pipeline (immutable) or apply immediately (mutable). */ - public function add( mixed $value ): void { - $this->internalMap->add( $this->internalMap->count(), $value ); + private function pipe( string $type, mixed $arg ): static { + if ( $this->mutable ) { + if ( in_array( $type, self::FUSEABLE, true ) ) { + $this->materialized = $this->applyFusedGroup( $this->materialized, [ [ $type, $arg ] ] ); + } else { + $this->materialized = $this->applyBulkStep( $this->materialized, [ $type, $arg ] ); + } + + return $this; + } + + return new static( + source: $this->source, + mutable: false, + pipeline: [ ...$this->pipeline, [ $type, $arg ] ], + ); } /** - * Iterate over each item. - * - * @param Closure(TValue):void $transform + * Evaluate the full pipeline and cache the result. * - * @return static + * @return array */ - #[Override] - public function foreach( Closure $transform ): static { - $this->internalMap->foreach( fn( $key, $value ) => $transform( $value ) ); + private function materialize(): array { + if ( $this->materialized !== null ) { + return $this->materialized; + } + if ( empty( $this->pipeline ) ) { + $this->materialized = array_values( $this->source ); - return $this; + return $this->materialized; + } + + $data = $this->source; + $fuseableGroup = []; + + foreach ( $this->pipeline as $step ) { + [ $type ] = $step; + + if ( in_array( $type, self::FUSEABLE, true ) ) { + $fuseableGroup[] = $step; + continue; + } + + // Flush the accumulated fuseable group as a single pass + if ( ! empty( $fuseableGroup ) ) { + $data = $this->applyFusedGroup( $data, $fuseableGroup ); + $fuseableGroup = []; + } + + $data = $this->applyBulkStep( $data, $step ); + } + + if ( ! empty( $fuseableGroup ) ) { + $data = $this->applyFusedGroup( $data, $fuseableGroup ); + } + + $this->materialized = array_values( $data ); + + return $this->materialized; } /** - * @return TValue|null + * Apply multiple fuseable steps in a single pass over the data. */ - #[Override] - public function firstOrNull(): mixed { - return $this->internalMap->firstOrNull(); + private function applyFusedGroup( array $data, array $steps ): array { + $result = []; + $filtered = self::filtered(); + + foreach ( $data as $item ) { + $value = $item; + $skip = false; + + foreach ( $steps as [ $type, $arg ] ) { + switch ( $type ) { + case 'filter': + if ( ! $arg( $value ) ) { + $skip = true; + break 2; + } + break; + case 'map': + $value = $arg( $value ); + break; + case 'mapNotNull': + $value = $arg( $value ); + if ( $value === null ) { + $skip = true; + break 2; + } + break; + case 'onEach': + $arg( $value ); + break; + } + } + + if ( ! $skip ) { + $result[] = $value; + } + } + + return $result; } /** - * @template TMap - * @param Closure(TValue):TMap $transform - * - * @return KMap + * Apply a single non-fuseable (cardinality-changing) step. */ - public function mapOf( Closure $transform ): KMap { - return $this->internalMap->map( fn( $key, $value ) => $transform( $value ) ); + private function applyBulkStep( array $data, array $step ): array { + [ $type, $arg ] = $step; + + return match ( $type ) { + 'flatMap' => $this->bulkFlatMap( $data, $arg ), + 'flatten' => $this->bulkFlatten( $data ), + 'take' => array_slice( $data, 0, $arg ), + 'drop' => array_slice( $data, $arg ), + 'distinct' => array_values( array_unique( $data, SORT_REGULAR ) ), + 'distinctBy' => $this->bulkDistinctBy( $data, $arg ), + 'sortedBy' => $this->bulkSort( $data, $arg, false ), + 'sortedByDescending' => $this->bulkSort( $data, $arg, true ), + 'reversed' => array_reverse( $data ), + 'chunked' => array_chunk( $data, $arg ), + default => $data, + }; + } + + private function bulkFlatMap( array $data, Closure $transform ): array { + $result = []; + foreach ( $data as $item ) { + $mapped = $transform( $item ); + if ( $mapped instanceof CollectionInterface ) { + array_push( $result, ...$mapped->toArray() ); + } elseif ( is_array( $mapped ) ) { + array_push( $result, ...$mapped ); + } else { + $result[] = $mapped; + } + } + + return $result; + } + + private function bulkFlatten( array $data ): array { + $result = []; + foreach ( $data as $item ) { + if ( $item instanceof CollectionInterface ) { + array_push( $result, ...$item->toArray() ); + } elseif ( is_array( $item ) ) { + array_push( $result, ...$item ); + } else { + $result[] = $item; + } + } + + return $result; + } + + private function bulkDistinctBy( array $data, Closure $selector ): array { + $seen = []; + $result = []; + foreach ( $data as $item ) { + $key = serialize( $selector( $item ) ); + if ( ! isset( $seen[ $key ] ) ) { + $seen[ $key ] = true; + $result[] = $item; + } + } + + return $result; + } + + private function bulkSort( array $data, Closure $selector, bool $descending ): array { + usort( $data, function ( $a, $b ) use ( $selector, $descending ) { + $cmp = $selector( $a ) <=> $selector( $b ); + + return $descending ? - $cmp : $cmp; + } ); + + return $data; } /** - * Reduce the collection to a single value. - * - * @param Closure(mixed, TValue):mixed $transform - * @param mixed|null $initial - * - * @return mixed + * Apply the fuseable pipeline to a single element. + * Returns the filtered() sentinel if the element should be excluded. */ - #[Override] - public function reduce( Closure $transform, mixed $initial = null ): mixed { - return $this->internalMap->reduce( $transform, $initial ); + private function applyFuseableChain( mixed $item ): mixed { + $value = $item; + $filtered = self::filtered(); + + foreach ( $this->pipeline as [ $type, $arg ] ) { + switch ( $type ) { + case 'filter': + if ( ! $arg( $value ) ) { + return $filtered; + } + break; + case 'map': + $value = $arg( $value ); + break; + case 'mapNotNull': + $value = $arg( $value ); + if ( $value === null ) { + return $filtered; + } + break; + case 'onEach': + $arg( $value ); + break; + } + } + + return $value; + } + + /** + * True if every pipeline step is fuseable (can short-circuit). + */ + private function isFullyFuseable(): bool { + foreach ( $this->pipeline as [ $type ] ) { + if ( ! in_array( $type, self::FUSEABLE, true ) ) { + return false; + } + } + + return true; } } diff --git a/src/Collection/Kmap.php b/src/Collection/Kmap.php index bc85c1e..b3fd994 100644 --- a/src/Collection/Kmap.php +++ b/src/Collection/Kmap.php @@ -7,50 +7,51 @@ use Override; /** - * A key-value based collection that supports functional operations. + * A key-value map collection with functional operations. + * + * Drops the redundant $keys array from the previous implementation. + * Iterator keys are built lazily on first iteration and invalidated on mutation. * * @template TKey of array-key * @template TValue * @implements CollectionInterface */ class Kmap implements CollectionInterface { - /** @var array */ - protected array $keys; - - /** @var array */ - protected array $array = []; - - protected int $index = 0; + private int $iteratorIndex = 0; + private ?array $iteratorKeys = null; /** - * @param array $array + * @param array $data * @param bool $mutable */ - public function __construct( array $array = [], private readonly bool $mutable = false ) { - $this->keys = array_keys( $array ); - $this->array = $array; + public function __construct( + protected array $data = [], + private readonly bool $mutable = false, + ) { } + // --------------------------------------------------------------- + // Iterator + // --------------------------------------------------------------- + #[Override] public function current(): mixed { - return $this->valid() ? $this->array[ $this->keys[ $this->index ] ] : null; + $keys = $this->iteratorKeys(); + return isset( $keys[ $this->iteratorIndex ] ) + ? $this->data[ $keys[ $this->iteratorIndex ] ] + : null; } #[Override] public function key(): mixed { - return $this->keys[ $this->index ] ?? null; + return $this->iteratorKeys()[ $this->iteratorIndex ] ?? null; } #[Override] public function next(): void { - $this->index ++; + $this->iteratorIndex++; } - /** - * Returns the current item and advances the internal pointer. - * - * @return TValue|null - */ #[Override] public function nextAndGet(): mixed { $current = $this->current(); @@ -61,22 +62,38 @@ public function nextAndGet(): mixed { #[Override] public function valid(): bool { - return isset( $this->keys[ $this->index ] ) && array_key_exists( $this->keys[ $this->index ], $this->array ); + $keys = $this->iteratorKeys(); + + return isset( $keys[ $this->iteratorIndex ] ) + && array_key_exists( $keys[ $this->iteratorIndex ], $this->data ); } #[Override] public function rewind(): void { - $this->index = 0; + $this->iteratorIndex = 0; + $this->iteratorKeys = null; + } + + private function iteratorKeys(): array { + return $this->iteratorKeys ??= array_keys( $this->data ); } + private function invalidateIterator(): void { + $this->iteratorKeys = null; + } + + // --------------------------------------------------------------- + // ArrayAccess + // --------------------------------------------------------------- + #[Override] public function offsetExists( mixed $offset ): bool { - return isset( $this->array[ $offset ] ); + return isset( $this->data[ $offset ] ); } #[Override] public function offsetGet( mixed $offset ): mixed { - return $this->array[ $offset ]; + return $this->data[ $offset ]; } /** @@ -89,8 +106,8 @@ public function offsetSet( mixed $offset, mixed $value ): void { } /** @psalm-suppress MixedAssignment */ - $this->array[ $offset ] = $value; - $this->keys = array_keys( $this->array ); + $this->data[ $offset ] = $value; + $this->invalidateIterator(); } /** @@ -102,18 +119,39 @@ public function offsetUnset( mixed $offset ): void { throw new Exception( "Immutable collection cannot be modified" ); } - unset( $this->array[ $offset ] ); - $this->keys = array_keys( $this->array ); + unset( $this->data[ $offset ] ); + $this->invalidateIterator(); } + // --------------------------------------------------------------- + // Countable & JsonSerializable + // --------------------------------------------------------------- + #[Override] public function count(): int { - return count( $this->array ); + return count( $this->data ); + } + + #[Override] + public function jsonSerialize(): mixed { + return $this->data; + } + + // --------------------------------------------------------------- + // Core reads + // --------------------------------------------------------------- + + /** + * @return array + */ + #[Override] + public function toArray(): array { + return $this->data; } #[Override] public function isEmpty(): bool { - return count( $this->array ) === 0; + return count( $this->data ) === 0; } #[Override] @@ -122,19 +160,128 @@ public function isNotEmpty(): bool { } /** + * @return TValue|null + */ + #[Override] + public function firstOrNull(): mixed { + if ( empty( $this->data ) ) { + return null; + } + + return reset( $this->data ); + } + + // --------------------------------------------------------------- + // Functional — filter + // --------------------------------------------------------------- + + /** + * Filter entries by a predicate receiving (key, value). + * + * @param Closure(TKey, TValue): bool $predicate + * * @return static */ + #[Override] + public function filter( Closure $predicate ): static { + $filtered = []; + foreach ( $this->data as $key => $value ) { + if ( $predicate( $key, $value ) ) { + $filtered[ $key ] = $value; + } + } + + return $this->deriveOrMutate( $filtered ); + } + #[Override] public function filterNotNull(): static { return $this->filter( fn( $k, $v ) => $v !== null ); } /** - * @param Closure(TValue):bool $predicate + * Filter entries by key. + * + * @param Closure(TKey): bool $predicate + * + * @return static + */ + public function filterKeys( Closure $predicate ): static { + return $this->filter( fn( $k, $v ) => $predicate( $k ) ); + } + + /** + * Filter entries by value. + * + * @param Closure(TValue): bool $predicate + * + * @return static + */ + public function filterValues( Closure $predicate ): static { + return $this->filter( fn( $k, $v ) => $predicate( $v ) ); + } + + // --------------------------------------------------------------- + // Functional — map + // --------------------------------------------------------------- + + /** + * Transform values, preserving keys. + * + * @template TNewValue + * @param Closure(TKey, TValue): TNewValue $transform + * + * @return Kmap + */ + #[Override] + public function map( Closure $transform ): Kmap { + $result = []; + foreach ( $this->data as $key => $value ) { + $result[ $key ] = $transform( $key, $value ); + } + + return $this->deriveOrMutate( $result ); + } + + /** + * Alias for map(). + * + * @template TNewValue + * @param Closure(TKey, TValue): TNewValue $transform + * + * @return Kmap + */ + public function mapValues( Closure $transform ): Kmap { + return $this->map( $transform ); + } + + /** + * Transform keys, preserving values. + * + * @template TNewKey of array-key + * @param Closure(TKey, TValue): TNewKey $transform + * + * @return Kmap + */ + public function mapKeys( Closure $transform ): Kmap { + $result = []; + foreach ( $this->data as $key => $value ) { + $result[ $transform( $key, $value ) ] = $value; + } + + return $this->deriveOrMutate( $result ); + } + + // --------------------------------------------------------------- + // Functional — predicates + // --------------------------------------------------------------- + + /** + * @param Closure(TValue): bool $predicate */ #[Override] public function any( Closure $predicate ): bool { - foreach ( $this->array as $element ) { + foreach ( $this->data as $element ) { if ( $predicate( $element ) ) { return true; } @@ -144,11 +291,11 @@ public function any( Closure $predicate ): bool { } /** - * @param Closure(TValue):bool $predicate + * @param Closure(TValue): bool $predicate */ #[Override] public function all( Closure $predicate ): bool { - foreach ( $this->array as $element ) { + foreach ( $this->data as $element ) { if ( ! $predicate( $element ) ) { return false; } @@ -157,97 +304,108 @@ public function all( Closure $predicate ): bool { return true; } - #[Override] - public function jsonSerialize(): mixed { - return $this->array; - } - /** - * @return array + * True if no element matches the predicate. + * + * @param Closure(TValue): bool $predicate */ - #[Override] - public function toArray(): array { - return $this->array; + public function none( Closure $predicate ): bool { + return ! $this->any( $predicate ); } + // --------------------------------------------------------------- + // Functional — iteration & reduction + // --------------------------------------------------------------- + /** - * @param Closure(TKey, TValue):bool $predicate + * @param Closure(TKey, TValue): void $transform * * @return static */ #[Override] - public function filter( Closure $predicate ): static { - $filtered = array_filter( $this->array, function ( $value, $key ) use ( $predicate ) { - return $predicate( $key, $value ); - }, ARRAY_FILTER_USE_BOTH ); - - if ( $this->mutable ) { - $this->array = $filtered; - $this->keys = array_keys( $filtered ); - - return $this; + public function foreach( Closure $transform ): static { + foreach ( $this->data as $key => $value ) { + $transform( $key, $value ); } - return new static( $filtered ); + return $this; } /** - * @param array $array + * Alias for foreach() — side-effect on each entry. + * + * @param Closure(TKey, TValue): void $action * * @return static */ - #[Override] - public function mergeArray( array $array ): static { - $data = array_merge( $array, $this->array ); - - if ( $this->mutable ) { - $this->array = $data; - $this->keys = array_keys( $data ); + public function onEach( Closure $action ): static { + return $this->foreach( $action ); + } - return $this; + #[Override] + public function reduce( Closure $transform, mixed $initial = null ): mixed { + $carry = $initial; + foreach ( $this->data as $value ) { + $carry = $transform( $carry, $value ); } - return new static( $data ); + return $carry; } /** - * @param CollectionInterface $collection - * - * @psalm-suppress PossiblyUnusedReturnValue - * - * @return static + * @param Closure(TKey, TValue): int|float $selector */ - #[Override] - public function merge( CollectionInterface $collection ): static { - return $this->mergeArray( $collection->toArray() ); + public function sumOf( Closure $selector ): int|float { + $sum = 0; + foreach ( $this->data as $key => $value ) { + $sum += $selector( $key, $value ); + } + + return $sum; } /** - * @template TMapKey of array-key - * @template TMapValue - * @param Closure(TKey, TValue): array $transform + * @param Closure(TKey, TValue): mixed $selector * - * @return Kmap + * @return TValue|null */ - #[Override] - public function map( Closure $transform ): Kmap { - $data = []; - - foreach ( $this->array as $key => $value ) { - $entry = $transform( $key, $value ); - $data[ key( $entry ) ] = current( $entry ); + public function minByOrNull( Closure $selector ): mixed { + $minItem = null; + $minSel = null; + foreach ( $this->data as $key => $value ) { + $sel = $selector( $key, $value ); + if ( $minSel === null || $sel < $minSel ) { + $minSel = $sel; + $minItem = $value; + } } - if ( $this->mutable ) { - $this->array = $data; - $this->keys = array_keys( $data ); + return $minItem; + } - return $this; + /** + * @param Closure(TKey, TValue): mixed $selector + * + * @return TValue|null + */ + public function maxByOrNull( Closure $selector ): mixed { + $maxItem = null; + $maxSel = null; + foreach ( $this->data as $key => $value ) { + $sel = $selector( $key, $value ); + if ( $maxSel === null || $sel > $maxSel ) { + $maxSel = $sel; + $maxItem = $value; + } } - return new Kmap( $data ); + return $maxItem; } + // --------------------------------------------------------------- + // Functional — string / merge / flatten + // --------------------------------------------------------------- + /** * @throws Exception */ @@ -257,93 +415,163 @@ public function join( string $separator ): string { throw new Exception( "Cannot join non-string elements" ); } - return join( $separator, $this->array ); + return implode( $separator, $this->data ); } /** - * @param Closure(self):void $predicate + * @param array $array * * @return static */ #[Override] - public function maybe( Closure $predicate ): static { - if ( $this->count() > 0 ) { - $predicate( $this ); - } - - return $this; + public function mergeArray( array $array ): static { + return $this->deriveOrMutate( array_merge( $array, $this->data ) ); } /** + * @param CollectionInterface $collection + * + * @psalm-suppress PossiblyUnusedReturnValue + * * @return static */ #[Override] - public function toMutable(): static { - return $this->mutable ? $this : new static( $this->array, true ); + public function merge( CollectionInterface $collection ): static { + return $this->mergeArray( $collection->toArray() ); + } + + #[Override] + public function flatten(): static { + $result = []; + foreach ( $this->data as $element ) { + if ( is_array( $element ) ) { + $result = array_merge( $result, $element ); + } elseif ( $element instanceof CollectionInterface ) { + $result = array_merge( $result, $element->toArray() ); + } else { + $result[] = $element; + } + } + + return $this->deriveOrMutate( $result ); } + // --------------------------------------------------------------- + // Conditional / conversion + // --------------------------------------------------------------- + /** + * @param Closure(self): void $predicate + * * @return static */ + #[Override] + public function maybe( Closure $predicate ): static { + if ( $this->isNotEmpty() ) { + $predicate( $this ); + } + + return $this; + } + + #[Override] + public function toMutable(): static { + return $this->mutable ? $this : new static( $this->data, true ); + } + #[Override] public function toImmutable(): static { - return ! $this->mutable ? $this : new static( $this->array, false ); + return ! $this->mutable ? $this : new static( $this->data, false ); } + // --------------------------------------------------------------- + // Map-specific lookups + // --------------------------------------------------------------- + /** - * @return static + * Get value by key, or return default. + * + * @param TKey $key + * @param TValue $default + * + * @return TValue */ - #[Override] - public function flatten(): static { - $data = []; + public function getOrDefault( mixed $key, mixed $default ): mixed { + return array_key_exists( $key, $this->data ) ? $this->data[ $key ] : $default; + } - foreach ( $this->array as $element ) { - if ( is_array( $element ) ) { - $data = array_merge( $data, $element ); - } elseif ( $element instanceof CollectionInterface ) { - $data = array_merge( $data, $element->toArray() ); - } else { - $data[] = $element; - } + /** + * Get value by key, or compute and store it (mutable only). + * + * @param TKey $key + * @param Closure(): TValue $defaultValue + * + * @return TValue + * @throws Exception + */ + public function getOrPut( mixed $key, Closure $defaultValue ): mixed { + if ( array_key_exists( $key, $this->data ) ) { + return $this->data[ $key ]; + } + if ( ! $this->mutable ) { + throw new Exception( "Cannot put into immutable map" ); } + $value = $defaultValue(); + $this->data[ $key ] = $value; + $this->invalidateIterator(); - if ( $this->mutable ) { - $this->array = $data; - $this->keys = array_keys( $data ); + return $value; + } - return $this; - } + public function containsKey( mixed $key ): bool { + return array_key_exists( $key, $this->data ); + } - return new static( $data ); + public function containsValue( mixed $value ): bool { + return in_array( $value, $this->data, true ); } /** - * @param Closure(TKey, TValue):void $transform - * - * @return static + * @return Klist */ - #[Override] - public function foreach( Closure $transform ): static { - foreach ( $this->array as $key => $element ) { - $transform( $key, $element ); + public function keys(): Klist { + return new Klist( array_keys( $this->data ) ); + } + + /** + * @return Klist + */ + public function values(): Klist { + return new Klist( array_values( $this->data ) ); + } + + /** + * @return Klist + */ + public function entries(): Klist { + $entries = []; + foreach ( $this->data as $key => $value ) { + $entries[] = [ $key, $value ]; } - return $this; + return new Klist( $entries ); } /** - * @return TValue|null + * @return Klist */ - #[Override] - public function firstOrNull(): mixed { - return $this->array[ $this->keys[0] ?? 0 ] ?? null; + public function toList(): Klist { + return $this->values(); } + // --------------------------------------------------------------- + // Mutation + // --------------------------------------------------------------- + /** * @param TKey $key * @param TValue $element * - * @return void * @throws Exception */ public function add( $key, mixed $element ): void { @@ -351,30 +579,34 @@ public function add( $key, mixed $element ): void { } /** - * Reset keys to numeric indexes (for use after filtering). + * @param TKey $key * - * @return void + * @throws Exception */ - public function resetKeys(): void { - $this->array = array_values( $this->array ); - $this->keys = array_keys( $this->array ); + public function remove( mixed $key ): void { + $this->offsetUnset( $key ); } /** - * Reduce the collection to a single value. - * - * @param Closure(mixed, TValue):mixed $transform - * @param mixed|null $initial - * - * @return mixed + * Reset keys to numeric indexes. */ - #[Override] - public function reduce( Closure $transform, mixed $initial = null ): mixed { - $carry = $initial; - foreach ( $this->array as $element ) { - $carry = $transform( $carry, $element ); + public function resetKeys(): void { + $this->data = array_values( $this->data ); + $this->invalidateIterator(); + } + + // --------------------------------------------------------------- + // Internal + // --------------------------------------------------------------- + + private function deriveOrMutate( array $data ): static { + if ( $this->mutable ) { + $this->data = $data; + $this->invalidateIterator(); + + return $this; } - return $carry; + return new static( $data ); } } diff --git a/src/Container/Annotation/DefaultImplementation.php b/src/Container/Annotation/DefaultImplementation.php new file mode 100644 index 0000000..06556b8 --- /dev/null +++ b/src/Container/Annotation/DefaultImplementation.php @@ -0,0 +1,20 @@ + $className + */ + public function __construct( + public readonly string $className, + ) { + } +} \ No newline at end of file diff --git a/src/Container/Annotation/Inject.php b/src/Container/Annotation/Inject.php index f5d3f0f..2fd9c89 100644 --- a/src/Container/Annotation/Inject.php +++ b/src/Container/Annotation/Inject.php @@ -14,13 +14,13 @@ #[Attribute( Attribute::TARGET_PROPERTY )] class Inject extends Annotation { /** + * @template T * Constructor for the Inject annotation. * - * @param array $args Arguments to be injected into the property. + * @param class-string|null $class */ public function __construct( public readonly ?string $class = null, - public readonly array $args = [], ) { } } diff --git a/src/Container/CircularReferenceGuard.php b/src/Container/CircularReferenceGuard.php new file mode 100644 index 0000000..552cfaa --- /dev/null +++ b/src/Container/CircularReferenceGuard.php @@ -0,0 +1,25 @@ + */ + private array $stack = []; + + public function enter( string $class ): void { + if ( isset( $this->stack[ $class ] ) ) { + throw new CircularReferenceException( $class ); + } + $this->stack[ $class ] = true; + } + + public function leave( string $class ): void { + unset( $this->stack[ $class ] ); + } +} diff --git a/src/Container/Container.php b/src/Container/Container.php index 550bab4..5bd6b46 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -1,295 +1,100 @@ reflect = new ReflectionUtils(); - $this->instances[ ReflectionUtils::class ] = $this->reflect; - - $this->annotationReader = new AnnotationReader( - container: $this, - reflection: $this->reflect, - ); - $this->instances[ AnnotationReader::class ] = $this->annotationReader; - - $this->classBuilder = new ClassBuilder( - reflect: $this->reflect, - reader: $this->annotationReader, - ); - $this->instances[ ClassBuilder::class ] = $this->classBuilder; - $this->instances[ self::class ] = $this; + $this->guard = new CircularReferenceGuard; + $reflect = new ReflectionUtils; + $reader = new AnnotationReader( $reflect ); + $this->resolver = new DependencyResolver( $reflect, $this, $reader ); + $annotationService = new AnnotationService( $reader, $this->resolver ); + $this->builder = new ClassBuilder( $reflect, $annotationService ); + + // preset self + $this->singletons->add( self::class, $this ); } /** - * Adds a class instance to the container. - * - * @param string $class The class name. - * @param object $instance The instance of the class. + * @throws Exception */ - public function addClassInstance( string $class, object $instance ): void { - $this->instances[ $class ] = $instance; + public function addValue( string $key, mixed $value ): void { + $this->values->add( $key, $value ); } /** - * Adds a value (e.g., config or constant) to the container. - * - * @psalm-suppress PossiblyUnusedMethod - * - * @param string $name The name of the value. - * @param mixed $value The value to add. + * @throws Exception */ - public function addValue( string $name, mixed $value ): void { - $this->values[ $this->getValueKey( $name ) ] = $value; + public function addClassInstance( string $class, object $instance ): void { + $this->singletons->add( $class, $instance ); } /** - * 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. + * @throws Exception */ - public function bind( string $classOrInterface, string $class ): void { - $this->bindings[ $classOrInterface ] = $class; + public function bind( string $abstract, string $concrete ): void { + $this->bindings->add( $abstract, $concrete ); } /** - * Retrieves a dependency from the container. - * - * @param string | string $dependencyName The name of the dependency. - * - * @return T| mixed The resolved dependency. - * @throws Exception If the dependency cannot be resolved. + * @template T + * @throws Exception + * @param class-string $id + * @return T|mixed */ - public function get( string $dependencyName ): mixed { - try { - return $this->seekDependency( $dependencyName ); - } catch ( UnresolvedDependencyException ) { - // If not found, attempt autowiring - $class = $this->buildClass( $dependencyName ); - - return $this->autoWire( $class ); + public function get( string $id ): mixed { + // Do we have a singleton for it + if ( isset( $this->singletons[ $id ] ) ) { + return $this->singletons[ $id ]; } - } - /** - * Autowires the class, injecting its dependencies. - * - * @param string $class The class to autowire. - * - * @return object The autowired instance. - * @throws Exception If autowiring fails or circular references are detected. - */ - private function autoWire( string $class ): object { - $this->checkCircularReference( $class ); - $this->addAutoWiring( $class ); - - // Instantiate and inject dependencies - $instance = new $class( ...$this->autoWireConstructorArguments( $class ) ); - $this->applyPropertyInjection( $instance ); - - $this->addClassInstance( $class, $instance ); - $this->removeAutoWiring( $class ); - - return $instance; - } - - /** - * Applies property injection to an instance based on the Inject annotation. - * - * @param object $instance The instance to inject. - * - * @throws Exception If the dependency cannot be resolved. - */ - public function applyPropertyInjection( object $instance ): void { - $propertiesToInject = $this->reflect->getAnnotatedProperties( $instance::class, Inject::class ); - - foreach ( $propertiesToInject as $property ) { - /** @var Inject $annotation */ - $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." ); - } - - $value = new $type( ...$annotation->args ); - } elseif ( ! empty( $annotation->class ) ) { - $value = $this->get( $annotation->class ); - } else { - $value = $this->get( $property->type ); - } - - // Set the property value - $this->reflect->setPropertyValue( $instance, $property->name, $value ); + // Do we have a value stored for it + if ( isset( $this->values[ $id ] ) ) { + return $this->values[ $id ]; } - } - /** - * Resolves constructor arguments via autowiring. - * - * @param string $class The class name. - * - * @return array The resolved constructor arguments. - * @throws Exception If the dependencies cannot be resolved. - */ - private function autoWireConstructorArguments( string $class ): array { - return $this->reflect - ->getConstructorArguments( $class ) - ->map( fn( Argument $arg ) => $this->getFromArgument( $arg ) ) - ->toArray(); - } - - /** - * Resolves the value or service for the given argument. - * - * @param Argument $arg The argument to resolve. - * - * @return mixed The resolved value or dependency. - * @throws Exception If the dependency cannot be resolved. - */ - private function getFromArgument( Argument $arg ): mixed { - return $this->get( in_array( $arg->type, [ 'string', 'int', 'bool' ] ) ? $arg->name : $arg->type ); - } + // Check for a concrete binding or fallback to the id + $type = $this->bindings[ $id ] ?? $id; - /** - * Checks if there is a circular reference during autowiring. - * - * @param string $class The class name. - * - * @throws CircularReferenceException If a circular reference is detected. - */ - private function checkCircularReference( string $class ): void { - if ( isset( $this->autoWiring[ $class ] ) ) { - throw new CircularReferenceException( $class ); + // Check if the resolved type already has a singleton + if ( $type !== $id && isset( $this->singletons[ $type ] ) ) { + return $this->singletons[ $type ]; } - } - - /** - * Seeks and returns the dependency from bindings, instances, or values. - * - * @param string $dependencyName The name of the dependency. - * - * @return mixed The resolved dependency. - * @throws UnresolvedDependencyException If the dependency cannot be resolved. - */ - private function seekDependency( string $dependencyName ): mixed { - $dependencyName = $this->bindings[ $dependencyName ] ?? $dependencyName; - - return $this->instances[ $dependencyName ] - ?? $this->values[ $this->getValueKey( $dependencyName ) ] - ?? throw new UnresolvedDependencyException( $dependencyName ); - } - /** - * Builds a class and binds it to the container. - * - * @param string $dependency The dependency name. - * - * @return string The built class name. - * @throws AutowireDependencyException If the class cannot be built. - */ - private function buildClass( string $dependency ): string { - $dependency = $this->bindings[ $dependency ] ?? $dependency; + // Build and autowire the class try { - $class = $this->classBuilder->build( $dependency ); - $this->bind( $dependency, $class ); - - return $class; - } catch ( ClassAlreadyBuiltException ) { - return $this->bindings[ $dependency ]; - } catch ( ReflectionException $exception ) { - throw new AutowireDependencyException( end( $this->autoWiring ), $dependency, $exception ); + $className = $this->builder->build( $type ); + $this->guard->enter( $className ); + $instance = $this->resolver->autowire( $className ); + $this->resolver->applyPropertyInjection( $instance ); + $this->singletons->add( $id, $instance ); + $this->guard->leave( $className ); + + return $instance; + } catch ( ReflectionException $e ) { + // Map a generic ReflectionException to a more specific one. + throw new AutowireDependencyException( $type, $type, $e ); } } - - /** - * Adds a class to the auto-wiring tracking to detect circular references. - * - * @param string $class The class name. - */ - private function addAutoWiring( string $class ): void { - $this->autoWiring[ $class ] = $class; - } - - /** - * Removes a class from the auto-wiring tracking. - * - * @param string $class The class name. - */ - private function removeAutoWiring( string $class ): void { - unset( $this->autoWiring[ $class ] ); - } - - /** - * Generates a value key for internal value storage. - * - * @param string $name The base name. - * - * @return string The generated value key. - */ - private function getValueKey( string $name ): string { - return "container.value.$name"; - } } diff --git a/src/Container/DependencyResolver.php b/src/Container/DependencyResolver.php new file mode 100644 index 0000000..00f5265 --- /dev/null +++ b/src/Container/DependencyResolver.php @@ -0,0 +1,76 @@ +reflect + ->getConstructorArguments( $class ) + ->map( fn( Argument $arg ) => $this->resolveArgument( $arg ) ) + ->toArray(); + + if ( ! class_exists( $class ) ) { + $defaultImplementation = $this->annotationReader->getClassAnnotations( $class, DefaultImplementation::class )->firstOrNull(); + if ( $defaultImplementation === null ) { + throw new Exception( "Class $class not found and no default implementation provided." ); + } + + return $this->autowire( $defaultImplementation->className ); + } + + return new $class( ...$args ); + } + + /** + * @template T + * @param object $instance + * + * @return T + * @throws ReflectionException + * @throws Exception + */ + public function applyPropertyInjection( object $instance ): object { + foreach ( $this->reflect->getAnnotatedProperties( $instance::class, Inject::class ) as $arg ) { + $annotation = $this->annotationReader->getPropertyAnnotation( $instance::class, $arg->name, Inject::class ); + + $value = $this->container->get( $annotation->class ?? $arg->type ?? $arg->name ); + + $this->reflect->setPropertyValue( $instance, $arg->name, $value ); + } + + return $instance; + } + + /** + * @throws Exception + */ + private function resolveArgument( Argument $arg ): mixed { + $dependencyOrValue = in_array( $arg->type, [ 'string', 'int', 'bool' ] ) ? $arg->name : $arg->type; + + return $this->container->get( $dependencyOrValue ); + } +} diff --git a/src/MethodExecution/Builder/MethodExecutionBuildHandler.php b/src/MethodExecution/Builder/MethodExecutionBuildHandler.php index d5e4531..ca0fcc5 100644 --- a/src/MethodExecution/Builder/MethodExecutionBuildHandler.php +++ b/src/MethodExecution/Builder/MethodExecutionBuildHandler.php @@ -5,7 +5,10 @@ use Axpecto\Annotation\BuildAnnotation; use Axpecto\ClassBuilder\BuildHandler; use Axpecto\ClassBuilder\BuildOutput; +use Axpecto\Code\AnnotationCodeGenerator; use Axpecto\Code\MethodCodeGenerator; +use Axpecto\Reflection\ReflectionUtils; +use Exception; use Override; use ReflectionException; @@ -22,10 +25,14 @@ class MethodExecutionBuildHandler implements BuildHandler { /** * MethodExecutionBuildHandler constructor. * - * @param MethodCodeGenerator $code + * @param MethodCodeGenerator $methodCoder + * @param AnnotationCodeGenerator $annotationCoder + * @param ReflectionUtils $reflection */ public function __construct( - protected readonly MethodCodeGenerator $code, + protected readonly MethodCodeGenerator $methodCoder, + protected readonly AnnotationCodeGenerator $annotationCoder, + protected readonly ReflectionUtils $reflection, ) { } @@ -38,17 +45,27 @@ public function __construct( * @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( BuildAnnotation $annotation, BuildOutput $buildOutput ): void { $class = $annotation->getAnnotatedClass(); $method = $annotation->getAnnotatedMethod(); + $isAbstract = $this->reflection->getClassMethod( $class, $method )->isAbstract(); + + if ( $isAbstract ) { + // Interfaces don't have parent implementations, we add the annotation back and wait for the next build pass. + $annotationCode = $this->annotationCoder->serializeAnnotation( $annotation ); + $buildOutput->annotateMethod( $method, $annotationCode ); + return; + } + // Define properties and add them as injectable dependencies. $buildOutput->injectProperty( self::PROXY_PROPERTY_NAME, MethodExecutionProxy::class ); // Generate the method signature and implementation using reflection. - $methodSignature = $this->code->implementMethodSignature( $class, $method ); + $methodSignature = $this->methodCoder->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. diff --git a/src/MethodExecution/Builder/MethodExecutionProxy.php b/src/MethodExecution/Builder/MethodExecutionProxy.php index 78467f2..afefbe2 100644 --- a/src/MethodExecution/Builder/MethodExecutionProxy.php +++ b/src/MethodExecution/Builder/MethodExecutionProxy.php @@ -2,8 +2,7 @@ namespace Axpecto\MethodExecution\Builder; -use Axpecto\Annotation\Annotation; -use Axpecto\Annotation\AnnotationReader; +use Axpecto\Annotation\AnnotationService; use Axpecto\Annotation\MethodExecutionAnnotation; use Axpecto\MethodExecution\MethodExecutionContext; use Axpecto\Reflection\ReflectionUtils; @@ -23,11 +22,11 @@ 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 AnnotationService $annotationService Reads annotations for the given class and method. */ public function __construct( private readonly ReflectionUtils $reflect, - private readonly AnnotationReader $reader, + private readonly AnnotationService $annotationService, ) { } @@ -54,7 +53,7 @@ public function handle( array $arguments, ): mixed { // Get method annotations - $annotations = $this->reader->getMethodAnnotations( $class, $method, MethodExecutionAnnotation::class ); + $annotations = $this->annotationService->getMethodAnnotations( $class, $method, MethodExecutionAnnotation::class ); // Resolve method arguments using reflection $mappedArguments = $this->reflect->mapValuesToArguments( $class, $method, $arguments ); diff --git a/src/MethodExecution/MethodExecutionContext.php b/src/MethodExecution/MethodExecutionContext.php index 5bd3aef..f3ccfd6 100644 --- a/src/MethodExecution/MethodExecutionContext.php +++ b/src/MethodExecution/MethodExecutionContext.php @@ -36,13 +36,12 @@ public function __construct( } /** - * Get the current annotation being processed. - * - * @psalm-suppress PossiblyUnusedMethod - * - * @return Annotation|null + * @template T of Annotation + * @psalm-param class-string|null $_class the FQCN of the annotation you want + * @psalm-return T|null the current annotation, or null if none/mismatched */ - public function getAnnotation(): ?Annotation { + public function getAnnotation( ?string $_class = null ): ?Annotation { + /** @var T|null */ return $this->currentAnnotation; } diff --git a/src/MethodExecution/MethodExecutionHandler.php b/src/MethodExecution/MethodExecutionHandler.php index 609efde..54c9d8e 100644 --- a/src/MethodExecution/MethodExecutionHandler.php +++ b/src/MethodExecution/MethodExecutionHandler.php @@ -21,9 +21,9 @@ interface MethodExecutionHandler { * based on the annotation and execution context. Subclasses should override this method to * provide custom behavior during method execution. * - * @param MethodExecutionContext $methodExecutionContext The context of the method being executed. + * @param MethodExecutionContext $context The context of the method being executed. * * @return MethodExecutionContext Modified or original method execution context after interception. */ - public function intercept( MethodExecutionContext $methodExecutionContext ): mixed; + public function intercept( MethodExecutionContext $context ): mixed; } diff --git a/src/Reflection/ReflectionUtils.php b/src/Reflection/ReflectionUtils.php index 0107df1..b3267cb 100644 --- a/src/Reflection/ReflectionUtils.php +++ b/src/Reflection/ReflectionUtils.php @@ -12,6 +12,7 @@ use ReflectionMethod; use ReflectionParameter; use ReflectionProperty; +use RuntimeException; /** * ReflectionUtils @@ -72,7 +73,7 @@ public function getMethodAttributes( string $class, string $method ): Klist { public function getAnnotatedMethods( string $class, string $with = Annotation::class ): Klist { return listFrom( $this->getReflectionClass( $class )->getMethods() ) ->filter( fn( ReflectionMethod $m ) => listFrom( $m->getAttributes() ) - ->filter( fn( ReflectionAttribute $attribute ) => $attribute->getName() === $with ) + ->filter( fn( ReflectionAttribute $attribute ) => $attribute->newInstance() instanceof $with ) ->isNotEmpty() ); } @@ -117,10 +118,11 @@ public function getConstructorArguments( string $class ): Klist { } /** - * Fetches properties annotated with a specific annotation and returns an Argument list. + * Fetches all properties (including inherited ones) annotated with a specific annotation, + * and returns an Argument list for each. * - * @param string $class - * @param string $annotationClass + * @param class-string $class The class to inspect. + * @param class-string $annotationClass The annotation you’re looking for. * * @return Klist * @throws ReflectionException @@ -143,8 +145,6 @@ public function getAnnotatedProperties( string $class, string $annotationClass = */ public function setPropertyValue( object $instance, string $property, mixed $value ): void { $reflectionProperty = new ReflectionProperty( $instance, $property ); - /** @psalm-suppress UnusedMethodCall */ - $reflectionProperty->setAccessible( true ); $reflectionProperty->setValue( $instance, $value ); } @@ -244,9 +244,9 @@ private function filterAnnotatedProperties( ReflectionProperty $property, string * * @return Klist */ - private function getAnnotations( Klist $attributes, ?string $target, string $annotationClass ): Klist { + private function getAnnotations( Klist $attributes, ?int $target, string $annotationClass ): Klist { return $attributes - ->filter( fn( ReflectionAttribute $attribute ) => $attribute->getTarget() == $target ) + ->filter( fn( ReflectionAttribute $attribute ) => $attribute->getTarget() === $target ) ->map( fn( ReflectionAttribute $attribute ) => class_exists( $attribute->getName() ) ? $attribute->newInstance() : null ) ->filter( fn( $annotation ) => $annotation instanceof $annotationClass ); } diff --git a/src/Repository/Handler/RepositoryBuildHandler.php b/src/Repository/Handler/RepositoryBuildHandler.php index afafaee..9f45442 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\AnnotationService; use Axpecto\Annotation\BuildAnnotation; use Axpecto\ClassBuilder\BuildHandler; use Axpecto\ClassBuilder\BuildOutput; @@ -35,14 +36,14 @@ * @param MethodCodeGenerator $codeGenerator * @param RepositoryMethodNameParser $methodNameParser * @param EntityMetadataService $metadataService - * @param AnnotationReader $annotationReader + * @param AnnotationService $annotationService */ public function __construct( private ReflectionUtils $reflectionUtils, private MethodCodeGenerator $codeGenerator, private RepositoryMethodNameParser $methodNameParser, private EntityMetadataService $metadataService, - private AnnotationReader $annotationReader, + private AnnotationService $annotationService, ) { } @@ -80,7 +81,7 @@ private function ensureRepositoryAnnotation( BuildAnnotation $ann ): Repository * @throws ReflectionException */ private function fetchEntityMetadata( Repository $repo ): ?EntityAttribute { - return $this->annotationReader + return $this->annotationService ->getClassAnnotations( $repo->entityClass, EntityAttribute::class ) ->firstOrNull(); } @@ -178,7 +179,7 @@ private function generateCriteriaBody( $code .= "\t\treturn \$this->" . self::STORAGE_PROP . "->findAllByCriteria(\$criteria, '$entityClass')\n"; $code .= "\t\t ->map(fn(\$item) => \$this->" . - self::MAPPER_PROP . "->map('$entityClass', \$item));"; + self::MAPPER_PROP . "->mapEntityFromArray('$entityClass', \$item));"; return $code; } diff --git a/src/Storage/Connection/Mysqli/MysqliStatement.php b/src/Storage/Connection/Mysqli/MysqliStatement.php index 7b6f468..9501f82 100644 --- a/src/Storage/Connection/Mysqli/MysqliStatement.php +++ b/src/Storage/Connection/Mysqli/MysqliStatement.php @@ -23,11 +23,10 @@ public function __construct( private readonly mysqli_stmt $stmt ) { public function execute( array $params = [] ): bool { if ( ! empty( $params ) ) { $types = $this->getParamTypes( $params ); - // Prepare an array of references. - $bindParams = []; - $bindParams[] = $types; - foreach ( $params as $value ) { - $bindParams[] = &$value; + $values = array_values( $params ); + $bindParams = [ $types ]; + for ( $i = 0, $count = count( $values ); $i < $count; $i++ ) { + $bindParams[] = &$values[ $i ]; } if ( ! call_user_func_array( [ $this->stmt, 'bind_param' ], $bindParams ) ) { throw new Exception( "Mysqli bind_param failed: " . $this->stmt->error ); diff --git a/src/Storage/Entity/EntityMetadataService.php b/src/Storage/Entity/EntityMetadataService.php index 34a0aea..e9ba794 100644 --- a/src/Storage/Entity/EntityMetadataService.php +++ b/src/Storage/Entity/EntityMetadataService.php @@ -2,7 +2,7 @@ namespace Axpecto\Storage\Entity; -use Axpecto\Annotation\AnnotationReader; +use Axpecto\Annotation\AnnotationService; use Axpecto\Collection\Klist; use Axpecto\Reflection\Dto\Argument; use Axpecto\Reflection\ReflectionUtils; @@ -18,11 +18,11 @@ class EntityMetadataService { * @psalm-suppress PossiblyUnusedMethod * * @param ReflectionUtils $reflectionUtils - * @param AnnotationReader $annotationReader + * @param AnnotationService $annotationService */ public function __construct( private readonly ReflectionUtils $reflectionUtils, - private readonly AnnotationReader $annotationReader, + private readonly AnnotationService $annotationService, ) { } @@ -47,7 +47,7 @@ public function getFields( string $entityClass ): Klist { * @throws Exception */ public function getEntity( string $entityClass ): Entity { - $entityAnnotation = $this->annotationReader + $entityAnnotation = $this->annotationService ->getClassAnnotations( $entityClass, Entity::class ) ->firstOrNull(); @@ -63,7 +63,7 @@ public function getEntity( string $entityClass ): Entity { */ private function mapArgumentToEntityField( Argument $argument, string $entity ): EntityField { /* @var Column $column */ - $column = $this->annotationReader->getParameterAnnotations( + $column = $this->annotationService->getParameterAnnotations( $entity, self::CONSTRUCTOR_METHOD, $argument->name, diff --git a/src/Storage/EntitySchemaGenerator/MySql/MySqlEntitySchemaGenerator.php b/src/Storage/EntitySchemaGenerator/MySql/MySqlEntitySchemaGenerator.php index 2d3b2bf..d8025b7 100644 --- a/src/Storage/EntitySchemaGenerator/MySql/MySqlEntitySchemaGenerator.php +++ b/src/Storage/EntitySchemaGenerator/MySql/MySqlEntitySchemaGenerator.php @@ -39,7 +39,7 @@ public function create( string $entityClass ): void { } } - private function isTableCreated( $tableName ) { + private function isTableCreated( string $tableName ): bool { $sql = "SHOW TABLES LIKE '$tableName'"; $stmt = $this->connection->prepare( $sql ); $stmt->execute(); @@ -47,7 +47,7 @@ private function isTableCreated( $tableName ) { return $stmt->rowCount() > 0; } - public function destroy( string $entityClass ) { + public function destroy( string $entityClass ): void { $entity = $this->metadataService->getEntity( $entityClass ); $stmt = $this->connection->prepare( "DROP TABLE IF EXISTS {$entity->table}" ); diff --git a/src/Telemetry/Annotation/RecordTiming.php b/src/Telemetry/Annotation/RecordTiming.php new file mode 100644 index 0000000..fd614fc --- /dev/null +++ b/src/Telemetry/Annotation/RecordTiming.php @@ -0,0 +1,20 @@ +getAnnotation( RecordTiming::class ); + + if ( ! $annotation->enabled ) { + return $context->proceed(); + } + + $start = microtime( true ); + $result = $context->proceed(); + $end = microtime( true ); + $duration = $end - $start; + + $this->telemetryService->recordTiming( $context->className . '::' . $context->methodName, $duration * 1000, $annotation->labels ); + + return $result; + } +} \ No newline at end of file diff --git a/src/Telemetry/EchoTelemetryService.php b/src/Telemetry/EchoTelemetryService.php new file mode 100644 index 0000000..eac46cb --- /dev/null +++ b/src/Telemetry/EchoTelemetryService.php @@ -0,0 +1,92 @@ +getCurrentTime() . "[Telemetry][Event] $name\n"; + if ( $this->context['verbose'] ?? false ) { + var_dump( $payload ); + } + } + + public function recordCounter( + string $name, + int|float $value = 1, + array $labels = [] + ): void { + echo "[Telemetry][Counter] {$name} += {$value}\n"; + var_dump( $labels ); + } + + public function recordGauge( + string $name, + int|float $value, + array $labels = [] + ): void { + echo "[Telemetry][Gauge] {$name} = {$value}\n"; + var_dump( $labels ); + } + + public function recordTiming( + string $name, + int|float $milliseconds, + array $labels = [] + ): void { + echo $this->getCurrentTime() . "[Telemetry][Timing] {$name} took {$milliseconds} ms\n"; + if ( $this->context['verbose'] ?? false ) { + var_dump( [ 'labels' => $labels, 'context' => $this->context ] ); + } + } + + public function startTrace( string $spanName ): string { + $traceId = uniqid( 'trace_', true ); + echo "[Telemetry][Trace:start] {$spanName} → {$traceId}\n"; + + return $traceId; + } + + public function endTrace( + string $traceId, + bool $success = true, + ?string $error = null + ): void { + $status = $success ? 'success' : 'failure'; + echo "[Telemetry][Trace:end] {$traceId} → {$status}\n"; + if ( $error !== null ) { + echo " Error: {$error}\n"; + } + } + + public function setContext( string $key, string $value ): void { + echo "[Telemetry][Context:set] {$key} = {$value}\n"; + $this->context[ $key ] = $value; + } + + public function unsetContext( string $key ): void { + echo "[Telemetry][Context:unset] {$key}\n"; + unset( $this->context[ $key ] ); + } + + public function flush(): void { + echo "[Telemetry][Flush] all buffered data (no-op)\n"; + } + + private function getCurrentTime(): string { + return "[" . ( new DateTimeImmutable() )->format( 'c' ) . "]"; + } +} \ No newline at end of file diff --git a/src/Telemetry/TelemetryService.php b/src/Telemetry/TelemetryService.php new file mode 100644 index 0000000..ec6b845 --- /dev/null +++ b/src/Telemetry/TelemetryService.php @@ -0,0 +1,128 @@ + $payload Key/value data associated with the event. + * @param DateTimeInterface|null $timestamp When the event occurred (default: now). + * + * @return void + */ + public function recordEvent( + string $name, + array $payload = [], + ?DateTimeInterface $timestamp = null + ): void; + + /** + * Increment (or set) a numeric counter metric. + * + * @param string $name Metric name (e.g. "http_requests_total"). + * @param int|float $value Amount to add (default: 1). + * @param array $labels Labels/dimensions for this metric (e.g. ["method" => "POST"]). + * + * @return void + */ + public function recordCounter( + string $name, + int|float $value = 1, + array $labels = [] + ): void; + + /** + * Record a gauge metric (arbitrary value), e.g. memory usage. + * + * @param string $name Metric name (e.g. "memory_usage_bytes"). + * @param int|float $value Gauge value. + * @param array $labels Labels/dimensions for this metric. + * + * @return void + */ + public function recordGauge( + string $name, + int|float $value, + array $labels = [] + ): void; + + /** + * Record a timing/latency measurement. + * + * @param string $name Metric name (e.g. "db_query_duration_ms"). + * @param int|float $milliseconds Duration in milliseconds. + * @param array $labels Labels/dimensions for this measurement. + * + * @return void + */ + public function recordTiming( + string $name, + int|float $milliseconds, + array $labels = [] + ): void; + + /** + * Begin a named trace/span. + * + * @param string $spanName A human-readable span name (e.g. "http_request"). + * + * @return string A trace/span identifier to pass to `endTrace()`. + */ + public function startTrace( string $spanName ): string; + + /** + * End a previously started trace/span, optionally with success/failure status. + * + * @param string $traceId The ID returned by `startTrace()`. + * @param bool $success Whether the operation succeeded (default: true). + * @param string|null $error Optional error message if `success === false`. + * + * @return void + */ + public function endTrace( + string $traceId, + bool $success = true, + ?string $error = null + ): void; + + /** + * Add a key/value pair to the current telemetry context (tags/labels + * that will be automatically attached to all subsequent events/metrics). + * + * @param string $key + * @param string $value + * + * @return void + */ + public function setContext( string $key, string $value ): void; + + /** + * Remove a key from the current telemetry context. + * + * @param string $key + * + * @return void + */ + public function unsetContext( string $key ): void; + + /** + * Flush any buffered telemetry data to the back-end. + * + * @return void + */ + public function flush(): void; +} \ No newline at end of file diff --git a/tests/Axpecto/Annotation/AnnotationReaderTest.php b/tests/Axpecto/Annotation/AnnotationReaderTest.php index e1d9029..f54fe2a 100644 --- a/tests/Axpecto/Annotation/AnnotationReaderTest.php +++ b/tests/Axpecto/Annotation/AnnotationReaderTest.php @@ -5,7 +5,6 @@ namespace Axpecto\Annotation; use Axpecto\Collection\Klist; -use Axpecto\Container\Container; use Axpecto\Reflection\ReflectionUtils; use PHPUnit\Framework\TestCase; use ReflectionMethod; @@ -19,14 +18,12 @@ class AnnotationReaderTest extends TestCase { private ReflectionUtils $reflect; - private Container $container; private AnnotationReader $reader; protected function setUp(): void { $this->reflect = $this->createMock(ReflectionUtils::class); - $this->container = $this->createMock(Container::class); - $this->reader = new AnnotationReader($this->container, $this->reflect); + $this->reader = new AnnotationReader($this->reflect); } public function testGetClassAnnotationsFiltersByTypeAndInjects(): void @@ -41,12 +38,6 @@ public function testGetClassAnnotationsFiltersByTypeAndInjects(): void ->with($class) ->willReturn(listOf($good, $bad)); - // only $good should be injected - $this->container - ->expects($this->once()) - ->method('applyPropertyInjection') - ->with($good); - $good ->expects($this->once()) ->method('setAnnotatedClass') @@ -72,11 +63,6 @@ public function testGetMethodAnnotationsAddsClassAndMethod(): void ->with($class, $method) ->willReturn(listOf($ann)); - $this->container - ->expects($this->once()) - ->method('applyPropertyInjection') - ->with($ann); - $ann ->expects($this->once()) ->method('setAnnotatedClass') @@ -106,7 +92,6 @@ public function testGetAllAnnotationsMergesClassAndMethods(): void ->with($class) ->willReturn(listOf($c1)); $c1->method('setAnnotatedClass')->willReturnSelf(); - $this->container->method('applyPropertyInjection')->willReturnCallback(fn($a) => $a); // method list $methodRef = new ReflectionMethod(TestSubject::class, 'bar'); diff --git a/tests/Axpecto/ClassBuilder/ClassBuilderTest.php b/tests/Axpecto/ClassBuilder/ClassBuilderTest.php index 4daced3..e1140d5 100644 --- a/tests/Axpecto/ClassBuilder/ClassBuilderTest.php +++ b/tests/Axpecto/ClassBuilder/ClassBuilderTest.php @@ -3,7 +3,7 @@ namespace Axpecto\ClassBuilder\Tests; use Axpecto\Annotation\Annotation; -use Axpecto\Annotation\AnnotationReader; +use Axpecto\Annotation\AnnotationService; use Axpecto\Annotation\BuildAnnotation; use Axpecto\ClassBuilder\BuildHandler; use Axpecto\ClassBuilder\BuildOutput; @@ -17,23 +17,23 @@ class ClassBuilderTest extends TestCase { private ReflectionUtils $reflectionUtilsMock; - private AnnotationReader $annotationReaderMock; + private AnnotationService $annotationServiceMock; private ClassBuilder $classBuilder; protected function setUp(): void { // Create mock objects for dependencies $this->reflectionUtilsMock = $this->createMock( ReflectionUtils::class ); - $this->annotationReaderMock = $this->createMock( AnnotationReader::class ); + $this->annotationServiceMock = $this->createMock( AnnotationService::class ); // Instantiate the ClassBuilder with mocked dependencies - $this->classBuilder = new ClassBuilder( $this->reflectionUtilsMock, $this->annotationReaderMock ); + $this->classBuilder = new ClassBuilder( $this->reflectionUtilsMock, $this->annotationServiceMock ); } public function testBuildReturnsOriginalClassWhenNoAnnotations(): void { $class = 'TestClass'; // Mock the reader to return no annotations - $this->annotationReaderMock + $this->annotationServiceMock ->expects( $this->once() ) ->method( 'getAllAnnotations' ) ->with( $class, BuildAnnotation::class ) @@ -50,7 +50,7 @@ public function testBuildThrowsExceptionIfClassAlreadyBuilt(): void { $class = 'TestClass'; // Set up the ClassBuilder with a previously built class - $this->classBuilder = new ClassBuilder( $this->reflectionUtilsMock, $this->annotationReaderMock, [ $class => 'TestClassProxy' ] ); + $this->classBuilder = new ClassBuilder( $this->reflectionUtilsMock, $this->annotationServiceMock, [ $class => 'TestClassProxy' ] ); $this->expectException( ClassAlreadyBuiltException::class ); $this->expectExceptionMessage( $class ); @@ -72,13 +72,21 @@ public function testBuildGeneratesProxyClass(): void { ->method( 'getBuilder' ) ->willReturn( $builderMock ); - // Mock annotations and builders + // Mock annotations and builders: first call returns annotations, second (for proxy class) returns empty $annotations = new Klist( [ $annotationMock ] ); - $this->annotationReaderMock - ->expects( $this->once() ) + $this->annotationServiceMock ->method( 'getAllAnnotations' ) - ->with( $class, BuildAnnotation::class ) - ->willReturn( $annotations ); + ->willReturnCallback( function ( string $cls, string $annClass ) use ( $class, $annotations ) { + if ( $cls === $class ) { + return $annotations; + } + return emptyList(); + } ); + + // Mock getMethodAnnotations for the method annotation loop + $this->annotationServiceMock + ->method( 'getMethodAnnotations' ) + ->willReturn( emptyList() ); // Expect the builder to be called and add a method/property $builderMock @@ -93,10 +101,10 @@ public function testBuildGeneratesProxyClass(): void { $result = $this->classBuilder->build( $class ); // Assert that a proxy class is generated and returned - $this->assertEquals( 'Axpecto_ClassBuilder_Tests_ClassBuilderTestProxy', $result ); + $this->assertEquals( 'Axpecto_ClassBuilder_Tests_ClassBuilderTest__x0', $result ); } - public function testGenerateProxyClass(): void { + public function testBuildClassGeneratesCorrectClassName(): void { $class = SampleClass::class; $buildOutput = new BuildOutput( $class, @@ -111,15 +119,15 @@ public function testGenerateProxyClass(): void { ->with( $class ) ->willReturn( false ); - // Execute private method 'generateProxyClass' using reflection + // Execute private method 'buildClass' using reflection $reflection = new ReflectionClass( $this->classBuilder ); - $method = $reflection->getMethod( 'generateProxyClass' ); + $method = $reflection->getMethod( 'buildClass' ); $method->setAccessible( true ); - $proxiedClassName = $method->invoke( $this->classBuilder, $class, $buildOutput ); + $proxiedClassName = $method->invoke( $this->classBuilder, $class, $buildOutput, 0 ); // Assert that the generated class name is correct - $this->assertEquals( 'Axpecto_ClassBuilder_Tests_SampleClassProxy', $proxiedClassName ); + $this->assertEquals( 'Axpecto_ClassBuilder_Tests_SampleClass__x0', $proxiedClassName ); } } diff --git a/tests/Axpecto/Code/MethodCodeGeneratorTest.php b/tests/Axpecto/Code/MethodCodeGeneratorTest.php index cac4542..c7d26c9 100644 --- a/tests/Axpecto/Code/MethodCodeGeneratorTest.php +++ b/tests/Axpecto/Code/MethodCodeGeneratorTest.php @@ -17,7 +17,7 @@ protected function setUp(): void { $this->generator = new MethodCodeGenerator( $this->reflectionUtils ); } - public function testThrowsWhenNotAbstractOrIsPrivate(): void { + public function testGeneratesSignatureForConcretePublicMethod(): void { // Prepare a ReflectionMethod for a concrete (non-abstract) public method $rMethod = new ReflectionMethod( TestClassConcrete::class, 'concreteMethod' ); $this->reflectionUtils @@ -26,10 +26,9 @@ public function testThrowsWhenNotAbstractOrIsPrivate(): void { ->with( TestClassConcrete::class, 'concreteMethod' ) ->willReturn( $rMethod ); - $this->expectException( Exception::class ); - $this->expectExceptionMessage( "Can't implement non-abstract or private method " . TestClassConcrete::class . '::concreteMethod()' ); + $sig = $this->generator->implementMethodSignature( TestClassConcrete::class, 'concreteMethod' ); - $this->generator->implementMethodSignature( TestClassConcrete::class, 'concreteMethod' ); + $this->assertSame( 'public function concreteMethod(): void', $sig ); } public function testGeneratesSignatureForPublicAbstractMethodWithTypes(): void { diff --git a/tests/Axpecto/Collection/KlistTest.php b/tests/Axpecto/Collection/KlistTest.php new file mode 100644 index 0000000..d41e085 --- /dev/null +++ b/tests/Axpecto/Collection/KlistTest.php @@ -0,0 +1,819 @@ +filter( function ( $v ) use ( &$callCount ) { + $callCount++; + return $v > 1; + } ) + ->map( fn( $v ) => $v * 10 ); + + $this->assertSame( 0, $callCount, 'Filter must NOT execute before a terminal is called' ); + + // Terminal forces evaluation. + $lazy->toArray(); + $this->assertGreaterThan( 0, $callCount ); + } + + public function testShortCircuitFirstOrNullDoesNotProcessAllElements(): void { + $visited = []; + $list = listOf( 1, 2, 3, 4, 5 ); + + $result = $list + ->filter( function ( $v ) use ( &$visited ) { + $visited[] = $v; + return $v > 2; + } ) + ->firstOrNull(); + + $this->assertSame( 3, $result ); + // Should stop after finding 3 — elements 4 and 5 must not be visited. + $this->assertNotContains( 4, $visited ); + $this->assertNotContains( 5, $visited ); + } + + public function testShortCircuitAnyDoesNotProcessAllElements(): void { + $visited = []; + $list = listOf( 1, 2, 3, 4, 5 ); + + $result = $list + ->map( function ( $v ) use ( &$visited ) { + $visited[] = $v; + return $v; + } ) + ->any( fn( $v ) => $v === 2 ); + + $this->assertTrue( $result ); + $this->assertNotContains( 3, $visited ); + } + + // =============================================================== + // flatMap + // =============================================================== + + public function testFlatMap(): void { + $result = listOf( 1, 2, 3 ) + ->flatMap( fn( $v ) => [ $v, $v * 10 ] ) + ->toArray(); + + $this->assertSame( [ 1, 10, 2, 20, 3, 30 ], $result ); + } + + public function testFlatMapWithCollectionReturn(): void { + $result = listOf( 1, 2 ) + ->flatMap( fn( $v ) => listOf( $v, $v + 10 ) ) + ->toArray(); + + $this->assertSame( [ 1, 11, 2, 12 ], $result ); + } + + // =============================================================== + // mapNotNull + // =============================================================== + + public function testMapNotNull(): void { + $result = listOf( 1, 2, 3, 4 ) + ->mapNotNull( fn( $v ) => $v % 2 === 0 ? $v * 10 : null ) + ->toArray(); + + $this->assertSame( [ 20, 40 ], $result ); + } + + // =============================================================== + // first / last / single + // =============================================================== + + public function testFirst(): void { + $this->assertSame( 'a', listOf( 'a', 'b', 'c' )->first() ); + } + + public function testFirstThrowsOnEmpty(): void { + $this->expectException( Exception::class ); + emptyList()->first(); + } + + public function testFirstOrNullReturnsNullOnEmpty(): void { + $this->assertNull( emptyList()->firstOrNull() ); + } + + public function testFirstOrNullReturnFirstElement(): void { + $this->assertSame( 42, listOf( 42, 99 )->firstOrNull() ); + } + + public function testLast(): void { + $this->assertSame( 'c', listOf( 'a', 'b', 'c' )->last() ); + } + + public function testLastThrowsOnEmpty(): void { + $this->expectException( Exception::class ); + emptyList()->last(); + } + + public function testLastOrNull(): void { + $this->assertSame( 3, listOf( 1, 2, 3 )->lastOrNull() ); + } + + public function testLastOrNullReturnsNullOnEmpty(): void { + $this->assertNull( emptyList()->lastOrNull() ); + } + + public function testSingle(): void { + $this->assertSame( 7, listOf( 7 )->single() ); + } + + public function testSingleThrowsOnEmpty(): void { + $this->expectException( Exception::class ); + emptyList()->single(); + } + + public function testSingleThrowsOnMultipleElements(): void { + $this->expectException( Exception::class ); + listOf( 1, 2 )->single(); + } + + public function testSingleOrNull(): void { + $this->assertSame( 7, listOf( 7 )->singleOrNull() ); + } + + public function testSingleOrNullReturnsNullOnEmpty(): void { + $this->assertNull( emptyList()->singleOrNull() ); + } + + public function testSingleOrNullReturnsNullOnMultiple(): void { + $this->assertNull( listOf( 1, 2 )->singleOrNull() ); + } + + // =============================================================== + // take / drop + // =============================================================== + + public function testTake(): void { + $this->assertSame( [ 1, 2 ], listOf( 1, 2, 3, 4, 5 )->take( 2 )->toArray() ); + } + + public function testTakeMoreThanSize(): void { + $this->assertSame( [ 1, 2 ], listOf( 1, 2 )->take( 10 )->toArray() ); + } + + public function testDrop(): void { + $this->assertSame( [ 3, 4, 5 ], listOf( 1, 2, 3, 4, 5 )->drop( 2 )->toArray() ); + } + + public function testDropMoreThanSize(): void { + $this->assertSame( [], listOf( 1, 2 )->drop( 10 )->toArray() ); + } + + // =============================================================== + // distinct / distinctBy + // =============================================================== + + public function testDistinct(): void { + $this->assertSame( [ 1, 2, 3 ], listOf( 1, 2, 2, 3, 1 )->distinct()->toArray() ); + } + + public function testDistinctBy(): void { + $result = listOf( 'apple', 'avocado', 'banana', 'blueberry' ) + ->distinctBy( fn( $s ) => $s[0] ) + ->toArray(); + + $this->assertSame( [ 'apple', 'banana' ], $result ); + } + + // =============================================================== + // sortedBy / sortedByDescending / reversed + // =============================================================== + + public function testSortedBy(): void { + $result = listOf( 3, 1, 2 )->sortedBy( fn( $v ) => $v )->toArray(); + $this->assertSame( [ 1, 2, 3 ], $result ); + } + + public function testSortedByDescending(): void { + $result = listOf( 3, 1, 2 )->sortedByDescending( fn( $v ) => $v )->toArray(); + $this->assertSame( [ 3, 2, 1 ], $result ); + } + + public function testReversed(): void { + $this->assertSame( [ 3, 2, 1 ], listOf( 1, 2, 3 )->reversed()->toArray() ); + } + + // =============================================================== + // onEach (lazy side-effect) + // =============================================================== + + public function testOnEachIsLazy(): void { + $seen = []; + $lazy = listOf( 1, 2, 3 )->onEach( function ( $v ) use ( &$seen ) { + $seen[] = $v; + } ); + + $this->assertSame( [], $seen, 'onEach must not execute before terminal' ); + + $lazy->toArray(); + $this->assertSame( [ 1, 2, 3 ], $seen ); + } + + // =============================================================== + // chunked + // =============================================================== + + public function testChunked(): void { + $result = listOf( 1, 2, 3, 4, 5 )->chunked( 2 )->toArray(); + $this->assertSame( [ [ 1, 2 ], [ 3, 4 ], [ 5 ] ], $result ); + } + + // =============================================================== + // none / contains / indexOf / indexOfFirst + // =============================================================== + + public function testNone(): void { + $this->assertTrue( listOf( 1, 2, 3 )->none( fn( $v ) => $v > 10 ) ); + $this->assertFalse( listOf( 1, 2, 3 )->none( fn( $v ) => $v === 2 ) ); + } + + public function testContains(): void { + $list = listOf( 'a', 'b', 'c' ); + $this->assertTrue( $list->contains( 'b' ) ); + $this->assertFalse( $list->contains( 'z' ) ); + } + + public function testContainsUsesStrictComparison(): void { + $this->assertFalse( listOf( 1, 2, 3 )->contains( '1' ) ); + } + + public function testIndexOf(): void { + $this->assertSame( 1, listOf( 'a', 'b', 'c' )->indexOf( 'b' ) ); + } + + public function testIndexOfNotFound(): void { + $this->assertSame( -1, listOf( 'a', 'b' )->indexOf( 'z' ) ); + } + + public function testIndexOfFirst(): void { + $this->assertSame( 2, listOf( 1, 2, 3, 4 )->indexOfFirst( fn( $v ) => $v > 2 ) ); + } + + public function testIndexOfFirstNotFound(): void { + $this->assertSame( -1, listOf( 1, 2 )->indexOfFirst( fn( $v ) => $v > 100 ) ); + } + + // =============================================================== + // fold / reduce / sumOf + // =============================================================== + + public function testFold(): void { + $result = listOf( 1, 2, 3 )->fold( 10, fn( $acc, $v ) => $acc + $v ); + $this->assertSame( 16, $result ); + } + + public function testReduce(): void { + $result = listOf( 1, 2, 3 )->reduce( fn( $carry, $v ) => ( $carry ?? 0 ) + $v ); + $this->assertSame( 6, $result ); + } + + public function testReduceWithInitial(): void { + $result = listOf( 1, 2, 3 )->reduce( fn( $carry, $v ) => $carry + $v, 100 ); + $this->assertSame( 106, $result ); + } + + public function testSumOf(): void { + $this->assertSame( 14, listOf( 1, 2, 3, 4 )->sumOf( fn( $v ) => $v + $v / $v ) ); + } + + public function testSumOfFloat(): void { + $result = listOf( 1.5, 2.5 )->sumOf( fn( $v ) => $v ); + $this->assertSame( 4.0, $result ); + } + + // =============================================================== + // minByOrNull / maxByOrNull + // =============================================================== + + public function testMinByOrNull(): void { + $result = listOf( 'banana', 'a', 'cherry' )->minByOrNull( fn( $v ) => strlen( $v ) ); + $this->assertSame( 'a', $result ); + } + + public function testMinByOrNullOnEmpty(): void { + $this->assertNull( emptyList()->minByOrNull( fn( $v ) => $v ) ); + } + + public function testMaxByOrNull(): void { + $result = listOf( 'banana', 'a', 'kiwifruit' )->maxByOrNull( fn( $v ) => strlen( $v ) ); + $this->assertSame( 'kiwifruit', $result ); + } + + public function testMaxByOrNullOnEmpty(): void { + $this->assertNull( emptyList()->maxByOrNull( fn( $v ) => $v ) ); + } + + // =============================================================== + // partition + // =============================================================== + + public function testPartition(): void { + [ $evens, $odds ] = listOf( 1, 2, 3, 4, 5 )->partition( fn( $v ) => $v % 2 === 0 ); + + $this->assertSame( [ 2, 4 ], $evens->toArray() ); + $this->assertSame( [ 1, 3, 5 ], $odds->toArray() ); + } + + // =============================================================== + // groupBy + // =============================================================== + + public function testGroupBy(): void { + $groups = listOf( 'one', 'two', 'three', 'four' ) + ->groupBy( fn( $s ) => strlen( $s ) ); + + $this->assertInstanceOf( Kmap::class, $groups ); + $this->assertSame( [ 'one', 'two' ], $groups[3]->toArray() ); + $this->assertSame( [ 'three' ], $groups[5]->toArray() ); + $this->assertSame( [ 'four' ], $groups[4]->toArray() ); + } + + // =============================================================== + // associate / associateBy / mapOf + // =============================================================== + + public function testAssociate(): void { + $map = listOf( 'a', 'bb', 'ccc' ) + ->associate( fn( $v ) => [ $v => strlen( $v ) ] ); + + $this->assertInstanceOf( Kmap::class, $map ); + $this->assertSame( [ 'a' => 1, 'bb' => 2, 'ccc' => 3 ], $map->toArray() ); + } + + public function testAssociateBy(): void { + $map = listOf( 'a', 'bb', 'ccc' ) + ->associateBy( fn( $v ) => strlen( $v ) ); + + $this->assertSame( [ 1 => 'a', 2 => 'bb', 3 => 'ccc' ], $map->toArray() ); + } + + public function testMapOfIsAliasForAssociate(): void { + $map = listOf( 'x', 'yy' )->mapOf( fn( $v ) => [ $v => strlen( $v ) ] ); + + $this->assertInstanceOf( Kmap::class, $map ); + $this->assertSame( [ 'x' => 1, 'yy' => 2 ], $map->toArray() ); + } + + // =============================================================== + // zip + // =============================================================== + + public function testZip(): void { + $result = listOf( 'a', 'b', 'c' )->zip( [ 1, 2, 3 ] )->toArray(); + $this->assertSame( [ [ 'a', 1 ], [ 'b', 2 ], [ 'c', 3 ] ], $result ); + } + + public function testZipWithUnequalLengths(): void { + $result = listOf( 'a', 'b' )->zip( [ 1, 2, 3, 4 ] )->toArray(); + $this->assertSame( [ [ 'a', 1 ], [ 'b', 2 ] ], $result ); + } + + public function testZipWithKlist(): void { + $result = listOf( 1, 2 )->zip( listOf( 'x', 'y' ) )->toArray(); + $this->assertSame( [ [ 1, 'x' ], [ 2, 'y' ] ], $result ); + } + + // =============================================================== + // filter / filterNotNull / map / flatten (existing methods) + // =============================================================== + + public function testFilter(): void { + $result = listOf( 1, 2, 3, 4 )->filter( fn( $v ) => $v > 2 )->toArray(); + $this->assertSame( [ 3, 4 ], $result ); + } + + public function testFilterNotNull(): void { + $result = listOf( 1, null, 2, null, 3 )->filterNotNull()->toArray(); + $this->assertSame( [ 1, 2, 3 ], $result ); + } + + public function testMap(): void { + $result = listOf( 1, 2, 3 )->map( fn( $v ) => $v * 2 )->toArray(); + $this->assertSame( [ 2, 4, 6 ], $result ); + } + + public function testFlatten(): void { + $result = listOf( [ 1, 2 ], [ 3, 4 ], [ 5 ] )->flatten()->toArray(); + $this->assertSame( [ 1, 2, 3, 4, 5 ], $result ); + } + + public function testFlattenWithCollections(): void { + $result = listOf( listOf( 1, 2 ), listOf( 3 ) )->flatten()->toArray(); + $this->assertSame( [ 1, 2, 3 ], $result ); + } + + public function testFlattenNonArrayElementsPassThrough(): void { + $result = listOf( 'a', [ 'b', 'c' ], 'd' )->flatten()->toArray(); + $this->assertSame( [ 'a', 'b', 'c', 'd' ], $result ); + } + + // =============================================================== + // join + // =============================================================== + + public function testJoin(): void { + $this->assertSame( 'a,b,c', listOf( 'a', 'b', 'c' )->join( ',' ) ); + } + + public function testJoinEmpty(): void { + $this->assertSame( '', emptyList()->join( ',' ) ); + } + + // =============================================================== + // any / all + // =============================================================== + + public function testAny(): void { + $this->assertTrue( listOf( 1, 2, 3 )->any( fn( $v ) => $v === 2 ) ); + $this->assertFalse( listOf( 1, 2, 3 )->any( fn( $v ) => $v === 99 ) ); + } + + public function testAll(): void { + $this->assertTrue( listOf( 2, 4, 6 )->all( fn( $v ) => $v % 2 === 0 ) ); + $this->assertFalse( listOf( 2, 3, 6 )->all( fn( $v ) => $v % 2 === 0 ) ); + } + + // =============================================================== + // foreach (terminal) + // =============================================================== + + public function testForeach(): void { + $collected = []; + $returned = listOf( 10, 20, 30 )->foreach( function ( $v ) use ( &$collected ) { + $collected[] = $v; + } ); + + $this->assertSame( [ 10, 20, 30 ], $collected ); + $this->assertInstanceOf( Klist::class, $returned ); + } + + // =============================================================== + // merge / mergeArray + // =============================================================== + + public function testMerge(): void { + $a = listOf( 1, 2 ); + $b = listOf( 3, 4 ); + + $this->assertSame( [ 1, 2, 3, 4 ], $a->merge( $b )->toArray() ); + } + + public function testMergeArray(): void { + $this->assertSame( [ 1, 2, 3, 4 ], listOf( 1, 2 )->mergeArray( [ 3, 4 ] )->toArray() ); + } + + public function testMergeReturnsNewInstanceForImmutable(): void { + $original = listOf( 1, 2 ); + $merged = $original->mergeArray( [ 3 ] ); + + $this->assertSame( [ 1, 2 ], $original->toArray() ); + $this->assertSame( [ 1, 2, 3 ], $merged->toArray() ); + } + + // =============================================================== + // isEmpty / isNotEmpty / count / toArray + // =============================================================== + + public function testIsEmpty(): void { + $this->assertTrue( emptyList()->isEmpty() ); + $this->assertFalse( listOf( 1 )->isEmpty() ); + } + + public function testIsNotEmpty(): void { + $this->assertTrue( listOf( 1 )->isNotEmpty() ); + $this->assertFalse( emptyList()->isNotEmpty() ); + } + + public function testCount(): void { + $this->assertSame( 0, emptyList()->count() ); + $this->assertSame( 3, listOf( 'a', 'b', 'c' )->count() ); + } + + public function testToArray(): void { + $this->assertSame( [ 1, 2, 3 ], listOf( 1, 2, 3 )->toArray() ); + } + + public function testToArrayReindexes(): void { + // Source built from assoc array reindexes to 0-based. + $list = listFrom( [ 5 => 'a', 10 => 'b' ] ); + $this->assertSame( [ 'a', 'b' ], $list->toArray() ); + } + + // =============================================================== + // maybe + // =============================================================== + + public function testMaybeCallsPredicateWhenNotEmpty(): void { + $called = false; + listOf( 1 )->maybe( function () use ( &$called ) { + $called = true; + } ); + $this->assertTrue( $called ); + } + + public function testMaybeDoesNotCallPredicateWhenEmpty(): void { + $called = false; + emptyList()->maybe( function () use ( &$called ) { + $called = true; + } ); + $this->assertFalse( $called ); + } + + // =============================================================== + // toMutable / toImmutable + // =============================================================== + + public function testToMutableAndToImmutable(): void { + $immutable = listOf( 1, 2 ); + $mutable = $immutable->toMutable(); + + // Mutable allows add. + $mutable->add( 3 ); + $this->assertSame( [ 1, 2, 3 ], $mutable->toArray() ); + + // Original unchanged. + $this->assertSame( [ 1, 2 ], $immutable->toArray() ); + + // Convert back to immutable. + $back = $mutable->toImmutable(); + $this->expectException( Exception::class ); + $back->add( 4 ); + } + + public function testToMutableReturnsSelfWhenAlreadyMutable(): void { + $mutable = mutableListOf( 1 ); + $this->assertSame( $mutable, $mutable->toMutable() ); + } + + public function testToImmutableReturnsSelfWhenAlreadyImmutable(): void { + $immutable = listOf( 1 ); + $this->assertSame( $immutable, $immutable->toImmutable() ); + } + + // =============================================================== + // Iterator: current / next / nextAndGet / key / valid / rewind + // =============================================================== + + public function testIterator(): void { + $list = listOf( 'a', 'b', 'c' ); + $iterated = []; + foreach ( $list as $key => $value ) { + $iterated[ $key ] = $value; + } + + $this->assertSame( [ 0 => 'a', 1 => 'b', 2 => 'c' ], $iterated ); + } + + public function testNextAndGet(): void { + $list = listOf( 10, 20, 30 ); + $this->assertSame( 10, $list->nextAndGet() ); + $this->assertSame( 20, $list->nextAndGet() ); + $this->assertSame( 30, $list->nextAndGet() ); + $this->assertNull( $list->nextAndGet() ); + } + + public function testRewind(): void { + $list = listOf( 1, 2 ); + $list->nextAndGet(); + $list->nextAndGet(); + $list->rewind(); + $this->assertSame( 1, $list->current() ); + } + + // =============================================================== + // ArrayAccess + // =============================================================== + + public function testOffsetExists(): void { + $list = listOf( 'a', 'b' ); + $this->assertTrue( isset( $list[0] ) ); + $this->assertTrue( isset( $list[1] ) ); + $this->assertFalse( isset( $list[2] ) ); + } + + public function testOffsetGet(): void { + $list = listOf( 'x', 'y' ); + $this->assertSame( 'x', $list[0] ); + $this->assertSame( 'y', $list[1] ); + } + + // =============================================================== + // Mutable mode: add / offsetSet / offsetUnset + // =============================================================== + + public function testAddThrowsOnImmutable(): void { + $this->expectException( Exception::class ); + listOf( 1 )->add( 2 ); + } + + public function testMutableAdd(): void { + $list = mutableListOf( 1, 2 ); + $list->add( 3 ); + $this->assertSame( [ 1, 2, 3 ], $list->toArray() ); + } + + public function testMutableOffsetSetAppend(): void { + $list = mutableListOf( 'a' ); + $list[] = 'b'; + $this->assertSame( [ 'a', 'b' ], $list->toArray() ); + } + + public function testMutableOffsetSetByIndex(): void { + $list = mutableListOf( 'a', 'b', 'c' ); + $list[1] = 'B'; + $this->assertSame( [ 'a', 'B', 'c' ], $list->toArray() ); + } + + public function testOffsetSetThrowsOnImmutable(): void { + $this->expectException( Exception::class ); + $list = listOf( 1 ); + $list[0] = 99; + } + + public function testMutableOffsetUnset(): void { + $list = mutableListOf( 'a', 'b', 'c' ); + unset( $list[1] ); + // After unset, array is re-indexed. + $this->assertSame( [ 'a', 'c' ], $list->toArray() ); + } + + public function testOffsetUnsetThrowsOnImmutable(): void { + $this->expectException( Exception::class ); + $list = listOf( 1, 2 ); + unset( $list[0] ); + } + + // =============================================================== + // Mutable eagerly materializes + // =============================================================== + + public function testMutableOperationsAreEager(): void { + $count = 0; + $list = mutableListOf( 1, 2, 3 ); + $list->filter( function ( $v ) use ( &$count ) { + $count++; + return $v > 1; + } ); + + // Mutable applies immediately — count should be >0. + $this->assertGreaterThan( 0, $count ); + } + + public function testMutableMergeArrayMutatesInPlace(): void { + $list = mutableListOf( 1, 2 ); + $same = $list->mergeArray( [ 3, 4 ] ); + $this->assertSame( $list, $same ); + $this->assertSame( [ 1, 2, 3, 4 ], $list->toArray() ); + } + + // =============================================================== + // Edge cases + // =============================================================== + + public function testEmptyListEdgeCases(): void { + $empty = emptyList(); + + $this->assertSame( [], $empty->toArray() ); + $this->assertSame( 0, $empty->count() ); + $this->assertTrue( $empty->isEmpty() ); + $this->assertFalse( $empty->isNotEmpty() ); + $this->assertNull( $empty->firstOrNull() ); + $this->assertNull( $empty->lastOrNull() ); + $this->assertNull( $empty->singleOrNull() ); + $this->assertFalse( $empty->any( fn( $v ) => true ) ); + $this->assertTrue( $empty->all( fn( $v ) => false ) ); + $this->assertTrue( $empty->none( fn( $v ) => true ) ); + $this->assertSame( '', $empty->join( ',' ) ); + $this->assertSame( [], $empty->filter( fn( $v ) => true )->toArray() ); + $this->assertSame( [], $empty->map( fn( $v ) => $v )->toArray() ); + $this->assertSame( -1, $empty->indexOf( 'x' ) ); + $this->assertSame( -1, $empty->indexOfFirst( fn( $v ) => true ) ); + $this->assertFalse( $empty->contains( 1 ) ); + } + + public function testSingleElementList(): void { + $list = listOf( 42 ); + + $this->assertSame( 1, $list->count() ); + $this->assertSame( 42, $list->first() ); + $this->assertSame( 42, $list->last() ); + $this->assertSame( 42, $list->single() ); + $this->assertSame( 42, $list->firstOrNull() ); + $this->assertSame( 42, $list->lastOrNull() ); + $this->assertSame( 42, $list->singleOrNull() ); + $this->assertSame( 0, $list->indexOf( 42 ) ); + } + + public function testNullElementsArePreserved(): void { + $list = listOf( null, 1, null, 2 ); + $this->assertSame( [ null, 1, null, 2 ], $list->toArray() ); + $this->assertSame( 4, $list->count() ); + $this->assertNull( $list->firstOrNull() ); + } + + // =============================================================== + // Pipeline chaining combinations + // =============================================================== + + public function testComplexPipelineChain(): void { + $result = listOf( 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ) + ->filter( fn( $v ) => $v % 2 === 0 ) + ->map( fn( $v ) => $v * 3 ) + ->take( 3 ) + ->reversed() + ->toArray(); + + $this->assertSame( [ 18, 12, 6 ], $result ); + } + + public function testFilterMapFirstOrNullShortCircuit(): void { + $result = listOf( 1, 2, 3, 4 ) + ->filter( fn( $v ) => $v > 2 ) + ->map( fn( $v ) => $v * 100 ) + ->firstOrNull(); + + $this->assertSame( 300, $result ); + } + + // =============================================================== + // JsonSerializable + // =============================================================== + + public function testJsonSerialize(): void { + $json = json_encode( listOf( 1, 2, 3 ) ); + $this->assertSame( '[1,2,3]', $json ); + } + + // =============================================================== + // Helper functions + // =============================================================== + + public function testListOfHelper(): void { + $list = listOf( 'a', 'b' ); + $this->assertInstanceOf( Klist::class, $list ); + $this->assertSame( [ 'a', 'b' ], $list->toArray() ); + } + + public function testListFromHelper(): void { + $list = listFrom( [ 'x', 'y' ] ); + $this->assertInstanceOf( Klist::class, $list ); + $this->assertSame( [ 'x', 'y' ], $list->toArray() ); + } + + public function testEmptyListHelper(): void { + $list = emptyList(); + $this->assertInstanceOf( Klist::class, $list ); + $this->assertTrue( $list->isEmpty() ); + } + + public function testMutableListOfHelper(): void { + $list = mutableListOf( 1, 2 ); + $list->add( 3 ); + $this->assertSame( [ 1, 2, 3 ], $list->toArray() ); + } + + public function testMutableListFromHelper(): void { + $list = mutableListFrom( [ 'a', 'b' ] ); + $list->add( 'c' ); + $this->assertSame( [ 'a', 'b', 'c' ], $list->toArray() ); + } + + public function testMutableEmptyListHelper(): void { + $list = mutableEmptyList(); + $this->assertTrue( $list->isEmpty() ); + $list->add( 1 ); + $this->assertSame( [ 1 ], $list->toArray() ); + } + + // =============================================================== + // isEmpty short-circuit with fuseable pipeline + // =============================================================== + + public function testIsEmptyWithFuseablePipeline(): void { + $this->assertTrue( + listOf( 1, 2, 3 )->filter( fn( $v ) => $v > 100 )->isEmpty() + ); + $this->assertFalse( + listOf( 1, 2, 3 )->filter( fn( $v ) => $v > 1 )->isEmpty() + ); + } +} diff --git a/tests/Axpecto/Collection/KmapTest.php b/tests/Axpecto/Collection/KmapTest.php new file mode 100644 index 0000000..06d2f59 --- /dev/null +++ b/tests/Axpecto/Collection/KmapTest.php @@ -0,0 +1,733 @@ + 1, 'b' => 2 ] ); + $this->assertSame( [ 'a' => 1, 'b' => 2 ], $map->toArray() ); + } + + public function testIsEmpty(): void { + $this->assertTrue( emptyMap()->isEmpty() ); + $this->assertFalse( mapOf( [ 'x' => 1 ] )->isEmpty() ); + } + + public function testIsNotEmpty(): void { + $this->assertTrue( mapOf( [ 'x' => 1 ] )->isNotEmpty() ); + $this->assertFalse( emptyMap()->isNotEmpty() ); + } + + public function testCount(): void { + $this->assertSame( 0, emptyMap()->count() ); + $this->assertSame( 3, mapOf( [ 'a' => 1, 'b' => 2, 'c' => 3 ] )->count() ); + } + + public function testFirstOrNull(): void { + $this->assertNull( emptyMap()->firstOrNull() ); + $this->assertSame( 10, mapOf( [ 'a' => 10, 'b' => 20 ] )->firstOrNull() ); + } + + // =============================================================== + // map — preserves keys + // =============================================================== + + public function testMapPreservesKeys(): void { + $result = mapOf( [ 'a' => 1, 'b' => 2, 'c' => 3 ] ) + ->map( fn( $k, $v ) => $v * 10 ) + ->toArray(); + + $this->assertSame( [ 'a' => 10, 'b' => 20, 'c' => 30 ], $result ); + } + + public function testMapReceivesKeyAndValue(): void { + $result = mapOf( [ 'x' => 5 ] ) + ->map( fn( $k, $v ) => "$k=$v" ) + ->toArray(); + + $this->assertSame( [ 'x' => 'x=5' ], $result ); + } + + // =============================================================== + // mapValues / mapKeys + // =============================================================== + + public function testMapValuesIsAliasForMap(): void { + $result = mapOf( [ 'a' => 1, 'b' => 2 ] ) + ->mapValues( fn( $k, $v ) => $v + 100 ) + ->toArray(); + + $this->assertSame( [ 'a' => 101, 'b' => 102 ], $result ); + } + + public function testMapKeys(): void { + $result = mapOf( [ 'a' => 1, 'b' => 2 ] ) + ->mapKeys( fn( $k, $v ) => strtoupper( $k ) ) + ->toArray(); + + $this->assertSame( [ 'A' => 1, 'B' => 2 ], $result ); + } + + public function testMapKeysReceivesKeyAndValue(): void { + $result = mapOf( [ 'x' => 10 ] ) + ->mapKeys( fn( $k, $v ) => $k . $v ) + ->toArray(); + + $this->assertSame( [ 'x10' => 10 ], $result ); + } + + // =============================================================== + // filter / filterNotNull / filterKeys / filterValues + // =============================================================== + + public function testFilterReceivesKeyAndValue(): void { + $result = mapOf( [ 'a' => 1, 'b' => 2, 'c' => 3 ] ) + ->filter( fn( $k, $v ) => $v > 1 ) + ->toArray(); + + $this->assertSame( [ 'b' => 2, 'c' => 3 ], $result ); + } + + public function testFilterByKey(): void { + $result = mapOf( [ 'a' => 1, 'b' => 2, 'c' => 3 ] ) + ->filter( fn( $k, $v ) => $k !== 'b' ) + ->toArray(); + + $this->assertSame( [ 'a' => 1, 'c' => 3 ], $result ); + } + + public function testFilterNotNull(): void { + $result = mapOf( [ 'a' => 1, 'b' => null, 'c' => 3, 'd' => null ] ) + ->filterNotNull() + ->toArray(); + + $this->assertSame( [ 'a' => 1, 'c' => 3 ], $result ); + } + + public function testFilterKeys(): void { + $result = mapOf( [ 'apple' => 1, 'banana' => 2, 'avocado' => 3 ] ) + ->filterKeys( fn( $k ) => str_starts_with( $k, 'a' ) ) + ->toArray(); + + $this->assertSame( [ 'apple' => 1, 'avocado' => 3 ], $result ); + } + + public function testFilterValues(): void { + $result = mapOf( [ 'a' => 1, 'b' => 20, 'c' => 3, 'd' => 40 ] ) + ->filterValues( fn( $v ) => $v >= 10 ) + ->toArray(); + + $this->assertSame( [ 'b' => 20, 'd' => 40 ], $result ); + } + + // =============================================================== + // any / all / none + // =============================================================== + + public function testAny(): void { + $this->assertTrue( mapOf( [ 'a' => 1, 'b' => 2 ] )->any( fn( $v ) => $v === 2 ) ); + $this->assertFalse( mapOf( [ 'a' => 1, 'b' => 2 ] )->any( fn( $v ) => $v === 99 ) ); + } + + public function testAnyOnEmpty(): void { + $this->assertFalse( emptyMap()->any( fn( $v ) => true ) ); + } + + public function testAll(): void { + $this->assertTrue( mapOf( [ 'a' => 2, 'b' => 4 ] )->all( fn( $v ) => $v % 2 === 0 ) ); + $this->assertFalse( mapOf( [ 'a' => 2, 'b' => 3 ] )->all( fn( $v ) => $v % 2 === 0 ) ); + } + + public function testAllOnEmpty(): void { + $this->assertTrue( emptyMap()->all( fn( $v ) => false ) ); + } + + public function testNone(): void { + $this->assertTrue( mapOf( [ 'a' => 1, 'b' => 2 ] )->none( fn( $v ) => $v > 10 ) ); + $this->assertFalse( mapOf( [ 'a' => 1, 'b' => 2 ] )->none( fn( $v ) => $v === 1 ) ); + } + + // =============================================================== + // foreach / onEach + // =============================================================== + + public function testForeach(): void { + $collected = []; + $map = mapOf( [ 'a' => 1, 'b' => 2 ] ); + $returned = $map->foreach( function ( $k, $v ) use ( &$collected ) { + $collected[ $k ] = $v; + } ); + + $this->assertSame( [ 'a' => 1, 'b' => 2 ], $collected ); + $this->assertSame( $map, $returned ); + } + + public function testOnEachIsAliasForForeach(): void { + $collected = []; + mapOf( [ 'x' => 10 ] )->onEach( function ( $k, $v ) use ( &$collected ) { + $collected[ $k ] = $v; + } ); + + $this->assertSame( [ 'x' => 10 ], $collected ); + } + + // =============================================================== + // reduce + // =============================================================== + + public function testReduce(): void { + $result = mapOf( [ 'a' => 1, 'b' => 2, 'c' => 3 ] ) + ->reduce( fn( $carry, $v ) => $carry + $v, 0 ); + + $this->assertSame( 6, $result ); + } + + public function testReduceDefaultInitial(): void { + $result = mapOf( [ 'a' => 'hello', 'b' => ' world' ] ) + ->reduce( fn( $carry, $v ) => ( $carry ?? '' ) . $v ); + + $this->assertSame( 'hello world', $result ); + } + + // =============================================================== + // sumOf + // =============================================================== + + public function testSumOf(): void { + $result = mapOf( [ 'a' => 1, 'b' => 2, 'c' => 3 ] ) + ->sumOf( fn( $k, $v ) => $v * 10 ); + + $this->assertSame( 60, $result ); + } + + public function testSumOfUsesKey(): void { + $result = mapOf( [ 'ab' => 0, 'cdef' => 0 ] ) + ->sumOf( fn( $k, $v ) => strlen( $k ) ); + + $this->assertSame( 6, $result ); + } + + public function testSumOfEmpty(): void { + $this->assertSame( 0, emptyMap()->sumOf( fn( $k, $v ) => $v ) ); + } + + // =============================================================== + // minByOrNull / maxByOrNull + // =============================================================== + + public function testMinByOrNull(): void { + $result = mapOf( [ 'a' => 30, 'b' => 10, 'c' => 20 ] ) + ->minByOrNull( fn( $k, $v ) => $v ); + + $this->assertSame( 10, $result ); + } + + public function testMinByOrNullOnEmpty(): void { + $this->assertNull( emptyMap()->minByOrNull( fn( $k, $v ) => $v ) ); + } + + public function testMaxByOrNull(): void { + $result = mapOf( [ 'a' => 30, 'b' => 10, 'c' => 20 ] ) + ->maxByOrNull( fn( $k, $v ) => $v ); + + $this->assertSame( 30, $result ); + } + + public function testMaxByOrNullOnEmpty(): void { + $this->assertNull( emptyMap()->maxByOrNull( fn( $k, $v ) => $v ) ); + } + + // =============================================================== + // getOrDefault + // =============================================================== + + public function testGetOrDefaultReturnsValueWhenKeyExists(): void { + $map = mapOf( [ 'a' => 1, 'b' => 2 ] ); + $this->assertSame( 1, $map->getOrDefault( 'a', 99 ) ); + } + + public function testGetOrDefaultReturnsDefaultWhenKeyMissing(): void { + $map = mapOf( [ 'a' => 1 ] ); + $this->assertSame( 99, $map->getOrDefault( 'z', 99 ) ); + } + + // =============================================================== + // getOrPut (mutable only) + // =============================================================== + + public function testGetOrPutReturnsExistingValue(): void { + $map = mutableMapOf( [ 'a' => 1 ] ); + $this->assertSame( 1, $map->getOrPut( 'a', fn() => 999 ) ); + $this->assertSame( 1, $map['a'] ); + } + + public function testGetOrPutComputesAndStoresNewValue(): void { + $map = mutableMapOf( [ 'a' => 1 ] ); + + $result = $map->getOrPut( 'b', fn() => 42 ); + + $this->assertSame( 42, $result ); + $this->assertSame( 42, $map['b'] ); + $this->assertSame( 2, $map->count() ); + } + + public function testGetOrPutThrowsOnImmutable(): void { + $this->expectException( Exception::class ); + mapOf( [ 'a' => 1 ] )->getOrPut( 'b', fn() => 2 ); + } + + // =============================================================== + // containsKey / containsValue + // =============================================================== + + public function testContainsKey(): void { + $map = mapOf( [ 'a' => 1, 'b' => 2 ] ); + $this->assertTrue( $map->containsKey( 'a' ) ); + $this->assertFalse( $map->containsKey( 'z' ) ); + } + + public function testContainsValue(): void { + $map = mapOf( [ 'a' => 1, 'b' => 2 ] ); + $this->assertTrue( $map->containsValue( 2 ) ); + $this->assertFalse( $map->containsValue( 99 ) ); + } + + public function testContainsValueUsesStrictComparison(): void { + $this->assertFalse( mapOf( [ 'a' => 1 ] )->containsValue( '1' ) ); + } + + // =============================================================== + // keys / values / entries / toList + // =============================================================== + + public function testKeys(): void { + $keys = mapOf( [ 'a' => 1, 'b' => 2, 'c' => 3 ] )->keys(); + $this->assertInstanceOf( Klist::class, $keys ); + $this->assertSame( [ 'a', 'b', 'c' ], $keys->toArray() ); + } + + public function testValues(): void { + $values = mapOf( [ 'a' => 10, 'b' => 20 ] )->values(); + $this->assertInstanceOf( Klist::class, $values ); + $this->assertSame( [ 10, 20 ], $values->toArray() ); + } + + public function testEntries(): void { + $entries = mapOf( [ 'x' => 1, 'y' => 2 ] )->entries(); + $this->assertInstanceOf( Klist::class, $entries ); + $this->assertSame( [ [ 'x', 1 ], [ 'y', 2 ] ], $entries->toArray() ); + } + + public function testToList(): void { + $list = mapOf( [ 'a' => 10, 'b' => 20 ] )->toList(); + $this->assertInstanceOf( Klist::class, $list ); + $this->assertSame( [ 10, 20 ], $list->toArray() ); + } + + // =============================================================== + // join + // =============================================================== + + public function testJoin(): void { + $this->assertSame( 'a,b,c', mapOf( [ 'x' => 'a', 'y' => 'b', 'z' => 'c' ] )->join( ',' ) ); + } + + public function testJoinEmpty(): void { + $this->assertSame( '', emptyMap()->join( ',' ) ); + } + + public function testJoinThrowsOnNonStringValues(): void { + $this->expectException( Exception::class ); + mapOf( [ 'a' => 1, 'b' => 2 ] )->join( ',' ); + } + + // =============================================================== + // merge / mergeArray + // =============================================================== + + public function testMergeArray(): void { + $result = mapOf( [ 'a' => 1, 'b' => 2 ] ) + ->mergeArray( [ 'c' => 3 ] ) + ->toArray(); + + $this->assertArrayHasKey( 'a', $result ); + $this->assertArrayHasKey( 'b', $result ); + $this->assertArrayHasKey( 'c', $result ); + } + + public function testMerge(): void { + $a = mapOf( [ 'a' => 1 ] ); + $b = mapOf( [ 'b' => 2 ] ); + + $result = $a->merge( $b )->toArray(); + $this->assertArrayHasKey( 'a', $result ); + $this->assertArrayHasKey( 'b', $result ); + } + + public function testMergeReturnsNewInstanceForImmutable(): void { + $original = mapOf( [ 'a' => 1 ] ); + $merged = $original->mergeArray( [ 'b' => 2 ] ); + + $this->assertSame( [ 'a' => 1 ], $original->toArray() ); + $this->assertArrayHasKey( 'b', $merged->toArray() ); + } + + // =============================================================== + // flatten + // =============================================================== + + public function testFlatten(): void { + $result = mapOf( [ 'a' => [ 1, 2 ], 'b' => [ 3, 4 ] ] ) + ->flatten() + ->toArray(); + + $this->assertSame( [ 1, 2, 3, 4 ], $result ); + } + + public function testFlattenWithCollections(): void { + $result = mapOf( [ 'a' => listOf( 1, 2 ), 'b' => listOf( 3 ) ] ) + ->flatten() + ->toArray(); + + $this->assertSame( [ 1, 2, 3 ], $result ); + } + + public function testFlattenNonArrayValuesPassThrough(): void { + $result = mapOf( [ 'a' => 'hello', 'b' => [ 1, 2 ] ] ) + ->flatten() + ->toArray(); + + $this->assertSame( [ 'hello', 1, 2 ], $result ); + } + + // =============================================================== + // maybe + // =============================================================== + + public function testMaybeCallsPredicateWhenNotEmpty(): void { + $called = false; + mapOf( [ 'a' => 1 ] )->maybe( function () use ( &$called ) { + $called = true; + } ); + $this->assertTrue( $called ); + } + + public function testMaybeDoesNotCallPredicateWhenEmpty(): void { + $called = false; + emptyMap()->maybe( function () use ( &$called ) { + $called = true; + } ); + $this->assertFalse( $called ); + } + + // =============================================================== + // toMutable / toImmutable + // =============================================================== + + public function testToMutableAndToImmutable(): void { + $immutable = mapOf( [ 'a' => 1 ] ); + $mutable = $immutable->toMutable(); + + $mutable->add( 'b', 2 ); + $this->assertSame( 2, $mutable['b'] ); + + // Original unchanged. + $this->assertFalse( $immutable->containsKey( 'b' ) ); + + // Convert back to immutable. + $back = $mutable->toImmutable(); + $this->expectException( Exception::class ); + $back->add( 'c', 3 ); + } + + public function testToMutableReturnsSelfWhenAlreadyMutable(): void { + $mutable = mutableMapOf( [ 'a' => 1 ] ); + $this->assertSame( $mutable, $mutable->toMutable() ); + } + + public function testToImmutableReturnsSelfWhenAlreadyImmutable(): void { + $immutable = mapOf( [ 'a' => 1 ] ); + $this->assertSame( $immutable, $immutable->toImmutable() ); + } + + // =============================================================== + // Mutation: add / remove / offsetSet / offsetUnset + // =============================================================== + + public function testAddOnMutable(): void { + $map = mutableMapOf( [ 'a' => 1 ] ); + $map->add( 'b', 2 ); + $this->assertSame( [ 'a' => 1, 'b' => 2 ], $map->toArray() ); + } + + public function testAddThrowsOnImmutable(): void { + $this->expectException( Exception::class ); + mapOf( [ 'a' => 1 ] )->add( 'b', 2 ); + } + + public function testRemoveOnMutable(): void { + $map = mutableMapOf( [ 'a' => 1, 'b' => 2, 'c' => 3 ] ); + $map->remove( 'b' ); + $this->assertSame( [ 'a' => 1, 'c' => 3 ], $map->toArray() ); + } + + public function testRemoveThrowsOnImmutable(): void { + $this->expectException( Exception::class ); + mapOf( [ 'a' => 1 ] )->remove( 'a' ); + } + + public function testOffsetSetOnMutable(): void { + $map = mutableMapOf( [ 'a' => 1 ] ); + $map['a'] = 99; + $map['new'] = 42; + $this->assertSame( 99, $map['a'] ); + $this->assertSame( 42, $map['new'] ); + } + + public function testOffsetSetThrowsOnImmutable(): void { + $this->expectException( Exception::class ); + $map = mapOf( [ 'a' => 1 ] ); + $map['a'] = 2; + } + + public function testOffsetUnsetOnMutable(): void { + $map = mutableMapOf( [ 'a' => 1, 'b' => 2 ] ); + unset( $map['a'] ); + $this->assertSame( [ 'b' => 2 ], $map->toArray() ); + } + + public function testOffsetUnsetThrowsOnImmutable(): void { + $this->expectException( Exception::class ); + $map = mapOf( [ 'a' => 1 ] ); + unset( $map['a'] ); + } + + // =============================================================== + // Mutable operations mutate in place + // =============================================================== + + public function testMutableFilterMutatesInPlace(): void { + $map = mutableMapOf( [ 'a' => 1, 'b' => 2, 'c' => 3 ] ); + $same = $map->filter( fn( $k, $v ) => $v > 1 ); + $this->assertSame( $map, $same ); + $this->assertSame( [ 'b' => 2, 'c' => 3 ], $map->toArray() ); + } + + public function testMutableMapMutatesInPlace(): void { + $map = mutableMapOf( [ 'a' => 1, 'b' => 2 ] ); + $same = $map->map( fn( $k, $v ) => $v * 10 ); + $this->assertSame( $map, $same ); + $this->assertSame( [ 'a' => 10, 'b' => 20 ], $map->toArray() ); + } + + public function testMutableMergeArrayMutatesInPlace(): void { + $map = mutableMapOf( [ 'a' => 1 ] ); + $same = $map->mergeArray( [ 'b' => 2 ] ); + $this->assertSame( $map, $same ); + $this->assertTrue( $map->containsKey( 'b' ) ); + } + + // =============================================================== + // Iterator: rewind, current, key, next, valid — lazy key building + // =============================================================== + + public function testIterator(): void { + $map = mapOf( [ 'a' => 1, 'b' => 2, 'c' => 3 ] ); + $iterated = []; + foreach ( $map as $key => $value ) { + $iterated[ $key ] = $value; + } + + $this->assertSame( [ 'a' => 1, 'b' => 2, 'c' => 3 ], $iterated ); + } + + public function testIteratorLazyKeyBuilding(): void { + // Keys are built lazily on first iteration — verify by calling rewind + // which nullifies the cached keys. + $map = mapOf( [ 'x' => 10, 'y' => 20 ] ); + + $map->rewind(); + $this->assertSame( 'x', $map->key() ); + $this->assertSame( 10, $map->current() ); + $this->assertTrue( $map->valid() ); + + $map->next(); + $this->assertSame( 'y', $map->key() ); + $this->assertSame( 20, $map->current() ); + $this->assertTrue( $map->valid() ); + + $map->next(); + $this->assertFalse( $map->valid() ); + $this->assertNull( $map->key() ); + $this->assertNull( $map->current() ); + } + + public function testNextAndGet(): void { + $map = mapOf( [ 'a' => 10, 'b' => 20 ] ); + $this->assertSame( 10, $map->nextAndGet() ); + $this->assertSame( 20, $map->nextAndGet() ); + $this->assertNull( $map->nextAndGet() ); + } + + public function testRewindResetsIterator(): void { + $map = mapOf( [ 'a' => 1, 'b' => 2 ] ); + $map->nextAndGet(); + $map->nextAndGet(); + + $map->rewind(); + $this->assertSame( 1, $map->current() ); + $this->assertSame( 'a', $map->key() ); + } + + public function testIteratorKeysInvalidatedOnMutation(): void { + $map = mutableMapOf( [ 'a' => 1, 'b' => 2 ] ); + + // Iterate partially. + $map->rewind(); + $map->nextAndGet(); + + // Mutate — keys should be invalidated. + $map->add( 'c', 3 ); + + // Rewind and iterate again — new key should be present. + $map->rewind(); + $keys = []; + foreach ( $map as $key => $value ) { + $keys[] = $key; + } + $this->assertContains( 'c', $keys ); + } + + // =============================================================== + // ArrayAccess: offsetExists, offsetGet + // =============================================================== + + public function testOffsetExists(): void { + $map = mapOf( [ 'a' => 1, 'b' => 2 ] ); + $this->assertTrue( isset( $map['a'] ) ); + $this->assertFalse( isset( $map['z'] ) ); + } + + public function testOffsetGet(): void { + $map = mapOf( [ 'key' => 'value' ] ); + $this->assertSame( 'value', $map['key'] ); + } + + // =============================================================== + // JsonSerializable + // =============================================================== + + public function testJsonSerialize(): void { + $map = mapOf( [ 'a' => 1, 'b' => 2 ] ); + $json = json_encode( $map ); + $this->assertSame( '{"a":1,"b":2}', $json ); + } + + // =============================================================== + // Edge cases + // =============================================================== + + public function testEmptyMapEdgeCases(): void { + $empty = emptyMap(); + + $this->assertSame( [], $empty->toArray() ); + $this->assertSame( 0, $empty->count() ); + $this->assertTrue( $empty->isEmpty() ); + $this->assertFalse( $empty->isNotEmpty() ); + $this->assertNull( $empty->firstOrNull() ); + $this->assertFalse( $empty->any( fn( $v ) => true ) ); + $this->assertTrue( $empty->all( fn( $v ) => false ) ); + $this->assertTrue( $empty->none( fn( $v ) => true ) ); + $this->assertSame( [], $empty->keys()->toArray() ); + $this->assertSame( [], $empty->values()->toArray() ); + $this->assertSame( [], $empty->entries()->toArray() ); + $this->assertFalse( $empty->containsKey( 'a' ) ); + $this->assertFalse( $empty->containsValue( 1 ) ); + } + + public function testSingleEntryMap(): void { + $map = mapOf( [ 'only' => 42 ] ); + + $this->assertSame( 1, $map->count() ); + $this->assertSame( 42, $map->firstOrNull() ); + $this->assertSame( 42, $map['only'] ); + $this->assertTrue( $map->containsKey( 'only' ) ); + $this->assertTrue( $map->containsValue( 42 ) ); + $this->assertSame( [ 'only' ], $map->keys()->toArray() ); + $this->assertSame( [ 42 ], $map->values()->toArray() ); + } + + public function testNumericKeys(): void { + $map = mapOf( [ 0 => 'zero', 1 => 'one', 2 => 'two' ] ); + $this->assertSame( 'zero', $map[0] ); + $this->assertTrue( $map->containsKey( 1 ) ); + $this->assertSame( [ 0, 1, 2 ], $map->keys()->toArray() ); + } + + public function testNullValuesAreHandled(): void { + $map = mapOf( [ 'a' => null, 'b' => 1 ] ); + $this->assertNull( $map['a'] ); + $this->assertTrue( $map->containsValue( 1 ) ); + // containsValue uses strict comparison — null check. + $this->assertTrue( $map->containsValue( null ) ); + } + + // =============================================================== + // Helper functions + // =============================================================== + + public function testMapOfHelper(): void { + $map = mapOf( [ 'k' => 'v' ] ); + $this->assertInstanceOf( Kmap::class, $map ); + $this->assertSame( [ 'k' => 'v' ], $map->toArray() ); + } + + public function testEmptyMapHelper(): void { + $map = emptyMap(); + $this->assertInstanceOf( Kmap::class, $map ); + $this->assertTrue( $map->isEmpty() ); + } + + public function testMutableMapOfHelper(): void { + $map = mutableMapOf( [ 'a' => 1 ] ); + $map->add( 'b', 2 ); + $this->assertSame( [ 'a' => 1, 'b' => 2 ], $map->toArray() ); + } + + public function testMutableEmptyMapHelper(): void { + $map = mutableEmptyMap(); + $this->assertTrue( $map->isEmpty() ); + $map->add( 'key', 'val' ); + $this->assertSame( [ 'key' => 'val' ], $map->toArray() ); + } + + // =============================================================== + // Immutable operations return new instances + // =============================================================== + + public function testImmutableFilterReturnsNewInstance(): void { + $original = mapOf( [ 'a' => 1, 'b' => 2, 'c' => 3 ] ); + $filtered = $original->filter( fn( $k, $v ) => $v > 1 ); + + $this->assertNotSame( $original, $filtered ); + $this->assertSame( [ 'a' => 1, 'b' => 2, 'c' => 3 ], $original->toArray() ); + $this->assertSame( [ 'b' => 2, 'c' => 3 ], $filtered->toArray() ); + } + + public function testImmutableMapReturnsNewInstance(): void { + $original = mapOf( [ 'a' => 1 ] ); + $mapped = $original->map( fn( $k, $v ) => $v * 10 ); + + $this->assertNotSame( $original, $mapped ); + $this->assertSame( [ 'a' => 1 ], $original->toArray() ); + $this->assertSame( [ 'a' => 10 ], $mapped->toArray() ); + } +} diff --git a/tests/Axpecto/MethodExecution/Builder/MethodExecutionBuildHandlerTest.php b/tests/Axpecto/MethodExecution/Builder/MethodExecutionBuildHandlerTest.php index 54fc1c2..0cd1d7c 100644 --- a/tests/Axpecto/MethodExecution/Builder/MethodExecutionBuildHandlerTest.php +++ b/tests/Axpecto/MethodExecution/Builder/MethodExecutionBuildHandlerTest.php @@ -4,7 +4,9 @@ use Axpecto\Annotation\BuildAnnotation; use Axpecto\ClassBuilder\BuildOutput; +use Axpecto\Code\AnnotationCodeGenerator; use Axpecto\Code\MethodCodeGenerator; +use Axpecto\Reflection\ReflectionUtils; use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\TestCase; use ReflectionException; @@ -14,15 +16,19 @@ */ class MethodExecutionBuildHandlerTest extends TestCase { private MethodCodeGenerator $codeGen; + private AnnotationCodeGenerator $annotationCoder; + private ReflectionUtils $reflection; private MethodExecutionBuildHandler $handler; /** * @throws Exception */ protected function setUp(): void { - // mock the code generator - $this->codeGen = $this->createMock( MethodCodeGenerator::class ); - $this->handler = new MethodExecutionBuildHandler( $this->codeGen ); + // mock the dependencies + $this->codeGen = $this->createMock( MethodCodeGenerator::class ); + $this->annotationCoder = $this->createMock( AnnotationCodeGenerator::class ); + $this->reflection = $this->createMock( ReflectionUtils::class ); + $this->handler = new MethodExecutionBuildHandler( $this->codeGen, $this->annotationCoder, $this->reflection ); } /** @@ -43,7 +49,13 @@ public function testInterceptAddsProxyPropertyAndMethod(): void { ->method( 'getAnnotatedMethod' ) ->willReturn( $methodName ); - // 2) Stub the code generator to return a known signature + // 2) Stub reflection to return the real ReflectionMethod (non-abstract) + $this->reflection + ->method( 'getClassMethod' ) + ->with( $className, $methodName ) + ->willReturn( new \ReflectionMethod( $className, $methodName ) ); + + // 3) Stub the code generator to return a known signature $this->codeGen ->expects( $this->once() ) ->method( 'implementMethodSignature' ) diff --git a/tests/Axpecto/MethodExecution/Builder/MethodExecutionProxyTest.php b/tests/Axpecto/MethodExecution/Builder/MethodExecutionProxyTest.php index 9d259fb..0eafb99 100644 --- a/tests/Axpecto/MethodExecution/Builder/MethodExecutionProxyTest.php +++ b/tests/Axpecto/MethodExecution/Builder/MethodExecutionProxyTest.php @@ -3,7 +3,7 @@ namespace Axpecto\MethodExecution\Builder; use Axpecto\Annotation\Annotation; -use Axpecto\Annotation\AnnotationReader; +use Axpecto\Annotation\AnnotationService; use Axpecto\Annotation\MethodExecutionAnnotation; use Axpecto\Collection\Klist; use Axpecto\MethodExecution\MethodExecutionContext; @@ -13,16 +13,16 @@ class MethodExecutionProxyTest extends TestCase { private ReflectionUtils $reflectionUtilsMock; - private AnnotationReader $annotationReaderMock; + private AnnotationService $annotationServiceMock; private MethodExecutionProxy $methodExecutionProxy; protected function setUp(): void { // Create mock objects for dependencies $this->reflectionUtilsMock = $this->createMock( ReflectionUtils::class ); - $this->annotationReaderMock = $this->createMock( AnnotationReader::class ); + $this->annotationServiceMock = $this->createMock( AnnotationService::class ); // Instantiate the MethodExecutionProxy with mocked dependencies - $this->methodExecutionProxy = new MethodExecutionProxy( $this->reflectionUtilsMock, $this->annotationReaderMock ); + $this->methodExecutionProxy = new MethodExecutionProxy( $this->reflectionUtilsMock, $this->annotationServiceMock ); } public function testHandleExecutesMethodWithNoAnnotations(): void { @@ -36,7 +36,7 @@ public function testHandleExecutesMethodWithNoAnnotations(): void { }; // Mock empty annotations (no annotations are defined for the method) - $this->annotationReaderMock + $this->annotationServiceMock ->expects( $this->once() ) ->method( 'getMethodAnnotations' ) ->with( $class, $method, MethodExecutionAnnotation::class ) @@ -76,7 +76,7 @@ public function testHandleExecutesWithAnnotations(): void { // Mock annotations (single annotation for the method) $annotations = new Klist( [ $annotationMock ] ); - $this->annotationReaderMock + $this->annotationServiceMock ->expects( $this->once() ) ->method( 'getMethodAnnotations' ) ->with( $class, $method, MethodExecutionAnnotation::class ) @@ -131,7 +131,7 @@ public function testHandleHandlesMultipleAnnotations(): void { // Mock annotations (two annotations for the method) $annotations = new Klist( [ $annotationMock1, $annotationMock2 ] ); - $this->annotationReaderMock + $this->annotationServiceMock ->expects( $this->once() ) ->method( 'getMethodAnnotations' ) ->with( $class, $method, MethodExecutionAnnotation::class ) diff --git a/tests/Axpecto/Repository/Handler/RepositoryBuildHandlerTest.php b/tests/Axpecto/Repository/Handler/RepositoryBuildHandlerTest.php index 8f98e7d..f56cb93 100644 --- a/tests/Axpecto/Repository/Handler/RepositoryBuildHandlerTest.php +++ b/tests/Axpecto/Repository/Handler/RepositoryBuildHandlerTest.php @@ -2,7 +2,7 @@ namespace Axpecto\Repository\Handler; -use Axpecto\Annotation\AnnotationReader; +use Axpecto\Annotation\AnnotationService; use Axpecto\Annotation\BuildAnnotation; use Axpecto\ClassBuilder\BuildOutput; use Axpecto\Code\MethodCodeGenerator; @@ -25,7 +25,7 @@ class RepositoryBuildHandlerTest extends TestCase { private RepositoryMethodNameParser $parser; private EntityMetadataService $metadata; private RepositoryBuildHandler $handler; - private AnnotationReader $annotationReader; + private AnnotationService $annotationService; /** * @throws Exception @@ -35,14 +35,14 @@ protected function setUp(): void { $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->annotationService = $this->createMock( AnnotationService::class ); $this->handler = new RepositoryBuildHandler( $this->reflect, $this->codeGen, $this->parser, $this->metadata, - $this->annotationReader, + $this->annotationService, ); } @@ -62,7 +62,7 @@ public function testInterceptGeneratesMethod(): void { // 2) Stub fetching the Entity metadata $entityAnnotation = new Entity( storage: DummyStorage::class, table: 'dummy' ); - $this->annotationReader + $this->annotationService ->method( 'getClassAnnotations' ) ->willReturn( listOf( $entityAnnotation ) ); diff --git a/tests/Axpecto/Storage/Entity/EntityMetadataServiceTest.php b/tests/Axpecto/Storage/Entity/EntityMetadataServiceTest.php index e1be0c5..187389c 100644 --- a/tests/Axpecto/Storage/Entity/EntityMetadataServiceTest.php +++ b/tests/Axpecto/Storage/Entity/EntityMetadataServiceTest.php @@ -2,7 +2,7 @@ namespace Axpecto\Storage\Entity\Tests; -use Axpecto\Annotation\AnnotationReader; +use Axpecto\Annotation\AnnotationService; use Axpecto\Collection\Klist; use Axpecto\Reflection\Dto\Argument; use Axpecto\Reflection\ReflectionUtils; @@ -15,13 +15,13 @@ class EntityMetadataServiceTest extends TestCase { private ReflectionUtils $reflect; - private AnnotationReader $reader; + private AnnotationService $annotationService; private EntityMetadataService $svc; protected function setUp(): void { - $this->reflect = $this->createMock( ReflectionUtils::class ); - $this->reader = $this->createMock( AnnotationReader::class ); - $this->svc = new EntityMetadataService( $this->reflect, $this->reader ); + $this->reflect = $this->createMock( ReflectionUtils::class ); + $this->annotationService = $this->createMock( AnnotationService::class ); + $this->svc = new EntityMetadataService( $this->reflect, $this->annotationService ); } /** @@ -54,7 +54,7 @@ public function testGetFieldsWithAndWithoutColumnOverrides(): void { ); // Stub getParameterAnnotations: foo→[$column], bar→[] - $this->reader + $this->annotationService ->expects( $this->exactly( 2 ) ) ->method( 'getParameterAnnotations' ) ->willReturnCallback( function ( @@ -105,7 +105,7 @@ public function testGetEntityReturnsAnnotation(): void { $entityClass = DummyEntity::class; $entityAnno = new EntityAttribute( storage: DummyStorage::class, table: 'tbl' ); - $this->reader + $this->annotationService ->expects( $this->once() ) ->method( 'getClassAnnotations' ) ->with( $entityClass, EntityAttribute::class ) @@ -117,7 +117,7 @@ public function testGetEntityReturnsAnnotation(): void { public function testGetEntityMissingThrows(): void { $entityClass = DummyEntity::class; - $this->reader + $this->annotationService ->expects( $this->once() ) ->method( 'getClassAnnotations' ) ->with( $entityClass, EntityAttribute::class )