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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ See [UPGRADE-4.0.md](UPGRADE-4.0.md) for the migration guide.
* [Feature] [BC BREAK] **Interface-keyed registration listeners** — new `AspectContainer::onRegistration(string $interfaceFQCN, Closure $listener)`: the listener receives the id (class-name) and the container whenever a deferred service whose id implements the interface is registered via `addLazyService()`; matching never autoloads, so an id whose class is not loaded yet reaches the listener as a candidate. Values are never touched, so laziness is preserved; with no listeners armed (production) registration stays a pure array write. The kernel arms one `Aspect::class` listener in debug mode to track aspect source files as freshness resources at registration time, resolving them through composer without loading the aspects (previously hardwired into `registerAspect()`). `AspectContainer::addResource()` is now part of the interface and `final public` on `Container` (was `final protected`).
* [Feature] [BC BREAK] **PSR-11 container** — `AspectContainer` extends `Psr\Container\ContainerInterface`: `getValue()` is renamed to `get()`, `has()` is inherited from the PSR interface. `ServiceNotFoundException` implements `NotFoundExceptionInterface`, and a container entry of the wrong type in `getService()` throws the new `Go\Aop\Exception\ContainerException` (an `InvalidConfigurationException` implementing `ContainerExceptionInterface`).
* [Feature] **Aspects from the application container** — `AspectContainer::setDelegate(ContainerInterface $delegate)` attaches the PSR-11 container of the application: `get()`/`getService()`/`has()` fall back to it for unknown ids. Aspects are registered in `configureAop()` with `addLazyService()` as before, and their factories resolve dependencies from the delegate on first use, once the application has built its container; weaving and cache warm-up never need it. The delegate is never enumerated (PSR-11 has no lookup by interface or tag), framework bridges can collect tagged services and register them.
* [Feature] [BC BREAK] **Weaving never creates aspects** — the aspect loader reads aspects from their classes and builds every aspect-method advice in the lazy static-data form of the `Interceptor` facade, which exposes the aspect class, the advice method, the order and the expression without resolving the aspect. An aspect is created on the first call of one of its advices, also one without instance properties, whose factory used to run as soon as the weaver collected the aspects (before an application container attached with `setDelegate()` existed). New `AspectContainer::getServiceIdsByInterface()` lists service ids without creating the services. `AspectLoaderExtension::load()` takes the `ReflectionClass` of the aspect only, `AbstractAspectLoaderExtension::parsePointcut()` the `ReflectionClass` of the aspect instead of the instance.
* [Feature] Console commands declare their name, description and help with `#[AsCommand]`, `bin/aspect` loads them lazily, and every command returns `Command::SUCCESS`/`Command::FAILURE` (`debug:weaving` used to return the raw error count). `cache:warmup:aop` stops cleanly on SIGINT/SIGTERM.
* [Feature] [BC BREAK] **Doctrine bridge rewritten for the trait-based engine (#671)** — new `Go\Bridge\Doctrine\WovenEntityClassLocator` lets Doctrine mapping drivers (ORM 3.6+) discover entities through the autoloader, so the woven class is mapped instead of the original source, and entities loaded before discovery are no longer missed. `MetadataLoadInterceptor` no longer rewrites metadata (the old mapped-superclass workaround only matched the extend-based engine); it now reports woven entities with intercepted mapped properties when Doctrine native lazy objects are disabled. It is registered as a plain listener: `$eventManager->addEventListener(Events::loadClassMetadata, new MetadataLoadInterceptor())` (it is no longer an `EventSubscriber`).

Expand Down
7 changes: 4 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ ApplicationAspectKernel::getInstance()->getContainer()->setDelegate($psrContaine
```

Weaving and the cache warm-up only need the class of the aspect, so they run before `setDelegate()`, even in
`bin/aspect`. The factory runs on the first advice call; a dependency that is still missing then throws
`bin/aspect`, and never create an aspect. The factory runs on the first advice call; a dependency that is still missing then throws
`ServiceNotFoundException`.

- A factory never asks the aspect container for its own id: that id resolves to the aspect being built.
Expand All @@ -75,8 +75,9 @@ Weaving and the cache warm-up only need the class of the aspect, so they run bef
(some containers can, each in its own way). Aspects are therefore never discovered in the delegate, every aspect
is registered in `configureAop()`. An integration bridge for a specific framework can collect the services by
tag or interface from its container and register them with `addLazyService()`.
- An aspect without instance properties cannot be created lazily, so its factory runs when the aspects are
collected for weaving, before the delegate exists. Such an aspect has no dependencies to inject.
- Weaving never creates an aspect: it reads the aspect class, and the advices refer to the aspect by class and
method name. Every aspect, also one without instance properties (which PHP can not make lazy), is created by its
factory on the first call of one of its advices, once the application container is attached.

## Extending the transformation pipeline

Expand Down
2 changes: 1 addition & 1 deletion phpstan-baseline-strict.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
'message' => '#^Variable method call on Go\\\\Aop\\\\Aspect\\.$#',
'identifier' => 'method.dynamicName',
'count' => 1,
'path' => __DIR__ . '/src/Aop/Framework/Interceptor.php',
'path' => __DIR__ . '/src/Aop/Framework/AbstractInterceptor.php',
];
$ignoreErrors[] = [
'message' => '#^Only booleans are allowed in &&, list\\<int\\|list\\<mixed\\>\\|\\(T of object \\= object\\)\\|null\\>\\|null given on the right side\\.$#',
Expand Down
2 changes: 1 addition & 1 deletion src/Aop/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ when `$level > 0` and pops it in `finally`. Check every change with `composer te

## Advice wiring (src/Aop/Framework/)
- The — proxy-code accessor: aspect(X::class) fetches aspect from container; advice('advisorId') resolves container-backed closure advice (unwraps Advisor/AbstractInterceptor to raw Closure)
- Interceptor — @internal factory facade with TWO construction modes: before()/after()/around()/afterThrowing(class-string<Aspect>|Closure, ?string $methodName=null, int $order=0, string $expression=''). With aspect class + method name it returns a native PHP 8.4 lazy proxy (via Go\Core\NativeLazyProxy::create) — interceptor construction, The::aspect() resolution and FCC creation all defer until first real use (invocation/ordering), so unmatched advices never instantiate their aspect; ONLY compiled advisor cache files use this lazy form. A ready Closure constructs eagerly, and generated PROXY classes deliberately use the eager `The::aspect(X::class)->method(...)` FCC form (InterceptorListGenerator): the proxy method/hook is already executing, so the interceptor is needed right now and a lazy detour would be pure overhead; The::advice('<id>') stays eager too. Free to change between releases
- Interceptor — @internal factory facade, before()/after()/around()/afterThrowing(Closure|class-string<Aspect>, ?methodName, order, expression). Aspect class + method name → native lazy proxy whose order, expression and aspectMethod are readable without initialization: the aspect loader and advisor cache files use it, so weaving never creates an aspect. A Closure constructs eagerly; generated proxies use the eager `The::aspect(X::class)->method(...)` form on purpose (reasons in the class docblock)
- GeneratedInterceptor — internal descriptor built by AbstractJoinpoint::flatAndSortAdvices() via fromAdvice(); usesContainerAdvice=true when advice closure isn't scoped to an Aspect class
- AdviceTypeEnum — Advice::getType() kind + sorting priority (before → after/afterThrowing → around → introduction); replaced AdviceBefore/AdviceAfter/AdviceAround marker interfaces
- Advice methods MUST be public (FCC calls them on the aspect instance from generated code)
Expand Down
50 changes: 42 additions & 8 deletions src/Aop/Framework/AbstractInterceptor.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,30 @@
*/
abstract class AbstractInterceptor implements InterceptorInterface, OrderedAdvice
{
/**
* Advice closure, called with the joinpoint
*/
protected readonly Closure $adviceMethod;

/**
* Aspect class and advice method of an advice given by name, null for a closure advice
*
* @var array{class-string<Aspect>, string}|null
*
* @internal Read by the weaver and the advisor cache: the lazy {@see Interceptor} facade sets it without
* initializing the interceptor, so the aspect is not created
*/
public private(set) ?array $aspectMethod = null;

/**
* Default constructor for interceptor
*
* @param Closure|array{class-string<Aspect>, string} $adviceMethod Advice closure, or the aspect class and the
* name of its advice method: the aspect is then resolved from the container ({@see The::aspect()}).
* The array is not a callable for PHP (the advice method is not static), so the type can not be callable.
*/
public function __construct(
protected readonly Closure $adviceMethod,
Closure|array $adviceMethod,
/** Order of advice invocation, lower values are invoked first */
public readonly int $adviceOrder = 0,
/**
Expand All @@ -68,7 +87,15 @@ public function __construct(
* @internal Framework-internal introspection point (e.g. debug:advisor command), not a public API
*/
public private(set) string $pointcutExpression = '',
) {}
) {
if ($adviceMethod instanceof Closure) {
$this->adviceMethod = $adviceMethod;
} else {
[$aspectClass, $methodName] = $adviceMethod;
$this->adviceMethod = The::aspect($aspectClass)->$methodName(...);
$this->aspectMethod = $adviceMethod;
}
}

/**
* Getter for extracting the advice closure from Interceptor
Expand All @@ -90,10 +117,17 @@ public function getRawAdvice(): Closure
*/
final public function compileToPhp(): Expr
{
$reflectionAdvice = new ReflectionFunction($this->adviceMethod);
$scopeReflectionClass = $reflectionAdvice->getClosureScopeClass();
if (!isset($scopeReflectionClass) || !is_subclass_of($scopeReflectionClass->name, Aspect::class)) {
throw NotCompilableException::interceptorWithoutAspect();
// An advice given by name is compiled without reading the closure: that would create the aspect
if ($this->aspectMethod !== null) {
[$aspectClass, $methodName] = $this->aspectMethod;
} else {
$reflectionAdvice = new ReflectionFunction($this->adviceMethod);
$scopeReflectionClass = $reflectionAdvice->getClosureScopeClass();
if (!isset($scopeReflectionClass) || !is_subclass_of($scopeReflectionClass->name, Aspect::class)) {
throw NotCompilableException::interceptorWithoutAspect();
}
$aspectClass = $scopeReflectionClass->name;
$methodName = $reflectionAdvice->name;
}

$factoryMethod = match ($this::class) {
Expand All @@ -105,8 +139,8 @@ final public function compileToPhp(): Expr
};

$args = [
new Arg(new ClassConstFetch(new FullyQualified($scopeReflectionClass->name), 'class')),
new Arg(new String_($reflectionAdvice->name)),
new Arg(new ClassConstFetch(new FullyQualified($aspectClass), 'class')),
new Arg(new String_($methodName)),
];
if ($this->adviceOrder !== 0) {
$args[] = new Arg(new Int_($this->adviceOrder), name: new Identifier('order'));
Expand Down
18 changes: 13 additions & 5 deletions src/Aop/Framework/GeneratedInterceptor.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,22 @@ public static function fromAdvice(string $advisorId, Advice $advice): self
throw AspectException::unsupportedAdvice($advisorId, $advice);
}

$reflectionAdvice = new ReflectionFunction($advice->getRawAdvice());
$scopeClass = $reflectionAdvice->getClosureScopeClass();
$usesContainerAdvice = $scopeClass === null || !is_subclass_of($scopeClass->name, Aspect::class);
if ($advice->aspectMethod !== null) {
// An advice given by name: reading its closure would create the aspect
[$aspectClass, $adviceMethod] = $advice->aspectMethod;
$usesContainerAdvice = false;
} else {
$reflectionAdvice = new ReflectionFunction($advice->getRawAdvice());
$scopeClass = $reflectionAdvice->getClosureScopeClass();
$usesContainerAdvice = $scopeClass === null || !is_subclass_of($scopeClass->name, Aspect::class);
$aspectClass = $usesContainerAdvice ? null : $scopeClass->name;
$adviceMethod = $usesContainerAdvice ? null : $reflectionAdvice->name;
}

return new self(
$advice->getType()->value,
$usesContainerAdvice ? null : $scopeClass->name,
$usesContainerAdvice ? null : $reflectionAdvice->name,
$aspectClass,
$adviceMethod,
$advice->adviceOrder,
$advisorId,
$usesContainerAdvice,
Expand Down
10 changes: 5 additions & 5 deletions src/Aop/Framework/Interceptor.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,15 +93,15 @@ private static function createLazily(
throw AspectException::adviceMethodNameRequired();
}

$aspectMethod = [$aspectClassOrAdvice, $methodName];

// Interceptor classes are framework-owned and lazy-compatible by construction,
// so they take the probe-free trusted path.
return NativeLazyProxy::create(
$interceptorClass,
static fn(): AbstractInterceptor => new $interceptorClass(
The::aspect($aspectClassOrAdvice)->$methodName(...),
$order,
$expression,
),
static fn(): AbstractInterceptor => new $interceptorClass($aspectMethod, $order, $expression),
// What weaving, ordering and cache compilation read: the aspect is only created when the advice runs
['adviceOrder' => $order, 'pointcutExpression' => $expression, 'aspectMethod' => $aspectMethod],
);
}
}
30 changes: 18 additions & 12 deletions src/Console/Command/DebugAspectCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
use Go\Aop\Aspect;
use Go\Aop\Pointcut;
use Go\Core\AspectLoader;
use ReflectionObject;
use ReflectionClass;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
Expand Down Expand Up @@ -61,54 +61,60 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$aspectName = $input->getOption('aspect');
if (!is_string($aspectName) || $aspectName === '') {
$io->text('<info>' . $this->aspectKernel::class . '</info> has following enabled aspects:');
$aspects = $container->getServicesByInterface(Aspect::class);
$aspects = $container->getServiceIdsByInterface(Aspect::class);
} else {
$aspectName = ltrim($aspectName, '\\');
if (!$container->has($aspectName) || !is_subclass_of($aspectName, Aspect::class)) {
$registeredAspects = array_keys($container->getServicesByInterface(Aspect::class));
$registeredAspects = $container->getServiceIdsByInterface(Aspect::class);
$io->error(sprintf('Aspect "%s" is not registered in the kernel.', $aspectName) . self::suggestAlternative($aspectName, $registeredAspects));

return Command::FAILURE;
}
$aspects[] = $container->getService($aspectName);
$aspects[] = $aspectName;
}
$this->showRegisteredAspectsInfo($io, $aspects);

return Command::SUCCESS;
}

/**
* Shows an information about registered aspects
* Shows an information about registered aspects, read from their classes: no aspect is created
*
* @param Aspect[] $aspects List of aspects
* @param list<string> $aspects Classes of the aspects
*/
private function showRegisteredAspectsInfo(SymfonyStyle $io, array $aspects): void
{
foreach ($aspects as $aspect) {
$this->showAspectInfo($io, $aspect);
foreach ($aspects as $aspectClass) {
if (is_subclass_of($aspectClass, Aspect::class)) {
$this->showAspectInfo($io, $aspectClass);
}
}
}

/**
* Displays an information about single aspect
*
* @param class-string<Aspect> $aspectClass
*/
private function showAspectInfo(SymfonyStyle $io, Aspect $aspect): void
private function showAspectInfo(SymfonyStyle $io, string $aspectClass): void
{
$refAspect = new ReflectionObject($aspect);
$refAspect = new ReflectionClass($aspectClass);
$aspectName = $refAspect->getName();
$io->section($aspectName);
$io->writeln('Defined in: <info>' . $refAspect->getFileName() . '</info>');
$docComment = $refAspect->getDocComment();
if ($docComment) {
$io->writeln($this->getPrettyText($docComment));
}
$this->showAspectPointcutsAndAdvisors($io, $aspect);
$this->showAspectPointcutsAndAdvisors($io, $refAspect);
}

/**
* Shows an information about aspect pointcuts and advisors
*
* @param ReflectionClass<covariant Aspect> $aspect
*/
private function showAspectPointcutsAndAdvisors(SymfonyStyle $io, Aspect $aspect): void
private function showAspectPointcutsAndAdvisors(SymfonyStyle $io, ReflectionClass $aspect): void
{
$container = $this->aspectKernel->getContainer();

Expand Down
Loading
Loading