Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ Changelog
4.0.0 (unreleased)
* [BC BREAK] Requires PHP 8.4+
* [BC BREAK] Proxy engine switched from inheritance-based to **trait-based**: the original class body is converted to a PHP trait (`Foo__AopProxied`) and the proxy class uses it via `use` with private method aliases instead of extending the renamed class. This removes the `__AopProxied` parent from the inheritance chain.
* [BC BREAK] All invocation class constructors (`DynamicTraitAliasMethodInvocation`, `StaticTraitAliasMethodInvocation`, `ReflectionFunctionInvocation`) now require a `Closure $closureToCall` parameter (non-nullable). Generated proxy code always passes a first-class callable: `$this->__aop__method(...)` for own instance methods, `self::__aop__method(...)` for own static methods, `parent::method(...)` for inherited methods, and `\functionName(...)` for functions.
* [Feature] **Private method interception** — both dynamic (`private function foo()`) and static (`private static function bar()`) private methods can now be intercepted by aspects. This was impossible with the old extend-based engine because PHP does not allow overriding private methods in subclasses.
* [Feature] **PHP 8.1+ enum interception** — instance and static methods on both unit (pure) and backed enums can now be intercepted by aspects. The enum body is extracted into a trait (`Foo__AopProxied`); a proxy enum re-declares the cases and dispatches intercepted methods via per-method `static $__joinPoint` caching. Built-in enum methods (`cases`, `from`, `tryFrom`) and initialization joinpoints are never woven.
* [Feature] `self::` in proxied classes now resolves to the proxy class naturally (via PHP trait semantics), removing the need for `SelfValueTransformer`.
* [Feature] **First-class callable syntax** — generated proxy code and invocation constructors use PHP 8.1+ first-class callable syntax (`$this->__aop__method(...)`, `parent::method(...)`, `\func(...)`) to reference original method and function bodies, eliminating the need for `Closure::bind` at construction time.
* [Removed] `SelfValueTransformer` and `SelfValueVisitor` — no longer needed with the trait-based engine.
* [Performance] Pre-bound `Closure::bind` closures replace per-call `ReflectionMethod::getClosure()` + rebind in the method invocation proceed path, eliminating reflection overhead on every intercepted call.
* [Performance] **Direct static joinpoint initialization** — leveraging PHP 8.3+ support for dynamic expressions in static variable initializers, all generated proxy method bodies now initialize their static joinpoint variables directly.

3.0.0 (December 4, 2019)
Expand Down
17 changes: 7 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,8 @@ class Foo extends OriginalParent implements OriginalInterfaces, \Go\Aop\Proxy
}

public function interceptedMethod(ArgType $arg): ReturnType {
/** @var \Go\Aop\Intercept\DynamicMethodInvocation<self, ReturnType>|null $__joinPoint */
static $__joinPoint;
if ($__joinPoint === null) {
$__joinPoint = \Go\Aop\Framework\InterceptorInjector::forMethod(self::class, 'interceptedMethod', [...]);
}
/** @var \Go\Aop\Intercept\DynamicMethodInvocation<self, ReturnType> $__joinPoint */
static $__joinPoint = \Go\Aop\Framework\InterceptorInjector::forMethod(self::class, 'interceptedMethod', [...], $this->__aop__interceptedMethod(...));
return $__joinPoint->__invoke($this, [$arg]);
}
// ... one override per intercepted method
Expand All @@ -95,7 +92,7 @@ Key properties of this engine:
- The proxy class **re-inherits** the original parent and interfaces (read from reflection, not from the woven source).
- `self::` in the trait body resolves to `Foo` (the proxy class) — no rewriting needed.
- **Private methods can be intercepted** (impossible with the old extend-based engine).
- Proceed path uses a pre-bound `Closure::bind($fn, null, Foo::class)` stored once per join point — zero per-call reflection.
- Each generated proxy method passes a **first-class callable** as the 4th arg to `InterceptorInjector`: `$this->__aop__method(...)` for own dynamic methods, `self::__aop__method(...)` for own static methods, `parent::method(...)` for inherited methods (no trait alias), and `\functionName(...)` for function proxies.

### Proxy generation (`src/Proxy/`)

