This commit is contained in:
icefox 2025-12-22 17:54:16 -03:00
commit a5ce423afe
No known key found for this signature in database
30 changed files with 1807 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/vendor/
composer.lock

191
README.md Normal file
View file

@ -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
<?php
use Fnzr\Aop\AspectLoader;
use Fnzr\Aop\Aspect;
(new AspectLoader())
->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
<?php
namespace Example;
use Fnzr\Aop\Aspect;
class Calculator
{
#[Aspect]
public function add(int $a, int $b): int
{
echo "Adding {$a} + {$b}\n";
return $a + $b;
}
#[Aspect]
protected function divide(float $a, float $b): float
{
echo "Dividing {$a} / {$b}\n";
return $a / $b;
}
// This method will NOT have aspect behavior
public function subtract(int $a, int $b): int
{
return $a - $b;
}
}
```
### 2. Use the class normally
```php
<?php
require_once __DIR__ . '/vendor/autoload.php';
use Example\Calculator;
$calc = new Calculator();
// This will output:
// before
// Adding 5 + 3
// after
$result = $calc->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)

28
composer.json Normal file
View file

@ -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"
}
}

11
phpunit.xml Normal file
View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/12.5/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true">
<testsuites>
<testsuite name="Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>

19
src/Aspect.php Normal file
View file

@ -0,0 +1,19 @@
<?php
namespace IceFox\Aspect;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
class Aspect
{
public function before(mixed ...$args): void
{
echo "before\n";
}
public function after(mixed $result): void
{
echo "after\n";
}
}

40
src/AspectBuilder.php Normal file
View file

@ -0,0 +1,40 @@
<?php
namespace IceFox\Aspect;
class AspectBuilder
{
public array $classes = [];
public array $namespaces = [];
public static function begin(): self
{
return new self();
}
/**
* @param array<int,class-string> $classes
*/
public function withClasses(array $classes): self
{
$this->classes = $classes;
return $this;
}
/**
* @param array<int,string> $namespaces
*/
public function withNamespaces(array $namespaces): self
{
$this->namespaces = $namespaces;
return $this;
}
/**
* @param array<int,class-string> $aspects
*/
public function build(AspectWeaver $weaver): AspectLoader
{
return new AspectLoader($weaver, $this->classes, $this->namespaces);
}
}

18
src/AspectException.php Normal file
View file

@ -0,0 +1,18 @@
<?php
namespace IceFox\Aspect;
use Exception;
use Throwable;
class AspectException extends Exception
{
public function __construct(
string $message,
public readonly string $aspectClass,
public readonly string $method,
?Throwable $previous = null
) {
parent::__construct($message, 0, $previous);
}
}

77
src/AspectLoader.php Normal file
View file

@ -0,0 +1,77 @@
<?php
namespace IceFox\Aspect;
class AspectLoader
{
/**
* @param array<int,class-string> $classes
* @param array<int,string> $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;
}
}

446
src/AspectWeaver.php Normal file
View file

@ -0,0 +1,446 @@
<?php
namespace IceFox\Aspect;
use Psr\Log\LoggerInterface;
use ReflectionClass;
use ReflectionMethod;
use ReflectionNamedType;
use ReflectionType;
use ReflectionUnionType;
use ReflectionIntersectionType;
class AspectWeaver
{
/**
* @param array<int,class-string> $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<object> $reflection
* @return array<int, MethodMetadata>
*/
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<int, MethodMetadata> $methodAspectsMap
*/
private function buildMergedProxyAndOriginalFile(
string $namespace,
string $shortName,
string $renamedClassName,
array $methodAspectsMap,
string $modifiedSource
): string {
$code = "<?php\n\n";
$code .= trim($modifiedSource) . "\n\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<string, MethodMetadata> $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<int, AttributeMetadata> $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<int|string,mixed> $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);
}
}

21
src/AttributeMetadata.php Normal file
View file

@ -0,0 +1,21 @@
<?php
namespace IceFox\Aspect;
use ReflectionAttribute;
/**
* Metadata about an aspect attribute applied to a method
*/
final readonly class AttributeMetadata
{
/**
* @param class-string $aspectClass
* @param ReflectionAttribute<object> $attribute
*/
public function __construct(
public string $aspectClass,
public ReflectionAttribute $attribute,
) {
}
}

21
src/MethodMetadata.php Normal file
View file

