.
This commit is contained in:
commit
a5ce423afe
30 changed files with 1807 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/vendor/
|
||||||
|
composer.lock
|
||||||
191
README.md
Normal file
191
README.md
Normal 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
28
composer.json
Normal 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
11
phpunit.xml
Normal 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
19
src/Aspect.php
Normal 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
40
src/AspectBuilder.php
Normal 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
18
src/AspectException.php
Normal 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
77
src/AspectLoader.php
Normal 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
446
src/AspectWeaver.php
Normal 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
21
src/AttributeMetadata.php
Normal 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
21
src/MethodMetadata.php
Normal 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,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
58
tests/AspectExceptionTest.php
Normal file
58
tests/AspectExceptionTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
tests/AspectStackingTest.php
Normal file
67
tests/AspectStackingTest.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
tests/Aspects/BasicAspect.php
Normal file
23
tests/Aspects/BasicAspect.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
60
tests/Aspects/ConfigurableAspect.php
Normal file
60
tests/Aspects/ConfigurableAspect.php
Normal 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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
27
tests/Aspects/LoggingAspect.php
Normal file
27
tests/Aspects/LoggingAspect.php
Normal 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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
24
tests/Aspects/ModifyingAspect.php
Normal file
24
tests/Aspects/ModifyingAspect.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
tests/Aspects/ThrowingAspect.php
Normal file
34
tests/Aspects/ThrowingAspect.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
47
tests/Aspects/TrackingAspect.php
Normal file
47
tests/Aspects/TrackingAspect.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
163
tests/AttributeArgumentsTest.php
Normal file
163
tests/AttributeArgumentsTest.php
Normal 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
17
tests/BasicAspectTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
tests/Classes/ConfigurableClass.php
Normal file
32
tests/Classes/ConfigurableClass.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
tests/Classes/ModifyingClass.php
Normal file
26
tests/Classes/ModifyingClass.php
Normal 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'];
|
||||||
|
}
|
||||||
|
}
|
||||||
66
tests/Classes/ParameterTypesClass.php
Normal file
66
tests/Classes/ParameterTypesClass.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
tests/Classes/StackedAspectsClass.php
Normal file
33
tests/Classes/StackedAspectsClass.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
14
tests/Classes/ThrowingClass.php
Normal file
14
tests/Classes/ThrowingClass.php
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Classes;
|
||||||
|
|
||||||
|
use Tests\Aspects\ThrowingAspect;
|
||||||
|
|
||||||
|
class ThrowingClass
|
||||||
|
{
|
||||||
|
#[ThrowingAspect]
|
||||||
|
public function methodWithAspect(): string
|
||||||
|
{
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
}
|
||||||
14
tests/Classes/WrappedClass.php
Normal file
14
tests/Classes/WrappedClass.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
98
tests/ParameterTypesTest.php
Normal file
98
tests/ParameterTypesTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
tests/ReturnModificationTest.php
Normal file
91
tests/ReturnModificationTest.php
Normal 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
39
tests/bootstrap.php
Normal 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();
|
||||||
Loading…
Add table
Add a link
Reference in a new issue