Expand Down Expand Up @@ -124,11 +121,11 @@ Key properties of this engine:
- `FieldAccess<T of object, V = mixed>` — `T` is the declaring class; `V` is the property type (narrows `getValue()`, `getValueToSet()`, `__invoke()` return)
- The proxy generators (`ClassProxyGenerator`, `TraitProxyGenerator`, `EnumProxyGenerator`, `FunctionProxyGenerator`) use `TypeGenerator::renderTypeForPhpDoc()` to extract the return type from `ReflectionMethod`/`ReflectionFunction` and emit it as the second generic argument in the per-method `@var` annotation. This gives IDE and PHPStan full type-awareness on `$__joinPoint->__invoke(...)` calls.
- `src/Aop/Framework/` — concrete invocation implementations:
- `AbstractMethodInvocation` — base class; holds `protected Closure $closureToCall` (set in each subclass constructor via `Closure::bind`); `TRAIT_ALIAS_PREFIX = '__aop__'` constant; manages recursive/cross-call stack frames
- `DynamicTraitAliasMethodInvocation` — instance-method invocation; builds `Closure::bind(fn($i, $a) => $i->__aop__method(...$a), null, $class)` once at construction; `proceed()` calls `($this->closureToCall)($this->instance, $this->arguments)`
- `StaticTraitAliasMethodInvocation` — static-method invocation; builds `Closure::bind(fn($c, $a) => $c::__aop__method(...$a), null, $class)` once at construction; `proceed()` calls `($this->closureToCall)($this->scope, $this->arguments)`
- `AbstractMethodInvocation` — base class; holds `protected readonly Closure $closureToCall` (required, 4th constructor argument) — a first-class callable to the original method body; `TRAIT_ALIAS_PREFIX = '__aop__'` constant; manages recursive/cross-call stack frames
- `DynamicTraitAliasMethodInvocation` — instance-method invocation; receives `$this->__aop__method(...)` (own methods) or `parent::method(...)` (inherited) as `$closureToCall`; resolves a `private ReflectionMethod $originalMethodToCall` from the `__aop__` alias or `getPrototype()` and calls `invokeArgs($this->instance, $this->arguments)` in `proceed()` — faster than `Closure::call()` and correctly handles pass-by-reference args
- `StaticTraitAliasMethodInvocation` — static-method invocation; wraps `$closureToCall` in a `static fn(array $args) => forward_static_call($callable, ...$args)` shim and stores it in `$closureToCall`; `bindTo(null, $scope)` forwards the correct LSB class per call; `proceed()` calls `($this->closureToCall)(...$this->arguments)`
- `ReflectionConstructorInvocation` — constructor interception (used with `INTERCEPT_INITIALIZATIONS`); creates instance via `ReflectionClass::newInstanceWithoutConstructor()` then calls constructor
- `ReflectionFunctionInvocation` — function interception; `proceed()` calls `$this->reflectionFunction->invokeArgs($this->arguments)`
- `ReflectionFunctionInvocation` — function interception; receives a first-class callable to the original global function (e.g. `\strlen(...)` with leading `\` to avoid recursive proxy call); `proceed()` calls `($this->closureToCall)(...$this->arguments)` directly
- `ClassFieldAccess` — property interception join point; used via generated native property hooks on proxied properties
- `StaticInitializationJoinpoint` — fired once after proxy class is loaded via `injectJoinPoints()`
- `src/Aop/Pointcut/` — LALR pointcut grammar (`PointcutGrammar`, `PointcutParser`, `PointcutLexer`, `PointcutParseTable`) and pointcut combinators (`AndPointcut`, `OrPointcut`, `NotPointcut`, `NamePointcut`, `AttributePointcut`, etc.)
Expand Down
68 changes: 11 additions & 57 deletions src/Aop/Framework/AbstractMethodInvocation.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,10 @@
use Go\Aop\Intercept\Interceptor;
use Go\Aop\Intercept\MethodInvocation;
use ReflectionMethod;
use function array_merge;
use function array_pop;
use function count;

/**
* Abstract method invocation implementation
*
* @phpstan-type MethodInvocationFrame array{list<mixed>, mixed, int}
*
* @template T of object Declares the instance type of the method invocation.
* @template V Declares the generic return type of the method invocation.
* @implements MethodInvocation<T, V>
Expand All @@ -40,70 +35,29 @@ abstract class AbstractMethodInvocation extends AbstractInvocation implements Me
protected readonly ReflectionMethod $reflectionMethod;

/**
* Pre-bound closure that calls the private `__aop__<method>` alias on any instance of the proxy class.
* Created once in the constructor via Closure::bind bound to the proxy class scope.
*/
protected Closure $closureToCall;

/**
* This static string variable holds the name of field to use to avoid extra "if" section in the __invoke method
* First-class callable pointing to the original method.
* May be wrapped or rebound if needed in child classes or during the {@see proceed()} call.
*
* Overridden in children classes and initialized via LSB
* @link https://www.php.net/manual/en/functions.first_class_callable_syntax.php
*/
protected static string $propertyName;

/**
* Stack frames to work with recursive calls or with cross-calls inside object
*
* @var array<int, MethodInvocationFrame>
*/
private array $stackFrames = [];
protected readonly Closure $closureToCall;

/**
* Constructor for method invocation
*
* @param array<Interceptor> $advices List of advices for this invocation
* @param class-string<T> $className Class, containing method to invoke
* @param non-empty-string $methodName Name of the method to invoke
* @param array<Interceptor> $advices List of advices for this invocation
* @param class-string<T> $className Class, containing method to invoke
* @param non-empty-string $methodName Name of the method to invoke
* @param Closure $closureToCall First-class callable to the original method body.
*/
public function __construct(array $advices, string $className, string $methodName)
public function __construct(array $advices, string $className, string $methodName, Closure $closureToCall)
{
parent::__construct($advices);
$this->closureToCall = $closureToCall;
$this->reflectionMethod = new ReflectionMethod($className, $methodName);
}

final public function __invoke(object|string $instanceOrScope, array $arguments = [], array $variadicArguments = []): mixed
{
if ($this->level > 0) {
$this->stackFrames[] = [$this->arguments, $this->{static::$propertyName}, $this->current];
}

if (count($variadicArguments) > 0) {
$arguments = array_merge($arguments, $variadicArguments);
}

try {
++$this->level;

$this->current = 0;
$this->arguments = $arguments;

$this->{static::$propertyName} = $instanceOrScope;

return $this->proceed();
} finally {
--$this->level;

if ($this->level > 0 && ($stackFrame = array_pop($this->stackFrames))) {
[$this->arguments, $this->{static::$propertyName}, $this->current] = $stackFrame;
} else {
unset($this->{static::$propertyName});
$this->arguments = [];
}
}
}

public function getMethod(): ReflectionMethod
final public function getMethod(): ReflectionMethod
{
return $this->reflectionMethod;
}
Expand Down
99 changes: 68 additions & 31 deletions src/Aop/Framework/DynamicTraitAliasMethodInvocation.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,60 +12,100 @@

namespace Go\Aop\Framework;

use Closure;
use Go\Aop\Intercept\DynamicMethodInvocation;
use Go\Aop\Intercept\Interceptor;
use ReflectionFunction;
use ReflectionMethod;

/**
* Dynamic trait-alias method invocation calls instance methods via a pre-bound Closure::bind closure
* targeting the private `__aop__<method>` alias created in the proxy's trait-use block.
* Dynamic trait-alias method invocation calls instance methods via reflection.
*
* The closure is built once at construction time so that every invocation needs zero reflection.
* The callable is provided by the generated proxy code and points to the original method body:
* - For methods declared in the proxied class: `$this->__aop__<method>(...)` — the private
* alias created in the proxy's trait-use block.
* - For inherited methods (no trait alias): `parent::<method>(...)`.
*
* Note: `ReflectionMethod::invokeArgs()` is used in {@see proceed()} because it is faster than
* `Closure::call()` (see https://3v4l.org/DYj84) and reliably handles pass-by-reference
* parameters (unlike `Closure::call()` which has known issues with by-ref args, see
* https://bugs.php.net/bug.php?id=72326).
*
* @template T of object = object Declares the instance type of the method invocation.
* @template V = mixed Declares the generic return type of the method invocation.
* @extends AbstractMethodInvocation<T, V>
* @implements DynamicMethodInvocation<T, V>
*
* @phpstan-type DynamicMethodInvocationFrame array{list<mixed>, T, int}
*/
final class DynamicTraitAliasMethodInvocation extends AbstractMethodInvocation implements DynamicMethodInvocation
{
/**
* For dynamic calls we store given argument as 'instance' property
* Stack frames to work with recursive calls or with cross-calls inside object
*
* @see parent::__invoke() method to find out how this optimization works
* @see $instance Property, which is referenced by this static property
* @var array<int, DynamicMethodInvocationFrame>
*/
protected static string $propertyName = 'instance';
private array $stackFrames = [];

/**
* @phpstan-var T Instance of object for invoking, should be protected as it's read in parent class
* @see parent::__invoke() where this variable is accessed via {@see $propertyName} value
* @phpstan-var T Instance of object for invoking
*/
protected object $instance;
private object $instance;

/**
* We may have either original method in the same class via trait alias or prototype method
* from one of our parents.
* ReflectionMethod pointing to the original method body:
* - For methods with a trait alias: the private `__aop__<method>` alias.
* - For inherited methods without a trait alias: the prototype method from the parent class.
*/
private ReflectionMethod $originalMethodToCall;
private readonly ReflectionMethod $originalMethodToCall;

/**
* Constructor for method invocation
*
* @param class-string<T> $className Class, containing method to invoke
* @param array<Interceptor> $advices List of advices for this invocation
* @param class-string<T> $className Class, containing method to invoke
* @param non-empty-string $methodName Name of the method to invoke
* @param Closure $closureToCall First-class callable to the original method body,
* e.g. `$this->__aop__method(...)` for trait-aliased
* methods or `parent::method(...)` for inherited ones.
*/
public function __construct(array $advices, string $className, string $methodName)
public function __construct(array $advices, string $className, string $methodName, Closure $closureToCall)
{
parent::__construct($advices, $className, $methodName);
$aliasName = self::TRAIT_ALIAS_PREFIX . $methodName;
Comment thread
lisachenko marked this conversation as resolved.
if (method_exists($className, $aliasName)) {
$methodToCall = new ReflectionMethod($className, $aliasName);
} elseif ($this->reflectionMethod->hasPrototype()) {
$methodToCall = $this->reflectionMethod->getPrototype();
} else {
throw new \LogicException("Cannot proceed with method invocation for {$methodName}: no trait alias and no method prototype found for {$className}");
parent::__construct($advices, $className, $methodName, $closureToCall);

// Logic with reflection is used as workaround for PHP bug https://bugs.php.net/bug.php?id=72326
$reflectionClosure = new ReflectionFunction($closureToCall);
$closureScopeClass = $reflectionClosure->getClosureScopeClass();
if ($closureScopeClass === null) {
throw new \RuntimeException('Cannot determine the scope class of the closure');
}
$this->originalMethodToCall = new ReflectionMethod(
$closureScopeClass->getName(),
$reflectionClosure->getName()
);
}

final public function __invoke(object $instance, array $arguments = [], array $variadicArguments = []): mixed
{
if ($this->level > 0) {
$this->stackFrames[] = [$this->arguments, $this->instance, $this->current];
}
if ($variadicArguments !== []) {
$arguments = [...$arguments, ...$variadicArguments];
}
try {
++$this->level;
$this->current = 0;
$this->arguments = $arguments;
$this->instance = $instance;
return $this->proceed();
} finally {
--$this->level;
if ($this->level > 0 && ($stackFrame = array_pop($this->stackFrames))) {
[$this->arguments, $this->instance, $this->current] = $stackFrame;
} else {
unset($this->instance);
$this->arguments = [];
}
}
$this->originalMethodToCall = $methodToCall;
$this->closureToCall = static fn(object $instanceToCall, array $argumentsToCall): mixed => $methodToCall->invokeArgs($instanceToCall, $argumentsToCall);
}

/**
Expand All @@ -74,12 +114,9 @@ public function __construct(array $advices, string $className, string $methodNam
public function proceed(): mixed
{
if (isset($this->advices[$this->current])) {
$currentInterceptor = $this->advices[$this->current++];

return $currentInterceptor->invoke($this);
return $this->advices[$this->current++]->invoke($this);
}

// Bypassing ($this->closureToCall)($this->instance, $this->arguments) for performance reasons
return $this->originalMethodToCall->invokeArgs($this->instance, $this->arguments);
Comment thread
lisachenko marked this conversation as resolved.
}

Expand Down
Loading
Loading