commit a5ce423afe5e82c43e6decde945f712edc12b6a1 Author: icefox Date: Mon Dec 22 17:54:16 2025 -0300 . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a9875b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +composer.lock diff --git a/README.md b/README.md new file mode 100644 index 0000000..240fd69 --- /dev/null +++ b/README.md @@ -0,0 +1,191 @@ +# PHP AOP - Proof of Concept + +A minimalistic Aspect-Oriented Programming (AOP) implementation for PHP that uses attributes and Composer's autoloading system to weave aspects into classes at runtime. + +## Features + +- **Attribute-based AOP**: Use custom attributes to mark methods for aspect weaving +- **Builder Pattern Configuration**: Fluent API for configuring aspects and classes +- **Selective Class Loading**: Only process specified classes, avoiding vendor code +- **Multiple Aspect Support**: Register multiple aspect attributes and control which ones are processed +- **Reflection-based Weaving**: Uses native PHP Reflection for robust type handling +- **Complex Type Support**: Union types, nullable, variadic, by-reference parameters +- **Automatic proxy generation**: Hooks into Composer's class loading to generate proxy classes +- **Before/After execution**: Methods marked with aspects will execute interceptor code +- **Works with public and protected methods**: Full support for method visibility +- **Smart Caching**: Generated proxies cached for performance + +## How It Works + +### Two-Pass Reflection Architecture + +1. **Configuration**: Use builder pattern to specify which aspects and classes to process +2. **Class Loading Hook**: Custom autoloader intercepts class loading for registered classes +3. **Use Statement Detection**: Checks if source file imports registered aspect attributes +4. **Two-Pass Loading**: + - **Pass 1 (String + eval)**: Rename class, load into memory for reflection analysis + - **Pass 2 (Reflection)**: Use native PHP Reflection to inspect method signatures and attributes +5. **Proxy Generation**: Create self-contained proxy file with: + - Original class with renamed identifier + - Proxy class extending original with aspect methods overridden +6. **Caching**: Generated proxies cached to `/tmp/php-aop-cache` for performance + +## Installation + +```bash +composer install +``` + +## Configuration + +Configure the AspectLoader using the builder pattern in `src/bootstrap.php`: + +```php +withAspects([Aspect::class]) // Which aspect attributes to process + ->withClasses([ // Which classes to weave (avoids vendor code) + 'Example\Calculator', + 'Example\ComplexExample', + ]) + ->register(); +``` + +## Usage + +### 1. Mark methods with aspect attributes + +```php +add(5, 3); +``` + +## Running the Demo + +```bash +php example/demo.php +``` + +Expected output: +``` +=== PHP AOP Demo === + +1. Calling add() with Aspect attribute: +before +Adding 5 + 3 +after +Result: 8 + +2. Calling multiply() with Aspect attribute: +before +Multiplying 4 * 7 +after +Result: 28 + +3. Calling subtract() WITHOUT Aspect attribute: +Subtracting 10 - 3 +Result: 7 + +4. Calling protected divide() method (with Aspect) via public wrapper: +before +Dividing 20 / 4 +after +Result: 5 + +=== Demo Complete === +``` + +## Architecture + +### Components + +- **`Aspect.php`**: The attribute class that marks methods for aspect weaving +- **`AspectWeaver.php`**: Parses source files and generates proxy classes with aspect behavior +- **`AspectLoader.php`**: Hooks into Composer's autoloader to intercept class loading +- **`bootstrap.php`**: Initializes the AspectLoader when the autoloader is included + +### Proxy Generation Strategy + +The weaver uses a two-class approach: + +1. **Original Class (renamed)**: The original class is included in the proxy file with a modified name (e.g., `__AopOriginal_Calculator`) +2. **Proxy Class**: A new class with the original name that extends the renamed original class and overrides methods marked with `#[Aspect]` + +This approach ensures: +- No modification of source files +- Full type safety and IDE support +- Debuggability +- No runtime performance overhead (after initial proxy generation) + +## Limitations (POC) + +This is a proof of concept with the following limitations: + +- Only supports `before` and `after` behavior (no around, exception handling, etc.) +- Simple regex-based parsing (not a full PHP parser) +- Cache clearing requires manual deletion of `/tmp/php-aop-cache` +- No support for static methods with static variables +- No support for complex method signatures (references, variadic parameters may have issues) + +## Extending + +To extend this POC to support more advanced AOP features: + +1. **Custom Aspects**: Create different attribute classes with parameters +2. **Joinpoint Information**: Pass method name, arguments, and execution context to aspect code +3. **Around Advice**: Allow aspects to control method execution +4. **Exception Handling**: Add support for after-throwing and after-returning advice +5. **Performance**: Use a proper PHP parser (like nikic/php-parser) for more robust code analysis + +## References + +- [okapi-web/php-aop](https://github.com/okapi-web/php-aop) +- [goaop/framework](https://github.com/goaop/framework) diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..d204a36 --- /dev/null +++ b/composer.json @@ -0,0 +1,28 @@ +{ + "name": "icefox/aspect", + "description": "Aspect Oriented Programming for PHP", + "type": "library", + "require": { + "php": ">=8.0", + "psr/log": "^3.0" + }, + "autoload": { + "psr-4": { + "IceFox\\Aspect\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "authors": [ + { + "name": "fnzr", + "email": "felipe@imatos.dev" + } + ], + "require-dev": { + "phpunit/phpunit": "^12.5" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..fe4e436 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,11 @@ + + + + + tests + + + diff --git a/src/Aspect.php b/src/Aspect.php new file mode 100644 index 0000000..7bb8cc9 --- /dev/null +++ b/src/Aspect.php @@ -0,0 +1,19 @@ + $classes + */ + public function withClasses(array $classes): self + { + $this->classes = $classes; + return $this; + } + + /** + * @param array $namespaces + */ + public function withNamespaces(array $namespaces): self + { + $this->namespaces = $namespaces; + return $this; + } + + /** + * @param array $aspects + */ + public function build(AspectWeaver $weaver): AspectLoader + { + return new AspectLoader($weaver, $this->classes, $this->namespaces); + } +} diff --git a/src/AspectException.php b/src/AspectException.php new file mode 100644 index 0000000..45588d9 --- /dev/null +++ b/src/AspectException.php @@ -0,0 +1,18 @@ + $classes + * @param array $namespaces + */ + public function __construct( + public readonly AspectWeaver $weaver, + public readonly array $classes, + public readonly array $namespaces, + ) { + } + + public function register(): self + { + spl_autoload_register([$this, 'load'], true, true); + return $this; + } + + public function load(string $className): bool + { + if (!$this->shouldProcessClass($className)) { + return false; + } + + $file = $this->findClassFile($className); + + if ($file === null) { + return false; + } + + if ($this->weaver->weave($className, $file)) { + return true; + } + + return false; + } + + private function shouldProcessClass(string $className): bool + { + if (!empty($this->classes)) { + return in_array($className, $this->classes, true); + } + + if (!empty($this->namespaces)) { + foreach ($this->namespaces as $namespace) { + if (str_starts_with($className, $namespace)) { + return true; + } + } + } + + return false; + } + + private function findClassFile(string $className): ?string + { + $autoloaders = spl_autoload_functions(); + + foreach ($autoloaders as $autoloader) { + if (is_array($autoloader) && $autoloader[0] instanceof \Composer\Autoload\ClassLoader) { + $classLoader = $autoloader[0]; + $file = $classLoader->findFile($className); + + if ($file !== false) { + return $file; + } + } + } + + return null; + } +} diff --git a/src/AspectWeaver.php b/src/AspectWeaver.php new file mode 100644 index 0000000..8939039 --- /dev/null +++ b/src/AspectWeaver.php @@ -0,0 +1,446 @@ + $aspects + */ + public function __construct( + public readonly array $aspects, + public readonly string $cacheDir, + public readonly bool $useCache, + public readonly LoggerInterface $logger, + ) { + if (!is_dir($this->cacheDir)) { + mkdir($this->cacheDir, 0755, true); + } + } + + public function weave(string $className, string $filePath): ?string + { + $cacheKey = str_replace('\\', '_', $className); + $cachedFile = $this->cacheDir . '/' . $cacheKey . '.php'; + + if ($this->useCache && file_exists($cachedFile)) { + require $cachedFile; + return $cachedFile; + } + + $sourceCode = file_get_contents($filePath); + + if (!$this->hasAspectAttribute($sourceCode)) { + return null; + } + + $result = $this->generateProxyWithReflection($className, $sourceCode); + if ($result === null) { + return null; + } + + file_put_contents($cachedFile, $result); + + $this->logger->debug('Generated proxy class file', [ + 'class' => $className, + 'path' => $cachedFile, + ]); + + return $cachedFile; + } + + private function hasAspectAttribute(string $sourceCode): bool + { + foreach ($this->aspects as $aspect) { + $useStatement = '/use\s+' . preg_quote($aspect, '/') . '\s*(?:as\s+\w+)?\s*;/'; + if (preg_match($useStatement, $sourceCode) === 1) { + return true; + } + + if (strpos($sourceCode, '#[\\' . $aspect . ']') !== false || + strpos($sourceCode, '#[\\' . $aspect . '(') !== false) { + return true; + } + } + + return false; + } + + private function generateProxyWithReflection(string $className, string $sourceCode): ?string + { + $parts = explode('\\', $className); + $shortName = array_pop($parts); + $namespace = implode('\\', $parts); + + $renamedClassName = '__AopOriginal_' . $shortName; + $fullRenamedClassName = $namespace ? "{$namespace}\\{$renamedClassName}" : $renamedClassName; + + $modifiedSource = $this->renameClass($sourceCode, $shortName, $renamedClassName, $namespace); + + if (!class_exists($fullRenamedClassName)) { + eval($modifiedSource); + + if (!class_exists($fullRenamedClassName)) { + $this->logger->error('Failed to load renamed class after eval', [ + 'originalClass' => $className, + 'renamedClass' => $fullRenamedClassName, + ]); + return null; + } + } + + $reflection = new ReflectionClass($fullRenamedClassName); + + $methods = $this->getMethodsWithAspects($reflection); + + if (empty($methods)) { + return null; + } + + $proxyOnlyCode = $this->buildProxyClass($namespace, $shortName, $renamedClassName, $methods); + eval($proxyOnlyCode); + + return $this->buildMergedProxyAndOriginalFile( + $namespace, + $shortName, + $renamedClassName, + $methods, + $modifiedSource, + ); + } + + private function renameClass(string $sourceCode, string $originalName, string $newName, string $namespace): string + { + $modified = preg_replace('/<\?php\s*/', '', $sourceCode, 1); + + if ($namespace) { + $modified = preg_replace('/namespace\s+[\w\\\\]+\s*;/', '', $modified, 1); + } + + $modified = preg_replace( + '/\bclass\s+' . preg_quote($originalName, '/') . '\b/', + 'class ' . $newName, + $modified, + 1 + ); + + if ($namespace) { + $modified = "namespace {$namespace};\n\n" . $modified; + } + + return $modified; + } + + /** + * Map methods to their aspect classes and attributes for stacking support + * @param ReflectionClass $reflection + * @return array + */ + private function getMethodsWithAspects(ReflectionClass $reflection): array + { + $metadata = []; + + foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_PROTECTED) as $method) { + if ($method->getDeclaringClass()->getName() !== $reflection->getName()) { + continue; + } + + $methodAspects = array_map( + fn ($attribute) => new AttributeMetadata($attribute->getName(), $attribute), + array_filter( + $method->getAttributes(), + fn ($attribute) => in_array($attribute->getName(), $this->aspects, true) + ) + ); + + if (!empty($methodAspects)) { + $metadata[] = new MethodMetadata($method, $methodAspects); + } + } + + return $metadata; + } + + /** + * @param array $methodAspectsMap + */ + private function buildMergedProxyAndOriginalFile( + string $namespace, + string $shortName, + string $renamedClassName, + array $methodAspectsMap, + string $modifiedSource + ): string { + $code = "generateProxyMethodFromReflection($methodMetadata->method, $methodMetadata->aspects); + } + + $code .= "}\n"; + + return $code; + } + + /** + * @param array $methodAspectsMap + */ + private function buildProxyClass( + string $namespace, + string $shortName, + string $renamedClassName, + array $methodAspectsMap + ): string { + $code = ''; + + if ($namespace) { + $code .= "namespace {$namespace};\n"; + } + + $code .= "class {$shortName} extends {$renamedClassName}\n{\n"; + + foreach ($methodAspectsMap as $methodMetadata) { + $code .= $this->generateProxyMethodFromReflection($methodMetadata->method, $methodMetadata->aspects); + } + + $code .= "}\n"; + + return $code; + } + + /** + * @param array $aspects + */ + private function generateProxyMethodFromReflection(ReflectionMethod $method, array $aspects): string + { + $visibility = $method->isPublic() ? 'public' : 'protected'; + $static = $method->isStatic() ? 'static ' : ''; + $isStatic = $method->isStatic(); + $name = $method->getName(); + $className = $method->getDeclaringClass()->getName(); + + $this->logger->info('Generating proxy method', [ + 'class' => $className, + 'method' => $name, + 'aspects' => array_map(fn ($a) => $a->aspectClass, $aspects), + ]); + + $params = $this->buildParameterList($method); + $paramNames = $this->buildParameterCallList($method); + + $returnType = $this->buildReturnType($method); + + $code = " {$visibility} {$static}function {$name}({$params}){$returnType}\n"; + $code .= " {\n"; + + // Instantiate all aspects using their constructor arguments from attributes + foreach ($aspects as $index => $aspectMetadata) { + $aspectClass = $aspectMetadata->aspectClass; + $attribute = $aspectMetadata->attribute; + + // Get constructor arguments from the attribute + $args = $attribute->getArguments(); + $aspectConstructorArguments = $this->buildConstructorArguments($args); + + $code .= " \$aspect{$index} = new \\{$aspectClass}({$aspectConstructorArguments});\n"; + } + + // Prepare target argument for aspect methods + $targetArg = $isStatic ? "static::class" : "\$this"; + + // Call all before methods in a single try-catch + $code .= " \$currentAspect = '';\n"; + $code .= " try {\n"; + foreach ($aspects as $index => $aspectMetadata) { + $aspectClass = $aspectMetadata->aspectClass; + $aspectReflection = new ReflectionClass($aspectClass); + if ($aspectReflection->hasMethod('before')) { + $code .= " \$currentAspect = \\{$aspectClass}::class;\n"; + $beforeParams = $paramNames ? "{$targetArg}, {$paramNames}" : $targetArg; + $code .= " \$aspect{$index}->before({$beforeParams});\n"; + } + } + $code .= " } catch (\\Throwable \$e) {\n"; + $code .= " throw new \\IceFox\\Aspect\\AspectException(\n"; + $code .= " 'Exception in before() method of aspect',\n"; + $code .= " \$e instanceof \\IceFox\\Aspect\\AspectException ? \$e->aspectClass : \$currentAspect,\n"; + $code .= " 'before',\n"; + $code .= " \$e\n"; + $code .= " );\n"; + $code .= " }\n"; + + if ($returnType && $returnType !== ': void') { + $code .= " \$result = parent::{$name}({$paramNames});\n"; + + // Call all after methods in a single try-catch (in reverse order) + $code .= " \$currentAspect = '';\n"; + $code .= " try {\n"; + for ($i = count($aspects) - 1; $i >= 0; $i--) { + $aspectMetadata = $aspects[$i]; + $aspectClass = $aspectMetadata->aspectClass; + $aspectReflection = new ReflectionClass($aspectClass); + if ($aspectReflection->hasMethod('after')) { + $code .= " \$currentAspect = \\{$aspectClass}::class;\n"; + $code .= " \$result = \$aspect{$i}->after({$targetArg}, \$result);\n"; + } + } + $code .= " } catch (\\Throwable \$e) {\n"; + $code .= " throw new \\IceFox\\Aspect\\AspectException(\n"; + $code .= " 'Exception in after() method of aspect',\n"; + $code .= " \$e instanceof \\IceFox\\Aspect\\AspectException ? \$e->aspectClass : \$currentAspect,\n"; + $code .= " 'after',\n"; + $code .= " \$e\n"; + $code .= " );\n"; + $code .= " }\n"; + + $code .= " return \$result;\n"; + } else { + $code .= " parent::{$name}({$paramNames});\n"; + + $code .= " \$currentAspect = '';\n"; + $code .= " try {\n"; + for ($i = count($aspects) - 1; $i >= 0; $i--) { + $aspectMetadata = $aspects[$i]; + $aspectClass = $aspectMetadata->aspectClass; + $aspectReflection = new ReflectionClass($aspectClass); + if ($aspectReflection->hasMethod('after')) { + $code .= " \$currentAspect = \\{$aspectClass}::class;\n"; + $code .= " \$aspect{$i}->after({$targetArg}, null);\n"; + } + } + $code .= " } catch (\\Throwable \$e) {\n"; + $code .= " throw new \\IceFox\\Aspect\\AspectException(\n"; + $code .= " 'Exception in after() method of aspect',\n"; + $code .= " \$e instanceof \\IceFox\\Aspect\\AspectException ? \$e->aspectClass : \$currentAspect,\n"; + $code .= " 'after',\n"; + $code .= " \$e\n"; + $code .= " );\n"; + $code .= " }\n"; + } + + $code .= " }\n\n"; + + return $code; + } + + private function buildParameterList(ReflectionMethod $method): string + { + $params = []; + + foreach ($method->getParameters() as $param) { + $paramStr = ''; + + if ($param->hasType()) { + $paramStr .= $this->formatType($param->getType()) . ' '; + } + + if ($param->isPassedByReference()) { + $paramStr .= '&'; + } + + if ($param->isVariadic()) { + $paramStr .= '...'; + } + + $paramStr .= '$' . $param->getName(); + + if ($param->isDefaultValueAvailable()) { + $defaultValue = $param->getDefaultValue(); + $paramStr .= ' = ' . var_export($defaultValue, true); + } elseif ($param->isOptional() && $param->allowsNull()) { + $paramStr .= ' = null'; + } + + $params[] = $paramStr; + } + + return implode(', ', $params); + } + + private function buildParameterCallList(ReflectionMethod $method): string + { + $params = []; + + foreach ($method->getParameters() as $param) { + $name = ''; + + if ($param->isVariadic()) { + $name .= '...'; + } + + $name .= '$' . $param->getName(); + $params[] = $name; + } + + return implode(', ', $params); + } + + private function buildReturnType(ReflectionMethod $method): string + { + if (!$method->hasReturnType()) { + return ''; + } + + return ': ' . $this->formatType($method->getReturnType()); + } + + private function formatType(\ReflectionType $type): string + { + if ($type instanceof ReflectionNamedType) { + $name = $type->getName(); + + // Don't add ? for nullable built-in types that already allow null + if ($type->allowsNull() && $name !== 'mixed' && $name !== 'null') { + return '?' . $name; + } + + return $name; + } + + if ($type instanceof ReflectionUnionType) { + $types = array_map(fn ($t) => $this->formatType($t), $type->getTypes()); + return implode('|', $types); + } + + if ($type instanceof ReflectionIntersectionType) { + $types = array_map(fn ($t) => $this->formatType($t), $type->getTypes()); + return implode('&', $types); + } + + return ''; + } + + /** + * Build constructor arguments code from attribute arguments + * @param array $args + */ + private function buildConstructorArguments(array $args): string + { + if (empty($args)) { + return ''; + } + + $argParts = []; + foreach ($args as $key => $value) { + $valueCode = var_export($value, true); + + if (is_string($key)) { + $argParts[] = "{$key}: {$valueCode}"; + } else { + $argParts[] = $valueCode; + } + } + + return implode(', ', $argParts); + } +} diff --git a/src/AttributeMetadata.php b/src/AttributeMetadata.php new file mode 100644 index 0000000..5727a74 --- /dev/null +++ b/src/AttributeMetadata.php @@ -0,0 +1,21 @@ + $attribute + */ + public function __construct( + public string $aspectClass, + public ReflectionAttribute $attribute, + ) { + } +} diff --git a/src/MethodMetadata.php b/src/MethodMetadata.php new file mode 100644 index 0000000..1c893c7 --- /dev/null +++ b/src/MethodMetadata.php @@ -0,0 +1,21 @@ + $aspects + */ + public function __construct( + public ReflectionMethod $method, + public array $aspects, + ) { + } +} diff --git a/tests/AspectExceptionTest.php b/tests/AspectExceptionTest.php new file mode 100644 index 0000000..24ae6eb --- /dev/null +++ b/tests/AspectExceptionTest.php @@ -0,0 +1,58 @@ +methodWithAspect(); + $this->fail('Expected AspectException to be thrown'); + } catch (AspectException $e) { + $this->assertEquals('Exception in before() method of aspect', $e->getMessage()); + $this->assertEquals(ThrowingAspect::class, $e->aspectClass); + $this->assertEquals('before', $e->method); + $this->assertInstanceOf(\RuntimeException::class, $e->getPrevious()); + $this->assertEquals('Exception thrown in before()', $e->getPrevious()->getMessage()); + } + } + + public function testExceptionInAfterIsWrappedInAspectException(): void + { + ThrowingAspect::$throwInAfter = true; + + $instance = new ThrowingClass(); + + try { + $instance->methodWithAspect(); + $this->fail('Expected AspectException to be thrown'); + } catch (AspectException $e) { + $this->assertEquals('Exception in after() method of aspect', $e->getMessage()); + $this->assertEquals(ThrowingAspect::class, $e->aspectClass); + $this->assertEquals('after', $e->method); + $this->assertInstanceOf(\RuntimeException::class, $e->getPrevious()); + $this->assertEquals('Exception thrown in after()', $e->getPrevious()->getMessage()); + } + } + + public function testMethodExecutesSuccessfullyWhenNoExceptions(): void + { + $instance = new ThrowingClass(); + $result = $instance->methodWithAspect(); + + $this->assertEquals('success', $result); + } +} diff --git a/tests/AspectStackingTest.php b/tests/AspectStackingTest.php new file mode 100644 index 0000000..2cb5b22 --- /dev/null +++ b/tests/AspectStackingTest.php @@ -0,0 +1,67 @@ +instance = new StackedAspectsClass(); + LoggingAspect::clearLogs(); + } + + public function testMultipleAspectsExecuteInOrder(): void + { + $tracker = (object) ['before' => false, 'after' => false]; + $result = $this->instance->multipleAspects(10, $tracker); + + $this->assertEquals(20, $result); + $this->assertTrue($tracker->before); + $this->assertTrue($tracker->after); + + $this->assertCount(2, LoggingAspect::$logs); + $this->assertEquals('logging_before', LoggingAspect::$logs[0]['type']); + $this->assertEquals('logging_after', LoggingAspect::$logs[1]['type']); + $this->assertEquals(20, LoggingAspect::$logs[1]['result']); + } + + public function testSingleBasicAspect(): void + { + $tracker = (object) ['before' => false, 'after' => false]; + $result = $this->instance->onlyBasic(5, $tracker); + + $this->assertEquals(6, $result); + $this->assertTrue($tracker->before); + $this->assertTrue($tracker->after); + $this->assertEmpty(LoggingAspect::$logs); + } + + public function testSingleLoggingAspect(): void + { + $result = $this->instance->onlyLogging('hello'); + + $this->assertEquals('HELLO', $result); + $this->assertCount(2, LoggingAspect::$logs); + $this->assertEquals('logging_before', LoggingAspect::$logs[0]['type']); + $this->assertEquals('logging_after', LoggingAspect::$logs[1]['type']); + } + + public function testNoAspects(): void + { + $result = $this->instance->noAspects(); + $this->assertEquals('plain', $result); + $this->assertEmpty(LoggingAspect::$logs); + } + + public function testAspectsReceiveCorrectArguments(): void + { + $tracker = (object) ['before' => false, 'after' => false]; + $this->instance->multipleAspects(15, $tracker); + + $this->assertEquals([15, $tracker], LoggingAspect::$logs[0]['args']); + } +} diff --git a/tests/Aspects/BasicAspect.php b/tests/Aspects/BasicAspect.php new file mode 100644 index 0000000..84abeb6 --- /dev/null +++ b/tests/Aspects/BasicAspect.php @@ -0,0 +1,23 @@ +object = end($args); + $this->object->before = true; + } + + public function after(object|string $target, mixed $return): mixed + { + $this->object->after = true; + return $return; + } +} diff --git a/tests/Aspects/ConfigurableAspect.php b/tests/Aspects/ConfigurableAspect.php new file mode 100644 index 0000000..38a12a9 --- /dev/null +++ b/tests/Aspects/ConfigurableAspect.php @@ -0,0 +1,60 @@ +enabled) { + self::$executionLog[] = [ + 'event' => 'before', + 'prefix' => $this->prefix, + 'multiplier' => $this->multiplier, + 'args' => $args, + ]; + } + } + + public function after(object|string $target, mixed $result): mixed + { + if (!$this->enabled) { + return $result; + } + + self::$executionLog[] = [ + 'event' => 'after', + 'prefix' => $this->prefix, + 'multiplier' => $this->multiplier, + 'result' => $result, + ]; + + // Modify result based on constructor parameters + if (is_int($result)) { + return $result * $this->multiplier; + } + + if (is_string($result)) { + return $this->prefix . $result; + } + + return $result; + } + + public static function reset(): void + { + self::$executionLog = []; + } +} diff --git a/tests/Aspects/LoggingAspect.php b/tests/Aspects/LoggingAspect.php new file mode 100644 index 0000000..70fa4c5 --- /dev/null +++ b/tests/Aspects/LoggingAspect.php @@ -0,0 +1,27 @@ + 'logging_before', 'args' => $args]; + } + + public function after(object|string $target, mixed $result): mixed + { + self::$logs[] = ['type' => 'logging_after', 'result' => $result]; + return $result; + } + + public static function clearLogs(): void + { + self::$logs = []; + } +} diff --git a/tests/Aspects/ModifyingAspect.php b/tests/Aspects/ModifyingAspect.php new file mode 100644 index 0000000..90375f2 --- /dev/null +++ b/tests/Aspects/ModifyingAspect.php @@ -0,0 +1,24 @@ + 'before', 'args' => $args]; + } + + public function after(object|string $target, mixed $result): mixed + { + self::$calls[] = ['event' => 'after', 'result' => $result]; + return $result; + } + + public static function clearCalls(): void + { + self::$calls = []; + } + + public static function getLastBefore(): ?array + { + foreach (array_reverse(self::$calls) as $call) { + if ($call['event'] === 'before') { + return $call; + } + } + return null; + } + + public static function getLastAfter(): ?array + { + foreach (array_reverse(self::$calls) as $call) { + if ($call['event'] === 'after') { + return $call; + } + } + return null; + } +} diff --git a/tests/AttributeArgumentsTest.php b/tests/AttributeArgumentsTest.php new file mode 100644 index 0000000..ed3fa68 --- /dev/null +++ b/tests/AttributeArgumentsTest.php @@ -0,0 +1,163 @@ +instance = new ConfigurableClass(); + ConfigurableAspect::reset(); + } + + public function testAttributeArgumentsArePassedToConstructor(): void + { + $result = $this->instance->customConfigMethod(); + + // Verify the result was modified using the constructor arguments + $this->assertEquals('PREFIX:value', $result); + + // Verify the execution log contains the constructor parameters + $this->assertCount(2, ConfigurableAspect::$executionLog); + + $beforeLog = ConfigurableAspect::$executionLog[0]; + $this->assertEquals('before', $beforeLog['event']); + $this->assertEquals('PREFIX:', $beforeLog['prefix']); + $this->assertEquals(10, $beforeLog['multiplier']); + + $afterLog = ConfigurableAspect::$executionLog[1]; + $this->assertEquals('after', $afterLog['event']); + $this->assertEquals('PREFIX:', $afterLog['prefix']); + $this->assertEquals(10, $afterLog['multiplier']); + } + + public function testDifferentMethodsCanHaveDifferentConfigurations(): void + { + $result1 = $this->instance->customConfigMethod(); + ConfigurableAspect::reset(); + + $result2 = $this->instance->anotherMethod(); + + // First method uses prefix: 'PREFIX:', multiplier: 10 + $this->assertEquals('PREFIX:value', $result1); + + // Second method uses prefix: 'BEFORE_', multiplier: 5 + $this->assertEquals(35, $result2); // 7 * 5 + + // Verify execution log for second method + $afterLog = ConfigurableAspect::$executionLog[1]; + $this->assertEquals('BEFORE_', $afterLog['prefix']); + $this->assertEquals(5, $afterLog['multiplier']); + } + + public function testDisabledAspectDoesNotModifyResult(): void + { + $result = $this->instance->disabledMethod(); + + // enabled: false should prevent modification + $this->assertEquals('should not be modified', $result); + + // No logs should be created when disabled + $this->assertEmpty(ConfigurableAspect::$executionLog); + } + + public function testDefaultConstructorArgumentsAreUsed(): void + { + $result = $this->instance->defaultConfigMethod(); + + // Default multiplier is 1, so 42 * 1 = 42 + $this->assertEquals(42, $result); + + // Verify default values in execution log + $beforeLog = ConfigurableAspect::$executionLog[0]; + $this->assertEquals('default', $beforeLog['prefix']); + $this->assertEquals(1, $beforeLog['multiplier']); + } + + public function testConstructorArgumentsAffectBehavior(): void + { + // Test with multiplier + $result = $this->instance->anotherMethod(); + $this->assertEquals(35, $result); // 7 * 5 = 35 + + ConfigurableAspect::reset(); + + // Test with prefix + $result = $this->instance->customConfigMethod(); + $this->assertEquals('PREFIX:value', $result); + } + + public function testEachConstructorArgumentIsActuallyUsed(): void + { + // Test 1: Verify 'prefix' argument is used + ConfigurableAspect::reset(); + $result = $this->instance->customConfigMethod(); + $this->assertEquals('PREFIX:value', $result, 'prefix argument should prepend to string result'); + $afterLog = ConfigurableAspect::$executionLog[1]; + $this->assertEquals('PREFIX:', $afterLog['prefix'], 'prefix constructor argument should be stored and used'); + + // Test 2: Verify 'multiplier' argument is used + ConfigurableAspect::reset(); + $result = $this->instance->anotherMethod(); + $this->assertEquals(35, $result, 'multiplier argument should multiply integer result (7 * 5 = 35)'); + $afterLog = ConfigurableAspect::$executionLog[1]; + $this->assertEquals(5, $afterLog['multiplier'], 'multiplier constructor argument should be stored and used'); + + // Test 3: Verify 'enabled' argument is used + ConfigurableAspect::reset(); + $result = $this->instance->disabledMethod(); + $this->assertEquals('should not be modified', $result, 'enabled=false should prevent result modification'); + $this->assertEmpty(ConfigurableAspect::$executionLog, 'enabled=false should prevent logging'); + } + + public function testPartialConstructorArgumentsWithDefaults(): void + { + // disabledMethod uses only 'enabled: false', should use defaults for prefix and multiplier + ConfigurableAspect::reset(); + $this->instance->disabledMethod(); + + // Since enabled=false, no logs - but we can test with default values + ConfigurableAspect::reset(); + $result = $this->instance->defaultConfigMethod(); + + // Should use all defaults: prefix='default', multiplier=1, enabled=true + $this->assertEquals(42, $result, 'default multiplier (1) should not change result'); + + $beforeLog = ConfigurableAspect::$executionLog[0]; + $this->assertEquals('default', $beforeLog['prefix'], 'default prefix should be "default"'); + $this->assertEquals(1, $beforeLog['multiplier'], 'default multiplier should be 1'); + + $afterLog = ConfigurableAspect::$executionLog[1]; + $this->assertEquals('default', $afterLog['prefix'], 'default prefix should persist in after hook'); + $this->assertEquals(1, $afterLog['multiplier'], 'default multiplier should persist in after hook'); + } + + public function testAllThreeConstructorArgumentsSimultaneously(): void + { + ConfigurableAspect::reset(); + + // customConfigMethod uses: prefix='PREFIX:', multiplier=10, enabled=true (default) + $result = $this->instance->customConfigMethod(); + + // Verify all three arguments are working together + $this->assertEquals('PREFIX:value', $result); + + $beforeLog = ConfigurableAspect::$executionLog[0]; + $this->assertEquals('before', $beforeLog['event']); + $this->assertEquals('PREFIX:', $beforeLog['prefix'], 'First argument (prefix) should be used'); + $this->assertEquals(10, $beforeLog['multiplier'], 'Second argument (multiplier) should be used'); + // Third argument (enabled=true) is verified by the fact that logs exist + + $afterLog = ConfigurableAspect::$executionLog[1]; + $this->assertEquals('after', $afterLog['event']); + $this->assertEquals('PREFIX:', $afterLog['prefix'], 'First argument should persist in after hook'); + $this->assertEquals(10, $afterLog['multiplier'], 'Second argument should persist in after hook'); + + // Verify the log exists (proving enabled=true was used) + $this->assertCount(2, ConfigurableAspect::$executionLog, 'Third argument (enabled=true) should allow logging'); + } +} diff --git a/tests/BasicAspectTest.php b/tests/BasicAspectTest.php new file mode 100644 index 0000000..055550a --- /dev/null +++ b/tests/BasicAspectTest.php @@ -0,0 +1,17 @@ + false, 'after' => false]; + $c = new WrappedClass(); + $r = $c->wrappedMethod(1, 3, $sideEffect); + $this->assertEquals(4, $r); + $this->assertTrue($sideEffect->before); + $this->assertTrue($sideEffect->after); + } +} diff --git a/tests/Classes/ConfigurableClass.php b/tests/Classes/ConfigurableClass.php new file mode 100644 index 0000000..4529182 --- /dev/null +++ b/tests/Classes/ConfigurableClass.php @@ -0,0 +1,32 @@ + 'value']; + } +} diff --git a/tests/Classes/ParameterTypesClass.php b/tests/Classes/ParameterTypesClass.php new file mode 100644 index 0000000..334f126 --- /dev/null +++ b/tests/Classes/ParameterTypesClass.php @@ -0,0 +1,66 @@ + true]); + } + + #[TrackingAspect] + public function withDefaultValues(string $name = 'default', int $age = 0): string + { + return "$name:$age"; + } + + #[TrackingAspect] + public function voidReturn(): void + { + } + + #[TrackingAspect] + protected function protectedMethod(string $value): string + { + return strtoupper($value); + } + + public function callProtected(string $value): string + { + return $this->protectedMethod($value); + } +} diff --git a/tests/Classes/StackedAspectsClass.php b/tests/Classes/StackedAspectsClass.php new file mode 100644 index 0000000..aa2148d --- /dev/null +++ b/tests/Classes/StackedAspectsClass.php @@ -0,0 +1,33 @@ +instance = new ParameterTypesClass(); + TrackingAspect::clearCalls(); + } + + public function testNullableParameter(): void + { + $result = $this->instance->withNullable('test'); + $this->assertEquals('test', $result); + $this->assertCount(2, TrackingAspect::$calls); + + TrackingAspect::clearCalls(); + $result = $this->instance->withNullable(null); + $this->assertNull($result); + $this->assertCount(2, TrackingAspect::$calls); + } + + public function testVariadicParameters(): void + { + $result = $this->instance->withVariadic('a', 'b', 'c'); + $this->assertEquals(['a', 'b', 'c'], $result); + + $result = $this->instance->withVariadic(); + $this->assertEquals([], $result); + } + + public function testReferenceParameter(): void + { + $counter = 5; + $this->instance->withReference($counter); + $this->assertEquals(6, $counter); + } + + public function testUnionType(): void + { + $result = $this->instance->withUnionType(42); + $this->assertEquals(42, $result); + + $result = $this->instance->withUnionType('hello'); + $this->assertEquals('hello', $result); + } + + public function testMixedType(): void + { + $result = $this->instance->withMixed(['key' => 'value']); + $this->assertEquals(['key' => 'value'], $result); + + $result = $this->instance->withMixed('string'); + $this->assertEquals('string', $result); + + $result = $this->instance->withMixed(null); + $this->assertNull($result); + } + + public function testArrayParameter(): void + { + $result = $this->instance->withArray(['original' => true]); + $this->assertEquals(['original' => true, 'processed' => true], $result); + } + + public function testDefaultValues(): void + { + $result = $this->instance->withDefaultValues(); + $this->assertEquals('default:0', $result); + + $result = $this->instance->withDefaultValues('John', 25); + $this->assertEquals('John:25', $result); + + $result = $this->instance->withDefaultValues('Jane'); + $this->assertEquals('Jane:0', $result); + } + + public function testVoidReturnType(): void + { + $this->instance->voidReturn(); + $this->assertCount(2, TrackingAspect::$calls); + $this->assertEquals('before', TrackingAspect::$calls[0]['event']); + $this->assertEquals('after', TrackingAspect::$calls[1]['event']); + $this->assertNull(TrackingAspect::$calls[1]['result']); + } + + public function testProtectedMethod(): void + { + $result = $this->instance->callProtected('hello'); + $this->assertEquals('HELLO', $result); + } +} diff --git a/tests/ReturnModificationTest.php b/tests/ReturnModificationTest.php new file mode 100644 index 0000000..bef8346 --- /dev/null +++ b/tests/ReturnModificationTest.php @@ -0,0 +1,91 @@ +instance = new ModifyingClass(); + ModifyingAspect::reset(); + } + + public function testAfterReturningOriginalValueDoesNotModifyResult(): void + { + ModifyingAspect::$modifier = fn($result) => $result; + + $result = $this->instance->getValue(); + + $this->assertEquals(42, $result); + } + + public function testAfterCanModifyIntegerResult(): void + { + ModifyingAspect::$modifier = fn($result) => $result * 2; + + $result = $this->instance->getValue(); + + $this->assertEquals(84, $result); + } + + public function testAfterCanModifyStringResult(): void + { + ModifyingAspect::$modifier = fn($result) => strtoupper($result); + + $result = $this->instance->getString(); + + $this->assertEquals('ORIGINAL', $result); + } + + public function testAfterCanIncrementIntegerResult(): void + { + ModifyingAspect::$modifier = fn($result) => $result + 100; + + $result = $this->instance->getValue(); + + $this->assertEquals(142, $result); + } + + public function testAfterCanModifyArrayResult(): void + { + ModifyingAspect::$modifier = fn($result) => array_merge($result, ['added' => 'new']); + + $result = $this->instance->getArray(); + + $this->assertEquals(['key' => 'value', 'added' => 'new'], $result); + } + + public function testAfterCanFilterArrayResult(): void + { + ModifyingAspect::$modifier = fn($result) => array_filter($result, fn($v) => $v !== 'value'); + + $result = $this->instance->getArray(); + + $this->assertEquals([], $result); + } + + public function testMultipleCallsWithDifferentModifiers(): void + { + ModifyingAspect::$modifier = fn($result) => $result * 2; + $result1 = $this->instance->getValue(); + + ModifyingAspect::$modifier = fn($result) => $result + 10; + $result2 = $this->instance->getValue(); + + $this->assertEquals(84, $result1); + $this->assertEquals(52, $result2); + } + + public function testNoModifierReturnsOriginalValue(): void + { + // ModifyingAspect::$modifier is null, so after() returns original value + + $result = $this->instance->getValue(); + + $this->assertEquals(42, $result); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..aed571a --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,39 @@ +withClasses([ + WrappedClass::class, + ParameterTypesClass::class, + StackedAspectsClass::class, + ThrowingClass::class, + ModifyingClass::class, + ConfigurableClass::class, + ]) + ->build($weaver) + ->register();