@ -0,0 +1,21 @@
<?php
namespace IceFox\Aspect;
use ReflectionMethod;
/**
* Metadata about a method and its associated aspects
*/
final readonly class MethodMetadata
{
/**
* @param ReflectionMethod $method
* @param array<int, AttributeMetadata> $aspects
*/
public function __construct(
public ReflectionMethod $method,
public array $aspects,
) {
}
}

View file

@ -0,0 +1,58 @@
<?php
use IceFox\Aspect\AspectException;
use PHPUnit\Framework\TestCase;
use Tests\Aspects\ThrowingAspect;
use Tests\Classes\ThrowingClass;
final class AspectExceptionTest extends TestCase
{
protected function setUp(): void
{
ThrowingAspect::reset();
}
public function testExceptionInBeforeIsWrappedInAspectException(): void
{
ThrowingAspect::$throwInBefore = true;
$instance = new ThrowingClass();
try {
$instance->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);
}
}

View file

@ -0,0 +1,67 @@
<?php
use PHPUnit\Framework\TestCase;
use Tests\Classes\StackedAspectsClass;
use Tests\Aspects\LoggingAspect;
final class AspectStackingTest extends TestCase
{
private StackedAspectsClass $instance;
protected function setUp(): void
{
$this->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']);
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace Tests\Aspects;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
class BasicAspect
{
public ?object $object = null;
public function before(object|string $target, mixed ...$args): void
{
$this->object = end($args);
$this->object->before = true;
}
public function after(object|string $target, mixed $return): mixed
{
$this->object->after = true;
return $return;
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace Tests\Aspects;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
class ConfigurableAspect
{
public static array $executionLog = [];
public function __construct(
public readonly string $prefix = 'default',
public readonly int $multiplier = 1,
public readonly bool $enabled = true,
) {
}
public function before(object|string $target, mixed ...$args): void
{
if ($this->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 = [];
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace Tests\Aspects;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
class LoggingAspect
{
public static array $logs = [];
public function before(object|string $target, mixed ...$args): void
{
self::$logs[] = ['type' => '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 = [];
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace Tests\Aspects;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
class ModifyingAspect
{
public static mixed $modifier = null;
public function after(object|string $target, mixed $result): mixed
{
if (self::$modifier !== null) {
return (self::$modifier)($result);
}
return $result; // Return original value when not modifying
}
public static function reset(): void
{
self::$modifier = null;
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace Tests\Aspects;
use Attribute;
use RuntimeException;
#[Attribute(Attribute::TARGET_METHOD)]
class ThrowingAspect
{
public static bool $throwInBefore = false;
public static bool $throwInAfter = false;
public function before(object|string $target, mixed ...$args): void
{
if (self::$throwInBefore) {
throw new RuntimeException('Exception thrown in before()');
}
}
public function after(object|string $target, mixed $result): mixed
{
if (self::$throwInAfter) {
throw new RuntimeException('Exception thrown in after()');
}
return $result;
}
public static function reset(): void
{
self::$throwInBefore = false;
self::$throwInAfter = false;
}
}

View file

@ -0,0 +1,47 @@
<?php
namespace Tests\Aspects;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
class TrackingAspect
{
public static array $calls = [];
public function before(object|string $target, mixed ...$args): void
{
self::$calls[] = ['event' => '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;
}
}

View file

@ -0,0 +1,163 @@
<?php
use PHPUnit\Framework\TestCase;
use Tests\Aspects\ConfigurableAspect;
use Tests\Classes\ConfigurableClass;
final class AttributeArgumentsTest extends TestCase
{
private ConfigurableClass $instance;
protected function setUp(): void
{
$this->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');
}
}

17
tests/BasicAspectTest.php Normal file
View file

@ -0,0 +1,17 @@
<?php
use PHPUnit\Framework\TestCase;
use Tests\Classes\WrappedClass;
final class BasicAspectTest extends TestCase
{
public function testAspectMethodIsCalled(): void
{
$sideEffect = (object) ['before' => false, 'after' => false];
$c = new WrappedClass();
$r = $c->wrappedMethod(1, 3, $sideEffect);
$this->assertEquals(4, $r);
$this->assertTrue($sideEffect->before);
$this->assertTrue($sideEffect->after);
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace Tests\Classes;
use Tests\Aspects\ConfigurableAspect;
class ConfigurableClass
{
#[ConfigurableAspect(prefix: 'PREFIX:', multiplier: 10)]
public function customConfigMethod(): string
{
return 'value';
}
#[ConfigurableAspect(prefix: 'BEFORE_', multiplier: 5)]
public function anotherMethod(): int
{
return 7;
}
#[ConfigurableAspect(enabled: false)]
public function disabledMethod(): string
{
return 'should not be modified';
}
#[ConfigurableAspect] // Uses default values
public function defaultConfigMethod(): int
{
return 42;
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace Tests\Classes;
use Tests\Aspects\ModifyingAspect;
class ModifyingClass
{
#[ModifyingAspect]
public function getValue(): int
{
return 42;
}
#[ModifyingAspect]
public function getString(): string
{
return 'original';
}
#[ModifyingAspect]
public function getArray(): array
{
return ['key' => 'value'];
}
}

View file

@ -0,0 +1,66 @@
<?php
namespace Tests\Classes;
use Tests\Aspects\TrackingAspect;
class ParameterTypesClass
{
#[TrackingAspect]
public function withNullable(?string $value): ?string
{
return $value;
}
#[TrackingAspect]
public function withVariadic(string ...$items): array
{
return $items;
}
#[TrackingAspect]
public function withReference(int &$counter): void
{
$counter++;
}
#[TrackingAspect]
public function withUnionType(int|string $value): int|string
{
return $value;
}
#[TrackingAspect]
public function withMixed(mixed $data): mixed
{
return $data;
}
#[TrackingAspect]
public function withArray(array $data): array
{
return array_merge($data, ['processed' => 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);
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace Tests\Classes;
use Tests\Aspects\BasicAspect;
use Tests\Aspects\LoggingAspect;
class StackedAspectsClass
{
#[BasicAspect]
#[LoggingAspect]
public function multipleAspects(int $value, object $tracker): int
{
return $value * 2;
}
#[BasicAspect]
public function onlyBasic(int $value, object $tracker): int
{
return $value + 1;
}
#[LoggingAspect]
public function onlyLogging(string $message): string
{
return strtoupper($message);
}
public function noAspects(): string
{
return 'plain';
}
}

View file

@ -0,0 +1,14 @@
<?php
namespace Tests\Classes;
use Tests\Aspects\ThrowingAspect;
class ThrowingClass
{
#[ThrowingAspect]
public function methodWithAspect(): string
{
return 'success';
}
}

View file

@ -0,0 +1,14 @@
<?php
namespace Tests\Classes;
use Tests\Aspects\BasicAspect;
class WrappedClass
{
#[BasicAspect]
public function wrappedMethod(int $a, int $b, object $sideEffect): int
{
return $a + $b;
}
}

View file

@ -0,0 +1,98 @@
<?php
use PHPUnit\Framework\TestCase;
use Tests\Classes\ParameterTypesClass;
use Tests\Aspects\TrackingAspect;
final class ParameterTypesTest extends TestCase
{
private ParameterTypesClass $instance;
protected function setUp(): void
{
$this->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);
}
}

View file

@ -0,0 +1,91 @@
<?php
use PHPUnit\Framework\TestCase;
use Tests\Aspects\ModifyingAspect;
use Tests\Classes\ModifyingClass;
final class ReturnModificationTest extends TestCase
{
private ModifyingClass $instance;
protected function setUp(): void
{
$this->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);
}
}

39
tests/bootstrap.php Normal file
View file

@ -0,0 +1,39 @@
<?php
use IceFox\Aspect\AspectBuilder;
use IceFox\Aspect\AspectWeaver;
use Psr\Log\NullLogger;
use Tests\Aspects\BasicAspect;
use Tests\Aspects\LoggingAspect;
use Tests\Aspects\TrackingAspect;
use Tests\Aspects\ThrowingAspect;
use Tests\Aspects\ModifyingAspect;
use Tests\Aspects\ConfigurableAspect;
use Tests\Classes\WrappedClass;
use Tests\Classes\ParameterTypesClass;
use Tests\Classes\StackedAspectsClass;
use Tests\Classes\ThrowingClass;
use Tests\Classes\ModifyingClass;
use Tests\Classes\ConfigurableClass;
$cacheDir = sys_get_temp_dir() . '/cache/php-aop-cache';
$useCache = false;
$weaver = new AspectWeaver(
[BasicAspect::class, LoggingAspect::class, TrackingAspect::class, ThrowingAspect::class, ModifyingAspect::class, ConfigurableAspect::class],
$cacheDir,
$useCache,
new NullLogger(),
);
$loader = AspectBuilder::begin()
->withClasses([
WrappedClass::class,
ParameterTypesClass::class,
StackedAspectsClass::class,
ThrowingClass::class,
ModifyingClass::class,
ConfigurableClass::class,
])
->build($weaver)
->register();