From a4ebb580be20b4e7e9f87ac261ceb65e7a079760 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Mon, 12 Oct 2026 11:32:17 +0200 Subject: [PATCH 01/27] refactor: let discovery run without Laravel's application Two ports, InstanceResolverInterface and DiscoveryRegistrarInterface, replace the container in the engine, the instance pool and the reflection cache; DiscoveryCacheManager takes its driver and logger, and the Laravel provider builds them. Adds a PSR-16 cache driver, an architecture test for the part that becomes pollora/discovery, and fixes the benchmarks' memory measurement. --- CHANGELOG.md | 11 ++ .../Contracts/DiscoveryCacheInterface.php | 4 + .../Contracts/DiscoveryRegistrarInterface.php | 23 ++++ .../Contracts/InstanceResolverInterface.php | 27 ++++ .../Domain/Services/HasInstancePool.php | 10 +- .../Adapters/LaravelDiscoveryCache.php | 2 + .../LaravelDiscoveryCacheManagerFactory.php | 73 +++++++++++ .../Adapters/LaravelInstanceResolver.php | 27 ++++ .../Cache/Psr16DiscoverCacheDriver.php | 62 +++++++++ .../Providers/DiscoveryServiceProvider.php | 34 ++++- .../Resolvers/ContainerInstanceResolver.php | 26 ++++ .../Resolvers/ReflectionInstanceResolver.php | 83 ++++++++++++ .../Services/CircularDependencyException.php | 10 ++ .../Services/DiscoveryCacheManager.php | 59 +++------ .../Services/DiscoveryEngine.php | 35 +++--- .../Services/DiscoveryRegistrar.php | 7 +- .../Infrastructure/Services/InstancePool.php | 22 ++-- .../Services/ReflectionCache.php | 15 +-- tests/Benchmark/DiscoveryBenchmark.php | 40 ++---- tests/Benchmark/DiscoveryPerformanceTest.php | 40 +++--- .../DiscoveryServiceProviderTest.php | 34 +++++ tests/Feature/Doctor/InstallChecksTest.php | 3 +- .../Cache/Psr16DiscoverCacheDriverTest.php | 119 ++++++++++++++++++ .../ReflectionInstanceResolverTest.php | 87 +++++++++++++ .../DiscoveryCacheManagerSlowScanTest.php | 10 +- .../Services/DiscoveryEngineTest.php | 70 +++++++++-- .../Services/DiscoveryRegistrarTest.php | 10 +- tests/Unit/Discovery/PackageBoundaryTest.php | 48 +++++++ 28 files changed, 831 insertions(+), 160 deletions(-) create mode 100644 src/Discovery/Domain/Contracts/DiscoveryRegistrarInterface.php create mode 100644 src/Discovery/Domain/Contracts/InstanceResolverInterface.php create mode 100644 src/Discovery/Infrastructure/Adapters/LaravelDiscoveryCacheManagerFactory.php create mode 100644 src/Discovery/Infrastructure/Adapters/LaravelInstanceResolver.php create mode 100644 src/Discovery/Infrastructure/Cache/Psr16DiscoverCacheDriver.php create mode 100644 src/Discovery/Infrastructure/Resolvers/ContainerInstanceResolver.php create mode 100644 src/Discovery/Infrastructure/Resolvers/ReflectionInstanceResolver.php create mode 100644 src/Discovery/Infrastructure/Services/CircularDependencyException.php create mode 100644 tests/Unit/Discovery/Infrastructure/Cache/Psr16DiscoverCacheDriverTest.php create mode 100644 tests/Unit/Discovery/Infrastructure/Resolvers/ReflectionInstanceResolverTest.php create mode 100644 tests/Unit/Discovery/PackageBoundaryTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 76515c7a..b2c2af88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Pollora/framework/compare/v13.35.6...develop) +### Added +- Discovery can run without Laravel's application, the first step towards `pollora/discovery`: `new DiscoveryEngine()` works with no argument. Two contracts let an environment plug in — `InstanceResolverInterface` builds the classes discovery needs (Laravel's container in a Pollora application, `ReflectionInstanceResolver` otherwise) and `DiscoveryRegistrarInterface` adds the environment's discoveries before each run (`DiscoveryRegistrar` keeps scanning the container's `*Discovery` bindings). `Psr16DiscoverCacheDriver` caches scans in any PSR-16 cache + +### Changed +- `DiscoveryEngine`, `DiscoveryCacheManager`, `InstancePool` and `ReflectionCache` take their dependencies instead of reaching for `app()` and `config()`; reading `structure-discoverer.cache` moved to `LaravelDiscoveryCacheManagerFactory`. `DiscoveryRegistrar::registerFromContainer()` is now `registerInto()`. These are infrastructure classes, outside the public contracts (`Pollora\Discovery\Domain\*`), which do not change +- One `DiscoveryCacheManager` is shared by the engine and the `pollora:doctor` discovery cache check, which built its own +- The discovery benchmarks measure memory as PHP allocates it, from a reset peak: they read 2 MB whatever the load + +### Deprecated +- `DiscoveryCacheInterface` and `LaravelDiscoveryCache`, removed in 15.0: nothing reads this cache, scans are cached by the Spatie driver given to `DiscoveryCacheManager` + ## [v13.35.6](https://github.com/Pollora/framework/compare/v13.35.5...v13.35.6) - 2026-10-12 ### Fixed diff --git a/src/Discovery/Domain/Contracts/DiscoveryCacheInterface.php b/src/Discovery/Domain/Contracts/DiscoveryCacheInterface.php index d40ceffa..63dcd797 100644 --- a/src/Discovery/Domain/Contracts/DiscoveryCacheInterface.php +++ b/src/Discovery/Domain/Contracts/DiscoveryCacheInterface.php @@ -9,6 +9,10 @@ * * Defines the contract for caching discovered items to improve * performance by avoiding repeated discovery operations. + * + * @deprecated Removed in 15.0. Nothing in Pollora reads this cache: + * scans are cached by the Spatie DiscoverCacheDriver given to + * DiscoveryCacheManager (Psr16DiscoverCacheDriver outside Laravel). */ interface DiscoveryCacheInterface { diff --git a/src/Discovery/Domain/Contracts/DiscoveryRegistrarInterface.php b/src/Discovery/Domain/Contracts/DiscoveryRegistrarInterface.php new file mode 100644 index 00000000..19463d26 --- /dev/null +++ b/src/Discovery/Domain/Contracts/DiscoveryRegistrarInterface.php @@ -0,0 +1,23 @@ + The identifiers of the discoveries added + */ + public function registerInto(DiscoveryEngineInterface $engine): array; +} diff --git a/src/Discovery/Domain/Contracts/InstanceResolverInterface.php b/src/Discovery/Domain/Contracts/InstanceResolverInterface.php new file mode 100644 index 00000000..cf9d5f82 --- /dev/null +++ b/src/Discovery/Domain/Contracts/InstanceResolverInterface.php @@ -0,0 +1,27 @@ + $className The fully qualified class name + * @return T The built instance + * + * @throws \Throwable If the class cannot be built + */ + public function make(string $className): object; +} diff --git a/src/Discovery/Domain/Services/HasInstancePool.php b/src/Discovery/Domain/Services/HasInstancePool.php index 5c5e9b8d..fc0386da 100644 --- a/src/Discovery/Domain/Services/HasInstancePool.php +++ b/src/Discovery/Domain/Services/HasInstancePool.php @@ -4,6 +4,7 @@ namespace Pollora\Discovery\Domain\Services; +use Pollora\Discovery\Infrastructure\Resolvers\ReflectionInstanceResolver; use Pollora\Discovery\Infrastructure\Services\InstancePool; /** @@ -31,6 +32,9 @@ public function setInstancePool(InstancePool $instancePool): void /** * Get an instance from the pool if available, otherwise use fallback. * + * Without a pool or a fallback, the class is resolved through Laravel's + * container when there is one, and built by reflection otherwise. + * * @param string $className The class name to instantiate * @param callable|null $fallback Optional fallback creation function * @return object The class instance @@ -45,7 +49,11 @@ protected function getInstanceFromPool(string $className, ?callable $fallback = return $fallback(); } - return resolve($className); + if (function_exists('resolve')) { + return resolve($className); + } + + return (new ReflectionInstanceResolver)->make($className); } /** diff --git a/src/Discovery/Infrastructure/Adapters/LaravelDiscoveryCache.php b/src/Discovery/Infrastructure/Adapters/LaravelDiscoveryCache.php index 4d12e9a7..a3767ced 100644 --- a/src/Discovery/Infrastructure/Adapters/LaravelDiscoveryCache.php +++ b/src/Discovery/Infrastructure/Adapters/LaravelDiscoveryCache.php @@ -15,6 +15,8 @@ * * Implementation of discovery cache using Laravel's cache system. * Provides persistent caching of discovery results to improve performance. + * + * @deprecated Removed in 15.0, with DiscoveryCacheInterface. */ final readonly class LaravelDiscoveryCache implements DiscoveryCacheInterface { diff --git a/src/Discovery/Infrastructure/Adapters/LaravelDiscoveryCacheManagerFactory.php b/src/Discovery/Infrastructure/Adapters/LaravelDiscoveryCacheManagerFactory.php new file mode 100644 index 00000000..3ce56e9a --- /dev/null +++ b/src/Discovery/Infrastructure/Adapters/LaravelDiscoveryCacheManagerFactory.php @@ -0,0 +1,73 @@ +debugDetector->isDebugMode(); + + return new DiscoveryCacheManager( + cacheDriver: $isDebugMode ? new NullDiscoverCacheDriver : $this->resolveCacheDriver(), + logger: $this->resolveLogger(), + warnOnSlowScans: $isDebugMode, + ); + } + + private function resolveCacheDriver(): DiscoverCacheDriver + { + /** @var array{driver?: class-string, store?: string|null} $cacheConfig */ + $cacheConfig = $this->container->bound('config') + ? (array) $this->container->make('config')->get('structure-discoverer.cache', []) + : []; + + $driverClass = $cacheConfig['driver'] ?? LaravelDiscoverCacheDriver::class; + $store = $cacheConfig['store'] ?? null; + + if ($driverClass === LaravelDiscoverCacheDriver::class) { + return new LaravelDiscoverCacheDriver(prefix: 'pollora', store: $store); + } + + try { + return $this->container->make($driverClass); + } catch (\Throwable) { + return new LaravelDiscoverCacheDriver(prefix: 'pollora', store: $store); + } + } + + private function resolveLogger(): ?LoggerInterface + { + try { + return $this->container->bound(LoggerInterface::class) + ? $this->container->make(LoggerInterface::class) + : null; + } catch (\Throwable) { + // A missing logger must not keep discovery from starting. + return null; + } + } +} diff --git a/src/Discovery/Infrastructure/Adapters/LaravelInstanceResolver.php b/src/Discovery/Infrastructure/Adapters/LaravelInstanceResolver.php new file mode 100644 index 00000000..bc5e00f6 --- /dev/null +++ b/src/Discovery/Infrastructure/Adapters/LaravelInstanceResolver.php @@ -0,0 +1,27 @@ +container->make($className); + } +} diff --git a/src/Discovery/Infrastructure/Cache/Psr16DiscoverCacheDriver.php b/src/Discovery/Infrastructure/Cache/Psr16DiscoverCacheDriver.php new file mode 100644 index 00000000..4c4d6022 --- /dev/null +++ b/src/Discovery/Infrastructure/Cache/Psr16DiscoverCacheDriver.php @@ -0,0 +1,62 @@ +cache->has($this->key($id)); + } + + /** + * @return array + */ + public function get(string $id): array + { + $structures = $this->cache->get($this->key($id)); + + return is_array($structures) ? $structures : []; + } + + /** + * @param array $discovered + */ + public function put(string $id, array $discovered): void + { + $this->cache->set($this->key($id), $discovered, $this->ttl); + } + + public function forget(string $id): void + { + $this->cache->delete($this->key($id)); + } + + private function key(string $id): string + { + // PSR-16 reserves {}()/\@: in keys. + return $this->prefix.strtr($id, '{}()/\\@:', '________'); + } +} diff --git a/src/Discovery/Infrastructure/Providers/DiscoveryServiceProvider.php b/src/Discovery/Infrastructure/Providers/DiscoveryServiceProvider.php index c509d705..f11ab8f5 100644 --- a/src/Discovery/Infrastructure/Providers/DiscoveryServiceProvider.php +++ b/src/Discovery/Infrastructure/Providers/DiscoveryServiceProvider.php @@ -4,13 +4,19 @@ namespace Pollora\Discovery\Infrastructure\Providers; +use Illuminate\Container\Container; use Illuminate\Support\ServiceProvider; use Pollora\Application\Domain\Contracts\DebugDetectorInterface; use Pollora\Discovery\Application\Services\DiscoveryManager; use Pollora\Discovery\Domain\Contracts\DiscoveryEngineInterface; +use Pollora\Discovery\Domain\Contracts\InstanceResolverInterface; use Pollora\Discovery\Domain\Contracts\ReflectionCacheInterface; use Pollora\Discovery\Domain\Models\DirectoryLocation; +use Pollora\Discovery\Infrastructure\Adapters\LaravelDiscoveryCacheManagerFactory; +use Pollora\Discovery\Infrastructure\Adapters\LaravelInstanceResolver; +use Pollora\Discovery\Infrastructure\Services\DiscoveryCacheManager; use Pollora\Discovery\Infrastructure\Services\DiscoveryEngine; +use Pollora\Discovery\Infrastructure\Services\DiscoveryRegistrar; use Pollora\Discovery\Infrastructure\Services\InstancePool; use Pollora\Discovery\Infrastructure\Services\ReflectionCache; use Pollora\Discovery\Infrastructure\Services\ServiceProviderDiscovery; @@ -64,7 +70,9 @@ public function provides(): array DiscoveryEngineInterface::class, DiscoveryManager::class, ReflectionCacheInterface::class, + InstanceResolverInterface::class, InstancePool::class, + DiscoveryCacheManager::class, DiscoveryCommand::class, DiscoveryClearCommand::class, ]; @@ -75,11 +83,24 @@ public function provides(): array */ private function registerOptimizedServices(): void { + // Discovery builds classes through Laravel's container + $this->app->singleton(InstanceResolverInterface::class, fn (Container $app): LaravelInstanceResolver => new LaravelInstanceResolver($app)); + // Register reflection cache as singleton for maximum efficiency - $this->app->singleton(ReflectionCacheInterface::class, ReflectionCache::class); + $this->app->singleton(ReflectionCacheInterface::class, fn (Container $app): ReflectionCache => new ReflectionCache( + $app->make(InstanceResolverInterface::class) + )); // Register instance pool as singleton to maintain state across discoveries - $this->app->singleton(InstancePool::class); + $this->app->singleton(InstancePool::class, fn (Container $app): InstancePool => new InstancePool( + $app->make(InstanceResolverInterface::class) + )); + + // One cache manager, shared by the engine and the doctor's cache check + $this->app->singleton(DiscoveryCacheManager::class, fn (Container $app): DiscoveryCacheManager => (new LaravelDiscoveryCacheManagerFactory( + $app, + $app->make(DebugDetectorInterface::class) + ))->make()); // Register ServiceProviderDiscovery so the DiscoveryRegistrar can find it $this->app->singleton(ServiceProviderDiscovery::class); @@ -90,11 +111,12 @@ private function registerOptimizedServices(): void */ private function registerDiscoveryEngine(): void { - $this->app->singleton(DiscoveryEngineInterface::class, fn ($app): DiscoveryEngine => new DiscoveryEngine( - container: $app, - debugDetector: $app->make(DebugDetectorInterface::class), + $this->app->singleton(DiscoveryEngineInterface::class, fn (Container $app): DiscoveryEngine => new DiscoveryEngine( + resolver: $app->make(InstanceResolverInterface::class), reflectionCache: $app->make(ReflectionCacheInterface::class), instancePool: $app->make(InstancePool::class), + cacheManager: $app->make(DiscoveryCacheManager::class), + registrar: new DiscoveryRegistrar($app), skipClasses: (array) config('discovery.skip_classes', []), skipPaths: (array) config('discovery.skip_paths', []), )); @@ -105,7 +127,7 @@ private function registerDiscoveryEngine(): void */ private function registerDiscoveryManager(): void { - $this->app->singleton(DiscoveryManager::class, fn ($app): DiscoveryManager => new DiscoveryManager( + $this->app->singleton(DiscoveryManager::class, fn (Container $app): DiscoveryManager => new DiscoveryManager( engine: $app->make(DiscoveryEngineInterface::class) )); } diff --git a/src/Discovery/Infrastructure/Resolvers/ContainerInstanceResolver.php b/src/Discovery/Infrastructure/Resolvers/ContainerInstanceResolver.php new file mode 100644 index 00000000..cdfd2438 --- /dev/null +++ b/src/Discovery/Infrastructure/Resolvers/ContainerInstanceResolver.php @@ -0,0 +1,26 @@ +container->get($className); + } +} diff --git a/src/Discovery/Infrastructure/Resolvers/ReflectionInstanceResolver.php b/src/Discovery/Infrastructure/Resolvers/ReflectionInstanceResolver.php new file mode 100644 index 00000000..308756cf --- /dev/null +++ b/src/Discovery/Infrastructure/Resolvers/ReflectionInstanceResolver.php @@ -0,0 +1,83 @@ + + */ + private array $building = []; + + public function make(string $className): object + { + $reflection = new ReflectionClass($className); + + if (! $reflection->isInstantiable()) { + throw new \InvalidArgumentException(sprintf('Class %s is not instantiable', $className)); + } + + if (isset($this->building[$className])) { + throw new \RuntimeException(sprintf('Circular dependency detected while building %s', $className)); + } + + $constructor = $reflection->getConstructor(); + + if ($constructor === null || $constructor->getNumberOfParameters() === 0) { + return $reflection->newInstance(); + } + + $this->building[$className] = true; + + try { + return $reflection->newInstanceArgs(array_map( + fn (ReflectionParameter $parameter): mixed => $this->resolveParameter($className, $parameter), + $constructor->getParameters() + )); + } finally { + unset($this->building[$className]); + } + } + + private function resolveParameter(string $className, ReflectionParameter $parameter): mixed + { + if ($parameter->isDefaultValueAvailable()) { + return $parameter->getDefaultValue(); + } + + $type = $parameter->getType(); + + if ($type instanceof ReflectionNamedType && ! $type->isBuiltin()) { + /** @var class-string $dependency */ + $dependency = $type->getName(); + + return $this->make($dependency); + } + + if ($parameter->allowsNull()) { + return null; + } + + throw new \InvalidArgumentException(sprintf( + 'Cannot build %s: parameter $%s has no default value and no class type', + $className, + $parameter->getName() + )); + } +} diff --git a/src/Discovery/Infrastructure/Services/CircularDependencyException.php b/src/Discovery/Infrastructure/Services/CircularDependencyException.php new file mode 100644 index 00000000..6eabcfe1 --- /dev/null +++ b/src/Discovery/Infrastructure/Services/CircularDependencyException.php @@ -0,0 +1,10 @@ +cacheDriver = $this->resolveCacheDriver(); - } + private readonly ?DiscoverCacheDriver $cacheDriver = null, + private readonly ?LoggerInterface $logger = null, + private readonly bool $warnOnSlowScans = false, + ) {} /** * Get structures for a specific location, using static and Spatie cache. @@ -136,12 +133,12 @@ private function recordScan(DiscoveryLocationInterface $location, string $source */ private function warnIfSlow(DiscoveryLocationInterface $location, float $elapsed, int $structures): void { - if ($elapsed < self::SLOW_SCAN_MS || ! $this->debugDetector->isDebugMode()) { + if ($elapsed < self::SLOW_SCAN_MS || ! $this->warnOnSlowScans) { return; } try { - $this->container->make(LoggerInterface::class)->warning(sprintf( + $this->logger?->warning(sprintf( 'Pollora discovery scanned %s in %dms for %d structure(s). ' .'A directory this slow usually holds node_modules, vendor or a build output; ' .'discovery walks every file in it on each request while the cache is off.', @@ -150,7 +147,7 @@ private function warnIfSlow(DiscoveryLocationInterface $location, float $elapsed $structures )); } catch (\Throwable) { - // A missing logger must not turn a warning into a failure. + // A failing logger must not turn a warning into a failure. } } @@ -227,30 +224,6 @@ public function getStaticCacheSize(): int return count(self::$structuresCache); } - /** - * Resolve the cache driver from Laravel configuration. - */ - private function resolveCacheDriver(): ?DiscoverCacheDriver - { - if ($this->debugDetector->isDebugMode()) { - return new NullDiscoverCacheDriver; - } - - $cacheConfig = config('structure-discoverer.cache', []); - $driverClass = $cacheConfig['driver'] ?? LaravelDiscoverCacheDriver::class; - $store = $cacheConfig['store'] ?? null; - - if ($driverClass === LaravelDiscoverCacheDriver::class) { - return new LaravelDiscoverCacheDriver(prefix: 'pollora', store: $store); - } - - try { - return $this->container->make($driverClass); - } catch (\Throwable) { - return new LaravelDiscoverCacheDriver(prefix: 'pollora', store: $store); - } - } - /** * Create a configured Spatie discoverer instance. */ diff --git a/src/Discovery/Infrastructure/Services/DiscoveryEngine.php b/src/Discovery/Infrastructure/Services/DiscoveryEngine.php index 6671206e..e1faff33 100644 --- a/src/Discovery/Infrastructure/Services/DiscoveryEngine.php +++ b/src/Discovery/Infrastructure/Services/DiscoveryEngine.php @@ -4,19 +4,20 @@ namespace Pollora\Discovery\Infrastructure\Services; -use Illuminate\Container\Container; use Illuminate\Support\Collection; -use Pollora\Application\Domain\Contracts\DebugDetectorInterface; use Pollora\Attributes\SkipDiscovery; use Pollora\Discovery\Domain\Contracts\DiscoveryEngineInterface; use Pollora\Discovery\Domain\Contracts\DiscoveryInterface; use Pollora\Discovery\Domain\Contracts\DiscoveryLocationInterface; +use Pollora\Discovery\Domain\Contracts\DiscoveryRegistrarInterface; +use Pollora\Discovery\Domain\Contracts\InstanceResolverInterface; use Pollora\Discovery\Domain\Contracts\ReflectionCacheInterface; use Pollora\Discovery\Domain\Exceptions\DiscoveryException; use Pollora\Discovery\Domain\Exceptions\DiscoveryNotFoundException; use Pollora\Discovery\Domain\Exceptions\InvalidDiscoveryException; use Pollora\Discovery\Domain\Models\DiscoveryContext; use Pollora\Discovery\Domain\Models\DiscoveryItems; +use Pollora\Discovery\Infrastructure\Resolvers\ReflectionInstanceResolver; use Psr\Log\LoggerInterface; use Spatie\StructureDiscoverer\Cache\DiscoverCacheDriver; use Spatie\StructureDiscoverer\Data\DiscoveredClass; @@ -67,29 +68,33 @@ final class DiscoveryEngine implements DiscoveryEngineInterface private readonly DiscoveryCacheManager $cacheManager; /** - * Registrar for auto-discovering Discovery classes + * Builds the discovery classes given by name */ - private readonly DiscoveryRegistrar $registrar; + private readonly InstanceResolverInterface $resolver; /** * Create a new discovery engine * - * @param Container $container The service container for dependency injection - * @param DebugDetectorInterface $debugDetector Debug mode detector + * Every dependency is optional: without them, the engine builds classes + * by reflection, keeps no persistent cache and runs only the discoveries + * added to it explicitly. + * + * @param InstanceResolverInterface|null $resolver Builds the discovery classes given by name * @param ReflectionCacheInterface|null $reflectionCache Optional reflection cache * @param InstancePool|null $instancePool Optional instance pool * @param LoggerInterface|null $logger Optional PSR-3 logger * @param DiscoveryCacheManager|null $cacheManager Optional cache manager + * @param DiscoveryRegistrarInterface|null $registrar Adds the environment's discoveries before each run * @param array $skipClasses Classes to exclude from discovery * @param array $skipPaths Path patterns to exclude from discovery */ public function __construct( - private readonly Container $container, - DebugDetectorInterface $debugDetector, + ?InstanceResolverInterface $resolver = null, ?ReflectionCacheInterface $reflectionCache = null, ?InstancePool $instancePool = null, private readonly ?LoggerInterface $logger = null, ?DiscoveryCacheManager $cacheManager = null, + private readonly ?DiscoveryRegistrarInterface $registrar = null, private readonly array $skipClasses = [], private readonly array $skipPaths = [], ) { @@ -97,11 +102,11 @@ public function __construct( $this->discoveries = new Collection; // Initialize optimized services - $reflectionCache ??= new ReflectionCache($container); - $this->instancePool = $instancePool ?? new InstancePool($container); + $this->resolver = $resolver ?? new ReflectionInstanceResolver; + $reflectionCache ??= new ReflectionCache($this->resolver); + $this->instancePool = $instancePool ?? new InstancePool($this->resolver); $this->context = new DiscoveryContext($reflectionCache); - $this->cacheManager = $cacheManager ?? new DiscoveryCacheManager($container, $debugDetector); - $this->registrar = new DiscoveryRegistrar($container, $this->logger); + $this->cacheManager = $cacheManager ?? new DiscoveryCacheManager; } /** @@ -169,8 +174,8 @@ public function addDiscoveries(array $discoveries): static */ public function discover(): static { - // Auto-register Discovery classes from container bindings - $this->registrar->registerFromContainer($this); + // Add the discoveries the environment provides + $this->registrar?->registerInto($this); $allStructures = $this->discoverAllStructures(); @@ -379,7 +384,7 @@ private function resolveDiscovery(string|DiscoveryInterface $discovery): Discove } try { - return $this->container->make($discovery); + return $this->resolver->make($discovery); } catch (\Throwable $throwable) { throw InvalidDiscoveryException::invalidClass($discovery, 'Cannot instantiate: '.$throwable->getMessage()); } diff --git a/src/Discovery/Infrastructure/Services/DiscoveryRegistrar.php b/src/Discovery/Infrastructure/Services/DiscoveryRegistrar.php index 07c0f7db..40763f2b 100644 --- a/src/Discovery/Infrastructure/Services/DiscoveryRegistrar.php +++ b/src/Discovery/Infrastructure/Services/DiscoveryRegistrar.php @@ -7,6 +7,7 @@ use Illuminate\Container\Container; use Pollora\Discovery\Domain\Contracts\DiscoveryEngineInterface; use Pollora\Discovery\Domain\Contracts\DiscoveryInterface; +use Pollora\Discovery\Domain\Contracts\DiscoveryRegistrarInterface; use Psr\Log\LoggerInterface; /** @@ -19,7 +20,7 @@ * Each ServiceProvider still registers its Discovery as a singleton in * register(). The registrar picks them up and adds them to the engine. */ -final readonly class DiscoveryRegistrar +final readonly class DiscoveryRegistrar implements DiscoveryRegistrarInterface { public function __construct( private Container $container, @@ -30,9 +31,9 @@ public function __construct( * Scan container bindings and register any DiscoveryInterface implementations. * * @param DiscoveryEngineInterface $engine The engine to register discoveries with - * @return array Identifiers of auto-registered discoveries + * @return array Identifiers of auto-registered discoveries */ - public function registerFromContainer(DiscoveryEngineInterface $engine): array + public function registerInto(DiscoveryEngineInterface $engine): array { $registered = []; diff --git a/src/Discovery/Infrastructure/Services/InstancePool.php b/src/Discovery/Infrastructure/Services/InstancePool.php index de66a871..564584a4 100644 --- a/src/Discovery/Infrastructure/Services/InstancePool.php +++ b/src/Discovery/Infrastructure/Services/InstancePool.php @@ -4,7 +4,8 @@ namespace Pollora\Discovery\Infrastructure\Services; -use Illuminate\Container\Container; +use Pollora\Discovery\Domain\Contracts\InstanceResolverInterface; +use Pollora\Discovery\Infrastructure\Resolvers\ReflectionInstanceResolver; /** * Instance Pool @@ -16,7 +17,7 @@ * Key features: * - Single instance per class across all discoveries * - Circular dependency detection during instantiation - * - Lazy loading with automatic DI container resolution + * - Lazy loading through the instance resolver * - Memory-efficient cleanup and pooling strategies * - Thread-safe operations for concurrent access */ @@ -49,13 +50,13 @@ final class InstancePool ]; /** - * DI Container for instance resolution + * Builds the instances the pool does not hold yet */ - private readonly Container $container; + private readonly InstanceResolverInterface $resolver; - public function __construct(?Container $container = null) + public function __construct(?InstanceResolverInterface $resolver = null) { - $this->container = $container ?? app(); + $this->resolver = $resolver ?? new ReflectionInstanceResolver; } /** @@ -91,8 +92,8 @@ public function getInstance(string $className): object // Mark as currently instantiating $this->instantiating[$className] = true; - // Create instance through DI container - $instance = $this->container->make($className); + // Create instance through the resolver + $instance = $this->resolver->make($className); // Store in pool $this->instances[$className] = $instance; @@ -304,8 +305,3 @@ private function formatBytes(int $bytes): string return round($bytes, 2).' '.$units[$unitIndex]; } } - -/** - * Exception thrown when circular dependency is detected during instantiation - */ -class CircularDependencyException extends \RuntimeException {} diff --git a/src/Discovery/Infrastructure/Services/ReflectionCache.php b/src/Discovery/Infrastructure/Services/ReflectionCache.php index 6e8b3a25..abf3176c 100644 --- a/src/Discovery/Infrastructure/Services/ReflectionCache.php +++ b/src/Discovery/Infrastructure/Services/ReflectionCache.php @@ -4,8 +4,9 @@ namespace Pollora\Discovery\Infrastructure\Services; -use Illuminate\Container\Container; +use Pollora\Discovery\Domain\Contracts\InstanceResolverInterface; use Pollora\Discovery\Domain\Contracts\ReflectionCacheInterface; +use Pollora\Discovery\Infrastructure\Resolvers\ReflectionInstanceResolver; use ReflectionClass; use ReflectionException; use ReflectionMethod; @@ -54,7 +55,7 @@ final class ReflectionCache implements ReflectionCacheInterface private array $publicMethods = []; /** - * Cache for class instances from DI container + * Cache for class instances built by the resolver * * @var array */ @@ -68,13 +69,13 @@ final class ReflectionCache implements ReflectionCacheInterface private array $processedClasses = []; /** - * DI Container for instance resolution + * Builds the instances the pool does not hold yet */ - private readonly Container $container; + private readonly InstanceResolverInterface $resolver; - public function __construct(?Container $container = null) + public function __construct(?InstanceResolverInterface $resolver = null) { - $this->container = $container ?? app(); + $this->resolver = $resolver ?? new ReflectionInstanceResolver; } /** @@ -165,7 +166,7 @@ public function getClassInstance(string $className): object throw new \InvalidArgumentException(sprintf('Class %s is not instantiable', $className)); } - $this->classInstances[$className] = $this->container->make($className); + $this->classInstances[$className] = $this->resolver->make($className); } catch (\Throwable $e) { throw new \RuntimeException( sprintf('Failed to instantiate class %s: %s', $className, $e->getMessage()), diff --git a/tests/Benchmark/DiscoveryBenchmark.php b/tests/Benchmark/DiscoveryBenchmark.php index 603c701f..271e8d39 100644 --- a/tests/Benchmark/DiscoveryBenchmark.php +++ b/tests/Benchmark/DiscoveryBenchmark.php @@ -4,8 +4,6 @@ namespace Tests\Benchmark; -use Illuminate\Container\Container; -use Pollora\Application\Domain\Contracts\DebugDetectorInterface; use Pollora\Discovery\Domain\Models\DiscoveryContext; use Pollora\Discovery\Domain\Models\DiscoveryLocation; use Pollora\Discovery\Infrastructure\Services\DiscoveryCacheManager; @@ -21,10 +19,6 @@ */ final class DiscoveryBenchmark { - private readonly Container $container; - - private readonly DebugDetectorInterface $debugDetector; - private readonly string $fixturesBasePath; /** @var array> */ @@ -35,17 +29,6 @@ final class DiscoveryBenchmark public function __construct() { - $this->container = new Container; - Container::setInstance($this->container); - - $this->debugDetector = new class implements DebugDetectorInterface - { - public function isDebugMode(): bool - { - return true; // Force no persistent cache - } - }; - $this->fixturesBasePath = __DIR__.'/Generated'; } @@ -170,8 +153,8 @@ private function benchmarkClassCount(int $count, int $iterations): void */ private function benchmarkSpatieScan(array $fixtureInfo): array { - $cacheManager = new DiscoveryCacheManager($this->container, $this->debugDetector); - $reflectionCache = new ReflectionCache($this->container); + $cacheManager = new DiscoveryCacheManager; + $reflectionCache = new ReflectionCache; $context = new DiscoveryContext($reflectionCache); $location = new DiscoveryLocation( @@ -199,10 +182,7 @@ private function benchmarkSpatieScan(array $fixtureInfo): array */ private function benchmarkFullDiscovery(array $fixtureInfo): array { - $engine = new DiscoveryEngine( - $this->container, - $this->debugDetector - ); + $engine = new DiscoveryEngine; $location = new DiscoveryLocation( $fixtureInfo['namespace'], @@ -217,13 +197,16 @@ private function benchmarkFullDiscovery(array $fixtureInfo): array $engine->addDiscovery('rest_routes', new BenchmarkDiscovery('rest_routes')); $engine->addDiscovery('schedules', new BenchmarkDiscovery('schedules')); - $memBefore = memory_get_usage(true); + // memory_get_usage(true) counts 2 MB blocks from the system, which + // hides anything smaller: measure what PHP allocates, from a reset peak. + memory_reset_peak_usage(); + $memBefore = memory_get_usage(); $start = hrtime(true); $engine->discover(); $elapsed = (hrtime(true) - $start) / 1_000_000; - $memAfter = memory_get_peak_usage(true); + $memAfter = memory_get_peak_usage(); $stats = $engine->getPerformanceStats(); @@ -271,7 +254,7 @@ private function benchmarkMultiLocation(int $totalClasses, int $moduleCount, int $this->clearStaticCaches(); // Full discovery with multiple locations - $engine = new DiscoveryEngine($this->container, $this->debugDetector); + $engine = new DiscoveryEngine; foreach ($multiInfo['locations'] as $loc) { $engine->addLocation(new DiscoveryLocation($loc['namespace'], $loc['path'])); @@ -283,7 +266,8 @@ private function benchmarkMultiLocation(int $totalClasses, int $moduleCount, int $engine->addDiscovery('rest_routes', new BenchmarkDiscovery('rest_routes')); $engine->addDiscovery('schedules', new BenchmarkDiscovery('schedules')); - $memBefore = memory_get_usage(true); + memory_reset_peak_usage(); + $memBefore = memory_get_usage(); $start = hrtime(true); $engine->discover(); $elapsed = (hrtime(true) - $start) / 1_000_000; @@ -294,7 +278,7 @@ private function benchmarkMultiLocation(int $totalClasses, int $moduleCount, int } $timings['scan'][] = $elapsed; // Combined scan + processing - $timings['memory'][] = (memory_get_peak_usage(true) - $memBefore) / (1024 * 1024); + $timings['memory'][] = (memory_get_peak_usage() - $memBefore) / (1024 * 1024); $timings['items'][] = $totalItems; echo " done\n"; diff --git a/tests/Benchmark/DiscoveryPerformanceTest.php b/tests/Benchmark/DiscoveryPerformanceTest.php index 9eb86ff2..3dd42afa 100644 --- a/tests/Benchmark/DiscoveryPerformanceTest.php +++ b/tests/Benchmark/DiscoveryPerformanceTest.php @@ -4,7 +4,6 @@ use Illuminate\Container\Container; use Illuminate\Support\Facades\Facade; -use Pollora\Application\Domain\Contracts\DebugDetectorInterface; use Pollora\Discovery\Domain\Models\DiscoveryContext; use Pollora\Discovery\Domain\Models\DiscoveryLocation; use Pollora\Discovery\Infrastructure\Services\DiscoveryCacheManager; @@ -49,17 +48,6 @@ function restorePreviousContainer(): void } } -function createBenchDebugDetector(): DebugDetectorInterface -{ - return new class implements DebugDetectorInterface - { - public function isDebugMode(): bool - { - return true; - } - }; -} - function clearDiscoveryCacheManagerStatic(): void { $ref = new ReflectionClass(DiscoveryCacheManager::class); @@ -104,9 +92,9 @@ function generateAndRegister(int $count): array $setup = generateAndRegister(100); try { - $container = createBenchContainer(); - $cacheManager = new DiscoveryCacheManager($container, createBenchDebugDetector()); - $reflectionCache = new ReflectionCache($container); + createBenchContainer(); + $cacheManager = new DiscoveryCacheManager; + $reflectionCache = new ReflectionCache; $context = new DiscoveryContext($reflectionCache); $location = new DiscoveryLocation( @@ -130,8 +118,8 @@ function generateAndRegister(int $count): array $setup = generateAndRegister(100); try { - $container = createBenchContainer(); - $engine = new DiscoveryEngine($container, createBenchDebugDetector()); + createBenchContainer(); + $engine = new DiscoveryEngine; $location = new DiscoveryLocation( $setup['info']['namespace'], @@ -163,8 +151,8 @@ function generateAndRegister(int $count): array $setup = generateAndRegister(500); try { - $container = createBenchContainer(); - $engine = new DiscoveryEngine($container, createBenchDebugDetector()); + createBenchContainer(); + $engine = new DiscoveryEngine; $location = new DiscoveryLocation( $setup['info']['namespace'], @@ -195,10 +183,10 @@ function generateAndRegister(int $count): array it('scales near-linearly from 100 to 500 classes', function (): void { // Benchmark 100 classes $setup100 = generateAndRegister(100); - $container = createBenchContainer(); + createBenchContainer(); try { - $engine100 = new DiscoveryEngine($container, createBenchDebugDetector()); + $engine100 = new DiscoveryEngine; $loc100 = new DiscoveryLocation($setup100['info']['namespace'], $setup100['info']['path']); $engine100->addLocation($loc100); $engine100->addDiscovery('hooks', new BenchmarkDiscovery('hooks')); @@ -219,7 +207,7 @@ function generateAndRegister(int $count): array $setup500 = generateAndRegister(500); try { - $engine500 = new DiscoveryEngine($container, createBenchDebugDetector()); + $engine500 = new DiscoveryEngine; $loc500 = new DiscoveryLocation($setup500['info']['namespace'], $setup500['info']['path']); $engine500->addLocation($loc500); $engine500->addDiscovery('hooks', new BenchmarkDiscovery('hooks')); @@ -245,8 +233,8 @@ function generateAndRegister(int $count): array $setup = generateAndRegister(250); try { - $container = createBenchContainer(); - $engine = new DiscoveryEngine($container, createBenchDebugDetector()); + createBenchContainer(); + $engine = new DiscoveryEngine; $location = new DiscoveryLocation( $setup['info']['namespace'], @@ -300,8 +288,8 @@ function generateAndRegister(int $count): array } try { - $container = createBenchContainer(); - $engine = new DiscoveryEngine($container, createBenchDebugDetector()); + createBenchContainer(); + $engine = new DiscoveryEngine; foreach ($multiInfo['locations'] as $loc) { $engine->addLocation(new DiscoveryLocation($loc['namespace'], $loc['path'])); diff --git a/tests/Feature/Discovery/DiscoveryServiceProviderTest.php b/tests/Feature/Discovery/DiscoveryServiceProviderTest.php index 50f667c2..13112877 100644 --- a/tests/Feature/Discovery/DiscoveryServiceProviderTest.php +++ b/tests/Feature/Discovery/DiscoveryServiceProviderTest.php @@ -5,13 +5,21 @@ use Pollora\Application\Domain\Contracts\DebugDetectorInterface; use Pollora\Discovery\Application\Services\DiscoveryManager; use Pollora\Discovery\Domain\Contracts\DiscoveryEngineInterface; +use Pollora\Discovery\Domain\Contracts\InstanceResolverInterface; use Pollora\Discovery\Domain\Contracts\ReflectionCacheInterface; +use Pollora\Discovery\Infrastructure\Adapters\LaravelInstanceResolver; use Pollora\Discovery\Infrastructure\Providers\DiscoveryServiceProvider; +use Pollora\Discovery\Infrastructure\Services\DiscoveryCacheManager; use Pollora\Discovery\Infrastructure\Services\DiscoveryEngine; use Pollora\Discovery\Infrastructure\Services\InstancePool; use Pollora\Discovery\Infrastructure\Services\ReflectionCache; use Pollora\Discovery\Infrastructure\Services\ServiceProviderDiscovery; +final class InstancePoolTestService +{ + public function __construct(public string $source = 'by reflection') {} +} + beforeEach(function (): void { // DiscoveryServiceProvider depends on DebugDetectorInterface $this->app->singleton(DebugDetectorInterface::class, fn () => Mockery::mock(DebugDetectorInterface::class, [ @@ -56,6 +64,32 @@ expect($this->app->make(DiscoveryManager::class))->toBe($manager); }); + it('builds discovery classes through the application container', function (): void { + $this->app->bind(InstancePoolTestService::class, fn (): InstancePoolTestService => new InstancePoolTestService('from the container')); + + expect($this->app->make(InstanceResolverInterface::class))->toBeInstanceOf(LaravelInstanceResolver::class) + ->and($this->app->make(InstancePool::class)->getInstance(InstancePoolTestService::class)->source)->toBe('from the container'); + }); + + it('shares one cache manager between the engine and its other readers', function (): void { + $engine = $this->app->make(DiscoveryEngineInterface::class); + + expect($engine->getCacheManager())->toBe($this->app->make(DiscoveryCacheManager::class)); + }); + + it('turns the persistent cache off in debug mode', function (): void { + $this->app->forgetInstance(DiscoveryCacheManager::class); + $this->app->singleton(DebugDetectorInterface::class, fn () => Mockery::mock(DebugDetectorInterface::class, [ + 'isDebugMode' => true, + ])); + + expect($this->app->make(DiscoveryCacheManager::class)->isCacheEnabled())->toBeFalse(); + }); + + it('keeps the persistent cache on outside debug mode', function (): void { + expect($this->app->make(DiscoveryCacheManager::class)->isCacheEnabled())->toBeTrue(); + }); + it('registers ServiceProviderDiscovery as singleton for auto-registration', function (): void { expect($this->app->bound(ServiceProviderDiscovery::class))->toBeTrue(); }); diff --git a/tests/Feature/Doctor/InstallChecksTest.php b/tests/Feature/Doctor/InstallChecksTest.php index 29bfbd27..dd5832b8 100644 --- a/tests/Feature/Doctor/InstallChecksTest.php +++ b/tests/Feature/Doctor/InstallChecksTest.php @@ -8,6 +8,7 @@ use Pollora\Discovery\Application\Services\DiscoveryManager; use Pollora\Discovery\Domain\Contracts\DiscoveryLocationInterface; use Pollora\Discovery\Domain\Models\DiscoveryLocation; +use Pollora\Discovery\Infrastructure\Adapters\LaravelDiscoveryCacheManagerFactory; use Pollora\Discovery\Infrastructure\Services\DiscoveryCacheManager; use Pollora\Doctor\Domain\Enums\RunContext; use Pollora\Doctor\Infrastructure\Checks\DiscoveryCacheCheck; @@ -213,7 +214,7 @@ function cacheManager(bool $debug): DiscoveryCacheManager $detector = Mockery::mock(DebugDetectorInterface::class); $detector->shouldReceive('isDebugMode')->andReturn($debug); - return new DiscoveryCacheManager(app(), $detector); + return (new LaravelDiscoveryCacheManagerFactory(app(), $detector))->make(); } beforeEach(function (): void { diff --git a/tests/Unit/Discovery/Infrastructure/Cache/Psr16DiscoverCacheDriverTest.php b/tests/Unit/Discovery/Infrastructure/Cache/Psr16DiscoverCacheDriverTest.php new file mode 100644 index 00000000..18e7e01a --- /dev/null +++ b/tests/Unit/Discovery/Infrastructure/Cache/Psr16DiscoverCacheDriverTest.php @@ -0,0 +1,119 @@ + */ + public array $entries = []; + + public function get(string $key, mixed $default = null): mixed + { + return $this->entries[$this->valid($key)]['value'] ?? $default; + } + + public function set(string $key, mixed $value, DateInterval|int|null $ttl = null): bool + { + $this->entries[$this->valid($key)] = ['value' => $value, 'ttl' => $ttl]; + + return true; + } + + public function delete(string $key): bool + { + unset($this->entries[$this->valid($key)]); + + return true; + } + + public function clear(): bool + { + $this->entries = []; + + return true; + } + + public function getMultiple(iterable $keys, mixed $default = null): iterable + { + return []; + } + + public function setMultiple(iterable $values, DateInterval|int|null $ttl = null): bool + { + return true; + } + + public function deleteMultiple(iterable $keys): bool + { + return true; + } + + public function has(string $key): bool + { + return isset($this->entries[$this->valid($key)]); + } + + private function valid(string $key): string + { + if (strpbrk($key, '{}()/\\@:') !== false) { + throw new class('Reserved character in '.$key) extends Exception implements InvalidArgumentException {}; + } + + return $key; + } + }; +} + +describe('Psr16DiscoverCacheDriver', function (): void { + it('returns the structures it stored', function (): void { + $driver = new Psr16DiscoverCacheDriver(strictPsr16Cache()); + + $driver->put('discovery_abc', ['App\\Hooks\\Foo']); + + expect($driver->has('discovery_abc'))->toBeTrue() + ->and($driver->get('discovery_abc'))->toBe(['App\\Hooks\\Foo']); + }); + + it('reports a missing entry as absent and empty', function (): void { + $driver = new Psr16DiscoverCacheDriver(strictPsr16Cache()); + + expect($driver->has('discovery_missing'))->toBeFalse() + ->and($driver->get('discovery_missing'))->toBe([]); + }); + + it('forgets an entry', function (): void { + $driver = new Psr16DiscoverCacheDriver(strictPsr16Cache()); + $driver->put('discovery_abc', ['App\\Hooks\\Foo']); + + $driver->forget('discovery_abc'); + + expect($driver->has('discovery_abc'))->toBeFalse(); + }); + + it('accepts ids holding characters PSR-16 reserves', function (): void { + $driver = new Psr16DiscoverCacheDriver(strictPsr16Cache()); + + $driver->put('App\\Hooks:{id}', ['App\\Hooks\\Foo']); + + expect($driver->get('App\\Hooks:{id}'))->toBe(['App\\Hooks\\Foo']); + }); + + it('stores under its prefix, with its lifetime', function (): void { + $cache = strictPsr16Cache(); + $driver = new Psr16DiscoverCacheDriver($cache, prefix: 'site-', ttl: 3600); + + $driver->put('discovery_abc', []); + + expect($cache->entries)->toBe(['site-discovery_abc' => ['value' => [], 'ttl' => 3600]]); + }); +}); diff --git a/tests/Unit/Discovery/Infrastructure/Resolvers/ReflectionInstanceResolverTest.php b/tests/Unit/Discovery/Infrastructure/Resolvers/ReflectionInstanceResolverTest.php new file mode 100644 index 00000000..35d40e6f --- /dev/null +++ b/tests/Unit/Discovery/Infrastructure/Resolvers/ReflectionInstanceResolverTest.php @@ -0,0 +1,87 @@ +make(ResolverLeaf::class))->toBeInstanceOf(ResolverLeaf::class); + }); + + it('fills parameters from their default value', function (): void { + $instance = (new ReflectionInstanceResolver)->make(ResolverWithDefaults::class); + + expect($instance->name)->toBe('default') + ->and($instance->limit)->toBeNull(); + }); + + it('builds class-typed dependencies and passes null to nullable scalars', function (): void { + $instance = (new ReflectionInstanceResolver)->make(ResolverWithDependency::class); + + expect($instance->leaf)->toBeInstanceOf(ResolverLeaf::class) + ->and($instance->label)->toBeNull(); + }); + + it('names the parameter it cannot fill', function (): void { + (new ReflectionInstanceResolver)->make(ResolverWithScalar::class); + })->throws(InvalidArgumentException::class, 'parameter $required'); + + it('refuses an interface dependency it has no implementation for', function (): void { + (new ReflectionInstanceResolver)->make(ResolverWithInterface::class); + })->throws(InvalidArgumentException::class, 'Countable is not instantiable'); + + it('refuses an abstract class', function (): void { + (new ReflectionInstanceResolver)->make(ResolverAbstract::class); + })->throws(InvalidArgumentException::class, 'not instantiable'); + + it('stops a dependency cycle instead of overflowing the stack', function (): void { + (new ReflectionInstanceResolver)->make(ResolverCycleA::class); + })->throws(RuntimeException::class, 'Circular dependency detected while building ResolverCycleA'); + + it('can build the same class again after a failure', function (): void { + $resolver = new ReflectionInstanceResolver; + + try { + $resolver->make(ResolverCycleA::class); + } catch (RuntimeException) { + // The cycle is reported once; the resolver must not stay stuck on it. + } + + expect($resolver->make(ResolverWithDependency::class))->toBeInstanceOf(ResolverWithDependency::class); + }); +}); diff --git a/tests/Unit/Discovery/Infrastructure/Services/DiscoveryCacheManagerSlowScanTest.php b/tests/Unit/Discovery/Infrastructure/Services/DiscoveryCacheManagerSlowScanTest.php index 1266b4ed..271605d8 100644 --- a/tests/Unit/Discovery/Infrastructure/Services/DiscoveryCacheManagerSlowScanTest.php +++ b/tests/Unit/Discovery/Infrastructure/Services/DiscoveryCacheManagerSlowScanTest.php @@ -7,6 +7,7 @@ use Pollora\Application\Domain\Contracts\DebugDetectorInterface; use Pollora\Discovery\Domain\Contracts\DiscoveryLocationInterface; use Pollora\Discovery\Domain\Models\DiscoveryContext; +use Pollora\Discovery\Infrastructure\Adapters\LaravelDiscoveryCacheManagerFactory; use Pollora\Discovery\Infrastructure\Services\DiscoveryCacheManager; use Pollora\Discovery\Infrastructure\Services\ReflectionCache; use Psr\Log\LoggerInterface; @@ -38,15 +39,14 @@ function managerFor(bool $debug, ?LoggerInterface $logger = null): DiscoveryCach $container->instance(LoggerInterface::class, $logger); } - // config() reaches for the application container; without it the - // non-debug path cannot resolve a cache driver. + // Without a config repository the non-debug path cannot resolve a cache driver. $container->instance('config', new Repository(['structure-discoverer' => ['cache' => []]])); Container::setInstance($container); $detector = Mockery::mock(DebugDetectorInterface::class); $detector->shouldReceive('isDebugMode')->andReturn($debug); - return new DiscoveryCacheManager($container, $detector); + return (new LaravelDiscoveryCacheManagerFactory($container, $detector))->make(); } function scanOf(DiscoveryCacheManager $manager, string $path): void @@ -157,14 +157,14 @@ function scanOf(DiscoveryCacheManager $manager, string $path): void $detector = Mockery::mock(DebugDetectorInterface::class); $detector->shouldReceive('isDebugMode')->andReturn(false); - $first = new DiscoveryCacheManager($container, $detector); + $first = (new LaravelDiscoveryCacheManagerFactory($container, $detector))->make(); scanOf($first, $this->dir); // A new process starts with an empty copy of its own, and only the // persistent cache is left to answer. (new ReflectionProperty(DiscoveryCacheManager::class, 'structuresCache'))->setValue(null, []); - $second = new DiscoveryCacheManager($container, $detector); + $second = (new LaravelDiscoveryCacheManagerFactory($container, $detector))->make(); scanOf($second, $this->dir); expect($first->scans()[0]['source'])->toBe('disk') diff --git a/tests/Unit/Discovery/Infrastructure/Services/DiscoveryEngineTest.php b/tests/Unit/Discovery/Infrastructure/Services/DiscoveryEngineTest.php index 9987ab03..af1382ea 100644 --- a/tests/Unit/Discovery/Infrastructure/Services/DiscoveryEngineTest.php +++ b/tests/Unit/Discovery/Infrastructure/Services/DiscoveryEngineTest.php @@ -2,11 +2,12 @@ declare(strict_types=1); -use Illuminate\Container\Container; -use Pollora\Application\Domain\Contracts\DebugDetectorInterface; use Pollora\Attributes\CapabilitySet; +use Pollora\Discovery\Domain\Contracts\DiscoveryEngineInterface; use Pollora\Discovery\Domain\Contracts\DiscoveryInterface; use Pollora\Discovery\Domain\Contracts\DiscoveryLocationInterface; +use Pollora\Discovery\Domain\Contracts\DiscoveryRegistrarInterface; +use Pollora\Discovery\Domain\Contracts\InstanceResolverInterface; use Pollora\Discovery\Domain\Contracts\ReflectionCacheInterface; use Pollora\Discovery\Domain\Exceptions\DiscoveryException; use Pollora\Discovery\Domain\Exceptions\DiscoveryNotFoundException; @@ -21,12 +22,7 @@ function createEngine(?DiscoveryCacheManager $cacheManager = null): DiscoveryEngine { - $container = new Container; - $debugDetector = Mockery::mock(DebugDetectorInterface::class, ['isDebugMode' => true]); - return new DiscoveryEngine( - $container, - $debugDetector, cacheManager: $cacheManager ?? Mockery::mock(DiscoveryCacheManager::class) ); } @@ -340,3 +336,63 @@ public function apply(): void {} expect($seen)->toBe(['EngineTestAttributedEnum', 'EngineTestClass']); }); }); + +final class EngineTestNamedDiscovery implements DiscoveryInterface +{ + use IsDiscovery; + + public function discover(DiscoveryLocationInterface $location, DiscoveredStructure $structure, ?ReflectionCacheInterface $reflectionCache = null): void {} + + public function apply(): void {} +} + +describe('DiscoveryEngine without Laravel', function (): void { + it('builds a discovery given by class name through its resolver', function (): void { + $built = new EngineTestNamedDiscovery; + $resolver = Mockery::mock(InstanceResolverInterface::class); + $resolver->shouldReceive('make')->once()->with(EngineTestNamedDiscovery::class)->andReturn($built); + $engine = new DiscoveryEngine(resolver: $resolver, cacheManager: Mockery::mock(DiscoveryCacheManager::class)); + + $engine->addDiscovery('named', EngineTestNamedDiscovery::class); + + expect($engine->getDiscovery('named'))->toBe($built); + }); + + it('builds a discovery given by class name by reflection when it has no resolver', function (): void { + $engine = new DiscoveryEngine(cacheManager: Mockery::mock(DiscoveryCacheManager::class)); + + $engine->addDiscovery('named', EngineTestNamedDiscovery::class); + + expect($engine->getDiscovery('named'))->toBeInstanceOf(EngineTestNamedDiscovery::class); + }); + + it("asks its registrar for the environment's discoveries before discovering", function (): void { + $cacheManager = Mockery::mock(DiscoveryCacheManager::class); + $cacheManager->shouldReceive('getStructuresForLocation')->andReturn([]); + $registrar = Mockery::mock(DiscoveryRegistrarInterface::class); + $registrar->shouldReceive('registerInto')->once() + ->andReturnUsing(function (DiscoveryEngineInterface $engine): array { + $engine->addDiscovery('registered', new EngineTestNamedDiscovery); + + return ['registered']; + }); + $engine = new DiscoveryEngine(cacheManager: $cacheManager, registrar: $registrar); + $engine->addLocation(new DiscoveryLocation('Tests\\', __DIR__)); + + $engine->discover(); + + expect($engine->getDiscoveries()->keys()->all())->toBe(['registered']); + }); + + it('runs only the discoveries added to it when it has no registrar', function (): void { + $cacheManager = Mockery::mock(DiscoveryCacheManager::class); + $cacheManager->shouldReceive('getStructuresForLocation')->andReturn([]); + $engine = new DiscoveryEngine(cacheManager: $cacheManager); + $engine->addDiscovery('explicit', new EngineTestNamedDiscovery); + $engine->addLocation(new DiscoveryLocation('Tests\\', __DIR__)); + + $engine->discover(); + + expect($engine->getDiscoveries()->keys()->all())->toBe(['explicit']); + }); +}); diff --git a/tests/Unit/Discovery/Infrastructure/Services/DiscoveryRegistrarTest.php b/tests/Unit/Discovery/Infrastructure/Services/DiscoveryRegistrarTest.php index d30a7ec5..b3c9e069 100644 --- a/tests/Unit/Discovery/Infrastructure/Services/DiscoveryRegistrarTest.php +++ b/tests/Unit/Discovery/Infrastructure/Services/DiscoveryRegistrarTest.php @@ -71,7 +71,7 @@ function createMockRegistrarEngine(): DiscoveryEngineInterface ->once() ->with('test_discovery', $discovery); - $registered = $this->registrar->registerFromContainer($engine); + $registered = $this->registrar->registerInto($engine); expect($registered)->toContain('test_discovery'); }); @@ -82,7 +82,7 @@ function createMockRegistrarEngine(): DiscoveryEngineInterface $engine = createMockRegistrarEngine(); $engine->shouldNotReceive('addDiscovery'); - $registered = $this->registrar->registerFromContainer($engine); + $registered = $this->registrar->registerInto($engine); expect($registered)->toBe([]); }); @@ -96,7 +96,7 @@ function createMockRegistrarEngine(): DiscoveryEngineInterface ->andReturn(new Collection(['already_registered' => $discovery])); $engine->shouldNotReceive('addDiscovery'); - $registered = $this->registrar->registerFromContainer($engine); + $registered = $this->registrar->registerInto($engine); expect($registered)->toBe([]); }); @@ -107,7 +107,7 @@ function createMockRegistrarEngine(): DiscoveryEngineInterface $engine = createMockRegistrarEngine(); $engine->shouldNotReceive('addDiscovery'); - $registered = $this->registrar->registerFromContainer($engine); + $registered = $this->registrar->registerInto($engine); expect($registered)->toBe([]); }); @@ -118,7 +118,7 @@ function createMockRegistrarEngine(): DiscoveryEngineInterface $engine = createMockRegistrarEngine(); $engine->shouldNotReceive('addDiscovery'); - $registered = $this->registrar->registerFromContainer($engine); + $registered = $this->registrar->registerInto($engine); expect($registered)->toBe([]); }); diff --git a/tests/Unit/Discovery/PackageBoundaryTest.php b/tests/Unit/Discovery/PackageBoundaryTest.php new file mode 100644 index 00000000..d4517472 --- /dev/null +++ b/tests/Unit/Discovery/PackageBoundaryTest.php @@ -0,0 +1,48 @@ +expect([ + 'Pollora\Discovery\Domain', + 'Pollora\Discovery\Application', + 'Pollora\Discovery\Infrastructure\Cache', + 'Pollora\Discovery\Infrastructure\Resolvers', + DiscoveryCacheManager::class, + DiscoveryEngine::class, + InstancePool::class, + ReflectionCache::class, + ]) + ->toOnlyUse([ + 'Pollora\Discovery', + SkipDiscovery::class, + 'Spatie\StructureDiscoverer', + 'Psr\Log', + 'Psr\SimpleCache', + 'Psr\Container', + Collection::class, + // HasInstancePool falls back to Laravel's resolve() only when it is defined. + 'resolve', + ]) + ->ignoring([ + HasConfiguringSupport::class, + ConfigurableDiscoveryInterface::class, + ]); From 7618574465fee067e522b29e44296f827dec014f Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Mon, 12 Oct 2026 11:43:07 +0200 Subject: [PATCH 02/27] refactor: require pollora/discovery instead of shipping it The engine, its contracts, the cache and #[SkipDiscovery] now come from pollora/discovery, with the same class names. The framework keeps the Laravel side: provider, commands, registrar, adapters, and the post type and taxonomy support. --- CHANGELOG.md | 1 + composer.json | 1 + src/Attributes/SkipDiscovery.php | 31 - .../Application/Services/DiscoveryManager.php | 319 --------- .../Contracts/DiscoversPathInterface.php | 30 - .../Contracts/DiscoveryCacheInterface.php | 68 -- .../Contracts/DiscoveryEngineInterface.php | 139 ---- .../Domain/Contracts/DiscoveryInterface.php | 73 -- .../Contracts/DiscoveryItemsInterface.php | 101 --- .../Contracts/DiscoveryLocationInterface.php | 36 - .../Contracts/DiscoveryRegistrarInterface.php | 23 - .../Contracts/InstanceResolverInterface.php | 27 - .../Contracts/ReflectionCacheInterface.php | 83 --- .../Domain/Exceptions/DiscoveryException.php | 44 -- .../Exceptions/DiscoveryNotFoundException.php | 34 - .../Exceptions/InvalidDiscoveryException.php | 48 -- .../Domain/Models/DirectoryLocation.php | 69 -- .../Domain/Models/DiscoveryContext.php | 252 ------- .../Domain/Models/DiscoveryItems.php | 192 ----- .../Domain/Models/DiscoveryLocation.php | 104 --- .../Domain/Services/HasInstancePool.php | 66 -- src/Discovery/Domain/Services/IsDiscovery.php | 54 -- .../Cache/Psr16DiscoverCacheDriver.php | 62 -- .../Resolvers/ContainerInstanceResolver.php | 26 - .../Resolvers/ReflectionInstanceResolver.php | 83 --- .../Services/CircularDependencyException.php | 10 - .../Services/DiscoveryCacheManager.php | 257 ------- .../Services/DiscoveryEngine.php | 650 ----------------- .../Infrastructure/Services/InstancePool.php | 307 -------- .../Services/ReflectionCache.php | 256 ------- tests/Benchmark/BenchmarkDiscovery.php | 120 ---- tests/Benchmark/DiscoveryBenchmark.php | 463 ------------ tests/Benchmark/DiscoveryPerformanceTest.php | 319 --------- tests/Benchmark/Fixtures/FixtureGenerator.php | 673 ------------------ tests/Benchmark/run.php | 54 -- tests/Pest.php | 1 - .../Domain/Models/DiscoveryItemsTest.php | 154 ---- .../Domain/Models/DiscoveryLocationTest.php | 67 -- .../Cache/Psr16DiscoverCacheDriverTest.php | 119 ---- .../ReflectionInstanceResolverTest.php | 87 --- .../Services/DiscoveryEngineTest.php | 398 ----------- tests/Unit/Discovery/PackageBoundaryTest.php | 48 -- 42 files changed, 2 insertions(+), 5947 deletions(-) delete mode 100644 src/Attributes/SkipDiscovery.php delete mode 100644 src/Discovery/Application/Services/DiscoveryManager.php delete mode 100644 src/Discovery/Domain/Contracts/DiscoversPathInterface.php delete mode 100644 src/Discovery/Domain/Contracts/DiscoveryCacheInterface.php delete mode 100644 src/Discovery/Domain/Contracts/DiscoveryEngineInterface.php delete mode 100644 src/Discovery/Domain/Contracts/DiscoveryInterface.php delete mode 100644 src/Discovery/Domain/Contracts/DiscoveryItemsInterface.php delete mode 100644 src/Discovery/Domain/Contracts/DiscoveryLocationInterface.php delete mode 100644 src/Discovery/Domain/Contracts/DiscoveryRegistrarInterface.php delete mode 100644 src/Discovery/Domain/Contracts/InstanceResolverInterface.php delete mode 100644 src/Discovery/Domain/Contracts/ReflectionCacheInterface.php delete mode 100644 src/Discovery/Domain/Exceptions/DiscoveryException.php delete mode 100644 src/Discovery/Domain/Exceptions/DiscoveryNotFoundException.php delete mode 100644 src/Discovery/Domain/Exceptions/InvalidDiscoveryException.php delete mode 100644 src/Discovery/Domain/Models/DirectoryLocation.php delete mode 100644 src/Discovery/Domain/Models/DiscoveryContext.php delete mode 100644 src/Discovery/Domain/Models/DiscoveryItems.php delete mode 100644 src/Discovery/Domain/Models/DiscoveryLocation.php delete mode 100644 src/Discovery/Domain/Services/HasInstancePool.php delete mode 100644 src/Discovery/Domain/Services/IsDiscovery.php delete mode 100644 src/Discovery/Infrastructure/Cache/Psr16DiscoverCacheDriver.php delete mode 100644 src/Discovery/Infrastructure/Resolvers/ContainerInstanceResolver.php delete mode 100644 src/Discovery/Infrastructure/Resolvers/ReflectionInstanceResolver.php delete mode 100644 src/Discovery/Infrastructure/Services/CircularDependencyException.php delete mode 100644 src/Discovery/Infrastructure/Services/DiscoveryCacheManager.php delete mode 100644 src/Discovery/Infrastructure/Services/DiscoveryEngine.php delete mode 100644 src/Discovery/Infrastructure/Services/InstancePool.php delete mode 100644 src/Discovery/Infrastructure/Services/ReflectionCache.php delete mode 100644 tests/Benchmark/BenchmarkDiscovery.php delete mode 100644 tests/Benchmark/DiscoveryBenchmark.php delete mode 100644 tests/Benchmark/DiscoveryPerformanceTest.php delete mode 100644 tests/Benchmark/Fixtures/FixtureGenerator.php delete mode 100644 tests/Benchmark/run.php delete mode 100644 tests/Unit/Discovery/Domain/Models/DiscoveryItemsTest.php delete mode 100644 tests/Unit/Discovery/Domain/Models/DiscoveryLocationTest.php delete mode 100644 tests/Unit/Discovery/Infrastructure/Cache/Psr16DiscoverCacheDriverTest.php delete mode 100644 tests/Unit/Discovery/Infrastructure/Resolvers/ReflectionInstanceResolverTest.php delete mode 100644 tests/Unit/Discovery/Infrastructure/Services/DiscoveryEngineTest.php delete mode 100644 tests/Unit/Discovery/PackageBoundaryTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b2c2af88..c41f41b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Pollora/framework/compare/v13.35.6...develop) ### Added +- Discovery lives in its own package, [`pollora/discovery`](https://github.com/Pollora/discovery), which the framework requires: the engine, its contracts, `DiscoveryManager`, the cache and the `#[SkipDiscovery]` attribute, with their tests and benchmarks. Class names do not change. The framework keeps what needs Laravel: the service provider, `discovery:run` / `discovery:clear`, `DiscoveryRegistrar`, `ServiceProviderDiscovery`, the Laravel adapters, and `HasConfiguringSupport`, which builds post types and taxonomies - Discovery can run without Laravel's application, the first step towards `pollora/discovery`: `new DiscoveryEngine()` works with no argument. Two contracts let an environment plug in — `InstanceResolverInterface` builds the classes discovery needs (Laravel's container in a Pollora application, `ReflectionInstanceResolver` otherwise) and `DiscoveryRegistrarInterface` adds the environment's discoveries before each run (`DiscoveryRegistrar` keeps scanning the container's `*Discovery` bindings). `Psr16DiscoverCacheDriver` caches scans in any PSR-16 cache ### Changed diff --git a/composer.json b/composer.json index c7662e94..a5f9f69b 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,7 @@ "illuminate/console": "^13.35", "pollora/abilities": "^1.0", "pollora/ajax": "^1.1", + "pollora/discovery": "^0.1 || ^1.0", "pollora/entity": "^1.2", "pollora/hook": "^1.5", "pollora/option": "^1.1", diff --git a/src/Attributes/SkipDiscovery.php b/src/Attributes/SkipDiscovery.php deleted file mode 100644 index fcb5704f..00000000 --- a/src/Attributes/SkipDiscovery.php +++ /dev/null @@ -1,31 +0,0 @@ - $except Discovery classes that should still process this class - */ -#[Attribute(Attribute::TARGET_CLASS)] -final readonly class SkipDiscovery -{ - /** - * @param array $except Discovery classes that should still process this class - */ - public function __construct(public array $except = []) {} -} diff --git a/src/Discovery/Application/Services/DiscoveryManager.php b/src/Discovery/Application/Services/DiscoveryManager.php deleted file mode 100644 index c3fd48ab..00000000 --- a/src/Discovery/Application/Services/DiscoveryManager.php +++ /dev/null @@ -1,319 +0,0 @@ -engine->addLocation($location); - - return $this; - } - - /** - * Add multiple discovery locations - * - * @param array $paths Array of filesystem paths - * @return static Returns self for method chaining - */ - public function addLocations(array $paths): static - { - foreach ($paths as $path) { - $this->addLocation($path); - } - - return $this; - } - - /** - * Register a discovery class - * - * @param string $identifier Unique identifier for the discovery - * @param string|DiscoveryInterface $discovery Discovery class name or instance - * @return static Returns self for method chaining - */ - public function addDiscovery(string $identifier, string|DiscoveryInterface $discovery): static - { - $this->engine->addDiscovery($identifier, $discovery); - - return $this; - } - - /** - * Register multiple discovery classes - * - * @param array $discoveries Map of identifier to discovery - * @return static Returns self for method chaining - */ - public function addDiscoveries(array $discoveries): static - { - $this->engine->addDiscoveries($discoveries); - - return $this; - } - - /** - * Execute discovery and apply all discoveries - * - * This is the main method that runs the entire discovery process. - * - * @return static Returns self for method chaining - */ - public function run(): static - { - $this->engine->run(); - - return $this; - } - - /** - * Execute only the discovery phase - * - * Useful for testing or when you want to inspect discovered items - * before applying them. - * - * @return static Returns self for method chaining - */ - public function discover(): static - { - $this->engine->discover(); - - return $this; - } - - /** - * Execute only the application phase - * - * Applies previously discovered items. Should be called after discover(). - * - * @return static Returns self for method chaining - */ - public function apply(): static - { - $this->engine->apply(); - - return $this; - } - - /** - * Get a specific discovery by identifier - * - * @param string $identifier The discovery identifier - * @return DiscoveryInterface The discovery instance - */ - public function getDiscovery(string $identifier): DiscoveryInterface - { - return $this->engine->getDiscovery($identifier); - } - - /** - * Get all registered discoveries - * - * @return Collection Collection of discoveries keyed by identifier - */ - public function getDiscoveries(): Collection - { - return $this->engine->getDiscoveries(); - } - - /** - * Get all discovery locations - * - * @return Collection Collection of discovery locations - */ - public function getLocations(): Collection - { - return $this->engine->getLocations(); - } - - /** - * Clear persistent discovery cache - * - * Only clears persistent cache (Spatie structure discoverer cache). - * In-memory caches are automatically cleared at process end. - * - * @return static Returns self for method chaining - */ - public function clearCache(): static - { - $this->engine->clearCache(); - - return $this; - } - - /** - * Check if a discovery is registered - * - * @param string $identifier The discovery identifier to check - * @return bool True if the discovery is registered, false otherwise - */ - public function hasDiscovery(string $identifier): bool - { - try { - $this->getDiscovery($identifier); - - return true; - } catch (\Throwable) { - return false; - } - } - - /** - * Get discovered items for a specific discovery - * - * @param string $identifier The discovery identifier - * @return array The discovered items - */ - public function getDiscoveredItems(string $identifier): array - { - $discovery = $this->getDiscovery($identifier); - - return $discovery->getItems()->all(); - } - - /** - * Run a specific discovery type on a specific location - * - * @param string $identifier The discovery identifier - * @param DiscoveryLocationInterface $location The location to discover in - * @return static Returns self for method chaining - */ - public function runSpecificDiscovery(string $identifier, DiscoveryLocationInterface $location): static - { - if (! $this->hasDiscovery($identifier)) { - return $this; - } - - // Create a temporary engine with just this discovery and location - $tempEngine = clone $this->engine; - $tempEngine->clearLocations(); - $tempEngine->addLocation($location); - - // Run only this specific discovery - $discovery = $this->getDiscovery($identifier); - $tempEngine->runDiscovery($identifier, $discovery); - - return $this; - } - - /** - * Discover all structure types in a location - * - * @param DiscoveryLocationInterface $location The location to discover in - * @return array Results grouped by discovery type - */ - public function discoverAllInLocation(DiscoveryLocationInterface $location): array - { - $results = []; - - // Create a temporary engine for this location - $tempEngine = clone $this->engine; - $tempEngine->clearLocations(); - $tempEngine->addLocation($location); - - // Run discovery and collect results by type - $tempEngine->discover(); - - foreach ($this->getDiscoveries() as $identifier => $discovery) { - $items = $discovery->getItems()->all(); - if (! empty($items)) { - $results[$identifier] = $items; - } - } - - return $results; - } - - /** - * Get the underlying discovery engine - * - * @return DiscoveryEngineInterface The discovery engine instance - */ - public function getEngine(): DiscoveryEngineInterface - { - return $this->engine; - } - - /** - * Get performance statistics from the optimized engine - * - * @return array Performance statistics or empty array if not available - */ - public function getPerformanceStats(): array - { - if (method_exists($this->engine, 'getPerformanceStats')) { - return $this->engine->getPerformanceStats(); - } - - return []; - } - - /** - * Get debugging information about the discovery state - * - * @return array Debug information - */ - public function getDebugInfo(): array - { - $debugInfo = [ - 'discoveries_count' => $this->getDiscoveries()->count(), - 'locations_count' => $this->getLocations()->count(), - 'discoveries' => [], - 'locations' => [], - ]; - - // Add discovery details - foreach ($this->getDiscoveries() as $identifier => $discovery) { - $items = $this->getDiscoveredItems($identifier); - $debugInfo['discoveries'][$identifier] = [ - 'class' => $discovery::class, - 'items_count' => count($items), - 'items' => array_slice($items, 0, 3), // Show first 3 items for debugging - ]; - } - - // Add location details - foreach ($this->getLocations() as $location) { - $debugInfo['locations'][] = [ - 'path' => $location->getPath(), - 'exists' => is_dir($location->getPath()), - ]; - } - - return $debugInfo; - } -} diff --git a/src/Discovery/Domain/Contracts/DiscoversPathInterface.php b/src/Discovery/Domain/Contracts/DiscoversPathInterface.php deleted file mode 100644 index b1bccd32..00000000 --- a/src/Discovery/Domain/Contracts/DiscoversPathInterface.php +++ /dev/null @@ -1,30 +0,0 @@ - $locations The discovery locations - * @return string The generated cache key - */ - public function generateKey(string $discoveryIdentifier, array $locations): string; -} diff --git a/src/Discovery/Domain/Contracts/DiscoveryEngineInterface.php b/src/Discovery/Domain/Contracts/DiscoveryEngineInterface.php deleted file mode 100644 index 4b875649..00000000 --- a/src/Discovery/Domain/Contracts/DiscoveryEngineInterface.php +++ /dev/null @@ -1,139 +0,0 @@ - $locations The locations to add - * @return static Returns self for method chaining - */ - public function addLocations(array $locations): static; - - /** - * Register a discovery class - * - * @param string $identifier Unique identifier for the discovery - * @param string|DiscoveryInterface $discovery Discovery class name or instance - * @return static Returns self for method chaining - */ - public function addDiscovery(string $identifier, string|DiscoveryInterface $discovery): static; - - /** - * Register multiple discovery classes - * - * @param array $discoveries Map of identifier to discovery - * @return static Returns self for method chaining - */ - public function addDiscoveries(array $discoveries): static; - - /** - * Execute discovery process for all registered discoveries - * - * Runs the discovery phase for all registered discovery classes - * across all configured locations. - * - * @return static Returns self for method chaining - * - * @throws DiscoveryException When discovery fails - */ - public function discover(): static; - - /** - * Apply all discoveries - * - * Applies all discovered items by calling the apply() method - * on all registered discovery classes. - * - * @return static Returns self for method chaining - * - * @throws DiscoveryException When application fails - */ - public function apply(): static; - - /** - * Execute discovery and apply in one operation - * - * Convenience method that runs both discover() and apply(). - * - * @return static Returns self for method chaining - * - * @throws DiscoveryException When discovery or application fails - */ - public function run(): static; - - /** - * Get a specific discovery by identifier - * - * @param string $identifier The discovery identifier - * @return DiscoveryInterface The discovery instance - * - * @throws DiscoveryNotFoundException When discovery not found - */ - public function getDiscovery(string $identifier): DiscoveryInterface; - - /** - * Get all registered discoveries - * - * @return Collection Collection of discoveries keyed by identifier - */ - public function getDiscoveries(): Collection; - - /** - * Get all discovery locations - * - * @return Collection Collection of discovery locations - */ - public function getLocations(): Collection; - - /** - * Clear persistent discovery cache. - * - * @return static Returns self for method chaining - */ - public function clearCache(): static; - - /** - * Clear all registered discovery locations. - * - * @return static Returns self for method chaining - */ - public function clearLocations(): static; - - /** - * Run a specific discovery by identifier. - * - * @param string $identifier The discovery identifier - * @param DiscoveryInterface $discovery The discovery instance to run - * @return static Returns self for method chaining - */ - public function runDiscovery(string $identifier, DiscoveryInterface $discovery): static; -} diff --git a/src/Discovery/Domain/Contracts/DiscoveryInterface.php b/src/Discovery/Domain/Contracts/DiscoveryInterface.php deleted file mode 100644 index 041d3b18..00000000 --- a/src/Discovery/Domain/Contracts/DiscoveryInterface.php +++ /dev/null @@ -1,73 +0,0 @@ - - */ -interface DiscoveryItemsInterface extends Countable, IteratorAggregate -{ - /** - * Add multiple values for a specific location - * - * @param DiscoveryLocationInterface $location The discovery location - * @param array $values The values to add - * @return static Returns self for method chaining - */ - public function addForLocation(DiscoveryLocationInterface $location, array $values): static; - - /** - * Get all values for a specific location - * - * @param DiscoveryLocationInterface $location The discovery location - * @return array The values for the location - */ - public function getForLocation(DiscoveryLocationInterface $location): array; - - /** - * Add a single value for a specific location - * - * @param DiscoveryLocationInterface $location The discovery location - * @param mixed $value The value to add - * @return static Returns self for method chaining - */ - public function add(DiscoveryLocationInterface $location, mixed $value): static; - - /** - * Check if a location has any discovered items - * - * @param DiscoveryLocationInterface $location The discovery location - * @return bool True if the location has items, false otherwise - */ - public function hasLocation(DiscoveryLocationInterface $location): bool; - - /** - * Check if any items have been loaded - * - * @return bool True if items are loaded, false if empty - */ - public function isLoaded(): bool; - - /** - * Filter to only include vendor locations - * - * Creates a new instance containing only items from vendor directories. - * - * @return static A new instance with only vendor items - */ - public function onlyVendor(): static; - - /** - * Get all items flattened into a single array - * - * @return array All discovered items - */ - public function all(): array; - - /** - * Get the items not applied yet - * - * Creates a new instance holding, for each location, the items added - * since the last call to markApplied(). - * - * @return static A new instance with only the pending items - */ - public function pending(): static; - - /** - * Mark every current item as applied - * - * Items added afterwards are the next pending() ones. - */ - public function markApplied(): void; - - /** - * Serialize the discovery items for caching. - * - * @return array - */ - public function __serialize(): array; -} diff --git a/src/Discovery/Domain/Contracts/DiscoveryLocationInterface.php b/src/Discovery/Domain/Contracts/DiscoveryLocationInterface.php deleted file mode 100644 index 7fcb8745..00000000 --- a/src/Discovery/Domain/Contracts/DiscoveryLocationInterface.php +++ /dev/null @@ -1,36 +0,0 @@ - The identifiers of the discoveries added - */ - public function registerInto(DiscoveryEngineInterface $engine): array; -} diff --git a/src/Discovery/Domain/Contracts/InstanceResolverInterface.php b/src/Discovery/Domain/Contracts/InstanceResolverInterface.php deleted file mode 100644 index cf9d5f82..00000000 --- a/src/Discovery/Domain/Contracts/InstanceResolverInterface.php +++ /dev/null @@ -1,27 +0,0 @@ - $className The fully qualified class name - * @return T The built instance - * - * @throws \Throwable If the class cannot be built - */ - public function make(string $className): object; -} diff --git a/src/Discovery/Domain/Contracts/ReflectionCacheInterface.php b/src/Discovery/Domain/Contracts/ReflectionCacheInterface.php deleted file mode 100644 index 6bd82c16..00000000 --- a/src/Discovery/Domain/Contracts/ReflectionCacheInterface.php +++ /dev/null @@ -1,83 +0,0 @@ - Array of methods with the specified attributes - */ - public function getMethodsWithAttributes(string $className, ?string $attributeClass = null): array; - - /** - * Get cached class-level attributes for a class. - * - * @param string $className The fully qualified class name - * @param string|null $attributeClass Optional filter by attribute class - * @return array<\ReflectionAttribute> Array of class attributes - */ - public function getClassAttributes(string $className, ?string $attributeClass = null): array; - - /** - * Get cached public methods for a class. - * - * Eliminates redundant getMethods(IS_PUBLIC) calls when multiple - * discoveries inspect the same class. - * - * @param string $className The fully qualified class name - * @return array Array of public methods - */ - public function getPublicMethods(string $className): array; - - /** - * Get cached class instance from the DI container. - * - * @param string $className The fully qualified class name - * @return object The cached class instance - * - * @throws \Throwable If the class cannot be instantiated - */ - public function getClassInstance(string $className): object; - - /** - * Check if a class has been processed by reflection cache. - * - * @param string $className The fully qualified class name - * @return bool True if the class is cached - */ - public function hasClass(string $className): bool; - - /** - * Preload multiple classes into the cache. - * - * @param array $classNames Array of class names to preload - */ - public function preloadClasses(array $classNames): void; -} diff --git a/src/Discovery/Domain/Exceptions/DiscoveryException.php b/src/Discovery/Domain/Exceptions/DiscoveryException.php deleted file mode 100644 index 55a1f519..00000000 --- a/src/Discovery/Domain/Exceptions/DiscoveryException.php +++ /dev/null @@ -1,44 +0,0 @@ -path; - } - - /** - * Check if the directory exists - * - * @return bool True if the directory exists and is readable - */ - public function exists(): bool - { - return is_dir($this->path) && is_readable($this->path); - } - - /** - * {@inheritDoc} - */ - public function getKey(): string - { - return md5($this->path); - } - - /** - * {@inheritDoc} - */ - public function isVendor(): bool - { - return str_contains($this->path, '/vendor/') || str_contains($this->path, '\\vendor\\'); - } - - /** - * Get a string representation of the location - * - * @return string The string representation of the location - */ - public function __toString(): string - { - return $this->path; - } -} diff --git a/src/Discovery/Domain/Models/DiscoveryContext.php b/src/Discovery/Domain/Models/DiscoveryContext.php deleted file mode 100644 index 4bab9a89..00000000 --- a/src/Discovery/Domain/Models/DiscoveryContext.php +++ /dev/null @@ -1,252 +0,0 @@ -> - */ - private array $processedClasses = []; - - /** - * Shared metadata between discoveries - * - * @var array> - */ - private array $sharedData = []; - - /** - * Current processing stats - * - * @var array - */ - private array $processingStats = [ - 'classes_processed' => 0, - 'discoveries_executed' => 0, - 'cache_hits' => 0, - 'cache_misses' => 0, - 'errors' => 0, - ]; - - /** - * Create a new discovery context - * - * @param ReflectionCacheInterface $reflectionCache The reflection cache service - */ - public function __construct( - private readonly ReflectionCacheInterface $reflectionCache - ) {} - - /** - * Mark a class as processed by a specific discovery type. - * - * @param string $className The fully qualified class name - * @param string $discoveryType The discovery type identifier - */ - public function markProcessed(string $className, string $discoveryType): void - { - $this->processedClasses[$className][$discoveryType] = true; - } - - /** - * Check if a class has been processed by a specific discovery type. - * - * @param string $className The fully qualified class name - * @param string $discoveryType The discovery type identifier - * @return bool True if the class has been processed - */ - public function isProcessed(string $className, string $discoveryType): bool - { - return $this->processedClasses[$className][$discoveryType] ?? false; - } - - /** - * Check if a class has been processed by any discovery type. - * - * @param string $className The fully qualified class name - * @return bool True if the class has been processed by any discovery - */ - public function isClassProcessed(string $className): bool - { - return isset($this->processedClasses[$className]) && - (isset($this->processedClasses[$className]) && $this->processedClasses[$className] !== []); - } - - /** - * Get the list of discovery types that have processed a class. - * - * @param string $className The fully qualified class name - * @return array Array of discovery type identifiers - */ - public function getProcessedDiscoveries(string $className): array - { - return array_keys($this->processedClasses[$className] ?? []); - } - - /** - * Store shared data for a class that can be accessed by other discoveries. - * - * @param string $className The fully qualified class name - * @param string $key The data key - * @param mixed $value The data value - */ - public function setSharedData(string $className, string $key, mixed $value): void - { - $this->sharedData[$className][$key] = $value; - } - - /** - * Get shared data for a class. - * - * @param string $className The fully qualified class name - * @param string|null $key Optional specific key to retrieve - * @return mixed The shared data value or array of all data - */ - public function getSharedData(string $className, ?string $key = null): mixed - { - if ($key === null) { - return $this->sharedData[$className] ?? []; - } - - return $this->sharedData[$className][$key] ?? null; - } - - /** - * Check if shared data exists for a class and key. - * - * @param string $className The fully qualified class name - * @param string $key The data key - * @return bool True if the data exists - */ - public function hasSharedData(string $className, string $key): bool - { - return isset($this->sharedData[$className][$key]); - } - - /** - * Get the reflection cache instance. - */ - public function getReflectionCache(): ReflectionCacheInterface - { - return $this->reflectionCache; - } - - /** - * Preload a batch of classes into the reflection cache. - * - * @param array $structures Array of discovered structures - */ - public function preloadStructures(array $structures): void - { - $classNames = []; - - foreach ($structures as $structure) { - if ($structure instanceof DiscoveredClass && - ! $structure->isAbstract) { - $classNames[] = $structure->namespace.'\\'.$structure->name; - } - } - - $this->reflectionCache->preloadClasses($classNames); - } - - /** - * Increment a processing statistic. - * - * @param string $stat The statistic name - * @param int $increment The increment value - */ - public function incrementStat(string $stat, int $increment = 1): void - { - $this->processingStats[$stat] = ($this->processingStats[$stat] ?? 0) + $increment; - } - - /** - * Get processing statistics. - * - * @return array Array of processing statistics - */ - public function getStats(): array - { - return $this->processingStats; - } - - /** - * Record a cache hit. - */ - public function recordCacheHit(): void - { - $this->incrementStat('cache_hits'); - } - - /** - * Record a cache miss. - */ - public function recordCacheMiss(): void - { - $this->incrementStat('cache_misses'); - } - - /** - * Record a processing error. - */ - public function recordError(): void - { - $this->incrementStat('errors'); - } - - /** - * Get cache efficiency as a percentage. - * - * @return float Cache hit ratio as percentage - */ - public function getCacheEfficiency(): float - { - $hits = $this->processingStats['cache_hits']; - $misses = $this->processingStats['cache_misses']; - $total = $hits + $misses; - - return $total > 0 ? round(($hits / $total) * 100, 2) : 0.0; - } - - /** - * Get a summary of the discovery context state. - * - * @return array Summary information - */ - public function getSummary(): array - { - $totalClasses = count($this->processedClasses); - $totalDiscoveries = array_sum(array_map(count(...), $this->processedClasses)); - - return [ - 'total_classes' => $totalClasses, - 'total_discovery_executions' => $totalDiscoveries, - 'cache_efficiency' => $this->getCacheEfficiency(), - 'shared_data_entries' => array_sum(array_map(count(...), $this->sharedData)), - 'stats' => $this->processingStats, - ]; - } -} diff --git a/src/Discovery/Domain/Models/DiscoveryItems.php b/src/Discovery/Domain/Models/DiscoveryItems.php deleted file mode 100644 index de3a2ff7..00000000 --- a/src/Discovery/Domain/Models/DiscoveryItems.php +++ /dev/null @@ -1,192 +0,0 @@ -> $items Initial items organized by location key - */ - public function __construct( - /** - * The discovered items organized by location key - */ - private array $items = [] - ) {} - - /** - * How many items of each location have been applied, by location key - * - * @var array - */ - private array $applied = []; - - /** - * {@inheritDoc} - */ - public function addForLocation(DiscoveryLocationInterface $location, array $values): static - { - $locationKey = $location->getKey(); - $existingValues = $this->items[$locationKey] ?? []; - - $this->items[$locationKey] = [...$existingValues, ...$values]; - - return $this; - } - - /** - * {@inheritDoc} - */ - public function getForLocation(DiscoveryLocationInterface $location): array - { - return $this->items[$location->getKey()] ?? []; - } - - /** - * {@inheritDoc} - */ - public function add(DiscoveryLocationInterface $location, mixed $value): static - { - $locationKey = $location->getKey(); - $this->items[$locationKey] ??= []; - $this->items[$locationKey][] = $value; - - return $this; - } - - /** - * {@inheritDoc} - */ - public function hasLocation(DiscoveryLocationInterface $location): bool - { - return array_key_exists($location->getKey(), $this->items); - } - - /** - * {@inheritDoc} - */ - public function isLoaded(): bool - { - return $this->items !== []; - } - - /** - * {@inheritDoc} - */ - public function onlyVendor(): static - { - $vendorItems = []; - - foreach ($this->items as $locationKey => $items) { - // We need to check if the location is vendor, but we only have the key - // This is a limitation - we'd need to store location objects or add vendor flag - // For now, we'll use a simple heuristic based on common vendor paths - if ($this->isVendorLocation()) { - $vendorItems[$locationKey] = $items; - } - } - - return new self($vendorItems); - } - - /** - * {@inheritDoc} - */ - public function all(): array - { - return $this->items === [] ? [] : array_merge(...array_values($this->items)); - } - - /** - * {@inheritDoc} - */ - public function pending(): static - { - $pending = []; - - foreach ($this->items as $locationKey => $items) { - $remaining = array_slice($items, $this->applied[$locationKey] ?? 0); - - if ($remaining !== []) { - $pending[$locationKey] = $remaining; - } - } - - return new self($pending); - } - - /** - * {@inheritDoc} - */ - public function markApplied(): void - { - $this->applied = array_map(count(...), $this->items); - } - - /** - * {@inheritDoc} - */ - public function getIterator(): Traversable - { - return new ArrayIterator($this->all()); - } - - /** - * {@inheritDoc} - */ - public function count(): int - { - return count($this->all()); - } - - /** - * Check if a location key represents a vendor location - * - * This is a heuristic approach since we only have the CRC32 key. - * In a real implementation, we might need to store more location metadata. - * - * @return bool True if likely a vendor location - */ - private function isVendorLocation(): bool - { - // This is a simplified heuristic - in practice, you might want to - // store additional metadata about locations or use a different approach - return false; - } - - /** - * Serialize the items for caching - * - * @return array> - */ - public function __serialize(): array - { - return $this->items; - } - - /** - * Unserialize the items from cache - * - * @param array> $data The serialized data - */ - public function __unserialize(array $data): void - { - $this->items = $data; - } -} diff --git a/src/Discovery/Domain/Models/DiscoveryLocation.php b/src/Discovery/Domain/Models/DiscoveryLocation.php deleted file mode 100644 index 4abb7107..00000000 --- a/src/Discovery/Domain/Models/DiscoveryLocation.php +++ /dev/null @@ -1,104 +0,0 @@ -path = $this->resolvePath($path); - } - - /** - * {@inheritDoc} - */ - public function getNamespace(): string - { - return $this->namespace; - } - - /** - * {@inheritDoc} - */ - public function getPath(): string - { - return $this->path; - } - - /** - * {@inheritDoc} - */ - public function getKey(): string - { - return (string) crc32($this->path); - } - - /** - * {@inheritDoc} - */ - public function isVendor(): bool - { - return str_contains($this->path, '/vendor/') || str_contains($this->path, '\\vendor\\'); - } - - /** - * {@inheritDoc} - */ - public function toClassName(string $filePath): string - { - // Ensure the file path is within this location - if (! str_starts_with($filePath, $this->path)) { - return ''; - } - - // Convert file path to class name - $relativePath = substr($filePath, strlen($this->path)); - $relativePath = ltrim($relativePath, '/\\'); - - // Remove .php extension - $relativePath = preg_replace('/\.php$/', '', $relativePath); - - // Convert path separators to namespace separators - $className = str_replace(['/', '\\'], '\\', $relativePath); - - // Combine with base namespace - return rtrim($this->namespace, '\\').'\\'.$className; - } - - /** - * Resolve the absolute path - * - * @param string $path The path to resolve - * @return string The resolved absolute path - */ - private function resolvePath(string $path): string - { - $resolved = realpath(rtrim($path, '\\/')); - - return $resolved !== false ? $resolved : rtrim($path, '\\/'); - } -} diff --git a/src/Discovery/Domain/Services/HasInstancePool.php b/src/Discovery/Domain/Services/HasInstancePool.php deleted file mode 100644 index fc0386da..00000000 --- a/src/Discovery/Domain/Services/HasInstancePool.php +++ /dev/null @@ -1,66 +0,0 @@ -instancePool = $instancePool; - } - - /** - * Get an instance from the pool if available, otherwise use fallback. - * - * Without a pool or a fallback, the class is resolved through Laravel's - * container when there is one, and built by reflection otherwise. - * - * @param string $className The class name to instantiate - * @param callable|null $fallback Optional fallback creation function - * @return object The class instance - */ - protected function getInstanceFromPool(string $className, ?callable $fallback = null): object - { - if ($this->instancePool !== null) { - return $this->instancePool->getInstance($className); - } - - if ($fallback !== null) { - return $fallback(); - } - - if (function_exists('resolve')) { - return resolve($className); - } - - return (new ReflectionInstanceResolver)->make($className); - } - - /** - * Check if instance pool is available. - */ - protected function hasInstancePool(): bool - { - return $this->instancePool !== null; - } -} diff --git a/src/Discovery/Domain/Services/IsDiscovery.php b/src/Discovery/Domain/Services/IsDiscovery.php deleted file mode 100644 index 81c8359d..00000000 --- a/src/Discovery/Domain/Services/IsDiscovery.php +++ /dev/null @@ -1,54 +0,0 @@ -discoveryItems ??= new DiscoveryItems; - - return $this->discoveryItems; - } - - /** - * {@inheritDoc} - */ - public function setItems(DiscoveryItemsInterface $items): void - { - $this->discoveryItems = $items; - } - - /** - * {@inheritDoc} - */ - public function getIdentifier(): string - { - return static::class; - } -} diff --git a/src/Discovery/Infrastructure/Cache/Psr16DiscoverCacheDriver.php b/src/Discovery/Infrastructure/Cache/Psr16DiscoverCacheDriver.php deleted file mode 100644 index 4c4d6022..00000000 --- a/src/Discovery/Infrastructure/Cache/Psr16DiscoverCacheDriver.php +++ /dev/null @@ -1,62 +0,0 @@ -cache->has($this->key($id)); - } - - /** - * @return array - */ - public function get(string $id): array - { - $structures = $this->cache->get($this->key($id)); - - return is_array($structures) ? $structures : []; - } - - /** - * @param array $discovered - */ - public function put(string $id, array $discovered): void - { - $this->cache->set($this->key($id), $discovered, $this->ttl); - } - - public function forget(string $id): void - { - $this->cache->delete($this->key($id)); - } - - private function key(string $id): string - { - // PSR-16 reserves {}()/\@: in keys. - return $this->prefix.strtr($id, '{}()/\\@:', '________'); - } -} diff --git a/src/Discovery/Infrastructure/Resolvers/ContainerInstanceResolver.php b/src/Discovery/Infrastructure/Resolvers/ContainerInstanceResolver.php deleted file mode 100644 index cdfd2438..00000000 --- a/src/Discovery/Infrastructure/Resolvers/ContainerInstanceResolver.php +++ /dev/null @@ -1,26 +0,0 @@ -container->get($className); - } -} diff --git a/src/Discovery/Infrastructure/Resolvers/ReflectionInstanceResolver.php b/src/Discovery/Infrastructure/Resolvers/ReflectionInstanceResolver.php deleted file mode 100644 index 308756cf..00000000 --- a/src/Discovery/Infrastructure/Resolvers/ReflectionInstanceResolver.php +++ /dev/null @@ -1,83 +0,0 @@ - - */ - private array $building = []; - - public function make(string $className): object - { - $reflection = new ReflectionClass($className); - - if (! $reflection->isInstantiable()) { - throw new \InvalidArgumentException(sprintf('Class %s is not instantiable', $className)); - } - - if (isset($this->building[$className])) { - throw new \RuntimeException(sprintf('Circular dependency detected while building %s', $className)); - } - - $constructor = $reflection->getConstructor(); - - if ($constructor === null || $constructor->getNumberOfParameters() === 0) { - return $reflection->newInstance(); - } - - $this->building[$className] = true; - - try { - return $reflection->newInstanceArgs(array_map( - fn (ReflectionParameter $parameter): mixed => $this->resolveParameter($className, $parameter), - $constructor->getParameters() - )); - } finally { - unset($this->building[$className]); - } - } - - private function resolveParameter(string $className, ReflectionParameter $parameter): mixed - { - if ($parameter->isDefaultValueAvailable()) { - return $parameter->getDefaultValue(); - } - - $type = $parameter->getType(); - - if ($type instanceof ReflectionNamedType && ! $type->isBuiltin()) { - /** @var class-string $dependency */ - $dependency = $type->getName(); - - return $this->make($dependency); - } - - if ($parameter->allowsNull()) { - return null; - } - - throw new \InvalidArgumentException(sprintf( - 'Cannot build %s: parameter $%s has no default value and no class type', - $className, - $parameter->getName() - )); - } -} diff --git a/src/Discovery/Infrastructure/Services/CircularDependencyException.php b/src/Discovery/Infrastructure/Services/CircularDependencyException.php deleted file mode 100644 index 6eabcfe1..00000000 --- a/src/Discovery/Infrastructure/Services/CircularDependencyException.php +++ /dev/null @@ -1,10 +0,0 @@ - - */ - private static array $structuresCache = []; - - /** - * Above this, one location's scan is reported rather than absorbed. - */ - private const int SLOW_SCAN_MS = 250; - - /** - * Where each location's structures came from in this process, in order. - * - * @var list - */ - private array $scans = []; - - /** - * @param DiscoverCacheDriver|null $cacheDriver Persistent cache for Spatie's structure discovery - * @param LoggerInterface|null $logger Where slow scans are reported - * @param bool $warnOnSlowScans Report scans above the threshold (meant for debug mode, where the cache is off) - */ - public function __construct( - private readonly ?DiscoverCacheDriver $cacheDriver = null, - private readonly ?LoggerInterface $logger = null, - private readonly bool $warnOnSlowScans = false, - ) {} - - /** - * Get structures for a specific location, using static and Spatie cache. - * - * @param DiscoveryLocationInterface $location The discovery location - * @param DiscoveryContext $context Context for recording cache stats - * @return array The discovered structures - */ - public function getStructuresForLocation(DiscoveryLocationInterface $location, DiscoveryContext $context): array - { - $cacheId = $this->generateCacheId($location); - - if (isset(self::$structuresCache[$cacheId])) { - $context->recordCacheHit(); - $this->recordScan($location, 'memory', 0.0, count(self::$structuresCache[$cacheId])); - - return self::$structuresCache[$cacheId]; - } - - $context->recordCacheMiss(); - - // Asked before reading: once the discoverer has run, a cold cache has - // been filled and looks exactly like a warm one. - $fromCache = $this->shouldUseCache() && $this->cacheDriver->has($cacheId); - - $discover = $this->createSpatieDiscoverer($location, $cacheId); - - $startedAt = microtime(true); - $structures = $discover->get(); - $elapsed = (microtime(true) - $startedAt) * 1000; - - self::$structuresCache[$cacheId] = $structures; - $this->recordScan($location, $fromCache ? 'cache' : 'disk', $elapsed, count($structures)); - - $this->warnIfSlow($location, $elapsed, count($structures)); - - return $structures; - } - - /** - * Where each location's structures came from, and what it cost. - * - * `memory` is this process's own copy, `cache` the persistent cache, `disk` - * a real scan — the one that costs, and the only one in debug mode, where - * the persistent cache is off. The context's hit and miss counters only - * know about the first of the three. - * - * @return list - */ - public function scans(): array - { - return $this->scans; - } - - /** - * @param 'memory'|'cache'|'disk' $source - */ - private function recordScan(DiscoveryLocationInterface $location, string $source, float $milliseconds, int $structures): void - { - $this->scans[] = [ - 'path' => $location->getPath(), - 'source' => $source, - 'milliseconds' => round($milliseconds, 2), - 'structures' => $structures, - ]; - } - - /** - * Say so when one location costs more than it can possibly be worth. - * - * Discovery walks a directory in full, and Symfony's Finder enumerates - * before it filters by extension — so a `node_modules/` anywhere under a - * location is paid for on every request that misses the cache, which in - * debug mode is every request. Measured once: 69,741 files walked to reach - * three PHP files, 1,691 ms against 1 ms, and nothing anywhere said so. - * - * The threshold is deliberately far above a healthy scan — the same site's - * other four locations came in between 2 and 25 ms — so this stays quiet - * until something is actually wrong. Debug only: the cache carries the - * cost in production, and a log line per request is its own problem. - */ - private function warnIfSlow(DiscoveryLocationInterface $location, float $elapsed, int $structures): void - { - if ($elapsed < self::SLOW_SCAN_MS || ! $this->warnOnSlowScans) { - return; - } - - try { - $this->logger?->warning(sprintf( - 'Pollora discovery scanned %s in %dms for %d structure(s). ' - .'A directory this slow usually holds node_modules, vendor or a build output; ' - .'discovery walks every file in it on each request while the cache is off.', - $location->getPath(), - (int) round($elapsed), - $structures - )); - } catch (\Throwable) { - // A failing logger must not turn a warning into a failure. - } - } - - /** - * Clear persistent Spatie cache for the given locations. - * - * @param iterable $locations - */ - public function clearCache(iterable $locations): void - { - if (! $this->cacheDriver instanceof DiscoverCacheDriver) { - return; - } - - foreach ($locations as $location) { - $cacheId = $this->generateCacheId($location); - $this->cacheDriver->forget($cacheId); - } - } - - /** - * Classes on disk that the cached entry for a location does not list. - * - * They stay invisible to discovery — no hook, no post type, no route — until - * the cache is cleared, and nothing says so. Null when the location has no - * cached entry, or the cache is off, since then discovery reads the disk. - * - * @return list|null - */ - public function classesMissingFromCache(DiscoveryLocationInterface $location): ?array - { - if (! $this->shouldUseCache()) { - return null; - } - - $cacheId = $this->generateCacheId($location); - - if (! $this->cacheDriver->has($cacheId)) { - return null; - } - - $names = static fn (array $structures): array => array_map( - static fn (mixed $structure): string => is_object($structure) && method_exists($structure, 'getFcqn') ? $structure->getFcqn() : (string) $structure, - $structures, - ); - - return array_values(array_diff( - $names(Discover::in($location->getPath())->full()->get()), - $names($this->cacheDriver->get($cacheId)), - )); - } - - /** - * Whether discovery reads through a persistent cache (it does not in debug mode). - */ - public function isCacheEnabled(): bool - { - return $this->shouldUseCache(); - } - - /** - * Get the cache driver instance. - */ - public function getCacheDriver(): ?DiscoverCacheDriver - { - return $this->cacheDriver; - } - - /** - * Get the number of statically cached entries. - */ - public function getStaticCacheSize(): int - { - return count(self::$structuresCache); - } - - /** - * Create a configured Spatie discoverer instance. - */ - private function createSpatieDiscoverer(DiscoveryLocationInterface $location, string $cacheId): Discover - { - $discover = Discover::in($location->getPath())->full(); - - if ($this->shouldUseCache()) { - return $discover->withCache($cacheId, $this->cacheDriver); - } - - return $discover; - } - - /** - * Determine if caching should be used. - */ - private function shouldUseCache(): bool - { - return $this->cacheDriver instanceof DiscoverCacheDriver - && ! ($this->cacheDriver instanceof NullDiscoverCacheDriver); - } - - /** - * Generate cache ID for a discovery location. - */ - private function generateCacheId(DiscoveryLocationInterface $location): string - { - return 'discovery_'.md5($location->getPath()); - } -} diff --git a/src/Discovery/Infrastructure/Services/DiscoveryEngine.php b/src/Discovery/Infrastructure/Services/DiscoveryEngine.php deleted file mode 100644 index e1faff33..00000000 --- a/src/Discovery/Infrastructure/Services/DiscoveryEngine.php +++ /dev/null @@ -1,650 +0,0 @@ - - */ - private Collection $locations; - - /** - * Collection of registered discoveries - * - * @var Collection - */ - private Collection $discoveries; - - /** - * Discovery context for coordinating across discoveries - */ - private readonly DiscoveryContext $context; - - /** - * Instance pool for managing class instances - */ - private readonly InstancePool $instancePool; - - /** - * Cache manager for Spatie's structure discovery - */ - private readonly DiscoveryCacheManager $cacheManager; - - /** - * Builds the discovery classes given by name - */ - private readonly InstanceResolverInterface $resolver; - - /** - * Create a new discovery engine - * - * Every dependency is optional: without them, the engine builds classes - * by reflection, keeps no persistent cache and runs only the discoveries - * added to it explicitly. - * - * @param InstanceResolverInterface|null $resolver Builds the discovery classes given by name - * @param ReflectionCacheInterface|null $reflectionCache Optional reflection cache - * @param InstancePool|null $instancePool Optional instance pool - * @param LoggerInterface|null $logger Optional PSR-3 logger - * @param DiscoveryCacheManager|null $cacheManager Optional cache manager - * @param DiscoveryRegistrarInterface|null $registrar Adds the environment's discoveries before each run - * @param array $skipClasses Classes to exclude from discovery - * @param array $skipPaths Path patterns to exclude from discovery - */ - public function __construct( - ?InstanceResolverInterface $resolver = null, - ?ReflectionCacheInterface $reflectionCache = null, - ?InstancePool $instancePool = null, - private readonly ?LoggerInterface $logger = null, - ?DiscoveryCacheManager $cacheManager = null, - private readonly ?DiscoveryRegistrarInterface $registrar = null, - private readonly array $skipClasses = [], - private readonly array $skipPaths = [], - ) { - $this->locations = new Collection; - $this->discoveries = new Collection; - - // Initialize optimized services - $this->resolver = $resolver ?? new ReflectionInstanceResolver; - $reflectionCache ??= new ReflectionCache($this->resolver); - $this->instancePool = $instancePool ?? new InstancePool($this->resolver); - $this->context = new DiscoveryContext($reflectionCache); - $this->cacheManager = $cacheManager ?? new DiscoveryCacheManager; - } - - /** - * {@inheritDoc} - */ - public function addLocation(DiscoveryLocationInterface $location): static - { - // Check for duplicate locations (same path) - $locationPath = realpath($location->getPath()) ?: $location->getPath(); - - $isDuplicate = $this->locations->contains(function (DiscoveryLocationInterface $existingLocation) use ($locationPath): bool { - $existingPath = realpath($existingLocation->getPath()) ?: $existingLocation->getPath(); - - return $existingPath === $locationPath; - }); - - if (! $isDuplicate) { - $this->locations->push($location); - } - - return $this; - } - - /** - * {@inheritDoc} - */ - public function addLocations(array $locations): static - { - foreach ($locations as $location) { - $this->addLocation($location); - } - - return $this; - } - - /** - * {@inheritDoc} - */ - public function addDiscovery(string $identifier, string|DiscoveryInterface $discovery): static - { - if ($this->discoveries->has($identifier)) { - throw InvalidDiscoveryException::duplicateIdentifier($identifier); - } - - $discoveryInstance = $this->resolveDiscovery($discovery); - $this->discoveries->put($identifier, $discoveryInstance); - - return $this; - } - - /** - * {@inheritDoc} - */ - public function addDiscoveries(array $discoveries): static - { - foreach ($discoveries as $identifier => $discovery) { - $this->addDiscovery($identifier, $discovery); - } - - return $this; - } - - /** - * {@inheritDoc} - */ - public function discover(): static - { - // Add the discoveries the environment provides - $this->registrar?->registerInto($this); - - $allStructures = $this->discoverAllStructures(); - - $this->processStructuresUnified($allStructures); - - return $this; - } - - /** - * {@inheritDoc} - */ - public function apply(): static - { - foreach ($this->discoveries as $discovery) { - try { - // Inject instance pool into discoveries that can use it - $this->injectInstancePoolIfSupported($discovery); - - $this->applyPending($discovery); - } catch (\Throwable $e) { - throw DiscoveryException::applicationFailed($discovery::class, $e); - } - } - - return $this; - } - - /** - * {@inheritDoc} - */ - public function run(): static - { - return $this->discover()->apply(); - } - - /** - * {@inheritDoc} - */ - public function getDiscovery(string $identifier): DiscoveryInterface - { - if (! $this->discoveries->has($identifier)) { - throw DiscoveryNotFoundException::withIdentifier($identifier); - } - - return $this->discoveries->get($identifier); - } - - /** - * {@inheritDoc} - */ - public function getDiscoveries(): Collection - { - return $this->discoveries; - } - - /** - * {@inheritDoc} - */ - public function getLocations(): Collection - { - return $this->locations; - } - - /** - * Discover items for a single discovery class - * - * @param DiscoveryInterface $discovery The discovery instance - * - * @throws DiscoveryException When discovery fails - */ - private function discoverSingle(DiscoveryInterface $discovery): void - { - try { - // Initialize fresh discovery items - $discovery->setItems(new DiscoveryItems); - - // Discover PHP structures using Spatie's native cache - $this->discoverStructures($discovery); - } catch (\Throwable $throwable) { - $this->logDiscoveryError($discovery::class, $throwable); - throw DiscoveryException::discoveryFailed($discovery::class, $throwable); - } - } - - /** - * Discover PHP structures using Spatie's discoverer - * - * @param DiscoveryInterface $discovery The discovery instance - */ - private function discoverStructures(DiscoveryInterface $discovery): void - { - $reflectionCache = $this->context->getReflectionCache(); - - foreach ($this->locations as $location) { - $structures = $this->cacheManager->getStructuresForLocation($location, $this->context); - - foreach ($structures as $structure) { - $discovery->discover($location, $structure, $reflectionCache); - } - } - } - - /** - * Clear all locations - */ - public function clearLocations(): static - { - $this->locations = new Collection; - - return $this; - } - - /** - * Clear persistent discovery cache - * - * Only clears persistent cache (Spatie's structure discoverer cache). - * In-memory caches (reflection, instance pool, static cache) are automatically - * cleared at the end of the PHP process and don't need manual clearing. - */ - public function clearCache(): static - { - $this->cacheManager->clearCache($this->locations); - - return $this; - } - - /** - * Run a specific discovery - * - * @param string $identifier The discovery identifier - * @param DiscoveryInterface $discovery The discovery instance - */ - public function runDiscovery(string $identifier, DiscoveryInterface $discovery): static - { - try { - $this->discoverSingle($discovery); - $this->applyPending($discovery); - } catch (\Throwable $throwable) { - $this->logDiscoveryError($discovery::class, $throwable); - throw DiscoveryException::discoveryFailed($discovery::class, $throwable); - } - - return $this; - } - - /** - * Apply only the items the discovery has not applied yet. - * - * Discoveries are shared by every engine (the main one, its clones for - * modules) and their items accumulate across locations, so the engine is - * asked to apply several times per request. Handing the discovery only its - * new items makes each item apply once, whether or not the discovery's - * apply() is idempotent. Its full item list is restored afterwards, for - * whatever reads it later (status, doctor, dashboard). - * - * @param DiscoveryInterface $discovery The discovery to apply - */ - private function applyPending(DiscoveryInterface $discovery): void - { - $items = $discovery->getItems(); - $pending = $items->pending(); - - if (! $pending->isLoaded()) { - return; - } - - $discovery->setItems($pending); - - try { - $discovery->apply(); - } finally { - $items->markApplied(); - $discovery->setItems($items); - } - } - - /** - * Clone the engine - */ - public function __clone(): void - { - $this->locations = clone $this->locations; - $this->discoveries = clone $this->discoveries; - } - - /** - * Resolve a discovery instance from class name or instance - * - * @param string|DiscoveryInterface $discovery The discovery to resolve - * @return DiscoveryInterface The resolved discovery instance - * - * @throws InvalidDiscoveryException When the discovery is invalid - */ - private function resolveDiscovery(string|DiscoveryInterface $discovery): DiscoveryInterface - { - if ($discovery instanceof DiscoveryInterface) { - return $discovery; - } - - if (! class_exists($discovery)) { - throw InvalidDiscoveryException::invalidClass($discovery, 'Class does not exist'); - } - - if (! is_subclass_of($discovery, DiscoveryInterface::class)) { - throw InvalidDiscoveryException::missingInterface($discovery, DiscoveryInterface::class); - } - - try { - return $this->resolver->make($discovery); - } catch (\Throwable $throwable) { - throw InvalidDiscoveryException::invalidClass($discovery, 'Cannot instantiate: '.$throwable->getMessage()); - } - } - - /** - * Discover all structures from all locations using Spatie's discoverer. - * - * @return array All discovered structures with their locations - */ - private function discoverAllStructures(): array - { - $allStructures = []; - - foreach ($this->locations as $location) { - $locationStructures = $this->cacheManager->getStructuresForLocation($location, $this->context); - - foreach ($locationStructures as $structure) { - $allStructures[] = [ - 'structure' => $structure, - 'location' => $location, - ]; - } - } - - return $allStructures; - } - - /** - * Process structures using unified approach to minimize redundant operations. - * - * @param array $structures All discovered structures with their locations - */ - private function processStructuresUnified(array $structures): void - { - // Group structures by class name for batch processing - $structuresByClass = []; - - foreach ($structures as $entry) { - $structure = $entry['structure']; - - // Concrete classes, and enums carrying attributes (#[CapabilitySet]…). - $isDiscoverable = ($structure instanceof DiscoveredClass && ! $structure->isAbstract) - || ($structure instanceof DiscoveredEnum && $structure->attributes !== []); - - if (! $isDiscoverable) { - continue; - } - - $className = $structure->namespace.'\\'.$structure->name; - - // Config-level exclusions (no reflection needed) - if ($this->isSkippedByConfig($className, $structure->file)) { - continue; - } - - // Attribute-level exclusion - $skipAttr = $this->getSkipDiscoveryAttribute($structure); - if ($skipAttr instanceof SkipDiscovery && $skipAttr->except === []) { - continue; - } - - $structuresByClass[$className] = [ - 'structure' => $structure, - 'location' => $entry['location'], - 'skip_except' => $skipAttr?->except, - ]; - } - - // Initialize discoveries with fresh items only if they don't have items yet - foreach ($this->discoveries as $discovery) { - if (! $discovery->getItems()->isLoaded()) { - $discovery->setItems(new DiscoveryItems); - } - } - - // Process each class once for all applicable discoveries - foreach ($structuresByClass as $className => $data) { - $this->processClassForAllDiscoveries( - $data['structure'], - $data['location'], - $className, - $data['skip_except'] - ); - } - - $this->context->incrementStat('classes_processed', count($structuresByClass)); - } - - /** - * Process a single class for all applicable discoveries. - * - * @param DiscoveredClass|DiscoveredEnum $structure The discovered structure - * @param DiscoveryLocationInterface $location The discovery location - * @param string $className The fully qualified class name - */ - /** - * @param array|null $skipExcept If set, only these discovery classes may process the structure - */ - private function processClassForAllDiscoveries( - DiscoveredClass|DiscoveredEnum $structure, - DiscoveryLocationInterface $location, - string $className, - ?array $skipExcept = null - ): void { - try { - $reflectionCache = $this->context->getReflectionCache(); - - foreach ($this->discoveries as $discoveryId => $discovery) { - try { - if ($this->context->isProcessed($className, $discoveryId)) { - continue; - } - - // #[SkipDiscovery(except: [...])] — only allow listed discoveries - if ($skipExcept !== null && ! in_array($discovery::class, $skipExcept, true)) { - continue; - } - - $discovery->discover($location, $structure, $reflectionCache); - - $this->context->markProcessed($className, $discoveryId); - $this->context->incrementStat('discoveries_executed'); - - } catch (\Throwable $e) { - $this->context->recordError(); - $this->logDiscoveryError(sprintf('Discovery %s for class %s', $discoveryId, $className), $e); - } - } - - } catch (\Throwable $throwable) { - $this->context->recordError(); - $this->logDiscoveryError('Failed to process class '.$className, $throwable); - } - } - - /** - * Check if a class or file is excluded by config-level skip rules. - */ - private function isSkippedByConfig(string $className, string $filePath): bool - { - if (in_array($className, $this->skipClasses, true)) { - return true; - } - - foreach ($this->skipPaths as $pattern) { - if (str_contains($filePath, $pattern)) { - return true; - } - } - - return false; - } - - /** - * Get the #[SkipDiscovery] attribute instance if present on the structure. - * - * Uses Spatie's token-parsed data to detect the attribute without reflection. - * Only loads reflection to read parameters when the attribute is found. - */ - private function getSkipDiscoveryAttribute(DiscoveredClass|DiscoveredEnum $structure): ?SkipDiscovery - { - foreach ($structure->attributes as $attribute) { - if ($attribute->class === SkipDiscovery::class) { - return $this->instantiateSkipDiscovery($structure); - } - } - - return null; - } - - /** - * Instantiate the #[SkipDiscovery] attribute to read its parameters. - */ - private function instantiateSkipDiscovery(DiscoveredClass|DiscoveredEnum $structure): SkipDiscovery - { - try { - $className = $structure->namespace.'\\'.$structure->name; - $reflection = new \ReflectionClass($className); - $attrs = $reflection->getAttributes(SkipDiscovery::class); - - if ($attrs !== []) { - return $attrs[0]->newInstance(); - } - } catch (\Throwable) { - // If reflection fails, treat as full skip - } - - return new SkipDiscovery; - } - - /** - * Get the discovery context. - */ - public function getContext(): DiscoveryContext - { - return $this->context; - } - - /** - * Get the instance pool. - */ - public function getInstancePool(): InstancePool - { - return $this->instancePool; - } - - /** - * Get performance statistics. - * - * @return array - */ - public function getPerformanceStats(): array - { - return [ - 'context' => $this->context->getSummary(), - 'instance_pool' => $this->instancePool->getStats(), - 'static_cache_size' => $this->cacheManager->getStaticCacheSize(), - ]; - } - - /** - * Inject instance pool into discoveries that support it - * - * @param DiscoveryInterface $discovery The discovery to potentially inject into - */ - private function injectInstancePoolIfSupported(DiscoveryInterface $discovery): void - { - // Check if the discovery has a method to accept the instance pool - if (method_exists($discovery, 'setInstancePool')) { - $discovery->setInstancePool($this->instancePool); - } - } - - /** - * Get the cache driver instance - */ - public function getCacheDriver(): ?DiscoverCacheDriver - { - return $this->cacheManager->getCacheDriver(); - } - - /** - * Get the cache manager instance - */ - public function getCacheManager(): DiscoveryCacheManager - { - return $this->cacheManager; - } - - /** - * Log discovery errors with consistent format - * - * @param string $context The error context - * @param \Throwable $exception The exception - */ - private function logDiscoveryError(string $context, \Throwable $exception): void - { - $this->logger?->error(sprintf('%s: %s', $context, $exception->getMessage()), [ - 'exception' => $exception, - ]); - } -} diff --git a/src/Discovery/Infrastructure/Services/InstancePool.php b/src/Discovery/Infrastructure/Services/InstancePool.php deleted file mode 100644 index 564584a4..00000000 --- a/src/Discovery/Infrastructure/Services/InstancePool.php +++ /dev/null @@ -1,307 +0,0 @@ - - */ - private array $instances = []; - - /** - * Currently instantiating classes (for circular dependency detection) - * - * @var array - */ - private array $instantiating = []; - - /** - * Instance creation statistics - * - * @var array - */ - private array $stats = [ - 'hits' => 0, - 'misses' => 0, - 'circular_dependencies' => 0, - 'errors' => 0, - ]; - - /** - * Builds the instances the pool does not hold yet - */ - private readonly InstanceResolverInterface $resolver; - - public function __construct(?InstanceResolverInterface $resolver = null) - { - $this->resolver = $resolver ?? new ReflectionInstanceResolver; - } - - /** - * Get or create an instance of the specified class. - * - * @param string $className The fully qualified class name - * @return object The class instance - * - * @throws CircularDependencyException If a circular dependency is detected - * @throws \RuntimeException If the class cannot be instantiated - */ - public function getInstance(string $className): object - { - // Cache hit - return existing instance - if (isset($this->instances[$className])) { - $this->stats['hits']++; - - return $this->instances[$className]; - } - - // Cache miss - need to create instance - $this->stats['misses']++; - - // Detect circular dependencies - if (isset($this->instantiating[$className])) { - $this->stats['circular_dependencies']++; - throw new CircularDependencyException( - 'Circular dependency detected while instantiating: '.$className - ); - } - - try { - // Mark as currently instantiating - $this->instantiating[$className] = true; - - // Create instance through the resolver - $instance = $this->resolver->make($className); - - // Store in pool - $this->instances[$className] = $instance; - - // Remove from instantiating list - unset($this->instantiating[$className]); - - return $instance; - - } catch (\Throwable $throwable) { - // Clean up instantiating state on error - unset($this->instantiating[$className]); - - $this->stats['errors']++; - - throw new \RuntimeException( - sprintf('Failed to instantiate class %s: %s', $className, $throwable->getMessage()), - $throwable->getCode(), - $throwable - ); - } - } - - /** - * Check if an instance exists in the pool. - * - * @param string $className The fully qualified class name - * @return bool True if the instance exists in the pool - */ - public function hasInstance(string $className): bool - { - return isset($this->instances[$className]); - } - - /** - * Preload multiple instances into the pool. - * - * @param array $classNames Array of class names to preload - * @return array Map of successfully preloaded instances - */ - public function preloadInstances(array $classNames): array - { - $preloadedInstances = []; - - foreach ($classNames as $className) { - try { - $preloadedInstances[$className] = $this->getInstance($className); - } catch (\Throwable) { - // Skip classes that cannot be instantiated - continue; - } - } - - return $preloadedInstances; - } - - /** - * Remove an instance from the pool. - * - * @param string $className The class name to remove - * @return bool True if the instance was removed, false if it didn't exist - */ - public function removeInstance(string $className): bool - { - if (isset($this->instances[$className])) { - unset($this->instances[$className]); - - return true; - } - - return false; - } - - /** - * Get the number of instances in the pool. - * - * @return int The number of pooled instances - */ - public function getPoolSize(): int - { - return count($this->instances); - } - - /** - * Get pool statistics. - * - * @return array Pool usage statistics - */ - public function getStats(): array - { - $total = $this->stats['hits'] + $this->stats['misses']; - $hitRatio = $total > 0 ? round(($this->stats['hits'] / $total) * 100, 2) : 0.0; - - return [ - 'pool_size' => $this->getPoolSize(), - 'cache_hits' => $this->stats['hits'], - 'cache_misses' => $this->stats['misses'], - 'hit_ratio_percent' => $hitRatio, - 'circular_dependencies' => $this->stats['circular_dependencies'], - 'instantiation_errors' => $this->stats['errors'], - 'total_requests' => $total, - ]; - } - - /** - * Get list of all pooled class names. - * - * @return array Array of pooled class names - */ - public function getPooledClasses(): array - { - return array_keys($this->instances); - } - - /** - * Perform memory optimization by removing unused instances. - * - * @param int $maxPoolSize Maximum number of instances to keep - */ - public function optimize(int $maxPoolSize = 100): void - { - if ($this->getPoolSize() <= $maxPoolSize) { - return; - } - - // Simple LRU-style cleanup - remove excess instances - $excessCount = $this->getPoolSize() - $maxPoolSize; - $classNames = array_keys($this->instances); - - for ($i = 0; $i < $excessCount; $i++) { - if (isset($classNames[$i])) { - unset($this->instances[$classNames[$i]]); - } - } - } - - /** - * Reset statistics counters. - */ - public function resetStats(): void - { - $this->stats = [ - 'hits' => 0, - 'misses' => 0, - 'circular_dependencies' => 0, - 'errors' => 0, - ]; - } - - /** - * Check if a class is currently being instantiated (circular dependency check). - * - * @param string $className The class name to check - * @return bool True if the class is currently being instantiated - */ - public function isInstantiating(string $className): bool - { - return isset($this->instantiating[$className]); - } - - /** - * Get debug information about the instance pool state. - * - * @return array Debug information - */ - public function getDebugInfo(): array - { - return [ - 'instances' => $this->getPooledClasses(), - 'instantiating' => array_keys($this->instantiating), - 'stats' => $this->getStats(), - 'memory_usage' => $this->estimateMemoryUsage(), - ]; - } - - /** - * Estimate memory usage of the instance pool. - * - * @return string Human-readable memory usage estimate - */ - private function estimateMemoryUsage(): string - { - $memoryUsage = 0; - - foreach ($this->instances as $instance) { - // Rough estimate - this is not precise but gives an idea - $memoryUsage += strlen(serialize($instance)); - } - - return $this->formatBytes($memoryUsage); - } - - /** - * Format bytes into human-readable format. - * - * @param int $bytes Number of bytes - * @return string Formatted string - */ - private function formatBytes(int $bytes): string - { - $units = ['B', 'KB', 'MB', 'GB']; - $unitIndex = 0; - - while ($bytes >= 1024 && $unitIndex < count($units) - 1) { - $bytes /= 1024; - $unitIndex++; - } - - return round($bytes, 2).' '.$units[$unitIndex]; - } -} diff --git a/src/Discovery/Infrastructure/Services/ReflectionCache.php b/src/Discovery/Infrastructure/Services/ReflectionCache.php deleted file mode 100644 index abf3176c..00000000 --- a/src/Discovery/Infrastructure/Services/ReflectionCache.php +++ /dev/null @@ -1,256 +0,0 @@ - - */ - private array $classReflections = []; - - /** - * Cache for class-level attributes - * - * @var array>> - */ - private array $classAttributes = []; - - /** - * Cache for method-level attributes grouped by class and attribute type - * - * @var array>> - */ - private array $methodsWithAttributes = []; - - /** - * Cache for public methods per class - * - * @var array> - */ - private array $publicMethods = []; - - /** - * Cache for class instances built by the resolver - * - * @var array - */ - private array $classInstances = []; - - /** - * Cache for processed classes to avoid reprocessing - * - * @var array - */ - private array $processedClasses = []; - - /** - * Builds the instances the pool does not hold yet - */ - private readonly InstanceResolverInterface $resolver; - - public function __construct(?InstanceResolverInterface $resolver = null) - { - $this->resolver = $resolver ?? new ReflectionInstanceResolver; - } - - /** - * {@inheritDoc} - */ - public function getClassReflection(string $className): ReflectionClass - { - if (! isset($this->classReflections[$className])) { - try { - // Check if class exists and can be autoloaded first - if (! class_exists($className, true)) { - throw new ReflectionException(sprintf('Class %s does not exist or cannot be autoloaded', $className)); - } - - $this->classReflections[$className] = new ReflectionClass($className); - $this->processedClasses[$className] = true; - } catch (\Throwable $e) { - // Log the error but mark as processed to avoid retrying - $this->processedClasses[$className] = true; - - throw new ReflectionException( - sprintf('Failed to create reflection for class %s: %s', $className, $e->getMessage()), - $e->getCode(), - $e - ); - } - } - - return $this->classReflections[$className]; - } - - /** - * {@inheritDoc} - */ - public function getPublicMethods(string $className): array - { - $this->publicMethods[$className] ??= $this->getClassReflection($className) - ->getMethods(ReflectionMethod::IS_PUBLIC); - - return $this->publicMethods[$className]; - } - - /** - * {@inheritDoc} - */ - public function getMethodsWithAttributes(string $className, ?string $attributeClass = null): array - { - $cacheKey = $attributeClass ?? '*'; - - if (! isset($this->methodsWithAttributes[$className][$cacheKey])) { - $this->buildMethodAttributeCache($className, $attributeClass); - } - - return $this->methodsWithAttributes[$className][$cacheKey]; - } - - /** - * {@inheritDoc} - */ - public function getClassAttributes(string $className, ?string $attributeClass = null): array - { - $cacheKey = $attributeClass ?? '*'; - - if (! isset($this->classAttributes[$className][$cacheKey])) { - $this->buildClassAttributeCache($className, $attributeClass); - } - - return $this->classAttributes[$className][$cacheKey]; - } - - /** - * {@inheritDoc} - */ - public function getClassInstance(string $className): object - { - if (! isset($this->classInstances[$className])) { - try { - // Validate the class can be instantiated before attempting - $reflection = $this->getClassReflection($className); - - if ($reflection->isAbstract() || $reflection->isInterface() || $reflection->isTrait()) { - throw new \InvalidArgumentException( - 'Cannot instantiate abstract class, interface, or trait: '.$className - ); - } - - if (! $reflection->isInstantiable()) { - throw new \InvalidArgumentException(sprintf('Class %s is not instantiable', $className)); - } - - $this->classInstances[$className] = $this->resolver->make($className); - } catch (\Throwable $e) { - throw new \RuntimeException( - sprintf('Failed to instantiate class %s: %s', $className, $e->getMessage()), - $e->getCode(), - $e - ); - } - } - - return $this->classInstances[$className]; - } - - /** - * {@inheritDoc} - */ - public function hasClass(string $className): bool - { - return isset($this->processedClasses[$className]); - } - - /** - * {@inheritDoc} - */ - public function preloadClasses(array $classNames): void - { - foreach ($classNames as $className) { - if (! $this->hasClass($className)) { - try { - $this->getClassReflection($className); - } catch (\Throwable) { - // Skip classes that cannot be loaded - continue; - } - } - } - } - - /** - * Build method attribute cache for a specific class and attribute type. - * - * @param string $className The class name to process - * @param string|null $attributeClass Optional attribute class filter - */ - private function buildMethodAttributeCache(string $className, ?string $attributeClass = null): void - { - $cacheKey = $attributeClass ?? '*'; - $methodsWithAttributes = []; - - foreach ($this->getPublicMethods($className) as $method) { - if ($attributeClass === null) { - // Get all methods with any attributes - $hasMatchingAttribute = ! empty($method->getAttributes()); - } else { - // Get methods with specific attribute class - $hasMatchingAttribute = ! empty($method->getAttributes($attributeClass)); - } - - if ($hasMatchingAttribute) { - $methodsWithAttributes[] = $method; - } - } - - $this->methodsWithAttributes[$className][$cacheKey] = $methodsWithAttributes; - } - - /** - * Build class attribute cache for a specific class and attribute type. - * - * @param string $className The class name to process - * @param string|null $attributeClass Optional attribute class filter - */ - private function buildClassAttributeCache(string $className, ?string $attributeClass = null): void - { - $cacheKey = $attributeClass ?? '*'; - $reflection = $this->getClassReflection($className); - - if ($attributeClass === null) { - // Get all class attributes - $attributes = $reflection->getAttributes(); - } else { - // Get specific class attributes - $attributes = $reflection->getAttributes($attributeClass); - } - - $this->classAttributes[$className][$cacheKey] = $attributes; - } -} diff --git a/tests/Benchmark/BenchmarkDiscovery.php b/tests/Benchmark/BenchmarkDiscovery.php deleted file mode 100644 index ab42faac..00000000 --- a/tests/Benchmark/BenchmarkDiscovery.php +++ /dev/null @@ -1,120 +0,0 @@ -items = new DiscoveryItems; - } - - public function discover( - DiscoveryLocationInterface $location, - DiscoveredStructure $structure, - ?ReflectionCacheInterface $reflectionCache = null - ): void { - if (! $structure instanceof DiscoveredClass) { - return; - } - - if ($structure->isAbstract) { - return; - } - - $className = $structure->namespace.'\\'.$structure->name; - - if (! $reflectionCache instanceof ReflectionCacheInterface) { - return; - } - - try { - $reflection = $reflectionCache->getClassReflection($className); - - // Process ALL class-level attributes (like PostTypeDiscovery does) - $classAttributes = $reflection->getAttributes(); - foreach ($classAttributes as $attribute) { - try { - $instance = $attribute->newInstance(); - $this->items->add($location, [ - 'type' => 'class_attribute', - 'class' => $className, - 'attribute' => $attribute->getName(), - 'instance' => $instance, - ]); - } catch (\Throwable) { - // Skip attributes that can't be instantiated (missing classes) - } - } - - // Process ALL public methods and their attributes (like HookDiscovery does) - $methods = $reflection->getMethods(ReflectionMethod::IS_PUBLIC); - foreach ($methods as $method) { - $methodAttributes = $method->getAttributes(); - foreach ($methodAttributes as $attribute) { - try { - $instance = $attribute->newInstance(); - $this->items->add($location, [ - 'type' => 'method_attribute', - 'class' => $className, - 'method' => $method->getName(), - 'attribute' => $attribute->getName(), - 'instance' => $instance, - 'param_count' => $method->getNumberOfParameters(), - ]); - } catch (\Throwable) { - // Skip attributes that can't be instantiated - } - } - } - } catch (\Throwable) { - // Skip unloadable classes, like real discoveries - } - } - - public function getItems(): DiscoveryItemsInterface - { - return $this->items; - } - - public function setItems(DiscoveryItemsInterface $items): void - { - $this->items = $items; - } - - public function apply(): void - { - // No-op for benchmarking — we measure discovery, not application - } - - public function getIdentifier(): string - { - return $this->identifier; - } -} diff --git a/tests/Benchmark/DiscoveryBenchmark.php b/tests/Benchmark/DiscoveryBenchmark.php deleted file mode 100644 index 271e8d39..00000000 --- a/tests/Benchmark/DiscoveryBenchmark.php +++ /dev/null @@ -1,463 +0,0 @@ -> */ - private array $results = []; - - /** @var array> */ - private array $resultsMulti = []; - - public function __construct() - { - $this->fixturesBasePath = __DIR__.'/Generated'; - } - - /** - * Run the full benchmark suite. - * - * @param int[] $classCounts Class counts to benchmark - * @param int $iterations Number of iterations per benchmark for averaging - */ - public function run(array $classCounts = [50, 100, 250, 500, 1000, 2000], int $iterations = 3): void - { - $this->printHeader(); - - echo "\n ── SINGLE LOCATION (flat directory) ──\n"; - foreach ($classCounts as $count) { - $this->benchmarkClassCount($count, $iterations); - } - - $this->printSummary(); - - // Multi-location benchmark - echo "\n ── MULTI-LOCATION (modules with DDD subdirs) ──\n"; - $this->resultsMulti = []; - $multiScenarios = [ - ['classes' => 100, 'modules' => 5], - ['classes' => 250, 'modules' => 8], - ['classes' => 500, 'modules' => 10], - ['classes' => 1000, 'modules' => 15], - ]; - - foreach ($multiScenarios as $scenario) { - $this->benchmarkMultiLocation($scenario['classes'], $scenario['modules'], $iterations); - } - - $this->printMultiLocationSummary(); - } - - private function benchmarkClassCount(int $count, int $iterations): void - { - echo "\n".str_repeat('─', 70)."\n"; - echo " Benchmarking with {$count} classes ({$iterations} iterations)\n"; - echo str_repeat('─', 70)."\n"; - - // Generate fixtures - $generator = new FixtureGenerator( - $this->fixturesBasePath, - 'Tests\\Benchmark\\Generated' - ); - - $fixtureInfo = $generator->generate($count); - $this->printDistribution($fixtureInfo['distribution']); - - // Register autoloader for generated classes - $autoloader = $this->registerAutoloader($fixtureInfo['path'], $fixtureInfo['namespace']); - - $timings = [ - 'spatie_scan' => [], - 'full_discovery' => [], - 'memory_peak' => [], - 'classes_processed' => [], - 'total_items' => [], - ]; - - for ($i = 0; $i < $iterations; $i++) { - echo ' Iteration '.($i + 1).'...'; - - // Clear any static caches between iterations - $this->clearStaticCaches(); - - // Benchmark: Spatie scan phase only - $scanResult = $this->benchmarkSpatieScan($fixtureInfo); - $timings['spatie_scan'][] = $scanResult['time_ms']; - - // Clear static caches again - $this->clearStaticCaches(); - - // Benchmark: Full discovery (scan + process for all discovery types) - $fullResult = $this->benchmarkFullDiscovery($fixtureInfo); - $timings['full_discovery'][] = $fullResult['time_ms']; - $timings['memory_peak'][] = $fullResult['memory_peak_mb']; - $timings['classes_processed'][] = $fullResult['classes_processed']; - $timings['total_items'][] = $fullResult['total_items']; - - echo " done\n"; - } - - // Unregister autoloader - spl_autoload_unregister($autoloader); - - // Cleanup generated files - $generator->cleanup(); - - // Store averaged results - $this->results[$count] = [ - 'count' => $count, - 'spatie_scan_avg_ms' => $this->average($timings['spatie_scan']), - 'spatie_scan_min_ms' => min($timings['spatie_scan']), - 'spatie_scan_max_ms' => max($timings['spatie_scan']), - 'full_discovery_avg_ms' => $this->average($timings['full_discovery']), - 'full_discovery_min_ms' => min($timings['full_discovery']), - 'full_discovery_max_ms' => max($timings['full_discovery']), - 'memory_peak_mb' => $this->average($timings['memory_peak']), - 'classes_processed' => (int) $this->average($timings['classes_processed']), - 'total_items' => (int) $this->average($timings['total_items']), - 'per_class_us' => ($this->average($timings['full_discovery']) * 1000) / $count, - ]; - - $r = $this->results[$count]; - echo "\n"; - echo sprintf(" Spatie scan: avg %7.2f ms (min %.2f / max %.2f)\n", $r['spatie_scan_avg_ms'], $r['spatie_scan_min_ms'], $r['spatie_scan_max_ms']); - echo sprintf(" Full discovery: avg %7.2f ms (min %.2f / max %.2f)\n", $r['full_discovery_avg_ms'], $r['full_discovery_min_ms'], $r['full_discovery_max_ms']); - echo sprintf(" Per class: %7.1f us\n", $r['per_class_us']); - echo sprintf(" Memory peak: %7.2f MB\n", $r['memory_peak_mb']); - echo sprintf(" Classes found: %d\n", $r['classes_processed']); - echo sprintf(" Attributes found: %d (avg %.1f/class)\n", $r['total_items'], $r['total_items'] / max(1, $r['classes_processed'])); - } - - /** - * Benchmark: Spatie structure scan only (no discovery processing). - * - * @return array{time_ms: float, structures_count: int} - */ - private function benchmarkSpatieScan(array $fixtureInfo): array - { - $cacheManager = new DiscoveryCacheManager; - $reflectionCache = new ReflectionCache; - $context = new DiscoveryContext($reflectionCache); - - $location = new DiscoveryLocation( - $fixtureInfo['namespace'], - $fixtureInfo['path'] - ); - - $start = hrtime(true); - $structures = $cacheManager->getStructuresForLocation($location, $context); - $elapsed = (hrtime(true) - $start) / 1_000_000; - - return [ - 'time_ms' => $elapsed, - 'structures_count' => count($structures), - ]; - } - - /** - * Benchmark: Full discovery with multiple discovery types. - * - * Uses a lightweight HookDiscovery mock to test the real DiscoveryEngine - * processing pipeline without depending on WordPress runtime. - * - * @return array{time_ms: float, memory_peak_mb: float, classes_processed: int, stats: array} - */ - private function benchmarkFullDiscovery(array $fixtureInfo): array - { - $engine = new DiscoveryEngine; - - $location = new DiscoveryLocation( - $fixtureInfo['namespace'], - $fixtureInfo['path'] - ); - - // Add multiple discovery types to simulate realistic load - $engine->addLocation($location); - $engine->addDiscovery('hooks', new BenchmarkDiscovery('hooks')); - $engine->addDiscovery('providers', new BenchmarkDiscovery('providers')); - $engine->addDiscovery('post_types', new BenchmarkDiscovery('post_types')); - $engine->addDiscovery('rest_routes', new BenchmarkDiscovery('rest_routes')); - $engine->addDiscovery('schedules', new BenchmarkDiscovery('schedules')); - - // memory_get_usage(true) counts 2 MB blocks from the system, which - // hides anything smaller: measure what PHP allocates, from a reset peak. - memory_reset_peak_usage(); - $memBefore = memory_get_usage(); - $start = hrtime(true); - - $engine->discover(); - - $elapsed = (hrtime(true) - $start) / 1_000_000; - $memAfter = memory_get_peak_usage(); - - $stats = $engine->getPerformanceStats(); - - // Count total discovered items across all discoveries - $totalItems = 0; - foreach ($engine->getDiscoveries() as $discovery) { - $totalItems += count($discovery->getItems()); - } - - return [ - 'time_ms' => $elapsed, - 'memory_peak_mb' => ($memAfter - $memBefore) / (1024 * 1024), - 'classes_processed' => $stats['context']['total_classes'], - 'total_items' => $totalItems, - 'stats' => $stats, - ]; - } - - private function benchmarkMultiLocation(int $totalClasses, int $moduleCount, int $iterations): void - { - $key = sprintf('%dc_%dm', $totalClasses, $moduleCount); - echo "\n".str_repeat('─', 70)."\n"; - echo " {$totalClasses} classes across {$moduleCount} modules ({$iterations} iterations)\n"; - echo str_repeat('─', 70)."\n"; - - $generator = new FixtureGenerator( - $this->fixturesBasePath, - 'Tests\\Benchmark\\Generated' - ); - - $multiInfo = $generator->generateMultiLocation($totalClasses, $moduleCount); - echo sprintf(" Generated: %d classes, %d modules, %d subdirs\n", - $multiInfo['total_classes'], $multiInfo['modules'], $multiInfo['dirs_created']); - - // Register autoloaders for all locations - $autoloaders = []; - foreach ($multiInfo['locations'] as $loc) { - $autoloaders[] = $this->registerAutoloader($loc['path'], $loc['namespace']); - } - - $timings = ['scan' => [], 'discovery' => [], 'memory' => [], 'items' => []]; - - for ($i = 0; $i < $iterations; $i++) { - echo ' Iteration '.($i + 1).'...'; - $this->clearStaticCaches(); - - // Full discovery with multiple locations - $engine = new DiscoveryEngine; - - foreach ($multiInfo['locations'] as $loc) { - $engine->addLocation(new DiscoveryLocation($loc['namespace'], $loc['path'])); - } - - $engine->addDiscovery('hooks', new BenchmarkDiscovery('hooks')); - $engine->addDiscovery('providers', new BenchmarkDiscovery('providers')); - $engine->addDiscovery('post_types', new BenchmarkDiscovery('post_types')); - $engine->addDiscovery('rest_routes', new BenchmarkDiscovery('rest_routes')); - $engine->addDiscovery('schedules', new BenchmarkDiscovery('schedules')); - - memory_reset_peak_usage(); - $memBefore = memory_get_usage(); - $start = hrtime(true); - $engine->discover(); - $elapsed = (hrtime(true) - $start) / 1_000_000; - - $totalItems = 0; - foreach ($engine->getDiscoveries() as $d) { - $totalItems += count($d->getItems()); - } - - $timings['scan'][] = $elapsed; // Combined scan + processing - $timings['memory'][] = (memory_get_peak_usage() - $memBefore) / (1024 * 1024); - $timings['items'][] = $totalItems; - - echo " done\n"; - } - - foreach ($autoloaders as $al) { - spl_autoload_unregister($al); - } - - $generator->cleanup(); - - $avgMs = $this->average($timings['scan']); - $this->resultsMulti[$key] = [ - 'classes' => $multiInfo['total_classes'], - 'modules' => $multiInfo['modules'], - 'dirs' => $multiInfo['dirs_created'], - 'avg_ms' => $avgMs, - 'min_ms' => min($timings['scan']), - 'max_ms' => max($timings['scan']), - 'per_class_us' => ($avgMs * 1000) / max(1, $multiInfo['total_classes']), - 'items' => (int) $this->average($timings['items']), - 'memory_mb' => $this->average($timings['memory']), - ]; - - $r = $this->resultsMulti[$key]; - echo sprintf("\n Discovery: avg %7.2f ms (min %.2f / max %.2f)\n", $r['avg_ms'], $r['min_ms'], $r['max_ms']); - echo sprintf(" Per class: %7.1f us\n", $r['per_class_us']); - echo sprintf(" Attrs found: %d\n", $r['items']); - echo sprintf(" Memory peak: %7.2f MB\n", $r['memory_mb']); - } - - private function printMultiLocationSummary(): void - { - if ($this->resultsMulti === []) { - return; - } - - echo "\n\n"; - echo "╔════════════════════════════════════════════════════════════════════════════╗\n"; - echo "║ MULTI-LOCATION SUMMARY ║\n"; - echo "╠════════════════════════════════════════════════════════════════════════════╣\n"; - echo sprintf("║ %-7s │ %-7s │ %-5s │ %-11s │ %-9s │ %-6s │ %-6s ║\n", - 'Classes', 'Modules', 'Dirs', 'Discov (ms)', 'Per cls', 'Attrs', 'Mem MB'); - echo "╠════════════════════════════════════════════════════════════════════════════╣\n"; - - foreach ($this->resultsMulti as $r) { - echo sprintf( - "║ %-7d │ %-7d │ %-5d │ %11.2f │ %6.1f us │ %6d │ %5.2f ║\n", - $r['classes'], $r['modules'], $r['dirs'], - $r['avg_ms'], $r['per_class_us'], $r['items'], $r['memory_mb'] - ); - } - - echo "╚════════════════════════════════════════════════════════════════════════════╝\n"; - - // Compare single vs multi for same class count - echo "\n Single-location vs Multi-location comparison:\n"; - foreach ($this->resultsMulti as $r) { - $classCount = $r['classes']; - // Find closest single-location result - $closest = null; - $closestDiff = PHP_INT_MAX; - foreach ($this->results as $sr) { - $diff = abs($sr['count'] - $classCount); - if ($diff < $closestDiff) { - $closestDiff = $diff; - $closest = $sr; - } - } - - if ($closest && $closestDiff < $classCount * 0.3) { - $overhead = (($r['avg_ms'] / max(0.01, $closest['full_discovery_avg_ms'])) - 1) * 100; - echo sprintf(" %d classes: single=%.1fms, multi(%d modules)=%.1fms => %+.0f%% overhead\n", - $classCount, $closest['full_discovery_avg_ms'], $r['modules'], $r['avg_ms'], $overhead); - } - } - - echo "\n"; - } - - private function registerAutoloader(string $path, string $namespace): \Closure - { - $autoloader = function (string $class) use ($path, $namespace): void { - if (! str_starts_with($class, $namespace)) { - return; - } - - $relative = str_replace($namespace.'\\', '', $class); - $file = $path.'/'.str_replace('\\', '/', $relative).'.php'; - - if (file_exists($file)) { - require_once $file; - } - }; - - spl_autoload_register($autoloader); - - return $autoloader; - } - - private function clearStaticCaches(): void - { - // Use reflection to clear DiscoveryCacheManager's static cache - $ref = new \ReflectionClass(DiscoveryCacheManager::class); - $prop = $ref->getProperty('structuresCache'); - $prop->setValue(null, []); - } - - private function average(array $values): float - { - return array_sum($values) / count($values); - } - - private function printHeader(): void - { - echo "\n"; - echo "╔══════════════════════════════════════════════════════════════════════╗\n"; - echo "║ Pollora Discovery System — Performance Benchmark ║\n"; - echo "╠══════════════════════════════════════════════════════════════════════╣\n"; - echo "║ Tests discovery WITHOUT persistent cache (debug mode). ║\n"; - echo "║ Measures: Spatie scan, full discovery pipeline, per-class cost. ║\n"; - echo "╚══════════════════════════════════════════════════════════════════════╝\n"; - echo "\n"; - echo ' PHP '.phpversion().' | Memory limit: '.ini_get('memory_limit')."\n"; - } - - private function printDistribution(array $distribution): void - { - $parts = []; - foreach ($distribution as $type => $count) { - $parts[] = sprintf('%s: %s', $type, $count); - } - - echo ' Distribution: '.implode(', ', $parts)."\n"; - } - - private function printSummary(): void - { - echo "\n\n"; - echo "╔══════════════════════════════════════════════════════════════════════╗\n"; - echo "║ SUMMARY TABLE ║\n"; - echo "╠══════════════════════════════════════════════════════════════════════╣\n"; - echo sprintf("║ %-7s │ %-10s │ %-12s │ %-9s │ %-6s │ %-6s ║\n", 'Classes', 'Scan (ms)', 'Discov (ms)', 'Per cls', 'Attrs', 'Mem MB'); - echo "╠══════════════════════════════════════════════════════════════════════╣\n"; - - foreach ($this->results as $r) { - echo sprintf( - "║ %-7d │ %10.2f │ %12.2f │ %6.1f us │ %6d │ %5.2f ║\n", - $r['count'], - $r['spatie_scan_avg_ms'], - $r['full_discovery_avg_ms'], - $r['per_class_us'], - $r['total_items'], - $r['memory_peak_mb'] - ); - } - - echo "╚══════════════════════════════════════════════════════════════════════╝\n"; - - // Scaling analysis - if (count($this->results) >= 2) { - echo "\n Scaling analysis:\n"; - $keys = array_keys($this->results); - $first = $this->results[$keys[0]]; - $last = $this->results[$keys[count($keys) - 1]]; - $classRatio = $last['count'] / $first['count']; - $timeRatio = $last['full_discovery_avg_ms'] / max(0.01, $first['full_discovery_avg_ms']); - - echo sprintf(" %dx classes => %.1fx time (ideal linear = %.1fx)\n", (int) $classRatio, $timeRatio, $classRatio); - - if ($timeRatio > $classRatio * 1.5) { - echo " => Super-linear scaling detected — optimization opportunities likely\n"; - } elseif ($timeRatio <= $classRatio * 1.1) { - echo " => Linear scaling — discovery scales well\n"; - } else { - echo " => Near-linear scaling — acceptable\n"; - } - } - - echo "\n"; - } -} diff --git a/tests/Benchmark/DiscoveryPerformanceTest.php b/tests/Benchmark/DiscoveryPerformanceTest.php deleted file mode 100644 index 3dd42afa..00000000 --- a/tests/Benchmark/DiscoveryPerformanceTest.php +++ /dev/null @@ -1,319 +0,0 @@ -getProperty('structuresCache'); - $prop->setValue(null, []); -} - -function generateAndRegister(int $count): array -{ - $basePath = __DIR__.'/Generated'; - $namespace = 'Tests\\Benchmark\\Generated'; - - $generator = new FixtureGenerator($basePath, $namespace); - $info = $generator->generate($count); - - $autoloader = function (string $class) use ($basePath, $namespace): void { - if (! str_starts_with($class, $namespace)) { - return; - } - - $relative = str_replace($namespace.'\\', '', $class); - $file = $basePath.'/'.str_replace('\\', '/', $relative).'.php'; - if (file_exists($file)) { - require_once $file; - } - }; - spl_autoload_register($autoloader); - - return ['info' => $info, 'generator' => $generator, 'autoloader' => $autoloader]; -} - -describe('Discovery Performance', function (): void { - - afterEach(function (): void { - clearDiscoveryCacheManagerStatic(); - restorePreviousContainer(); - Facade::clearResolvedInstances(); - Facade::setFacadeApplication(Container::getInstance()); - }); - - it('scans 100 classes via Spatie in under 500ms', function (): void { - $setup = generateAndRegister(100); - - try { - createBenchContainer(); - $cacheManager = new DiscoveryCacheManager; - $reflectionCache = new ReflectionCache; - $context = new DiscoveryContext($reflectionCache); - - $location = new DiscoveryLocation( - $setup['info']['namespace'], - $setup['info']['path'] - ); - - $start = hrtime(true); - $structures = $cacheManager->getStructuresForLocation($location, $context); - $elapsedMs = (hrtime(true) - $start) / 1_000_000; - - expect(count($structures))->toBeGreaterThanOrEqual(100); - expect($elapsedMs)->toBeLessThan(500); - } finally { - spl_autoload_unregister($setup['autoloader']); - $setup['generator']->cleanup(); - } - }); - - it('runs full discovery on 100 classes with 5 discoveries in under 2000ms', function (): void { - $setup = generateAndRegister(100); - - try { - createBenchContainer(); - $engine = new DiscoveryEngine; - - $location = new DiscoveryLocation( - $setup['info']['namespace'], - $setup['info']['path'] - ); - - $engine->addLocation($location); - $engine->addDiscovery('hooks', new BenchmarkDiscovery('hooks')); - $engine->addDiscovery('providers', new BenchmarkDiscovery('providers')); - $engine->addDiscovery('post_types', new BenchmarkDiscovery('post_types')); - $engine->addDiscovery('rest_routes', new BenchmarkDiscovery('rest_routes')); - $engine->addDiscovery('schedules', new BenchmarkDiscovery('schedules')); - - $start = hrtime(true); - $engine->discover(); - $elapsedMs = (hrtime(true) - $start) / 1_000_000; - - $stats = $engine->getPerformanceStats(); - - expect($stats['context']['total_classes'])->toBeGreaterThanOrEqual(100); - expect($elapsedMs)->toBeLessThan(2000); - } finally { - spl_autoload_unregister($setup['autoloader']); - $setup['generator']->cleanup(); - } - }); - - it('runs full discovery on 500 classes with 5 discoveries in under 10000ms', function (): void { - $setup = generateAndRegister(500); - - try { - createBenchContainer(); - $engine = new DiscoveryEngine; - - $location = new DiscoveryLocation( - $setup['info']['namespace'], - $setup['info']['path'] - ); - - $engine->addLocation($location); - $engine->addDiscovery('hooks', new BenchmarkDiscovery('hooks')); - $engine->addDiscovery('providers', new BenchmarkDiscovery('providers')); - $engine->addDiscovery('post_types', new BenchmarkDiscovery('post_types')); - $engine->addDiscovery('rest_routes', new BenchmarkDiscovery('rest_routes')); - $engine->addDiscovery('schedules', new BenchmarkDiscovery('schedules')); - - $start = hrtime(true); - $engine->discover(); - $elapsedMs = (hrtime(true) - $start) / 1_000_000; - - $stats = $engine->getPerformanceStats(); - - expect($stats['context']['total_classes'])->toBeGreaterThanOrEqual(500); - expect($elapsedMs)->toBeLessThan(10000); - } finally { - spl_autoload_unregister($setup['autoloader']); - $setup['generator']->cleanup(); - } - }); - - it('scales near-linearly from 100 to 500 classes', function (): void { - // Benchmark 100 classes - $setup100 = generateAndRegister(100); - createBenchContainer(); - - try { - $engine100 = new DiscoveryEngine; - $loc100 = new DiscoveryLocation($setup100['info']['namespace'], $setup100['info']['path']); - $engine100->addLocation($loc100); - $engine100->addDiscovery('hooks', new BenchmarkDiscovery('hooks')); - $engine100->addDiscovery('providers', new BenchmarkDiscovery('providers')); - $engine100->addDiscovery('post_types', new BenchmarkDiscovery('post_types')); - - $start = hrtime(true); - $engine100->discover(); - $time100 = (hrtime(true) - $start) / 1_000_000; - } finally { - spl_autoload_unregister($setup100['autoloader']); - $setup100['generator']->cleanup(); - } - - clearDiscoveryCacheManagerStatic(); - - // Benchmark 500 classes - $setup500 = generateAndRegister(500); - - try { - $engine500 = new DiscoveryEngine; - $loc500 = new DiscoveryLocation($setup500['info']['namespace'], $setup500['info']['path']); - $engine500->addLocation($loc500); - $engine500->addDiscovery('hooks', new BenchmarkDiscovery('hooks')); - $engine500->addDiscovery('providers', new BenchmarkDiscovery('providers')); - $engine500->addDiscovery('post_types', new BenchmarkDiscovery('post_types')); - - $start = hrtime(true); - $engine500->discover(); - $time500 = (hrtime(true) - $start) / 1_000_000; - } finally { - spl_autoload_unregister($setup500['autoloader']); - $setup500['generator']->cleanup(); - } - - // 5x classes should be less than 8x time (allowing for overhead) - $ratio = $time500 / max(0.01, $time100); - expect($ratio)->toBeLessThan(8.0, - sprintf('Scaling ratio: %sx for 5x classes (100: %sms, 500: %sms)', $ratio, $time100, $time500) - ); - }); - - it('processes each class in under 2000 microseconds on average', function (): void { - $setup = generateAndRegister(250); - - try { - createBenchContainer(); - $engine = new DiscoveryEngine; - - $location = new DiscoveryLocation( - $setup['info']['namespace'], - $setup['info']['path'] - ); - - $engine->addLocation($location); - $engine->addDiscovery('hooks', new BenchmarkDiscovery('hooks')); - $engine->addDiscovery('providers', new BenchmarkDiscovery('providers')); - $engine->addDiscovery('post_types', new BenchmarkDiscovery('post_types')); - $engine->addDiscovery('rest_routes', new BenchmarkDiscovery('rest_routes')); - $engine->addDiscovery('schedules', new BenchmarkDiscovery('schedules')); - - $start = hrtime(true); - $engine->discover(); - $elapsedUs = (hrtime(true) - $start) / 1_000; - - $perClassUs = $elapsedUs / 250; - - expect($perClassUs)->toBeLessThan(3000, - sprintf('Per-class cost: %s us (total: ', $perClassUs).($elapsedUs / 1000).' ms for 250 classes)' - ); - } finally { - spl_autoload_unregister($setup['autoloader']); - $setup['generator']->cleanup(); - } - }); - - it('handles multi-location with DDD subdirectories without excessive overhead', function (): void { - $basePath = __DIR__.'/Generated'; - $namespace = 'Tests\\Benchmark\\Generated'; - $generator = new FixtureGenerator($basePath, $namespace); - - $multiInfo = $generator->generateMultiLocation(200, 5); - - $autoloaders = []; - foreach ($multiInfo['locations'] as $loc) { - $al = function (string $class) use ($loc): void { - if (! str_starts_with($class, $loc['namespace'])) { - return; - } - - $relative = str_replace($loc['namespace'].'\\', '', $class); - $file = $loc['path'].'/'.str_replace('\\', '/', $relative).'.php'; - if (file_exists($file)) { - require_once $file; - } - }; - spl_autoload_register($al); - $autoloaders[] = $al; - } - - try { - createBenchContainer(); - $engine = new DiscoveryEngine; - - foreach ($multiInfo['locations'] as $loc) { - $engine->addLocation(new DiscoveryLocation($loc['namespace'], $loc['path'])); - } - - $engine->addDiscovery('hooks', new BenchmarkDiscovery('hooks')); - $engine->addDiscovery('providers', new BenchmarkDiscovery('providers')); - $engine->addDiscovery('post_types', new BenchmarkDiscovery('post_types')); - - $start = hrtime(true); - $engine->discover(); - $elapsedMs = (hrtime(true) - $start) / 1_000_000; - - $stats = $engine->getPerformanceStats(); - - expect($stats['context']['total_classes'])->toBeGreaterThanOrEqual(200); - // 5 modules with subdirs should not be dramatically slower than flat - expect($elapsedMs)->toBeLessThan(5000); - } finally { - foreach ($autoloaders as $al) { - spl_autoload_unregister($al); - } - - $generator->cleanup(); - } - }); -}); diff --git a/tests/Benchmark/Fixtures/FixtureGenerator.php b/tests/Benchmark/Fixtures/FixtureGenerator.php deleted file mode 100644 index c35e6758..00000000 --- a/tests/Benchmark/Fixtures/FixtureGenerator.php +++ /dev/null @@ -1,673 +0,0 @@ -} - */ - public function generate(int $count): array - { - $this->ensureDirectory($this->basePath); - - $distribution = $this->calculateDistribution($count); - $index = 0; - - foreach ($distribution as $type => $typeCount) { - for ($i = 0; $i < $typeCount; $i++) { - $this->generateClass($type, $index); - $index++; - } - } - - return [ - 'path' => $this->basePath, - 'namespace' => $this->namespace, - 'count' => $index, - 'distribution' => $distribution, - ]; - } - - /** - * Generate a multi-location structure simulating a real Pollora project. - * - * Creates N modules, each with a DDD-like directory structure: - * ModuleName/ - * app/ - * PostTypes/ - * Hooks/ - * Http/Controllers/ - * Providers/ - * Jobs/ - * Models/ - * - * Each module is a separate discovery location (like nwidart modules). - * - * @param int $totalClasses Total number of classes across all modules - * @param int $moduleCount Number of modules to create - * @return array{locations: array, total_classes: int, modules: int, dirs_created: int} - */ - public function generateMultiLocation(int $totalClasses, int $moduleCount): array - { - $this->ensureDirectory($this->basePath); - - $classesPerModule = (int) ceil($totalClasses / $moduleCount); - $locations = []; - $totalGenerated = 0; - $dirsCreated = 0; - $remaining = $totalClasses; - - for ($mod = 0; $mod < $moduleCount; $mod++) { - $moduleName = 'Module'.chr(65 + ($mod % 26)).($mod >= 26 ? $mod : ''); - $modulePath = $this->basePath.'/'.$moduleName.'/app'; - $moduleNamespace = $this->namespace.'\\'.$moduleName.'\\App'; - $classCount = min($classesPerModule, $remaining); - - if ($classCount <= 0) { - break; - } - - // DDD subdirectories - $subdirs = [ - 'PostTypes' => 'post_type', - 'Hooks' => 'hook', - 'Http/Controllers' => 'rest', - 'Providers' => 'provider', - 'Jobs' => 'schedule', - 'Models' => 'plain', - 'Services' => 'mixed', - ]; - - $perSubdir = (int) ceil($classCount / count($subdirs)); - $moduleGenerated = 0; - - foreach ($subdirs as $subdir => $type) { - $subdirPath = $modulePath.'/'.$subdir; - $subdirNamespace = $moduleNamespace.'\\'.str_replace('/', '\\', $subdir); - - $subdirCount = min($perSubdir, $classCount - $moduleGenerated); - if ($subdirCount <= 0) { - break; - } - - $this->ensureDirectory($subdirPath); - $dirsCreated++; - - for ($i = 0; $i < $subdirCount; $i++) { - $globalIndex = $totalGenerated + $moduleGenerated + $i; - $className = $this->classNameForType($type, $globalIndex); - $content = $this->generateClassContent($type, $className, $globalIndex, $subdirNamespace); - file_put_contents($subdirPath.'/'.$className.'.php', $content); - } - - $moduleGenerated += $subdirCount; - } - - $locations[] = [ - 'path' => $modulePath, - 'namespace' => $moduleNamespace, - ]; - - $totalGenerated += $moduleGenerated; - $remaining -= $moduleGenerated; - } - - return [ - 'locations' => $locations, - 'total_classes' => $totalGenerated, - 'modules' => count($locations), - 'dirs_created' => $dirsCreated, - ]; - } - - /** - * Clean up generated fixtures. - */ - public function cleanup(): void - { - if (is_dir($this->basePath)) { - $this->removeDirectory($this->basePath); - } - } - - private function calculateDistribution(int $count): array - { - $hooks = (int) round($count * 0.30); - $providers = (int) round($count * 0.15); - $postTypes = (int) round($count * 0.15); - $rest = (int) round($count * 0.10); - $schedule = (int) round($count * 0.10); - $mixed = (int) round($count * 0.10); - $plain = $count - $hooks - $providers - $postTypes - $rest - $schedule - $mixed; - - return [ - 'hook' => $hooks, - 'provider' => $providers, - 'post_type' => $postTypes, - 'rest' => $rest, - 'schedule' => $schedule, - 'mixed' => $mixed, - 'plain' => max(0, $plain), - ]; - } - - private function generateClass(string $type, int $index): void - { - $className = $this->classNameForType($type, $index); - $content = $this->generateClassContent($type, $className, $index, $this->namespace); - - file_put_contents($this->basePath.'/'.$className.'.php', $content); - } - - private function generateClassContent(string $type, string $className, int $index, string $namespace): string - { - return match ($type) { - 'hook' => $this->generateHookClass($className, $index, $namespace), - 'provider' => $this->generateProviderClass($className, $namespace), - 'post_type' => $this->generatePostTypeClass($className, $index, $namespace), - 'rest' => $this->generateRestClass($className, $index, $namespace), - 'schedule' => $this->generateScheduleClass($className, $index, $namespace), - 'mixed' => $this->generateMixedClass($className, $index, $namespace), - 'plain' => $this->generatePlainClass($className, $namespace), - }; - } - - private function classNameForType(string $type, int $index): string - { - return match ($type) { - 'hook' => 'BenchHook'.$index, - 'provider' => 'BenchProvider'.$index, - 'post_type' => 'BenchPostType'.$index, - 'rest' => 'BenchRestController'.$index, - 'schedule' => 'BenchSchedule'.$index, - 'mixed' => 'BenchMixed'.$index, - 'plain' => 'BenchPlain'.$index, - }; - } - - /** - * Hook class: 2-6 methods, each with 1-3 repeatable Action/Filter attributes. - */ - private function generateHookClass(string $className, int $index, ?string $namespace = null): string - { - $ns = $namespace ?? $this->namespace; - $methodCount = ($index % 5) + 2; // 2 to 6 methods - $methods = ''; - - for ($m = 0; $m < $methodCount; $m++) { - $attrCount = ($m % 3) + 1; // 1 to 3 attributes per method - $attrs = ''; - - for ($a = 0; $a < $attrCount; $a++) { - $hookName = sprintf('bench_hook_%d_%d_%d', $index, $m, $a); - $attr = ($m + $a) % 3 === 0 ? 'Filter' : 'Action'; - $priority = (($m * 3) + $a) * 5 + 10; - $attrs .= " #[\\Pollora\\Attributes\\{$attr}(hook: '{$hookName}', priority: {$priority})]\n"; - } - - $methods .= <<namespace; - - return <<namespace; - $slug = 'bench_cpt_'.$index; - - // Pool of class-level PostType sub-attributes - $subAttributes = [ - "#[\\Pollora\\Attributes\\PostType\\Supports(features: ['title', 'editor', 'thumbnail', 'excerpt', 'comments'])]", - "#[\\Pollora\\Attributes\\PostType\\MenuIcon(value: 'dashicons-admin-post')]", - '#[\\Pollora\\Attributes\\PostType\\HasArchive(value: true)]', - '#[\\Pollora\\Attributes\\PostType\\ShowInRest(value: true)]', - '#[\\Pollora\\Attributes\\PostType\\ShowUI(value: true)]', - '#[\\Pollora\\Attributes\\PostType\\Hierarchical(value: false)]', - '#[\\Pollora\\Attributes\\PostType\\PublicPostType(value: true)]', - '#[\\Pollora\\Attributes\\PostType\\MenuPosition(value: 25)]', - '#[\\Pollora\\Attributes\\PostType\\ExcludeFromSearch(value: false)]', - '#[\\Pollora\\Attributes\\PostType\\PubliclyQueryable(value: true)]', - '#[\\Pollora\\Attributes\\PostType\\CanExport(value: true)]', - '#[\\Pollora\\Attributes\\PostType\\DeleteWithUser(value: false)]', - ]; - - // Pick 3-8 sub-attributes based on index - $subAttrCount = ($index % 6) + 3; - $selectedAttrs = array_slice($subAttributes, 0, $subAttrCount); - $attrBlock = implode("\n", $selectedAttrs); - - return <<namespace; - $methodCount = ($index % 4) + 2; // 2 to 5 methods - $methods = ''; - - $httpMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']; - - for ($m = 0; $m < $methodCount; $m++) { - $httpMethod = $httpMethods[$m % count($httpMethods)]; - $methodName = match ($httpMethod) { - 'GET' => $m === 0 ? 'index' : 'show', - 'POST' => 'store', - 'PUT' => 'update', - 'DELETE' => 'destroy', - 'PATCH' => 'patch', - }; - // Avoid duplicate method names - $methodName .= $m > 4 ? $m : ''; - - $methods .= << \$id, 'method' => '{$httpMethod}']; - } - - PHP; - } - - return <<\d+)')] - class {$className} - { - {$methods} - } - - PHP; - } - - /** - * Schedule class: 1-4 methods each with a Schedule attribute. - */ - private function generateScheduleClass(string $className, int $index, ?string $namespace = null): string - { - $ns = $namespace ?? $this->namespace; - $methodCount = ($index % 4) + 1; // 1 to 4 methods - $recurrences = ['hourly', 'daily', 'twicedaily', 'weekly']; - $methods = ''; - - for ($m = 0; $m < $methodCount; $m++) { - $recurrence = $recurrences[$m % count($recurrences)]; - $hookName = sprintf('bench_cron_%d_%d', $index, $m); - $methods .= <<namespace; - $slug = 'bench_mixed_'.$index; - $variant = $index % 3; - - return match ($variant) { - 0 => $this->generatePostTypeWithHooks($className, $index, $slug, $ns), - 1 => $this->generateRestWithHooksAndSchedule($className, $index, $ns), - 2 => $this->generateHeavyMixed($className, $index, $slug, $ns), - }; - } - - private function generatePostTypeWithHooks(string $className, int $index, string $slug, ?string $ns = null): string - { - $ns ??= $this->namespace; - $hookCount = ($index % 3) + 2; // 2-4 hook methods - $methods = ''; - - for ($m = 0; $m < $hookCount; $m++) { - $hookName = sprintf('bench_mixed_hook_%d_%d', $index, $m); - $methods .= <<namespace; - - return << \$id]; - } - - #[\\Pollora\\Attributes\\Action(hook: 'rest_api_init', priority: 10)] - public function onRestInit(): void - { - // Register additional routes - } - - #[\\Pollora\\Attributes\\Action(hook: 'init', priority: 5)] - #[\\Pollora\\Attributes\\Action(hook: 'admin_init', priority: 10)] - public function onInit(): void - { - // Multiple hooks on same method - } - - #[\\Pollora\\Attributes\\Schedule(recurrence: 'daily', hook: 'bench_mixed_cron_{$index}')] - public function cleanup(): void - { - // Scheduled cleanup - } - } - - PHP; - } - - private function generateHeavyMixed(string $className, int $index, string $slug, ?string $ns = null): string - { - $ns ??= $this->namespace; - - return <<namespace; - - return <<isDir()) { - rmdir($item->getPathname()); - } else { - unlink($item->getPathname()); - } - } - - rmdir($path); - } -} diff --git a/tests/Benchmark/run.php b/tests/Benchmark/run.php deleted file mode 100644 index 727cf88d..00000000 --- a/tests/Benchmark/run.php +++ /dev/null @@ -1,54 +0,0 @@ -run($classCounts, $iterations); diff --git a/tests/Pest.php b/tests/Pest.php index 37093e09..e5722449 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -17,7 +17,6 @@ uses(TestCase::class)->in('Feature/Modules', 'Feature/Option'); uses(TestCase::class)->in('Unit'); -uses()->in('Benchmark'); uses(Orchestra\Testbench\TestCase::class) ->beforeEach(function (): void { \Brain\Monkey\setUp(); diff --git a/tests/Unit/Discovery/Domain/Models/DiscoveryItemsTest.php b/tests/Unit/Discovery/Domain/Models/DiscoveryItemsTest.php deleted file mode 100644 index eebaf84c..00000000 --- a/tests/Unit/Discovery/Domain/Models/DiscoveryItemsTest.php +++ /dev/null @@ -1,154 +0,0 @@ -isLoaded())->toBeFalse(); - expect($items)->toHaveCount(0); - expect($items->all())->toBe([]); - }); - - it('can create with initial data', function (): void { - $items = new DiscoveryItems([ - 'location1' => ['item1', 'item2'], - 'location2' => ['item3'], - ]); - - expect($items->isLoaded())->toBeTrue(); - expect($items)->toHaveCount(3); - expect($items->all())->toEqual(['item1', 'item2', 'item3']); - }); - - it('can add single item for location', function (): void { - $items = new DiscoveryItems; - $location = new DiscoveryLocation('App\\Models', '/app/models'); - - $items->add($location, 'test-item'); - - expect($items->hasLocation($location))->toBeTrue(); - expect($items->getForLocation($location))->toEqual(['test-item']); - expect($items)->toHaveCount(1); - }); - - it('can add multiple items for location', function (): void { - $items = new DiscoveryItems; - $location = new DiscoveryLocation('App\\Models', '/app/models'); - - $items->addForLocation($location, ['item1', 'item2', 'item3']); - - expect($items->getForLocation($location))->toEqual(['item1', 'item2', 'item3']); - expect($items)->toHaveCount(3); - }); - - it('can add items to existing location', function (): void { - $items = new DiscoveryItems; - $location = new DiscoveryLocation('App\\Models', '/app/models'); - - $items->add($location, 'item1'); - $items->addForLocation($location, ['item2', 'item3']); - - expect($items->getForLocation($location))->toEqual(['item1', 'item2', 'item3']); - expect($items)->toHaveCount(3); - }); - - it('handles multiple locations', function (): void { - $items = new DiscoveryItems; - $location1 = new DiscoveryLocation('App\\Models', '/app/models'); - $location2 = new DiscoveryLocation('App\\Services', '/app/services'); - - $items->addForLocation($location1, ['model1', 'model2']); - $items->addForLocation($location2, ['service1']); - - expect($items)->toHaveCount(3); - expect($items->getForLocation($location1))->toEqual(['model1', 'model2']); - expect($items->getForLocation($location2))->toEqual(['service1']); - expect($items->all())->toEqual(['model1', 'model2', 'service1']); - }); - - it('returns empty array for unknown location', function (): void { - $items = new DiscoveryItems; - $location = new DiscoveryLocation('App\\Models', '/app/models'); - - expect($items->getForLocation($location))->toBe([]); - expect($items->hasLocation($location))->toBeFalse(); - }); - - it('can iterate over all items', function (): void { - $items = new DiscoveryItems; - $location1 = new DiscoveryLocation('App\\Models', '/app/models'); - $location2 = new DiscoveryLocation('App\\Services', '/app/services'); - - $items->addForLocation($location1, ['model1', 'model2']); - $items->addForLocation($location2, ['service1']); - - $iteratedItems = []; - foreach ($items as $item) { - $iteratedItems[] = $item; - } - - expect($iteratedItems)->toEqual(['model1', 'model2', 'service1']); - }); - - it('supports serialization and unserialization', function (): void { - $items = new DiscoveryItems; - $location = new DiscoveryLocation('App\\Models', '/app/models'); - - $items->addForLocation($location, ['item1', 'item2']); - - $serialized = $items->__serialize(); - expect($serialized)->toBeArray(); - - $newItems = new DiscoveryItems; - $newItems->__unserialize($serialized); - - expect($newItems->all())->toEqual($items->all()); - expect($newItems->count())->toBe($items->count()); - }); - - it('onlyVendor returns new instance', function (): void { - $items = new DiscoveryItems([ - 'location1' => ['item1'], - 'location2' => ['item2'], - ]); - - $vendorItems = $items->onlyVendor(); - - expect($vendorItems)->not->toBe($items); - expect($vendorItems)->toBeInstanceOf(DiscoveryItems::class); - }); -}); - -describe('DiscoveryItems pending items', function (): void { - it('has every item pending until marked applied', function (): void { - $items = new DiscoveryItems; - $items->addForLocation(new DiscoveryLocation('App\\Models', '/app/models'), ['item1', 'item2']); - - expect($items->pending()->all())->toBe(['item1', 'item2']); - - $items->markApplied(); - - expect($items->pending()->isLoaded())->toBeFalse() - ->and($items->all())->toBe(['item1', 'item2']); - }); - - it('keeps as pending only the items added since, in every location', function (): void { - $models = new DiscoveryLocation('App\\Models', '/app/models'); - $theme = new DiscoveryLocation('Theme\\Demo', '/themes/demo'); - $items = new DiscoveryItems; - $items->add($models, 'model1'); - $items->markApplied(); - - $items->add($models, 'model2'); - $items->add($theme, 'theme1'); - - expect($items->pending()->all())->toBe(['model2', 'theme1']) - ->and($items->pending())->not->toBe($items) - ->and($items->all())->toBe(['model1', 'model2', 'theme1']); - }); -}); diff --git a/tests/Unit/Discovery/Domain/Models/DiscoveryLocationTest.php b/tests/Unit/Discovery/Domain/Models/DiscoveryLocationTest.php deleted file mode 100644 index b821be06..00000000 --- a/tests/Unit/Discovery/Domain/Models/DiscoveryLocationTest.php +++ /dev/null @@ -1,67 +0,0 @@ -getNamespace())->toBe('App\\Models'); - expect($location->getPath())->toBe('/path/to/models'); - }); - - it('generates unique key based on path', function (): void { - $location1 = new DiscoveryLocation('App\\Models', '/path/to/models'); - $location2 = new DiscoveryLocation('App\\Services', '/path/to/models'); - $location3 = new DiscoveryLocation('App\\Models', '/path/to/services'); - - expect($location1->getKey())->toBe($location2->getKey()); - expect($location1->getKey())->not->toBe($location3->getKey()); - }); - - it('detects vendor locations', function (): void { - $vendorLocation = new DiscoveryLocation('Vendor\\Package', '/path/to/vendor/package'); - $appLocation = new DiscoveryLocation('App\\Models', '/path/to/app/models'); - - expect($vendorLocation->isVendor())->toBeTrue(); - expect($appLocation->isVendor())->toBeFalse(); - }); - - it('detects vendor locations with windows path', function (): void { - $vendorLocation = new DiscoveryLocation('Vendor\\Package', 'C:\\path\\to\\vendor\\package'); - - expect($vendorLocation->isVendor())->toBeTrue(); - }); - - it('converts file path to class name', function (): void { - $location = new DiscoveryLocation('App\\Models', '/app/Models'); - - expect($location->toClassName('/app/Models/User.php'))->toBe('App\\Models\\User'); - }); - - it('converts nested file path to class name', function (): void { - $location = new DiscoveryLocation('App\\Services', '/app/Services'); - - expect($location->toClassName('/app/Services/Auth/UserService.php'))->toBe('App\\Services\\Auth\\UserService'); - }); - - it('returns empty string for file outside location', function (): void { - $location = new DiscoveryLocation('App\\Models', '/app/Models'); - - expect($location->toClassName('/app/Services/UserService.php'))->toBe(''); - }); - - it('handles windows paths in class name conversion', function (): void { - $location = new DiscoveryLocation('App\\Models', 'C:\\app\\Models'); - - expect($location->toClassName('C:\\app\\Models\\Auth\\User.php'))->toBe('App\\Models\\Auth\\User'); - }); - - it('trims namespace slashes', function (): void { - $location = new DiscoveryLocation('App\\Models\\', '/app/Models'); - - expect($location->toClassName('/app/Models/User.php'))->toBe('App\\Models\\User'); - }); -}); diff --git a/tests/Unit/Discovery/Infrastructure/Cache/Psr16DiscoverCacheDriverTest.php b/tests/Unit/Discovery/Infrastructure/Cache/Psr16DiscoverCacheDriverTest.php deleted file mode 100644 index 18e7e01a..00000000 --- a/tests/Unit/Discovery/Infrastructure/Cache/Psr16DiscoverCacheDriverTest.php +++ /dev/null @@ -1,119 +0,0 @@ - */ - public array $entries = []; - - public function get(string $key, mixed $default = null): mixed - { - return $this->entries[$this->valid($key)]['value'] ?? $default; - } - - public function set(string $key, mixed $value, DateInterval|int|null $ttl = null): bool - { - $this->entries[$this->valid($key)] = ['value' => $value, 'ttl' => $ttl]; - - return true; - } - - public function delete(string $key): bool - { - unset($this->entries[$this->valid($key)]); - - return true; - } - - public function clear(): bool - { - $this->entries = []; - - return true; - } - - public function getMultiple(iterable $keys, mixed $default = null): iterable - { - return []; - } - - public function setMultiple(iterable $values, DateInterval|int|null $ttl = null): bool - { - return true; - } - - public function deleteMultiple(iterable $keys): bool - { - return true; - } - - public function has(string $key): bool - { - return isset($this->entries[$this->valid($key)]); - } - - private function valid(string $key): string - { - if (strpbrk($key, '{}()/\\@:') !== false) { - throw new class('Reserved character in '.$key) extends Exception implements InvalidArgumentException {}; - } - - return $key; - } - }; -} - -describe('Psr16DiscoverCacheDriver', function (): void { - it('returns the structures it stored', function (): void { - $driver = new Psr16DiscoverCacheDriver(strictPsr16Cache()); - - $driver->put('discovery_abc', ['App\\Hooks\\Foo']); - - expect($driver->has('discovery_abc'))->toBeTrue() - ->and($driver->get('discovery_abc'))->toBe(['App\\Hooks\\Foo']); - }); - - it('reports a missing entry as absent and empty', function (): void { - $driver = new Psr16DiscoverCacheDriver(strictPsr16Cache()); - - expect($driver->has('discovery_missing'))->toBeFalse() - ->and($driver->get('discovery_missing'))->toBe([]); - }); - - it('forgets an entry', function (): void { - $driver = new Psr16DiscoverCacheDriver(strictPsr16Cache()); - $driver->put('discovery_abc', ['App\\Hooks\\Foo']); - - $driver->forget('discovery_abc'); - - expect($driver->has('discovery_abc'))->toBeFalse(); - }); - - it('accepts ids holding characters PSR-16 reserves', function (): void { - $driver = new Psr16DiscoverCacheDriver(strictPsr16Cache()); - - $driver->put('App\\Hooks:{id}', ['App\\Hooks\\Foo']); - - expect($driver->get('App\\Hooks:{id}'))->toBe(['App\\Hooks\\Foo']); - }); - - it('stores under its prefix, with its lifetime', function (): void { - $cache = strictPsr16Cache(); - $driver = new Psr16DiscoverCacheDriver($cache, prefix: 'site-', ttl: 3600); - - $driver->put('discovery_abc', []); - - expect($cache->entries)->toBe(['site-discovery_abc' => ['value' => [], 'ttl' => 3600]]); - }); -}); diff --git a/tests/Unit/Discovery/Infrastructure/Resolvers/ReflectionInstanceResolverTest.php b/tests/Unit/Discovery/Infrastructure/Resolvers/ReflectionInstanceResolverTest.php deleted file mode 100644 index 35d40e6f..00000000 --- a/tests/Unit/Discovery/Infrastructure/Resolvers/ReflectionInstanceResolverTest.php +++ /dev/null @@ -1,87 +0,0 @@ -make(ResolverLeaf::class))->toBeInstanceOf(ResolverLeaf::class); - }); - - it('fills parameters from their default value', function (): void { - $instance = (new ReflectionInstanceResolver)->make(ResolverWithDefaults::class); - - expect($instance->name)->toBe('default') - ->and($instance->limit)->toBeNull(); - }); - - it('builds class-typed dependencies and passes null to nullable scalars', function (): void { - $instance = (new ReflectionInstanceResolver)->make(ResolverWithDependency::class); - - expect($instance->leaf)->toBeInstanceOf(ResolverLeaf::class) - ->and($instance->label)->toBeNull(); - }); - - it('names the parameter it cannot fill', function (): void { - (new ReflectionInstanceResolver)->make(ResolverWithScalar::class); - })->throws(InvalidArgumentException::class, 'parameter $required'); - - it('refuses an interface dependency it has no implementation for', function (): void { - (new ReflectionInstanceResolver)->make(ResolverWithInterface::class); - })->throws(InvalidArgumentException::class, 'Countable is not instantiable'); - - it('refuses an abstract class', function (): void { - (new ReflectionInstanceResolver)->make(ResolverAbstract::class); - })->throws(InvalidArgumentException::class, 'not instantiable'); - - it('stops a dependency cycle instead of overflowing the stack', function (): void { - (new ReflectionInstanceResolver)->make(ResolverCycleA::class); - })->throws(RuntimeException::class, 'Circular dependency detected while building ResolverCycleA'); - - it('can build the same class again after a failure', function (): void { - $resolver = new ReflectionInstanceResolver; - - try { - $resolver->make(ResolverCycleA::class); - } catch (RuntimeException) { - // The cycle is reported once; the resolver must not stay stuck on it. - } - - expect($resolver->make(ResolverWithDependency::class))->toBeInstanceOf(ResolverWithDependency::class); - }); -}); diff --git a/tests/Unit/Discovery/Infrastructure/Services/DiscoveryEngineTest.php b/tests/Unit/Discovery/Infrastructure/Services/DiscoveryEngineTest.php deleted file mode 100644 index af1382ea..00000000 --- a/tests/Unit/Discovery/Infrastructure/Services/DiscoveryEngineTest.php +++ /dev/null @@ -1,398 +0,0 @@ -shouldReceive('getPath')->andReturn($path); - $location->shouldReceive('getKey')->andReturn('key_'.md5($path)); - $location->shouldReceive('isVendor')->andReturn(false); - - return $location; -} - -describe('DiscoveryEngine', function (): void { - describe('addLocation', function (): void { - it('adds a location', function (): void { - $engine = createEngine(); - $location = createLocationMock('/tmp/test-path-'.uniqid()); - - $result = $engine->addLocation($location); - - expect($result)->toBe($engine); - expect($engine->getLocations())->toHaveCount(1); - }); - - it('deduplicates locations with same path', function (): void { - $engine = createEngine(); - $path = '/tmp/dedup-path-'.uniqid(); - $location1 = createLocationMock($path); - $location2 = createLocationMock($path); - - $engine->addLocation($location1); - $engine->addLocation($location2); - - expect($engine->getLocations())->toHaveCount(1); - }); - - it('adds locations with different paths', function (): void { - $engine = createEngine(); - - $engine->addLocation(createLocationMock('/tmp/path-a-'.uniqid())); - $engine->addLocation(createLocationMock('/tmp/path-b-'.uniqid())); - - expect($engine->getLocations())->toHaveCount(2); - }); - }); - - describe('addLocations', function (): void { - it('adds multiple locations at once', function (): void { - $engine = createEngine(); - - $engine->addLocations([ - createLocationMock('/tmp/loc-1-'.uniqid()), - createLocationMock('/tmp/loc-2-'.uniqid()), - ]); - - expect($engine->getLocations())->toHaveCount(2); - }); - }); - - describe('addDiscovery', function (): void { - it('adds a discovery instance', function (): void { - $engine = createEngine(); - $discovery = Mockery::mock(DiscoveryInterface::class); - - $engine->addDiscovery('test', $discovery); - - expect($engine->getDiscoveries())->toHaveCount(1); - expect($engine->getDiscoveries()->has('test'))->toBeTrue(); - }); - - it('throws on duplicate identifier', function (): void { - $engine = createEngine(); - $discovery = Mockery::mock(DiscoveryInterface::class); - - $engine->addDiscovery('dup', $discovery); - - expect(fn (): DiscoveryEngine => $engine->addDiscovery('dup', $discovery)) - ->toThrow(InvalidDiscoveryException::class); - }); - }); - - describe('addDiscoveries', function (): void { - it('adds multiple discoveries at once', function (): void { - $engine = createEngine(); - - $engine->addDiscoveries([ - 'first' => Mockery::mock(DiscoveryInterface::class), - 'second' => Mockery::mock(DiscoveryInterface::class), - ]); - - expect($engine->getDiscoveries())->toHaveCount(2); - }); - }); - - describe('getDiscovery', function (): void { - it('returns discovery by identifier', function (): void { - $engine = createEngine(); - $discovery = Mockery::mock(DiscoveryInterface::class); - - $engine->addDiscovery('my-discovery', $discovery); - - expect($engine->getDiscovery('my-discovery'))->toBe($discovery); - }); - - it('throws when discovery not found', function (): void { - $engine = createEngine(); - - expect(fn (): DiscoveryInterface => $engine->getDiscovery('nonexistent')) - ->toThrow(DiscoveryNotFoundException::class); - }); - }); - - describe('clearLocations', function (): void { - it('removes all locations', function (): void { - $engine = createEngine(); - $engine->addLocation(createLocationMock('/tmp/clear-'.uniqid())); - - expect($engine->getLocations())->toHaveCount(1); - - $engine->clearLocations(); - - expect($engine->getLocations())->toHaveCount(0); - }); - - it('returns the engine for chaining', function (): void { - $engine = createEngine(); - - expect($engine->clearLocations())->toBe($engine); - }); - }); - - describe('clearCache', function (): void { - it('delegates to cache manager', function (): void { - $cacheManager = Mockery::mock(DiscoveryCacheManager::class); - $cacheManager->shouldReceive('clearCache')->once(); - - $engine = createEngine($cacheManager); - $engine->clearCache(); - }); - }); - - describe('getPerformanceStats', function (): void { - it('returns stats array with expected keys', function (): void { - $cacheManager = Mockery::mock(DiscoveryCacheManager::class); - $cacheManager->shouldReceive('getStaticCacheSize')->andReturn(0); - - $engine = createEngine($cacheManager); - $stats = $engine->getPerformanceStats(); - - expect($stats)->toHaveKeys(['context', 'instance_pool', 'static_cache_size']); - expect($stats['static_cache_size'])->toBe(0); - }); - }); - - describe('__clone', function (): void { - it('creates independent copies of collections', function (): void { - $engine = createEngine(); - $engine->addLocation(createLocationMock('/tmp/clone-'.uniqid())); - - $clone = clone $engine; - $clone->addLocation(createLocationMock('/tmp/clone-extra-'.uniqid())); - - expect($engine->getLocations())->toHaveCount(1); - expect($clone->getLocations())->toHaveCount(2); - }); - }); -}); - -describe('DiscoveryEngine apply', function (): void { - /** - * A discovery that records the items each apply() receives. - */ - function recordingDiscovery(bool $failing = false): DiscoveryInterface - { - return new class($failing) implements DiscoveryInterface - { - use IsDiscovery; - - /** @var list> */ - public array $applied = []; - - public function __construct(private readonly bool $failing) {} - - public function discover(DiscoveryLocationInterface $location, DiscoveredStructure $structure, ?ReflectionCacheInterface $reflectionCache = null): void {} - - public function apply(): void - { - $this->applied[] = $this->getItems()->all(); - - if ($this->failing) { - throw new RuntimeException('apply failed'); - } - } - }; - } - - it('applies each item once, however many times it is asked to apply', function (): void { - $engine = createEngine(); - $discovery = recordingDiscovery(); - $engine->addDiscovery('recording', $discovery); - $app = new DiscoveryLocation('App\\', '/app'); - $theme = new DiscoveryLocation('Theme\\', '/theme'); - - $discovery->getItems()->add($app, 'app-item'); - $engine->apply(); - $engine->apply(); - $discovery->getItems()->add($theme, 'theme-item'); - $engine->apply(); - - expect($discovery->applied)->toBe([['app-item'], ['theme-item']]); - }); - - it('keeps every item readable after applying', function (): void { - $engine = createEngine(); - $discovery = recordingDiscovery(); - $engine->addDiscovery('recording', $discovery); - $location = new DiscoveryLocation('App\\', '/app'); - - $discovery->getItems()->add($location, 'first'); - $engine->apply(); - $discovery->getItems()->add($location, 'second'); - $engine->apply(); - - expect($discovery->getItems()->all())->toBe(['first', 'second']); - }); - - it('applies once for the engine and its clones, which share the discovery', function (): void { - $engine = createEngine(); - $discovery = recordingDiscovery(); - $engine->addDiscovery('recording', $discovery); - $discovery->getItems()->add(new DiscoveryLocation('App\\', '/app'), 'item'); - - $engine->apply(); - (clone $engine)->apply(); - - expect($discovery->applied)->toBe([['item']]); - }); - - it('does not call apply() when nothing new was discovered', function (): void { - $engine = createEngine(); - $discovery = recordingDiscovery(); - $engine->addDiscovery('recording', $discovery); - - $engine->apply(); - - expect($discovery->applied)->toBe([]); - }); - - it('does not retry items whose apply failed, and restores the full list', function (): void { - $engine = createEngine(); - $discovery = recordingDiscovery(failing: true); - $engine->addDiscovery('recording', $discovery); - $discovery->getItems()->add(new DiscoveryLocation('App\\', '/app'), 'item'); - - expect(fn (): DiscoveryEngine => $engine->apply())->toThrow(DiscoveryException::class); - - $engine->apply(); - - expect($discovery->applied)->toBe([['item']]) - ->and($discovery->getItems()->all())->toBe(['item']); - }); -}); - -#[CapabilitySet] -enum EngineTestAttributedEnum: string -{ - case Value = 'value'; -} - -enum EngineTestPlainEnum: string -{ - case Value = 'value'; -} - -final class EngineTestClass {} - -abstract class EngineTestAbstractClass {} - -describe('DiscoveryEngine structures', function (): void { - it('hands discoveries concrete classes and enums carrying attributes', function (): void { - $structures = array_map( - fn (string $name): DiscoveredStructure => enum_exists($name) - ? DiscoveredEnum::fromReflection(new ReflectionEnum($name)) - : DiscoveredClass::fromReflection(new ReflectionClass($name)), - [EngineTestAttributedEnum::class, EngineTestPlainEnum::class, EngineTestClass::class, EngineTestAbstractClass::class] - ); - $cacheManager = Mockery::mock(DiscoveryCacheManager::class); - $cacheManager->shouldReceive('getStructuresForLocation')->andReturn($structures); - $engine = createEngine($cacheManager); - $seen = []; - $discovery = new class($seen) implements DiscoveryInterface - { - use IsDiscovery; - - public function __construct(private array &$seen) {} - - public function discover(DiscoveryLocationInterface $location, DiscoveredStructure $structure, ?ReflectionCacheInterface $reflectionCache = null): void - { - $this->seen[] = $structure->name; - } - - public function apply(): void {} - }; - $engine->addDiscovery('seen', $discovery); - $engine->addLocation(new DiscoveryLocation('Tests\\', __DIR__)); - - $engine->discover(); - - expect($seen)->toBe(['EngineTestAttributedEnum', 'EngineTestClass']); - }); -}); - -final class EngineTestNamedDiscovery implements DiscoveryInterface -{ - use IsDiscovery; - - public function discover(DiscoveryLocationInterface $location, DiscoveredStructure $structure, ?ReflectionCacheInterface $reflectionCache = null): void {} - - public function apply(): void {} -} - -describe('DiscoveryEngine without Laravel', function (): void { - it('builds a discovery given by class name through its resolver', function (): void { - $built = new EngineTestNamedDiscovery; - $resolver = Mockery::mock(InstanceResolverInterface::class); - $resolver->shouldReceive('make')->once()->with(EngineTestNamedDiscovery::class)->andReturn($built); - $engine = new DiscoveryEngine(resolver: $resolver, cacheManager: Mockery::mock(DiscoveryCacheManager::class)); - - $engine->addDiscovery('named', EngineTestNamedDiscovery::class); - - expect($engine->getDiscovery('named'))->toBe($built); - }); - - it('builds a discovery given by class name by reflection when it has no resolver', function (): void { - $engine = new DiscoveryEngine(cacheManager: Mockery::mock(DiscoveryCacheManager::class)); - - $engine->addDiscovery('named', EngineTestNamedDiscovery::class); - - expect($engine->getDiscovery('named'))->toBeInstanceOf(EngineTestNamedDiscovery::class); - }); - - it("asks its registrar for the environment's discoveries before discovering", function (): void { - $cacheManager = Mockery::mock(DiscoveryCacheManager::class); - $cacheManager->shouldReceive('getStructuresForLocation')->andReturn([]); - $registrar = Mockery::mock(DiscoveryRegistrarInterface::class); - $registrar->shouldReceive('registerInto')->once() - ->andReturnUsing(function (DiscoveryEngineInterface $engine): array { - $engine->addDiscovery('registered', new EngineTestNamedDiscovery); - - return ['registered']; - }); - $engine = new DiscoveryEngine(cacheManager: $cacheManager, registrar: $registrar); - $engine->addLocation(new DiscoveryLocation('Tests\\', __DIR__)); - - $engine->discover(); - - expect($engine->getDiscoveries()->keys()->all())->toBe(['registered']); - }); - - it('runs only the discoveries added to it when it has no registrar', function (): void { - $cacheManager = Mockery::mock(DiscoveryCacheManager::class); - $cacheManager->shouldReceive('getStructuresForLocation')->andReturn([]); - $engine = new DiscoveryEngine(cacheManager: $cacheManager); - $engine->addDiscovery('explicit', new EngineTestNamedDiscovery); - $engine->addLocation(new DiscoveryLocation('Tests\\', __DIR__)); - - $engine->discover(); - - expect($engine->getDiscoveries()->keys()->all())->toBe(['explicit']); - }); -}); diff --git a/tests/Unit/Discovery/PackageBoundaryTest.php b/tests/Unit/Discovery/PackageBoundaryTest.php deleted file mode 100644 index d4517472..00000000 --- a/tests/Unit/Discovery/PackageBoundaryTest.php +++ /dev/null @@ -1,48 +0,0 @@ -expect([ - 'Pollora\Discovery\Domain', - 'Pollora\Discovery\Application', - 'Pollora\Discovery\Infrastructure\Cache', - 'Pollora\Discovery\Infrastructure\Resolvers', - DiscoveryCacheManager::class, - DiscoveryEngine::class, - InstancePool::class, - ReflectionCache::class, - ]) - ->toOnlyUse([ - 'Pollora\Discovery', - SkipDiscovery::class, - 'Spatie\StructureDiscoverer', - 'Psr\Log', - 'Psr\SimpleCache', - 'Psr\Container', - Collection::class, - // HasInstancePool falls back to Laravel's resolve() only when it is defined. - 'resolve', - ]) - ->ignoring([ - HasConfiguringSupport::class, - ConfigurableDiscoveryInterface::class, - ]); From 492718ef134f67836c8a72a9a41aadc4921009a9 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Mon, 12 Oct 2026 14:54:46 +0200 Subject: [PATCH 03/27] refactor: let attributes declare what they register Post type and taxonomy attributes implement ConfiguresArguments, a pure function of their own values, and the method attributes ConfiguresArgumentsWithMethod; the discoveries apply them. #[Method] routes are registered by WpRestRouteRegistrar, default names come from an internal Naming helper matching Str, and the dead handle() methods of Action, Filter and WpRestRoute are gone. Pollora\Attributes no longer reaches Laravel or a framework service, which an architecture test keeps. Fixes an #[Action] or #[Filter] method of a post type or taxonomy class being registered a second time on the discovery's configuration object. --- CHANGELOG.md | 9 + phpstan-baseline.neon | 18 -- src/Attributes/Ability.php | 10 +- src/Attributes/Action.php | 66 +----- src/Attributes/Ajax.php | 4 +- .../Contracts/ConfiguresArguments.php | 24 ++ .../ConfiguresArgumentsWithMethod.php | 24 ++ src/Attributes/Filter.php | 34 +-- src/Attributes/Hook.php | 4 +- src/Attributes/Meta.php | 4 +- src/Attributes/PostType.php | 4 +- src/Attributes/PostType/AdminCol.php | 37 +--- src/Attributes/PostType/AdminCols.php | 13 +- src/Attributes/PostType/AdminFilters.php | 13 +- src/Attributes/PostType/Archive.php | 13 +- src/Attributes/PostType/BlockEditor.php | 13 +- src/Attributes/PostType/CanExport.php | 13 +- src/Attributes/PostType/Capabilities.php | 13 +- src/Attributes/PostType/CapabilityType.php | 13 +- src/Attributes/PostType/Chronological.php | 15 +- src/Attributes/PostType/DashboardActivity.php | 13 +- src/Attributes/PostType/DashboardGlance.php | 13 +- src/Attributes/PostType/DeleteWithUser.php | 13 +- src/Attributes/PostType/Description.php | 13 +- src/Attributes/PostType/ExcludeFromSearch.php | 13 +- src/Attributes/PostType/FeaturedImage.php | 13 +- src/Attributes/PostType/HasArchive.php | 13 +- src/Attributes/PostType/Hierarchical.php | 13 +- src/Attributes/PostType/Label.php | 13 +- src/Attributes/PostType/Labels.php | 12 +- src/Attributes/PostType/MapMetaCap.php | 13 +- src/Attributes/PostType/MenuIcon.php | 13 +- src/Attributes/PostType/MenuPosition.php | 13 +- src/Attributes/PostType/PostTypeAttribute.php | 5 +- src/Attributes/PostType/Priority.php | 13 +- src/Attributes/PostType/PublicPostType.php | 13 +- src/Attributes/PostType/PubliclyQueryable.php | 13 +- src/Attributes/PostType/QueryVar.php | 13 +- src/Attributes/PostType/QuickEdit.php | 13 +- src/Attributes/PostType/RegisterMetaBoxCb.php | 24 +- src/Attributes/PostType/RestBase.php | 13 +- .../PostType/RestControllerClass.php | 13 +- src/Attributes/PostType/RestNamespace.php | 13 +- src/Attributes/PostType/Rewrite.php | 13 +- src/Attributes/PostType/ShowInAdminBar.php | 13 +- src/Attributes/PostType/ShowInFeed.php | 13 +- src/Attributes/PostType/ShowInMenu.php | 13 +- src/Attributes/PostType/ShowInNavMenus.php | 13 +- src/Attributes/PostType/ShowInRest.php | 13 +- src/Attributes/PostType/ShowUI.php | 13 +- src/Attributes/PostType/SiteFilters.php | 13 +- src/Attributes/PostType/SiteSortables.php | 13 +- src/Attributes/PostType/Supports.php | 13 +- src/Attributes/PostType/Taxonomies.php | 13 +- src/Attributes/PostType/Template.php | 13 +- src/Attributes/PostType/TemplateLock.php | 13 +- src/Attributes/PostType/TitlePlaceholder.php | 13 +- src/Attributes/Support/Naming.php | 73 ++++++ src/Attributes/Taxonomy.php | 4 +- src/Attributes/Taxonomy/AllowHierarchy.php | 13 +- src/Attributes/Taxonomy/Args.php | 13 +- src/Attributes/Taxonomy/Capabilities.php | 13 +- src/Attributes/Taxonomy/CheckedOntop.php | 10 +- src/Attributes/Taxonomy/DefaultTerm.php | 13 +- src/Attributes/Taxonomy/Description.php | 13 +- src/Attributes/Taxonomy/Exclusive.php | 13 +- src/Attributes/Taxonomy/Hierarchical.php | 13 +- src/Attributes/Taxonomy/Label.php | 13 +- src/Attributes/Taxonomy/Labels.php | 12 +- src/Attributes/Taxonomy/MetaBoxCb.php | 24 +- src/Attributes/Taxonomy/MetaBoxSanitizeCb.php | 24 +- src/Attributes/Taxonomy/ObjectType.php | 13 +- src/Attributes/Taxonomy/Priority.php | 13 +- src/Attributes/Taxonomy/PublicTaxonomy.php | 13 +- src/Attributes/Taxonomy/QueryVar.php | 13 +- src/Attributes/Taxonomy/RestBase.php | 13 +- .../Taxonomy/RestControllerClass.php | 13 +- src/Attributes/Taxonomy/RestNamespace.php | 13 +- src/Attributes/Taxonomy/Rewrite.php | 13 +- src/Attributes/Taxonomy/ShowAdminColumn.php | 13 +- src/Attributes/Taxonomy/ShowInMenu.php | 13 +- src/Attributes/Taxonomy/ShowInNavMenus.php | 13 +- src/Attributes/Taxonomy/ShowInQuickEdit.php | 13 +- src/Attributes/Taxonomy/ShowInRest.php | 13 +- src/Attributes/Taxonomy/ShowTagcloud.php | 13 +- src/Attributes/Taxonomy/ShowUI.php | 13 +- src/Attributes/Taxonomy/Sort.php | 13 +- src/Attributes/Taxonomy/TaxonomyAttribute.php | 5 +- .../Taxonomy/UpdateCountCallback.php | 24 +- src/Attributes/WpCli.php | 4 +- src/Attributes/WpCli/Command.php | 4 +- src/Attributes/WpRestRoute.php | 20 +- src/Attributes/WpRestRoute/Method.php | 125 +---------- .../Services/PostTypeConfiguration.php | 10 + .../Services/PostTypeDiscovery.php | 21 +- src/Support/Slug.php | 45 +--- .../Services/TaxonomyConfiguration.php | 10 + .../Services/TaxonomyDiscovery.php | 21 +- .../Services/WpRestDiscovery.php | 21 +- .../Services/WpRestRouteRegistrar.php | 133 +++++++++++ .../Feature/Hook/Async/AsyncAttributeTest.php | 40 ---- tests/Feature/Hook/HookDiscoveryTest.php | 94 ++++++++ tests/Unit/Attributes/ActionTest.php | 187 ---------------- tests/Unit/Attributes/ArchitectureTest.php | 31 +++ tests/Unit/Attributes/FilterTest.php | 209 ------------------ tests/Unit/Attributes/HookTest.php | 12 +- tests/Unit/Attributes/LabelsAttributeTest.php | 13 +- tests/Unit/Attributes/NamingTest.php | 49 ++++ .../Unit/Attributes/TaxonomyAttributeTest.php | 15 +- tests/Unit/PostType/PostTypeDiscoveryTest.php | 114 ++++++++++ tests/Unit/Taxonomy/TaxonomyDiscoveryTest.php | 86 +++++++ tests/Unit/WpRest/CanPermissionTest.php | 6 +- .../Unit/WpRest/RestRequestInjectionTest.php | 8 +- 113 files changed, 1352 insertions(+), 1241 deletions(-) create mode 100644 src/Attributes/Contracts/ConfiguresArguments.php create mode 100644 src/Attributes/Contracts/ConfiguresArgumentsWithMethod.php create mode 100644 src/Attributes/Support/Naming.php create mode 100644 src/WpRest/Infrastructure/Services/WpRestRouteRegistrar.php create mode 100644 tests/Feature/Hook/HookDiscoveryTest.php delete mode 100644 tests/Unit/Attributes/ActionTest.php create mode 100644 tests/Unit/Attributes/ArchitectureTest.php delete mode 100644 tests/Unit/Attributes/FilterTest.php create mode 100644 tests/Unit/Attributes/NamingTest.php create mode 100644 tests/Unit/PostType/PostTypeDiscoveryTest.php create mode 100644 tests/Unit/Taxonomy/TaxonomyDiscoveryTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index c41f41b6..92321442 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,14 +10,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Discovery lives in its own package, [`pollora/discovery`](https://github.com/Pollora/discovery), which the framework requires: the engine, its contracts, `DiscoveryManager`, the cache and the `#[SkipDiscovery]` attribute, with their tests and benchmarks. Class names do not change. The framework keeps what needs Laravel: the service provider, `discovery:run` / `discovery:clear`, `DiscoveryRegistrar`, `ServiceProviderDiscovery`, the Laravel adapters, and `HasConfiguringSupport`, which builds post types and taxonomies - Discovery can run without Laravel's application, the first step towards `pollora/discovery`: `new DiscoveryEngine()` works with no argument. Two contracts let an environment plug in — `InstanceResolverInterface` builds the classes discovery needs (Laravel's container in a Pollora application, `ReflectionInstanceResolver` otherwise) and `DiscoveryRegistrarInterface` adds the environment's discoveries before each run (`DiscoveryRegistrar` keeps scanning the container's `*Discovery` bindings). `Psr16DiscoverCacheDriver` caches scans in any PSR-16 cache +- Post type and taxonomy attributes say what they register: each implements `ConfiguresArguments`, a pure function that returns the registration arguments with its own contribution (`#[HasArchive('library')]` sets `has_archive`, `#[Labels]` merges into the generated labels), and the method attributes (`#[AdminCol]`, `#[RegisterMetaBoxCb]`, `#[MetaBoxCb]`, `#[MetaBoxSanitizeCb]`, `#[UpdateCountCallback]`) implement `ConfiguresArgumentsWithMethod`, the discovery handing them the method as a callback. The `Pollora\Attributes` namespace no longer reaches Laravel or a framework service, which an architecture test keeps: the first step towards `pollora/attributes`, a package to declare attributes without the framework ### Changed - `DiscoveryEngine`, `DiscoveryCacheManager`, `InstancePool` and `ReflectionCache` take their dependencies instead of reaching for `app()` and `config()`; reading `structure-discoverer.cache` moved to `LaravelDiscoveryCacheManagerFactory`. `DiscoveryRegistrar::registerFromContainer()` is now `registerInto()`. These are infrastructure classes, outside the public contracts (`Pollora\Discovery\Domain\*`), which do not change - One `DiscoveryCacheManager` is shared by the engine and the `pollora:doctor` discovery cache check, which built its own - The discovery benchmarks measure memory as PHP allocates it, from a reset peak: they read 2 MB whatever the load +- `#[WpRestRoute\Method]` no longer registers its route: `WpRestRouteRegistrar`, in the WpRest module, does, with the same arguments, permissions and request injection. Default slugs, meta keys and command names come from `Pollora\Attributes\Support\Naming`, which gives the same results as `Str::kebab()` and `Str::snake()`; `Pollora\Support\Slug` uses it ### Deprecated - `DiscoveryCacheInterface` and `LaravelDiscoveryCache`, removed in 15.0: nothing reads this cache, scans are cached by the Spatie driver given to `DiscoveryCacheManager` +- `Pollora\Attributes\PostType\PostTypeAttribute` and `Pollora\Attributes\Taxonomy\TaxonomyAttribute`, removed in 15.0: no built-in attribute extends them any more; implement `ConfiguresArguments` + +### Removed +- `handle()` on `#[Action]`, `#[Filter]`, `#[WpRestRoute]` and `#[WpRestRoute\Method]`, and `Hook` no longer implements `HandlesAttributes`: `HookDiscovery` and `WpRestDiscovery` register hooks and routes themselves. Only the bug below reached `Action::handle()`, and `WpRestRoute::handle()` wrote to a read-only wrapper + +### Fixed +- A public `#[Action]` or `#[Filter]` method in a `#[PostType]` or `#[Taxonomy]` class is registered once: the post type and taxonomy discoveries also handed it to `Action::handle()`, which registered it a second time on the discovery's configuration object, so the page firing that hook failed with `call_user_func(): Argument #1 ($callback) must be a valid callback, class Pollora\PostType\Infrastructure\Services\PostTypeConfiguration does not have a method …` ## [v13.35.6](https://github.com/Pollora/framework/compare/v13.35.5...v13.35.6) - 2026-10-12 diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index f75c76db..c13e879f 100755 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -18,24 +18,6 @@ parameters: count: 1 path: src/Asset/Infrastructure/Services/ViteManager.php - - - message: '#^Access to an undefined property Pollora\\Attributes\\Attributable\:\:\$classPermission\.$#' - identifier: property.notFound - count: 1 - path: src/Attributes/WpRestRoute/Method.php - - - - message: '#^Access to an undefined property Pollora\\Attributes\\Attributable\:\:\$namespace\.$#' - identifier: property.notFound - count: 1 - path: src/Attributes/WpRestRoute/Method.php - - - - message: '#^Access to an undefined property Pollora\\Attributes\\Attributable\:\:\$route\.$#' - identifier: property.notFound - count: 2 - path: src/Attributes/WpRestRoute/Method.php - - message: '#^Constant AUTH_COOKIE not found\.$#' identifier: constant.notFound diff --git a/src/Attributes/Ability.php b/src/Attributes/Ability.php index 5d8dcc35..1367df84 100644 --- a/src/Attributes/Ability.php +++ b/src/Attributes/Ability.php @@ -5,16 +5,13 @@ namespace Pollora\Attributes; use Attribute; -use Pollora\Abilities\Domain\Contracts\AbilityHandler; use Pollora\Abilities\Domain\Model\Behaviour; -use Pollora\Ability\Infrastructure\Services\AbilityDiscovery; /** * Attribute for declarative WordPress ability registration. * - * Place this attribute on a class implementing {@see AbilityHandler}. The class - * is discovered and registered with the WordPress Abilities API through the - * {@see AbilityDiscovery} system, and published to AI clients — an MCP server, + * Place this attribute on a class implementing `AbilityHandler` (pollora/abilities). + * The class is discovered and registered with the WordPress Abilities API, and published to AI clients — an MCP server, * the core abilities REST controllers — by whatever consumes them. * * The attribute carries what the ability *is*; the handler carries what it @@ -33,8 +30,7 @@ * The category must exist. Declare it once — in a service provider, through the * `Ability` facade — before the abilities that file under it. * - * @see AbilityHandler The interface the decorated class implements. - * @see Behaviour How the ability's effect on the site is described to clients. + * @see Behaviour How the ability's effect on the site is described to clients. */ #[Attribute(Attribute::TARGET_CLASS)] class Ability diff --git a/src/Attributes/Action.php b/src/Attributes/Action.php index fad73f62..39987ebc 100755 --- a/src/Attributes/Action.php +++ b/src/Attributes/Action.php @@ -5,12 +5,6 @@ namespace Pollora\Attributes; use Attribute; -use Pollora\Hook\Application\Services\AsyncDeclarationFailures; -use Pollora\Hook\Domain\Contract\Action as ActionService; -use Pollora\Hook\Infrastructure\Services\AsyncAttributeRegistrar; -use Psr\Log\LoggerInterface; -use ReflectionClass; -use ReflectionMethod; /** * Class Action @@ -19,62 +13,4 @@ * This class is used to define an action hook in WordPress. */ #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] -class Action extends Hook -{ - /** - * Handle the attribute processing. - * - * @param object $serviceLocator Service locator used to resolve dependencies - * @param object $instance The instance being processed - * @param ReflectionMethod|ReflectionClass $context The reflection context - * @param object $attribute The attribute instance - */ - public function handle( - $serviceLocator, - object $instance, - ReflectionMethod|ReflectionClass $context, - object $attribute, - ): void { - // Retrieve the Action service from the locator - $actionService = $serviceLocator->get(ActionService::class); - if (! $actionService) { - return; - } - - // #[Action] targets methods only - if (! $context instanceof ReflectionMethod) { - return; - } - - // Asynchronously when the method or its class carries #[Async] - $logger = $this->optional($serviceLocator, LoggerInterface::class); - $failures = $this->optional($serviceLocator, AsyncDeclarationFailures::class); - - (new AsyncAttributeRegistrar($actionService, $logger, $failures))->register( - $attribute->hook, - $instance, - $context, - $attribute->priority, - $context->getNumberOfParameters() - ); - } - - /** - * A service the service locator may not provide: the logger, the record of ignored #[Async]. - * - * @template T of object - * - * @param class-string $class - * @return T|null - */ - private function optional(object $serviceLocator, string $class): ?object - { - try { - $service = $serviceLocator->get($class); - } catch (\Throwable) { - return null; - } - - return $service instanceof $class ? $service : null; - } -} +class Action extends Hook {} diff --git a/src/Attributes/Ajax.php b/src/Attributes/Ajax.php index 5dd17909..d4880a3f 100644 --- a/src/Attributes/Ajax.php +++ b/src/Attributes/Ajax.php @@ -6,14 +6,12 @@ use Attribute; use Pollora\Ajax\Domain\Model\AjaxAccess; -use Pollora\Ajax\Infrastructure\Services\AjaxDiscovery; /** * Attribute for declarative WordPress AJAX action registration. * * Place this attribute on a public method to register it as a WordPress AJAX - * handler. The method is automatically discovered and wired via the - * {@see AjaxDiscovery} system. + * handler. The method is discovered and wired automatically. * * By default, the action is restricted to **logged-in users** (security-by-default). * Use the `access` parameter to change targeting. diff --git a/src/Attributes/Contracts/ConfiguresArguments.php b/src/Attributes/Contracts/ConfiguresArguments.php new file mode 100644 index 00000000..139c560f --- /dev/null +++ b/src/Attributes/Contracts/ConfiguresArguments.php @@ -0,0 +1,24 @@ + $arguments The arguments gathered so far + * @return array + */ + public function configureArguments(array $arguments): array; +} diff --git a/src/Attributes/Contracts/ConfiguresArgumentsWithMethod.php b/src/Attributes/Contracts/ConfiguresArgumentsWithMethod.php new file mode 100644 index 00000000..934c04ce --- /dev/null +++ b/src/Attributes/Contracts/ConfiguresArgumentsWithMethod.php @@ -0,0 +1,24 @@ + $arguments The arguments gathered so far + * @param callable $method The decorated method, bound to an instance of its class + * @return array + */ + public function configureArguments(array $arguments, callable $method): array; +} diff --git a/src/Attributes/Filter.php b/src/Attributes/Filter.php index 2722e8b0..9f33c92f 100755 --- a/src/Attributes/Filter.php +++ b/src/Attributes/Filter.php @@ -5,9 +5,6 @@ namespace Pollora\Attributes; use Attribute; -use Pollora\Hook\Domain\Contract\Filter as FilterService; -use ReflectionClass; -use ReflectionMethod; /** * Class Filter @@ -16,33 +13,4 @@ * This class is used to define a filter hook in WordPress. */ #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] -class Filter extends Hook -{ - /** - * Handle the attribute processing. - * - * @param object $serviceLocator Service locator used to resolve dependencies - * @param object $instance The instance being processed - * @param ReflectionMethod|ReflectionClass $context The reflection context - * @param object $attribute The attribute instance - */ - public function handle( - $serviceLocator, - object $instance, - ReflectionMethod|ReflectionClass $context, - object $attribute - ): void { - // Retrieve the Filter service from the locator - $filterService = $serviceLocator->get(FilterService::class); - if (! $filterService) { - return; - } - - $filterService->add( - $attribute->hook, - [$instance, $context->getName()], - $attribute->priority, - $context->getNumberOfParameters() - ); - } -} +class Filter extends Hook {} diff --git a/src/Attributes/Hook.php b/src/Attributes/Hook.php index 511667ec..7f550c0a 100755 --- a/src/Attributes/Hook.php +++ b/src/Attributes/Hook.php @@ -4,15 +4,13 @@ namespace Pollora\Attributes; -use Pollora\Attributes\Contracts\HandlesAttributes; - /** * Class Hook * * Abstract base class for WordPress hook attributes. * Provides common properties for hook attributes. */ -abstract class Hook implements HandlesAttributes +abstract class Hook { /** * Constructor for the Hook class. diff --git a/src/Attributes/Meta.php b/src/Attributes/Meta.php index e9262f8d..d0acaa7e 100644 --- a/src/Attributes/Meta.php +++ b/src/Attributes/Meta.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes; use Attribute; -use Illuminate\Support\Str; +use Pollora\Attributes\Support\Naming; use Pollora\Meta\Domain\Enums\Control; /** @@ -71,6 +71,6 @@ public function __construct( */ public function resolveKey(string $property): string { - return $this->key ?? Str::snake($property); + return $this->key ?? Naming::snake($property); } } diff --git a/src/Attributes/PostType.php b/src/Attributes/PostType.php index 80ba791f..94fac8ed 100644 --- a/src/Attributes/PostType.php +++ b/src/Attributes/PostType.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes; use Attribute; -use Illuminate\Support\Str; +use Pollora\Attributes\Support\Naming; /** * PostType Attribute @@ -51,6 +51,6 @@ public function __construct( */ public function resolveSlug(string $className): string { - return $this->slug ?? substr(Str::kebab(class_basename($className)), 0, 20); + return $this->slug ?? substr(Naming::kebab(Naming::classBasename($className)), 0, 20); } } diff --git a/src/Attributes/PostType/AdminCol.php b/src/Attributes/PostType/AdminCol.php index 645c882e..d5696bd3 100755 --- a/src/Attributes/PostType/AdminCol.php +++ b/src/Attributes/PostType/AdminCol.php @@ -5,10 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\Attributes\Contracts\HandlesAttributes; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; -use ReflectionClass; -use ReflectionMethod; +use Pollora\Attributes\Contracts\ConfiguresArgumentsWithMethod; /** * Attribute to define an admin column for a post type. @@ -19,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_METHOD)] -class AdminCol implements HandlesAttributes +class AdminCol implements ConfiguresArgumentsWithMethod { /** * Constructor. @@ -57,32 +54,20 @@ public function __construct( ) {} /** - * Handle the attribute processing. + * Add this column to the admin_cols argument, rendered by the decorated method. * - * @param object $container The container instance - * @param object $instance The instance being processed - * @param ReflectionMethod|ReflectionClass $context The method the attribute is applied to - * @param object $attribute The attribute instance + * @param array $arguments The registration arguments gathered so far + * @param callable $method The decorated method + * @return array */ - public function handle($container, object $instance, ReflectionMethod|ReflectionClass $context, object $attribute): void + public function configureArguments(array $arguments, callable $method): array { - if (! $instance instanceof PostTypeAttributeInterface) { - return; - } - - // Initialize admin_cols array if it doesn't exist - $adminCols = $instance->getArg('admin_cols', []); - // Build column configuration based on extended-cpts options $columnConfig = [ 'title' => $this->title, + 'function' => $method, ]; - // Add custom function callback if this is a method-level attribute - if ($context instanceof ReflectionMethod) { - $columnConfig['function'] = [new $context->class, $context->getName()]; - } - // Add optional configuration based on extended-cpts features if ($this->titleIcon !== null) { $columnConfig['title_icon'] = $this->titleIcon; @@ -133,8 +118,8 @@ public function handle($container, object $instance, ReflectionMethod|Reflection $columnConfig['sortable'] = $this->sortable; } - // Add the column to the admin_cols array - $adminCols[$this->key] = $columnConfig; - $instance->setArg('admin_cols', $adminCols); + $arguments['admin_cols'] = [...$arguments['admin_cols'] ?? [], $this->key => $columnConfig]; + + return $arguments; } } diff --git a/src/Attributes/PostType/AdminCols.php b/src/Attributes/PostType/AdminCols.php index ac92f1ba..2b43b9e2 100755 --- a/src/Attributes/PostType/AdminCols.php +++ b/src/Attributes/PostType/AdminCols.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the admin_cols parameter for a post type. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class AdminCols extends PostTypeAttribute +class AdminCols implements ConfiguresArguments { /** * Constructor. @@ -30,10 +30,13 @@ public function __construct( /** * Configure the post type with the admin_cols parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('admin_cols', $this->value); + $arguments['admin_cols'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/AdminFilters.php b/src/Attributes/PostType/AdminFilters.php index 6c6de4d1..4fbf2818 100755 --- a/src/Attributes/PostType/AdminFilters.php +++ b/src/Attributes/PostType/AdminFilters.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the admin_filters parameter for a post type. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class AdminFilters extends PostTypeAttribute +class AdminFilters implements ConfiguresArguments { /** * Constructor. @@ -30,10 +30,13 @@ public function __construct( /** * Configure the post type with the admin_filters parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('admin_filters', $this->value); + $arguments['admin_filters'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/Archive.php b/src/Attributes/PostType/Archive.php index d65294de..3d081ac4 100755 --- a/src/Attributes/PostType/Archive.php +++ b/src/Attributes/PostType/Archive.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the archive parameter for a post type. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class Archive extends PostTypeAttribute +class Archive implements ConfiguresArguments { /** * Constructor. @@ -30,10 +30,13 @@ public function __construct( /** * Configure the post type with the archive parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('archive', $this->value); + $arguments['archive'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/BlockEditor.php b/src/Attributes/PostType/BlockEditor.php index 145fdf1b..cdfc9f2b 100755 --- a/src/Attributes/PostType/BlockEditor.php +++ b/src/Attributes/PostType/BlockEditor.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the block_editor parameter for a post type. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class BlockEditor extends PostTypeAttribute +class BlockEditor implements ConfiguresArguments { /** * Constructor. @@ -30,10 +30,13 @@ public function __construct( /** * Configure the post type with the block_editor parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('show_in_rest', $this->value); + $arguments['show_in_rest'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/CanExport.php b/src/Attributes/PostType/CanExport.php index 73199a2c..7f1f5d76 100755 --- a/src/Attributes/PostType/CanExport.php +++ b/src/Attributes/PostType/CanExport.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the can_export parameter for a post type. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class CanExport extends PostTypeAttribute +class CanExport implements ConfiguresArguments { /** * Constructor. @@ -30,10 +30,13 @@ public function __construct( /** * Configure the post type with the can_export parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('can_export', $this->value); + $arguments['can_export'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/Capabilities.php b/src/Attributes/PostType/Capabilities.php index 7fc3e65d..b3459422 100755 --- a/src/Attributes/PostType/Capabilities.php +++ b/src/Attributes/PostType/Capabilities.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the capabilities parameter for a post type. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class Capabilities extends PostTypeAttribute +class Capabilities implements ConfiguresArguments { /** * Constructor. @@ -30,10 +30,13 @@ public function __construct( /** * Configure the post type with the capabilities parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('capabilities', $this->value); + $arguments['capabilities'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/CapabilityType.php b/src/Attributes/PostType/CapabilityType.php index 886630d6..e7196eca 100755 --- a/src/Attributes/PostType/CapabilityType.php +++ b/src/Attributes/PostType/CapabilityType.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the capability_type parameter for a post type. @@ -21,7 +21,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class CapabilityType extends PostTypeAttribute +class CapabilityType implements ConfiguresArguments { /** * Constructor. @@ -35,10 +35,13 @@ public function __construct( /** * Configure the post type with the capability_type parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('capability_type', $this->value); + $arguments['capability_type'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/Chronological.php b/src/Attributes/PostType/Chronological.php index 10065f3b..728fe084 100755 --- a/src/Attributes/PostType/Chronological.php +++ b/src/Attributes/PostType/Chronological.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the chronological parameter for a post type. @@ -17,7 +17,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class Chronological extends PostTypeAttribute +class Chronological implements ConfiguresArguments { /** * Constructor. @@ -31,14 +31,17 @@ public function __construct( /** * Configure the post type with the chronological parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { if ($this->value) { // Set default ordering to date, descending - $postType->setArg('orderby', 'date'); - $postType->setArg('order', 'DESC'); + $arguments['orderby'] = 'date'; + $arguments['order'] = 'DESC'; } + + return $arguments; } } diff --git a/src/Attributes/PostType/DashboardActivity.php b/src/Attributes/PostType/DashboardActivity.php index 97ae5c52..a808f98a 100755 --- a/src/Attributes/PostType/DashboardActivity.php +++ b/src/Attributes/PostType/DashboardActivity.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the dashboard_activity parameter for a post type. @@ -17,7 +17,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class DashboardActivity extends PostTypeAttribute +class DashboardActivity implements ConfiguresArguments { /** * Constructor. @@ -31,10 +31,13 @@ public function __construct( /** * Configure the post type with the dashboard_activity parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('dashboard_activity', $this->value); + $arguments['dashboard_activity'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/DashboardGlance.php b/src/Attributes/PostType/DashboardGlance.php index 99a0e08f..81df2cfa 100755 --- a/src/Attributes/PostType/DashboardGlance.php +++ b/src/Attributes/PostType/DashboardGlance.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the dashboard_glance parameter for a post type. @@ -15,7 +15,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class DashboardGlance extends PostTypeAttribute +class DashboardGlance implements ConfiguresArguments { /** * Constructor. @@ -29,10 +29,13 @@ public function __construct( /** * Configure the post type with the dashboard_glance parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('dashboard_glance', $this->value); + $arguments['dashboard_glance'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/DeleteWithUser.php b/src/Attributes/PostType/DeleteWithUser.php index b762aeed..2abeb23c 100755 --- a/src/Attributes/PostType/DeleteWithUser.php +++ b/src/Attributes/PostType/DeleteWithUser.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the delete_with_user parameter for a post type. @@ -17,7 +17,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class DeleteWithUser extends PostTypeAttribute +class DeleteWithUser implements ConfiguresArguments { /** * Constructor. @@ -31,10 +31,13 @@ public function __construct( /** * Configure the post type with the delete_with_user parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('delete_with_user', $this->value); + $arguments['delete_with_user'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/Description.php b/src/Attributes/PostType/Description.php index 7ba770eb..6654747f 100755 --- a/src/Attributes/PostType/Description.php +++ b/src/Attributes/PostType/Description.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the description parameter for a post type. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class Description extends PostTypeAttribute +class Description implements ConfiguresArguments { /** * Constructor. @@ -30,10 +30,13 @@ public function __construct( /** * Configure the post type with the description parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('description', $this->value); + $arguments['description'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/ExcludeFromSearch.php b/src/Attributes/PostType/ExcludeFromSearch.php index 69e75e60..a6cb5d53 100755 --- a/src/Attributes/PostType/ExcludeFromSearch.php +++ b/src/Attributes/PostType/ExcludeFromSearch.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the exclude_from_search parameter for a post type. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class ExcludeFromSearch extends PostTypeAttribute +class ExcludeFromSearch implements ConfiguresArguments { /** * Constructor. @@ -30,10 +30,13 @@ public function __construct( /** * Configure the post type with the exclude_from_search parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('exclude_from_search', $this->value); + $arguments['exclude_from_search'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/FeaturedImage.php b/src/Attributes/PostType/FeaturedImage.php index 8e8c8daa..d2af7006 100755 --- a/src/Attributes/PostType/FeaturedImage.php +++ b/src/Attributes/PostType/FeaturedImage.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the featured_image parameter for a post type. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class FeaturedImage extends PostTypeAttribute +class FeaturedImage implements ConfiguresArguments { /** * Constructor. @@ -30,10 +30,13 @@ public function __construct( /** * Configure the post type with the featured_image parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('featured_image', $this->value); + $arguments['featured_image'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/HasArchive.php b/src/Attributes/PostType/HasArchive.php index cc2c7786..59befcda 100755 --- a/src/Attributes/PostType/HasArchive.php +++ b/src/Attributes/PostType/HasArchive.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the has_archive parameter for a post type. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class HasArchive extends PostTypeAttribute +class HasArchive implements ConfiguresArguments { /** * Constructor. @@ -30,10 +30,13 @@ public function __construct( /** * Configure the post type with the has_archive parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('has_archive', $this->value); + $arguments['has_archive'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/Hierarchical.php b/src/Attributes/PostType/Hierarchical.php index f7e2610e..b4e085b8 100755 --- a/src/Attributes/PostType/Hierarchical.php +++ b/src/Attributes/PostType/Hierarchical.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the hierarchical parameter for a post type. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class Hierarchical extends PostTypeAttribute +class Hierarchical implements ConfiguresArguments { /** * Constructor. @@ -30,10 +30,13 @@ public function __construct( /** * Configure the post type with the hierarchical parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('hierarchical', $this->value); + $arguments['hierarchical'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/Label.php b/src/Attributes/PostType/Label.php index 33720b71..b9bbb22a 100755 --- a/src/Attributes/PostType/Label.php +++ b/src/Attributes/PostType/Label.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the label parameter for a post type. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class Label extends PostTypeAttribute +class Label implements ConfiguresArguments { /** * Constructor. @@ -30,10 +30,13 @@ public function __construct( /** * Configure the post type with the label parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('label', $this->value); + $arguments['label'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/Labels.php b/src/Attributes/PostType/Labels.php index 026fd71b..0ac367ff 100755 --- a/src/Attributes/PostType/Labels.php +++ b/src/Attributes/PostType/Labels.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set custom labels for a post type. @@ -23,7 +23,7 @@ * For runtime translations, use withArgs() in the class instead. */ #[Attribute(Attribute::TARGET_CLASS)] -class Labels extends PostTypeAttribute +class Labels implements ConfiguresArguments { /** @var array */ private readonly array $labels; @@ -92,9 +92,11 @@ public function __construct( ], fn (?string $v): bool => $v !== null); } - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $existing = $postType->getArg('labels', []); - $postType->setArg('labels', array_merge($existing, $this->labels)); + $existing = ($arguments['labels'] ?? []); + $arguments['labels'] = array_merge($existing, $this->labels); + + return $arguments; } } diff --git a/src/Attributes/PostType/MapMetaCap.php b/src/Attributes/PostType/MapMetaCap.php index 1dea4737..04b1ff39 100755 --- a/src/Attributes/PostType/MapMetaCap.php +++ b/src/Attributes/PostType/MapMetaCap.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the map_meta_cap parameter for a post type. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class MapMetaCap extends PostTypeAttribute +class MapMetaCap implements ConfiguresArguments { /** * Constructor. @@ -30,10 +30,13 @@ public function __construct( /** * Configure the post type with the map_meta_cap parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('map_meta_cap', $this->value); + $arguments['map_meta_cap'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/MenuIcon.php b/src/Attributes/PostType/MenuIcon.php index 7450e6ea..f2218cdc 100755 --- a/src/Attributes/PostType/MenuIcon.php +++ b/src/Attributes/PostType/MenuIcon.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the menu_icon parameter for a post type. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class MenuIcon extends PostTypeAttribute +class MenuIcon implements ConfiguresArguments { /** * Constructor. @@ -30,10 +30,13 @@ public function __construct( /** * Configure the post type with the menu_icon parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('menu_icon', $this->value); + $arguments['menu_icon'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/MenuPosition.php b/src/Attributes/PostType/MenuPosition.php index 41cb7b1c..0952d950 100755 --- a/src/Attributes/PostType/MenuPosition.php +++ b/src/Attributes/PostType/MenuPosition.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the menu_position parameter for a post type. @@ -27,7 +27,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class MenuPosition extends PostTypeAttribute +class MenuPosition implements ConfiguresArguments { /** * Constructor. @@ -41,10 +41,13 @@ public function __construct( /** * Configure the post type with the menu_position parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('menu_position', $this->value); + $arguments['menu_position'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/PostTypeAttribute.php b/src/Attributes/PostType/PostTypeAttribute.php index 0ecb963f..ef612c31 100755 --- a/src/Attributes/PostType/PostTypeAttribute.php +++ b/src/Attributes/PostType/PostTypeAttribute.php @@ -5,6 +5,7 @@ namespace Pollora\Attributes\PostType; use Pollora\Attributes\Attributable; +use Pollora\Attributes\Contracts\ConfiguresArguments; use Pollora\Attributes\Contracts\HandlesAttributes; use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; use ReflectionClass; @@ -16,7 +17,9 @@ * This attribute serves as the base for all post type configuration attributes. * It provides common functionality for handling post type registration arguments. * - * @Attribute + * @deprecated since 13.36, removed in 15.0. Implement + * {@see ConfiguresArguments}, as the + * built-in attributes now do. */ abstract class PostTypeAttribute implements HandlesAttributes { diff --git a/src/Attributes/PostType/Priority.php b/src/Attributes/PostType/Priority.php index a1176bb5..f416ca56 100755 --- a/src/Attributes/PostType/Priority.php +++ b/src/Attributes/PostType/Priority.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the priority a post type declaration. @@ -13,7 +13,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class Priority extends PostTypeAttribute +class Priority implements ConfiguresArguments { /** * Constructor. @@ -25,10 +25,13 @@ public function __construct( /** * Configure the post type priority declaration parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('priority', $this->priority); + $arguments['priority'] = $this->priority; + + return $arguments; } } diff --git a/src/Attributes/PostType/PublicPostType.php b/src/Attributes/PostType/PublicPostType.php index fdcc04a6..53b905c4 100755 --- a/src/Attributes/PostType/PublicPostType.php +++ b/src/Attributes/PostType/PublicPostType.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the public parameter for a post type. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class PublicPostType extends PostTypeAttribute +class PublicPostType implements ConfiguresArguments { /** * Constructor. @@ -30,10 +30,13 @@ public function __construct( /** * Configure the post type with the public parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('public', $this->value); + $arguments['public'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/PubliclyQueryable.php b/src/Attributes/PostType/PubliclyQueryable.php index 1957ecbe..42c5b3e5 100755 --- a/src/Attributes/PostType/PubliclyQueryable.php +++ b/src/Attributes/PostType/PubliclyQueryable.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the publicly_queryable parameter for a post type. @@ -15,7 +15,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class PubliclyQueryable extends PostTypeAttribute +class PubliclyQueryable implements ConfiguresArguments { /** * Constructor. @@ -29,10 +29,13 @@ public function __construct( /** * Configure the post type with the publicly_queryable parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('publicly_queryable', $this->value); + $arguments['publicly_queryable'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/QueryVar.php b/src/Attributes/PostType/QueryVar.php index d70da40e..54091c73 100755 --- a/src/Attributes/PostType/QueryVar.php +++ b/src/Attributes/PostType/QueryVar.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the query_var parameter for a post type. @@ -18,7 +18,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class QueryVar extends PostTypeAttribute +class QueryVar implements ConfiguresArguments { /** * Constructor. @@ -32,10 +32,13 @@ public function __construct( /** * Configure the post type with the query_var parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('query_var', $this->value); + $arguments['query_var'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/QuickEdit.php b/src/Attributes/PostType/QuickEdit.php index bbc2cc47..9a7cbe77 100755 --- a/src/Attributes/PostType/QuickEdit.php +++ b/src/Attributes/PostType/QuickEdit.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the quick_edit parameter for a post type. @@ -17,7 +17,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class QuickEdit extends PostTypeAttribute +class QuickEdit implements ConfiguresArguments { /** * Constructor. @@ -31,10 +31,13 @@ public function __construct( /** * Configure the post type with the quick_edit parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('quick_edit', $this->value); + $arguments['quick_edit'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/RegisterMetaBoxCb.php b/src/Attributes/PostType/RegisterMetaBoxCb.php index 40e66c41..8daff699 100755 --- a/src/Attributes/PostType/RegisterMetaBoxCb.php +++ b/src/Attributes/PostType/RegisterMetaBoxCb.php @@ -5,10 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\Attributes\Contracts\HandlesAttributes; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; -use ReflectionClass; -use ReflectionMethod; +use Pollora\Attributes\Contracts\ConfiguresArgumentsWithMethod; /** * Attribute to define a callback function that will be called when setting up meta boxes for the edit form. @@ -21,22 +18,19 @@ * @Attribute */ #[Attribute(Attribute::TARGET_METHOD)] -class RegisterMetaBoxCb implements HandlesAttributes +class RegisterMetaBoxCb implements ConfiguresArgumentsWithMethod { /** - * Handle the attribute processing. + * Set the register_meta_box_cb argument to the decorated method. * - * @param object $instance The instance being processed - * @param ReflectionMethod|ReflectionClass $context The method the attribute is applied to - * @param object $attribute The attribute instance + * @param array $arguments The registration arguments gathered so far + * @param callable $method The decorated method + * @return array */ - public function handle($container, object $instance, ReflectionMethod|ReflectionClass $context, object $attribute): void + public function configureArguments(array $arguments, callable $method): array { - if (! $instance instanceof PostTypeAttributeInterface) { - return; - } + $arguments['register_meta_box_cb'] = $method; - // Set the register_meta_box_cb parameter to the method - $instance->setArg('register_meta_box_cb', [new $context->class, $context->getName()]); + return $arguments; } } diff --git a/src/Attributes/PostType/RestBase.php b/src/Attributes/PostType/RestBase.php index 0dff0967..8ca0e7fd 100755 --- a/src/Attributes/PostType/RestBase.php +++ b/src/Attributes/PostType/RestBase.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the rest_base parameter for a post type. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class RestBase extends PostTypeAttribute +class RestBase implements ConfiguresArguments { /** * Constructor. @@ -30,10 +30,13 @@ public function __construct( /** * Configure the post type with the rest_base parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('rest_base', $this->value); + $arguments['rest_base'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/RestControllerClass.php b/src/Attributes/PostType/RestControllerClass.php index ac8ba8a6..d3d9004c 100755 --- a/src/Attributes/PostType/RestControllerClass.php +++ b/src/Attributes/PostType/RestControllerClass.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the rest_controller_class parameter for a post type. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class RestControllerClass extends PostTypeAttribute +class RestControllerClass implements ConfiguresArguments { /** * Constructor. @@ -30,10 +30,13 @@ public function __construct( /** * Configure the post type with the rest_controller_class parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('rest_controller_class', $this->value); + $arguments['rest_controller_class'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/RestNamespace.php b/src/Attributes/PostType/RestNamespace.php index 77244233..1eae5ba9 100755 --- a/src/Attributes/PostType/RestNamespace.php +++ b/src/Attributes/PostType/RestNamespace.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the rest_namespace parameter for a post type. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class RestNamespace extends PostTypeAttribute +class RestNamespace implements ConfiguresArguments { /** * Constructor. @@ -30,10 +30,13 @@ public function __construct( /** * Configure the post type with the rest_namespace parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('rest_namespace', $this->value); + $arguments['rest_namespace'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/Rewrite.php b/src/Attributes/PostType/Rewrite.php index 122e1c0d..1fa15cfb 100755 --- a/src/Attributes/PostType/Rewrite.php +++ b/src/Attributes/PostType/Rewrite.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the rewrite parameter for a post type. @@ -18,7 +18,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class Rewrite extends PostTypeAttribute +class Rewrite implements ConfiguresArguments { /** * Constructor. @@ -32,10 +32,13 @@ public function __construct( /** * Configure the post type with the rewrite parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('rewrite', $this->value); + $arguments['rewrite'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/ShowInAdminBar.php b/src/Attributes/PostType/ShowInAdminBar.php index 99cf0690..cdcf1aa8 100755 --- a/src/Attributes/PostType/ShowInAdminBar.php +++ b/src/Attributes/PostType/ShowInAdminBar.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the show_in_admin_bar parameter for a post type. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class ShowInAdminBar extends PostTypeAttribute +class ShowInAdminBar implements ConfiguresArguments { /** * Constructor. @@ -30,10 +30,13 @@ public function __construct( /** * Configure the post type with the show_in_admin_bar parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('show_in_admin_bar', $this->value); + $arguments['show_in_admin_bar'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/ShowInFeed.php b/src/Attributes/PostType/ShowInFeed.php index b7cc0ce0..129b9f2b 100755 --- a/src/Attributes/PostType/ShowInFeed.php +++ b/src/Attributes/PostType/ShowInFeed.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the show_in_feed parameter for a post type. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class ShowInFeed extends PostTypeAttribute +class ShowInFeed implements ConfiguresArguments { /** * Constructor. @@ -30,10 +30,13 @@ public function __construct( /** * Configure the post type with the show_in_feed parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('show_in_feed', $this->value); + $arguments['show_in_feed'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/ShowInMenu.php b/src/Attributes/PostType/ShowInMenu.php index fd2371fa..b9724ac2 100755 --- a/src/Attributes/PostType/ShowInMenu.php +++ b/src/Attributes/PostType/ShowInMenu.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the show_in_menu parameter for a post type. @@ -18,7 +18,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class ShowInMenu extends PostTypeAttribute +class ShowInMenu implements ConfiguresArguments { /** * Constructor. @@ -32,10 +32,13 @@ public function __construct( /** * Configure the post type with the show_in_menu parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('show_in_menu', $this->value); + $arguments['show_in_menu'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/ShowInNavMenus.php b/src/Attributes/PostType/ShowInNavMenus.php index c42b0f97..6dd21be9 100755 --- a/src/Attributes/PostType/ShowInNavMenus.php +++ b/src/Attributes/PostType/ShowInNavMenus.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the show_in_nav_menus parameter for a post type. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class ShowInNavMenus extends PostTypeAttribute +class ShowInNavMenus implements ConfiguresArguments { /** * Constructor. @@ -30,10 +30,13 @@ public function __construct( /** * Configure the post type with the show_in_nav_menus parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('show_in_nav_menus', $this->value); + $arguments['show_in_nav_menus'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/ShowInRest.php b/src/Attributes/PostType/ShowInRest.php index 81527a44..f83f97f2 100755 --- a/src/Attributes/PostType/ShowInRest.php +++ b/src/Attributes/PostType/ShowInRest.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the show_in_rest parameter for a post type. @@ -17,7 +17,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class ShowInRest extends PostTypeAttribute +class ShowInRest implements ConfiguresArguments { /** * Constructor. @@ -31,10 +31,13 @@ public function __construct( /** * Configure the post type with the show_in_rest parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('show_in_rest', $this->value); + $arguments['show_in_rest'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/ShowUI.php b/src/Attributes/PostType/ShowUI.php index eda88522..74c8eb82 100755 --- a/src/Attributes/PostType/ShowUI.php +++ b/src/Attributes/PostType/ShowUI.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the show_ui parameter for a post type. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class ShowUI extends PostTypeAttribute +class ShowUI implements ConfiguresArguments { /** * Constructor. @@ -30,10 +30,13 @@ public function __construct( /** * Configure the post type with the show_ui parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('show_ui', $this->value); + $arguments['show_ui'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/SiteFilters.php b/src/Attributes/PostType/SiteFilters.php index cc7ff9ab..a8b52288 100755 --- a/src/Attributes/PostType/SiteFilters.php +++ b/src/Attributes/PostType/SiteFilters.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the site_filters parameter for a post type. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class SiteFilters extends PostTypeAttribute +class SiteFilters implements ConfiguresArguments { /** * Constructor. @@ -30,10 +30,13 @@ public function __construct( /** * Configure the post type with the site_filters parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('site_filters', $this->value); + $arguments['site_filters'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/SiteSortables.php b/src/Attributes/PostType/SiteSortables.php index 0462fa75..772c99c3 100755 --- a/src/Attributes/PostType/SiteSortables.php +++ b/src/Attributes/PostType/SiteSortables.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the site_sortables parameter for a post type. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class SiteSortables extends PostTypeAttribute +class SiteSortables implements ConfiguresArguments { /** * Constructor. @@ -30,10 +30,13 @@ public function __construct( /** * Configure the post type with the site_sortables parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('site_sortables', $this->value); + $arguments['site_sortables'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/Supports.php b/src/Attributes/PostType/Supports.php index 5ed03d98..9fe01558 100755 --- a/src/Attributes/PostType/Supports.php +++ b/src/Attributes/PostType/Supports.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the supports parameter for a post type. @@ -15,7 +15,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class Supports extends PostTypeAttribute +class Supports implements ConfiguresArguments { /** * Constructor. @@ -29,10 +29,13 @@ public function __construct( /** * Configure the post type with the supports parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('supports', $this->features); + $arguments['supports'] = $this->features; + + return $arguments; } } diff --git a/src/Attributes/PostType/Taxonomies.php b/src/Attributes/PostType/Taxonomies.php index ec1fe97a..a4908587 100755 --- a/src/Attributes/PostType/Taxonomies.php +++ b/src/Attributes/PostType/Taxonomies.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the taxonomies parameter for a post type. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class Taxonomies extends PostTypeAttribute +class Taxonomies implements ConfiguresArguments { /** * Constructor. @@ -30,10 +30,13 @@ public function __construct( /** * Configure the post type with the taxonomies parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('taxonomies', $this->value); + $arguments['taxonomies'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/Template.php b/src/Attributes/PostType/Template.php index a4582bb8..ac33e809 100755 --- a/src/Attributes/PostType/Template.php +++ b/src/Attributes/PostType/Template.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the template parameter for a post type. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class Template extends PostTypeAttribute +class Template implements ConfiguresArguments { /** * Constructor. @@ -30,10 +30,13 @@ public function __construct( /** * Configure the post type with the template parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('template', $this->value); + $arguments['template'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/TemplateLock.php b/src/Attributes/PostType/TemplateLock.php index a06cb614..442afb56 100755 --- a/src/Attributes/PostType/TemplateLock.php +++ b/src/Attributes/PostType/TemplateLock.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the template_lock parameter for a post type. @@ -18,7 +18,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class TemplateLock extends PostTypeAttribute +class TemplateLock implements ConfiguresArguments { /** * Constructor. @@ -32,10 +32,13 @@ public function __construct( /** * Configure the post type with the template_lock parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('template_lock', $this->value); + $arguments['template_lock'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/PostType/TitlePlaceholder.php b/src/Attributes/PostType/TitlePlaceholder.php index 26418f29..c3ccd2b0 100755 --- a/src/Attributes/PostType/TitlePlaceholder.php +++ b/src/Attributes/PostType/TitlePlaceholder.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\PostType; use Attribute; -use Pollora\PostType\Domain\Contracts\PostTypeAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the title_placeholder parameter for a post type. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class TitlePlaceholder extends PostTypeAttribute +class TitlePlaceholder implements ConfiguresArguments { /** * Constructor. @@ -30,10 +30,13 @@ public function __construct( /** * Configure the post type with the title_placeholder parameter. * - * @param PostTypeAttributeInterface $postType The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(PostTypeAttributeInterface $postType): void + public function configureArguments(array $arguments): array { - $postType->setArg('title_placeholder', $this->value); + $arguments['title_placeholder'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/Support/Naming.php b/src/Attributes/Support/Naming.php new file mode 100644 index 00000000..0640640e --- /dev/null +++ b/src/Attributes/Support/Naming.php @@ -0,0 +1,73 @@ +slug ?? substr(Str::kebab(class_basename($className)), 0, 20); + return $this->slug ?? substr(Naming::kebab(Naming::classBasename($className)), 0, 20); } } diff --git a/src/Attributes/Taxonomy/AllowHierarchy.php b/src/Attributes/Taxonomy/AllowHierarchy.php index d9eff1ea..ebdee900 100755 --- a/src/Attributes/Taxonomy/AllowHierarchy.php +++ b/src/Attributes/Taxonomy/AllowHierarchy.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Attribute; -use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set whether to allow hierarchy in the taxonomy's rewrite rules. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class AllowHierarchy extends TaxonomyAttribute +class AllowHierarchy implements ConfiguresArguments { /** * Constructor. @@ -30,10 +30,13 @@ public function __construct( /** * Configure the taxonomy with the allow_hierarchy parameter. * - * @param TaxonomyAttributeInterface $taxonomy The taxonomy to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(TaxonomyAttributeInterface $taxonomy): void + public function configureArguments(array $arguments): array { - $taxonomy->setArg('allow_hierarchy', $this->value); + $arguments['allow_hierarchy'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/Taxonomy/Args.php b/src/Attributes/Taxonomy/Args.php index fc4889b2..90740947 100755 --- a/src/Attributes/Taxonomy/Args.php +++ b/src/Attributes/Taxonomy/Args.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Attribute; -use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set arguments to automatically use inside wp_get_object_terms() for this taxonomy. @@ -13,7 +13,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class Args extends TaxonomyAttribute +class Args implements ConfiguresArguments { /** * Constructor. @@ -27,10 +27,13 @@ public function __construct( /** * Configure the taxonomy with the args parameter. * - * @param TaxonomyAttributeInterface $taxonomy The taxonomy to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(TaxonomyAttributeInterface $taxonomy): void + public function configureArguments(array $arguments): array { - $taxonomy->setArg('args', $this->value); + $arguments['args'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/Taxonomy/Capabilities.php b/src/Attributes/Taxonomy/Capabilities.php index 36d87d9a..f7fea2ce 100644 --- a/src/Attributes/Taxonomy/Capabilities.php +++ b/src/Attributes/Taxonomy/Capabilities.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Attribute; -use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the capabilities parameter for a taxonomy. @@ -22,7 +22,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class Capabilities extends TaxonomyAttribute +class Capabilities implements ConfiguresArguments { /** * Constructor. @@ -36,10 +36,13 @@ public function __construct( /** * Configure the taxonomy with the capabilities parameter. * - * @param TaxonomyAttributeInterface $taxonomy The taxonomy to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(TaxonomyAttributeInterface $taxonomy): void + public function configureArguments(array $arguments): array { - $taxonomy->setArg('capabilities', $this->value); + $arguments['capabilities'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/Taxonomy/CheckedOntop.php b/src/Attributes/Taxonomy/CheckedOntop.php index bb8b092c..72cb1d68 100755 --- a/src/Attributes/Taxonomy/CheckedOntop.php +++ b/src/Attributes/Taxonomy/CheckedOntop.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Attribute; -use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set whether checked terms should appear on top. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class CheckedOntop extends TaxonomyAttribute +class CheckedOntop implements ConfiguresArguments { /** * Constructor. @@ -30,8 +30,10 @@ public function __construct( /** * Configure the taxonomy with the checked_ontop parameter. */ - protected function configure(TaxonomyAttributeInterface $taxonomy): void + public function configureArguments(array $arguments): array { - $taxonomy->setArg('checked_ontop', $this->value); + $arguments['checked_ontop'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/Taxonomy/DefaultTerm.php b/src/Attributes/Taxonomy/DefaultTerm.php index a6fa0e3a..8e7d553e 100755 --- a/src/Attributes/Taxonomy/DefaultTerm.php +++ b/src/Attributes/Taxonomy/DefaultTerm.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Attribute; -use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the default term name for this taxonomy. @@ -13,7 +13,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class DefaultTerm extends TaxonomyAttribute +class DefaultTerm implements ConfiguresArguments { /** * Constructor. @@ -27,10 +27,13 @@ public function __construct( /** * Configure the taxonomy with the default_term parameter. * - * @param TaxonomyAttributeInterface $taxonomy The taxonomy to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(TaxonomyAttributeInterface $taxonomy): void + public function configureArguments(array $arguments): array { - $taxonomy->setArg('default_term', $this->value); + $arguments['default_term'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/Taxonomy/Description.php b/src/Attributes/Taxonomy/Description.php index 6a9d91ca..bc613822 100755 --- a/src/Attributes/Taxonomy/Description.php +++ b/src/Attributes/Taxonomy/Description.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Attribute; -use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the description for a taxonomy. @@ -13,7 +13,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class Description extends TaxonomyAttribute +class Description implements ConfiguresArguments { /** * Constructor. @@ -27,10 +27,13 @@ public function __construct( /** * Configure the taxonomy with the description parameter. * - * @param TaxonomyAttributeInterface $taxonomy The taxonomy to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(TaxonomyAttributeInterface $taxonomy): void + public function configureArguments(array $arguments): array { - $taxonomy->setArg('description', $this->value); + $arguments['description'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/Taxonomy/Exclusive.php b/src/Attributes/Taxonomy/Exclusive.php index 5a14a75e..d34334f0 100755 --- a/src/Attributes/Taxonomy/Exclusive.php +++ b/src/Attributes/Taxonomy/Exclusive.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Attribute; -use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set whether the taxonomy should be exclusive. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class Exclusive extends TaxonomyAttribute +class Exclusive implements ConfiguresArguments { /** * Constructor. @@ -30,10 +30,13 @@ public function __construct( /** * Configure the taxonomy with the exclusive parameter. * - * @param TaxonomyAttributeInterface $taxonomy The taxonomy to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(TaxonomyAttributeInterface $taxonomy): void + public function configureArguments(array $arguments): array { - $taxonomy->setArg('exclusive', $this->value); + $arguments['exclusive'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/Taxonomy/Hierarchical.php b/src/Attributes/Taxonomy/Hierarchical.php index 96b633e4..afea0792 100755 --- a/src/Attributes/Taxonomy/Hierarchical.php +++ b/src/Attributes/Taxonomy/Hierarchical.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Attribute; -use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set whether the taxonomy is hierarchical. @@ -13,7 +13,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class Hierarchical extends TaxonomyAttribute +class Hierarchical implements ConfiguresArguments { /** * Constructor. @@ -27,10 +27,13 @@ public function __construct( /** * Configure the taxonomy with the hierarchical parameter. * - * @param TaxonomyAttributeInterface $taxonomy The taxonomy to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(TaxonomyAttributeInterface $taxonomy): void + public function configureArguments(array $arguments): array { - $taxonomy->setArg('hierarchical', $this->value); + $arguments['hierarchical'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/Taxonomy/Label.php b/src/Attributes/Taxonomy/Label.php index 18d11a03..5f8ec4c3 100755 --- a/src/Attributes/Taxonomy/Label.php +++ b/src/Attributes/Taxonomy/Label.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Attribute; -use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the singular label for a taxonomy. @@ -13,7 +13,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class Label extends TaxonomyAttribute +class Label implements ConfiguresArguments { /** * Constructor. @@ -27,10 +27,13 @@ public function __construct( /** * Configure the taxonomy with the label parameter. * - * @param TaxonomyAttributeInterface $taxonomy The taxonomy to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(TaxonomyAttributeInterface $taxonomy): void + public function configureArguments(array $arguments): array { - $taxonomy->setArg('label', $this->value); + $arguments['label'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/Taxonomy/Labels.php b/src/Attributes/Taxonomy/Labels.php index 0f9173dc..09efa745 100755 --- a/src/Attributes/Taxonomy/Labels.php +++ b/src/Attributes/Taxonomy/Labels.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Attribute; -use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set custom labels for a taxonomy. @@ -24,7 +24,7 @@ * evaluated at runtime (PHP attributes only accept constants). */ #[Attribute(Attribute::TARGET_CLASS)] -class Labels extends TaxonomyAttribute +class Labels implements ConfiguresArguments { /** @var array */ private readonly array $labels; @@ -79,9 +79,11 @@ public function __construct( ], fn (?string $v): bool => $v !== null); } - protected function configure(TaxonomyAttributeInterface $taxonomy): void + public function configureArguments(array $arguments): array { - $existing = $taxonomy->getArg('labels', []); - $taxonomy->setArg('labels', array_merge($existing, $this->labels)); + $existing = ($arguments['labels'] ?? []); + $arguments['labels'] = array_merge($existing, $this->labels); + + return $arguments; } } diff --git a/src/Attributes/Taxonomy/MetaBoxCb.php b/src/Attributes/Taxonomy/MetaBoxCb.php index 5eb310bd..6fe8045c 100755 --- a/src/Attributes/Taxonomy/MetaBoxCb.php +++ b/src/Attributes/Taxonomy/MetaBoxCb.php @@ -5,10 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Attribute; -use Pollora\Attributes\Contracts\HandlesAttributes; -use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; -use ReflectionClass; -use ReflectionMethod; +use Pollora\Attributes\Contracts\ConfiguresArgumentsWithMethod; /** * Attribute to define a callback function for the meta box display. @@ -19,22 +16,19 @@ * @Attribute */ #[Attribute(Attribute::TARGET_METHOD)] -class MetaBoxCb implements HandlesAttributes +class MetaBoxCb implements ConfiguresArgumentsWithMethod { /** - * Handle the attribute processing. + * Set the meta_box_cb argument to the decorated method. * - * @param object $instance The instance being processed - * @param ReflectionMethod|ReflectionClass $context The method the attribute is applied to - * @param object $attribute The attribute instance + * @param array $arguments The registration arguments gathered so far + * @param callable $method The decorated method + * @return array */ - public function handle($container, object $instance, ReflectionMethod|ReflectionClass $context, object $attribute): void + public function configureArguments(array $arguments, callable $method): array { - if (! $instance instanceof TaxonomyAttributeInterface) { - return; - } + $arguments['meta_box_cb'] = $method; - // Set the meta_box_cb parameter to the method - $instance->setArg('meta_box_cb', [new $context->class, $context->getName()]); + return $arguments; } } diff --git a/src/Attributes/Taxonomy/MetaBoxSanitizeCb.php b/src/Attributes/Taxonomy/MetaBoxSanitizeCb.php index e24dcdcf..994a2fc8 100755 --- a/src/Attributes/Taxonomy/MetaBoxSanitizeCb.php +++ b/src/Attributes/Taxonomy/MetaBoxSanitizeCb.php @@ -5,10 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Attribute; -use Pollora\Attributes\Contracts\HandlesAttributes; -use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; -use ReflectionClass; -use ReflectionMethod; +use Pollora\Attributes\Contracts\ConfiguresArgumentsWithMethod; /** * Attribute to define a callback function for sanitizing taxonomy data saved from a meta box. @@ -19,22 +16,19 @@ * @Attribute */ #[Attribute(Attribute::TARGET_METHOD)] -class MetaBoxSanitizeCb implements HandlesAttributes +class MetaBoxSanitizeCb implements ConfiguresArgumentsWithMethod { /** - * Handle the attribute processing. + * Set the meta_box_sanitize_cb argument to the decorated method. * - * @param object $instance The instance being processed - * @param ReflectionMethod|ReflectionClass $context The method the attribute is applied to - * @param object $attribute The attribute instance + * @param array $arguments The registration arguments gathered so far + * @param callable $method The decorated method + * @return array */ - public function handle($container, object $instance, ReflectionMethod|ReflectionClass $context, object $attribute): void + public function configureArguments(array $arguments, callable $method): array { - if (! $instance instanceof TaxonomyAttributeInterface) { - return; - } + $arguments['meta_box_sanitize_cb'] = $method; - // Set the meta_box_sanitize_cb parameter to the method - $instance->setArg('meta_box_sanitize_cb', [new $context->class, $context->getName()]); + return $arguments; } } diff --git a/src/Attributes/Taxonomy/ObjectType.php b/src/Attributes/Taxonomy/ObjectType.php index c39a90e9..7c39b7f3 100755 --- a/src/Attributes/Taxonomy/ObjectType.php +++ b/src/Attributes/Taxonomy/ObjectType.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Attribute; -use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the post types this taxonomy is associated with. @@ -13,7 +13,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class ObjectType extends TaxonomyAttribute +class ObjectType implements ConfiguresArguments { /** * Constructor. @@ -27,10 +27,13 @@ public function __construct( /** * Configure the taxonomy with the object_type parameter. * - * @param TaxonomyAttributeInterface $taxonomy The taxonomy to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(TaxonomyAttributeInterface $taxonomy): void + public function configureArguments(array $arguments): array { - $taxonomy->setArg('object_type', $this->value); + $arguments['object_type'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/Taxonomy/Priority.php b/src/Attributes/Taxonomy/Priority.php index fc9e3d44..7a37cbc4 100755 --- a/src/Attributes/Taxonomy/Priority.php +++ b/src/Attributes/Taxonomy/Priority.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Attribute; -use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the priority a post type declaration. @@ -13,7 +13,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class Priority extends TaxonomyAttribute +class Priority implements ConfiguresArguments { /** * Constructor. @@ -25,10 +25,13 @@ public function __construct( /** * Configure the post type priority declaration parameter. * - * @param TaxonomyAttributeInterface $taxonomy The post type to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(TaxonomyAttributeInterface $taxonomy): void + public function configureArguments(array $arguments): array { - $taxonomy->setArg('priority', $this->priority); + $arguments['priority'] = $this->priority; + + return $arguments; } } diff --git a/src/Attributes/Taxonomy/PublicTaxonomy.php b/src/Attributes/Taxonomy/PublicTaxonomy.php index b8205a15..29da08b8 100755 --- a/src/Attributes/Taxonomy/PublicTaxonomy.php +++ b/src/Attributes/Taxonomy/PublicTaxonomy.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Attribute; -use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the public parameter for a taxonomy. @@ -16,7 +16,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class PublicTaxonomy extends TaxonomyAttribute +class PublicTaxonomy implements ConfiguresArguments { /** * Constructor. @@ -30,10 +30,13 @@ public function __construct( /** * Configure the taxonomy with the public parameter. * - * @param TaxonomyAttributeInterface $taxonomy The taxonomy to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(TaxonomyAttributeInterface $taxonomy): void + public function configureArguments(array $arguments): array { - $taxonomy->setArg('public', $this->value); + $arguments['public'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/Taxonomy/QueryVar.php b/src/Attributes/Taxonomy/QueryVar.php index 61e3a168..4fbd686f 100755 --- a/src/Attributes/Taxonomy/QueryVar.php +++ b/src/Attributes/Taxonomy/QueryVar.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Attribute; -use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the query_var key for this taxonomy. @@ -13,7 +13,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class QueryVar extends TaxonomyAttribute +class QueryVar implements ConfiguresArguments { /** * Constructor. @@ -27,10 +27,13 @@ public function __construct( /** * Configure the taxonomy with the query_var parameter. * - * @param TaxonomyAttributeInterface $taxonomy The taxonomy to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(TaxonomyAttributeInterface $taxonomy): void + public function configureArguments(array $arguments): array { - $taxonomy->setArg('query_var', $this->value); + $arguments['query_var'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/Taxonomy/RestBase.php b/src/Attributes/Taxonomy/RestBase.php index 9eb7c2ab..b7548aed 100755 --- a/src/Attributes/Taxonomy/RestBase.php +++ b/src/Attributes/Taxonomy/RestBase.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Attribute; -use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the base URL segment that will be used in REST API endpoints for this taxonomy. @@ -13,7 +13,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class RestBase extends TaxonomyAttribute +class RestBase implements ConfiguresArguments { /** * Constructor. @@ -27,10 +27,13 @@ public function __construct( /** * Configure the taxonomy with the rest_base parameter. * - * @param TaxonomyAttributeInterface $taxonomy The taxonomy to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(TaxonomyAttributeInterface $taxonomy): void + public function configureArguments(array $arguments): array { - $taxonomy->setArg('rest_base', $this->value); + $arguments['rest_base'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/Taxonomy/RestControllerClass.php b/src/Attributes/Taxonomy/RestControllerClass.php index e54a48c7..fcc5aaa4 100755 --- a/src/Attributes/Taxonomy/RestControllerClass.php +++ b/src/Attributes/Taxonomy/RestControllerClass.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Attribute; -use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the controller class that should be used for handling REST API requests for this taxonomy. @@ -13,7 +13,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class RestControllerClass extends TaxonomyAttribute +class RestControllerClass implements ConfiguresArguments { /** * Constructor. @@ -27,10 +27,13 @@ public function __construct( /** * Configure the taxonomy with the rest_controller_class parameter. * - * @param TaxonomyAttributeInterface $taxonomy The taxonomy to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(TaxonomyAttributeInterface $taxonomy): void + public function configureArguments(array $arguments): array { - $taxonomy->setArg('rest_controller_class', $this->value); + $arguments['rest_controller_class'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/Taxonomy/RestNamespace.php b/src/Attributes/Taxonomy/RestNamespace.php index c2a28acd..9bf20d6e 100755 --- a/src/Attributes/Taxonomy/RestNamespace.php +++ b/src/Attributes/Taxonomy/RestNamespace.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Attribute; -use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the REST API namespace for the taxonomy. @@ -13,7 +13,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class RestNamespace extends TaxonomyAttribute +class RestNamespace implements ConfiguresArguments { /** * Constructor. @@ -27,10 +27,13 @@ public function __construct( /** * Configure the taxonomy with the rest_namespace parameter. * - * @param TaxonomyAttributeInterface $taxonomy The taxonomy to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(TaxonomyAttributeInterface $taxonomy): void + public function configureArguments(array $arguments): array { - $taxonomy->setArg('rest_namespace', $this->value); + $arguments['rest_namespace'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/Taxonomy/Rewrite.php b/src/Attributes/Taxonomy/Rewrite.php index ebd639d1..687946d7 100755 --- a/src/Attributes/Taxonomy/Rewrite.php +++ b/src/Attributes/Taxonomy/Rewrite.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Attribute; -use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the rewrite rules for the taxonomy. @@ -13,7 +13,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class Rewrite extends TaxonomyAttribute +class Rewrite implements ConfiguresArguments { /** * Constructor. @@ -27,10 +27,13 @@ public function __construct( /** * Configure the taxonomy with the rewrite parameter. * - * @param TaxonomyAttributeInterface $taxonomy The taxonomy to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(TaxonomyAttributeInterface $taxonomy): void + public function configureArguments(array $arguments): array { - $taxonomy->setArg('rewrite', $this->value); + $arguments['rewrite'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/Taxonomy/ShowAdminColumn.php b/src/Attributes/Taxonomy/ShowAdminColumn.php index eef42295..b5521ef6 100755 --- a/src/Attributes/Taxonomy/ShowAdminColumn.php +++ b/src/Attributes/Taxonomy/ShowAdminColumn.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Attribute; -use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set whether to display a column for the taxonomy on its post type listing screens. @@ -13,7 +13,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class ShowAdminColumn extends TaxonomyAttribute +class ShowAdminColumn implements ConfiguresArguments { /** * Constructor. @@ -27,10 +27,13 @@ public function __construct( /** * Configure the taxonomy with the show_admin_column parameter. * - * @param TaxonomyAttributeInterface $taxonomy The taxonomy to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(TaxonomyAttributeInterface $taxonomy): void + public function configureArguments(array $arguments): array { - $taxonomy->setArg('show_admin_column', $this->value); + $arguments['show_admin_column'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/Taxonomy/ShowInMenu.php b/src/Attributes/Taxonomy/ShowInMenu.php index 249546f6..6e40e179 100755 --- a/src/Attributes/Taxonomy/ShowInMenu.php +++ b/src/Attributes/Taxonomy/ShowInMenu.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Attribute; -use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set the show_in_menu parameter for a taxonomy. @@ -18,7 +18,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class ShowInMenu extends TaxonomyAttribute +class ShowInMenu implements ConfiguresArguments { /** * Constructor. @@ -32,10 +32,13 @@ public function __construct( /** * Configure the taxonomy with the show_in_menu parameter. * - * @param TaxonomyAttributeInterface $taxonomy The taxonomy to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(TaxonomyAttributeInterface $taxonomy): void + public function configureArguments(array $arguments): array { - $taxonomy->setArg('show_in_menu', $this->value); + $arguments['show_in_menu'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/Taxonomy/ShowInNavMenus.php b/src/Attributes/Taxonomy/ShowInNavMenus.php index 9bf014cc..e9a1a1ec 100755 --- a/src/Attributes/Taxonomy/ShowInNavMenus.php +++ b/src/Attributes/Taxonomy/ShowInNavMenus.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Attribute; -use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set whether this taxonomy is available for selection in navigation menus. @@ -13,7 +13,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class ShowInNavMenus extends TaxonomyAttribute +class ShowInNavMenus implements ConfiguresArguments { /** * Constructor. @@ -27,10 +27,13 @@ public function __construct( /** * Configure the taxonomy with the show_in_nav_menus parameter. * - * @param TaxonomyAttributeInterface $taxonomy The taxonomy to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(TaxonomyAttributeInterface $taxonomy): void + public function configureArguments(array $arguments): array { - $taxonomy->setArg('show_in_nav_menus', $this->value); + $arguments['show_in_nav_menus'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/Taxonomy/ShowInQuickEdit.php b/src/Attributes/Taxonomy/ShowInQuickEdit.php index 8105d502..49e29153 100755 --- a/src/Attributes/Taxonomy/ShowInQuickEdit.php +++ b/src/Attributes/Taxonomy/ShowInQuickEdit.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Attribute; -use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set whether to show the taxonomy in the quick/bulk edit panel. @@ -13,7 +13,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class ShowInQuickEdit extends TaxonomyAttribute +class ShowInQuickEdit implements ConfiguresArguments { /** * Constructor. @@ -27,10 +27,13 @@ public function __construct( /** * Configure the taxonomy with the show_in_quick_edit parameter. * - * @param TaxonomyAttributeInterface $taxonomy The taxonomy to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(TaxonomyAttributeInterface $taxonomy): void + public function configureArguments(array $arguments): array { - $taxonomy->setArg('show_in_quick_edit', $this->value); + $arguments['show_in_quick_edit'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/Taxonomy/ShowInRest.php b/src/Attributes/Taxonomy/ShowInRest.php index ed6fe08d..0cbaf204 100755 --- a/src/Attributes/Taxonomy/ShowInRest.php +++ b/src/Attributes/Taxonomy/ShowInRest.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Attribute; -use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set whether this taxonomy should appear in the REST API. @@ -13,7 +13,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class ShowInRest extends TaxonomyAttribute +class ShowInRest implements ConfiguresArguments { /** * Constructor. @@ -27,10 +27,13 @@ public function __construct( /** * Configure the taxonomy with the show_in_rest parameter. * - * @param TaxonomyAttributeInterface $taxonomy The taxonomy to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(TaxonomyAttributeInterface $taxonomy): void + public function configureArguments(array $arguments): array { - $taxonomy->setArg('show_in_rest', $this->value); + $arguments['show_in_rest'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/Taxonomy/ShowTagcloud.php b/src/Attributes/Taxonomy/ShowTagcloud.php index 603d7d5a..c915f2a1 100755 --- a/src/Attributes/Taxonomy/ShowTagcloud.php +++ b/src/Attributes/Taxonomy/ShowTagcloud.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Attribute; -use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set whether to show the taxonomy in the tag cloud widget. @@ -13,7 +13,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class ShowTagcloud extends TaxonomyAttribute +class ShowTagcloud implements ConfiguresArguments { /** * Constructor. @@ -27,10 +27,13 @@ public function __construct( /** * Configure the taxonomy with the show_tagcloud parameter. * - * @param TaxonomyAttributeInterface $taxonomy The taxonomy to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(TaxonomyAttributeInterface $taxonomy): void + public function configureArguments(array $arguments): array { - $taxonomy->setArg('show_tagcloud', $this->value); + $arguments['show_tagcloud'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/Taxonomy/ShowUI.php b/src/Attributes/Taxonomy/ShowUI.php index 6c0e346d..908e13a1 100755 --- a/src/Attributes/Taxonomy/ShowUI.php +++ b/src/Attributes/Taxonomy/ShowUI.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Attribute; -use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set whether to generate a default UI for managing this taxonomy in the admin. @@ -13,7 +13,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class ShowUI extends TaxonomyAttribute +class ShowUI implements ConfiguresArguments { /** * Constructor. @@ -27,10 +27,13 @@ public function __construct( /** * Configure the taxonomy with the show_ui parameter. * - * @param TaxonomyAttributeInterface $taxonomy The taxonomy to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(TaxonomyAttributeInterface $taxonomy): void + public function configureArguments(array $arguments): array { - $taxonomy->setArg('show_ui', $this->value); + $arguments['show_ui'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/Taxonomy/Sort.php b/src/Attributes/Taxonomy/Sort.php index f9902ada..bc65b152 100755 --- a/src/Attributes/Taxonomy/Sort.php +++ b/src/Attributes/Taxonomy/Sort.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Attribute; -use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; +use Pollora\Attributes\Contracts\ConfiguresArguments; /** * Attribute to set whether terms in this taxonomy should be sorted in the order they are provided. @@ -13,7 +13,7 @@ * @Attribute */ #[Attribute(Attribute::TARGET_CLASS)] -class Sort extends TaxonomyAttribute +class Sort implements ConfiguresArguments { /** * Constructor. @@ -27,10 +27,13 @@ public function __construct( /** * Configure the taxonomy with the sort parameter. * - * @param TaxonomyAttributeInterface $taxonomy The taxonomy to configure + * @param array $arguments The registration arguments gathered so far + * @return array */ - protected function configure(TaxonomyAttributeInterface $taxonomy): void + public function configureArguments(array $arguments): array { - $taxonomy->setArg('sort', $this->value); + $arguments['sort'] = $this->value; + + return $arguments; } } diff --git a/src/Attributes/Taxonomy/TaxonomyAttribute.php b/src/Attributes/Taxonomy/TaxonomyAttribute.php index 292210a3..9ca188dc 100755 --- a/src/Attributes/Taxonomy/TaxonomyAttribute.php +++ b/src/Attributes/Taxonomy/TaxonomyAttribute.php @@ -5,6 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Pollora\Attributes\Attributable; +use Pollora\Attributes\Contracts\ConfiguresArguments; use Pollora\Attributes\Contracts\HandlesAttributes; use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; use ReflectionClass; @@ -16,7 +17,9 @@ * This attribute serves as the base for all taxonomy configuration attributes. * It provides common functionality for handling taxonomy registration arguments. * - * @Attribute + * @deprecated since 13.36, removed in 15.0. Implement + * {@see ConfiguresArguments}, as the + * built-in attributes now do. */ abstract class TaxonomyAttribute implements HandlesAttributes { diff --git a/src/Attributes/Taxonomy/UpdateCountCallback.php b/src/Attributes/Taxonomy/UpdateCountCallback.php index 218a2465..b289db34 100755 --- a/src/Attributes/Taxonomy/UpdateCountCallback.php +++ b/src/Attributes/Taxonomy/UpdateCountCallback.php @@ -5,10 +5,7 @@ namespace Pollora\Attributes\Taxonomy; use Attribute; -use Pollora\Attributes\Contracts\HandlesAttributes; -use Pollora\Taxonomy\Domain\Contracts\TaxonomyAttributeInterface; -use ReflectionClass; -use ReflectionMethod; +use Pollora\Attributes\Contracts\ConfiguresArgumentsWithMethod; /** * Attribute to define a callback function that will be called when the count is updated. @@ -19,22 +16,19 @@ * @Attribute */ #[Attribute(Attribute::TARGET_METHOD)] -class UpdateCountCallback implements HandlesAttributes +class UpdateCountCallback implements ConfiguresArgumentsWithMethod { /** - * Handle the attribute processing. + * Set the update_count_callback argument to the decorated method. * - * @param object $instance The instance being processed - * @param ReflectionMethod|ReflectionClass $context The method the attribute is applied to - * @param object $attribute The attribute instance + * @param array $arguments The registration arguments gathered so far + * @param callable $method The decorated method + * @return array */ - public function handle($container, object $instance, ReflectionMethod|ReflectionClass $context, object $attribute): void + public function configureArguments(array $arguments, callable $method): array { - if (! $instance instanceof TaxonomyAttributeInterface) { - return; - } + $arguments['update_count_callback'] = $method; - // Set the update_count_callback parameter to the method - $instance->setArg('update_count_callback', [new $context->class, $context->getName()]); + return $arguments; } } diff --git a/src/Attributes/WpCli.php b/src/Attributes/WpCli.php index 7248b637..df7ff388 100644 --- a/src/Attributes/WpCli.php +++ b/src/Attributes/WpCli.php @@ -4,7 +4,7 @@ namespace Pollora\Attributes; -use Pollora\Support\Slug; +use Pollora\Attributes\Support\Naming; /** * Attribute to mark a class as a WordPress CLI command. @@ -39,6 +39,6 @@ public function __construct( */ public function getCommandName(string $className): string { - return $this->commandName ?? Slug::fromClassName($className); + return $this->commandName ?? Naming::commandFromClassName($className); } } diff --git a/src/Attributes/WpCli/Command.php b/src/Attributes/WpCli/Command.php index f6b02382..6e485bfd 100644 --- a/src/Attributes/WpCli/Command.php +++ b/src/Attributes/WpCli/Command.php @@ -5,7 +5,7 @@ namespace Pollora\Attributes\WpCli; use Attribute; -use Pollora\Support\Slug; +use Pollora\Attributes\Support\Naming; /** * WP CLI Command Attribute for Method-Level Subcommands @@ -42,6 +42,6 @@ public function __construct( */ public function getSubcommandName(string $methodName): string { - return $this->commandName ?? Slug::fromMethodName($methodName); + return $this->commandName ?? Naming::slug($methodName); } } diff --git a/src/Attributes/WpRestRoute.php b/src/Attributes/WpRestRoute.php index d3836ceb..2f40377e 100755 --- a/src/Attributes/WpRestRoute.php +++ b/src/Attributes/WpRestRoute.php @@ -5,16 +5,13 @@ namespace Pollora\Attributes; use Attribute; -use Pollora\Attributes\Contracts\HandlesAttributes; use Pollora\Attributes\WpRestRoute\Permission; -use ReflectionClass; -use ReflectionMethod; /** * Attribute for defining WordPress REST API routes. */ #[Attribute(Attribute::TARGET_CLASS)] -class WpRestRoute implements HandlesAttributes +class WpRestRoute { /** * Constructor for WpRestRoute attribute. @@ -28,19 +25,4 @@ public function __construct( public readonly string $route, public readonly string|Permission|null $permissionCallback = null ) {} - - /** - * Handle the attribute processing. - * - * @param object $serviceLocator Service locator used to resolve dependencies - * @param object $instance The instance to which the attribute applies - * @param ReflectionClass|ReflectionMethod $context The reflection context. - * @param object $attribute The attribute instance containing provided arguments. - */ - public function handle($serviceLocator, object $instance, ReflectionClass|ReflectionMethod $context, object $attribute): void - { - $instance->namespace = $attribute->namespace; - $instance->route = $attribute->route; - $instance->classPermission = $attribute->permissionCallback; - } } diff --git a/src/Attributes/WpRestRoute/Method.php b/src/Attributes/WpRestRoute/Method.php index a43417bb..53d4a066 100755 --- a/src/Attributes/WpRestRoute/Method.php +++ b/src/Attributes/WpRestRoute/Method.php @@ -6,16 +6,9 @@ use Attribute; use InvalidArgumentException; -use Pollora\Attributes\Attributable; -use Pollora\Attributes\Contracts\HandlesAttributes; -use Pollora\Support\WpGlobals; -use ReflectionClass; -use ReflectionMethod; -use WP_Error; -use WP_REST_Request; #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] -class Method implements HandlesAttributes +class Method { private const array ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']; @@ -64,120 +57,4 @@ public function getMethods(): array { return $this->methods; } - - /** - * Handles the registration of the REST route for the method. - * - * @param object $serviceLocator Service locator used to resolve dependencies - * @param Attributable $instance The class instance - * @param ReflectionMethod|ReflectionClass $context The reflection context - * @param object $attribute The attribute instance - */ - public function handle($serviceLocator, Attributable $instance, ReflectionMethod|ReflectionClass $context, object $attribute): void - { - $methodPermission = $attribute->permissionCallback; - $permissionCallback = $methodPermission ?? $instance->classPermission; - - register_rest_route( - $instance->namespace, - $instance->route, - [ - 'methods' => $attribute->getMethods(), - 'callback' => WpGlobals::wrap( - fn (WP_REST_Request $request): mixed => $this->handleRequest($instance, $context, $request) - ), - 'args' => $this->extractArgsFromRoute($instance->route), - 'permission_callback' => $this->resolvePermissionCallback($permissionCallback), - ] - ); - } - - /** - * Executes the class method associated with the REST request. - * - * @param Attributable $instance The class instance - * @param ReflectionMethod $method The method to invoke - * @param WP_REST_Request $request The REST request instance - * @return mixed The result of the method invocation - */ - private function handleRequest(Attributable $instance, ReflectionMethod $method, WP_REST_Request $request): mixed - { - $args = []; - - foreach ($method->getParameters() as $param) { - // A WP_REST_Request type hint gets the request itself. Looking its - // name up among the route parameters found nothing — no route - // declares one called "request" — so the handler was invoked with - // null and a controller declaring the type fataled before running. - if ($this->expectsTheRequest($param)) { - $args[] = $request; - - continue; - } - - $args[] = $request->get_param($param->getName()); - } - - // Get the real instance if available, otherwise use the provided instance - $realInstance = method_exists($instance, 'getRealInstance') ? $instance->getRealInstance() : $instance; - - return $method->invoke($realInstance, ...$args); - } - - /** - * Whether a parameter asks for the request object rather than a route value. - */ - private function expectsTheRequest(\ReflectionParameter $param): bool - { - $type = $param->getType(); - - if (! $type instanceof \ReflectionNamedType || $type->isBuiltin()) { - return false; - } - - $name = $type->getName(); - - // The name comparison stands on its own: ::class resolves whether or - // not WordPress is loaded, so this holds outside a WordPress runtime. - return $name === WP_REST_Request::class - || is_a($name, WP_REST_Request::class, true); - } - - /** - * Extracts dynamic arguments from a WordPress route. - * - * @param string $route The route pattern - * @return array The extracted arguments - */ - private function extractArgsFromRoute(string $route): array - { - preg_match_all('/\(\?P<(\w+)>/', $route, $matches); - - return array_fill_keys($matches[1], [ - 'validate_callback' => fn ($param): bool => is_string($param) || is_numeric($param), - ]); - } - - /** - * Resolves and executes the permission callback. - * - * @param class-string|Permission|null $permissionCallback The permission class or instance to use - * @return callable The permission function - */ - private function resolvePermissionCallback(string|Permission|null $permissionCallback): callable - { - if ($permissionCallback === null) { - return '__return_true'; - } - - if (is_string($permissionCallback) && (! class_exists($permissionCallback) || ! is_subclass_of($permissionCallback, Permission::class))) { - return fn (): WP_Error => new WP_Error('rest_forbidden', __('Invalid permission handler.'), ['status' => 403]); - } - - return WpGlobals::wrap(function (WP_REST_Request $request) use ($permissionCallback): bool|WP_Error { - $permissionInstance = is_string($permissionCallback) ? new $permissionCallback : $permissionCallback; - - return $permissionInstance->allow($request); - }); - } } diff --git a/src/PostType/Infrastructure/Services/PostTypeConfiguration.php b/src/PostType/Infrastructure/Services/PostTypeConfiguration.php index 27ddefd8..3b7e2f15 100644 --- a/src/PostType/Infrastructure/Services/PostTypeConfiguration.php +++ b/src/PostType/Infrastructure/Services/PostTypeConfiguration.php @@ -79,6 +79,16 @@ public function getArg(string $key, mixed $default = null): mixed return $this->attributeArgs[$key] ?? $default; } + /** + * Replace every attribute argument. + * + * @param array $args The arguments to keep + */ + public function replaceArgs(array $args): void + { + $this->attributeArgs = $args; + } + /** * Merge additional arguments into the configuration. * diff --git a/src/PostType/Infrastructure/Services/PostTypeDiscovery.php b/src/PostType/Infrastructure/Services/PostTypeDiscovery.php index 0095dd91..de149d40 100644 --- a/src/PostType/Infrastructure/Services/PostTypeDiscovery.php +++ b/src/PostType/Infrastructure/Services/PostTypeDiscovery.php @@ -5,6 +5,9 @@ namespace Pollora\PostType\Infrastructure\Services; use Illuminate\Support\Str; +use Pollora\Attributes\Contracts\ConfiguresArguments; +use Pollora\Attributes\Contracts\ConfiguresArgumentsWithMethod; +use Pollora\Attributes\Contracts\HandlesAttributes; use Pollora\Attributes\PostType; use Pollora\Discovery\Domain\Contracts\ConfigurableDiscoveryInterface; use Pollora\Discovery\Domain\Contracts\DiscoveryInterface; @@ -302,8 +305,13 @@ private function processClassAttribute(ReflectionClass $reflectionClass, \Reflec { $attributeInstance = $attribute->newInstance(); - // Check if the attribute has a handle method and call it - if (method_exists($attributeInstance, 'handle')) { + if ($attributeInstance instanceof ConfiguresArguments) { + $config->replaceArgs($attributeInstance->configureArguments($config->withArgs())); + + return; + } + + if ($attributeInstance instanceof HandlesAttributes) { $attributeInstance->handle(app(), $config, $reflectionClass, $attributeInstance); } } @@ -325,8 +333,13 @@ private function processMethodAttribute( ): void { $attributeInstance = $attribute->newInstance(); - // Check if the attribute has a handle method and call it - if (method_exists($attributeInstance, 'handle')) { + if ($attributeInstance instanceof ConfiguresArgumentsWithMethod) { + $config->replaceArgs($attributeInstance->configureArguments($config->withArgs(), [new $method->class, $method->getName()])); + + return; + } + + if ($attributeInstance instanceof HandlesAttributes) { $attributeInstance->handle(app(), $config, $method, $attributeInstance); } } diff --git a/src/Support/Slug.php b/src/Support/Slug.php index 7d0ae61a..94a1c9a9 100644 --- a/src/Support/Slug.php +++ b/src/Support/Slug.php @@ -4,6 +4,8 @@ namespace Pollora\Support; +use Pollora\Attributes\Support\Naming; + /** * Utility class for slug generation. * @@ -25,15 +27,7 @@ class Slug */ public static function fromClassName(string $className): string { - // Extract the base class name (remove namespace) - $baseName = basename(str_replace('\\', '/', $className)); - - // Remove "Command" suffix if present - if (self::endsWith($baseName, 'Command')) { - $baseName = substr($baseName, 0, -7); - } - - return self::toKebabCase($baseName); + return Naming::commandFromClassName($className); } /** @@ -50,7 +44,7 @@ public static function fromClassName(string $className): string */ public static function fromMethodName(string $methodName): string { - return self::toKebabCase($methodName); + return Naming::slug($methodName); } /** @@ -61,35 +55,6 @@ public static function fromMethodName(string $methodName): string */ public static function toKebabCase(string $string): string { - // Insert a hyphen before any uppercase letter that follows a lowercase letter or number - $string = preg_replace('/([a-z0-9])([A-Z])/', '$1-$2', $string); - - // Convert to lowercase - $string = strtolower((string) $string); - - // Replace multiple consecutive hyphens with a single hyphen - $string = preg_replace('/-+/', '-', $string); - - // Trim hyphens from the beginning and end - $string = trim((string) $string, '-'); - - return $string; - } - - /** - * Check if a string ends with a specific suffix. - * - * @param string $haystack The string to check - * @param string $needle The suffix to look for - * @return bool True if the string ends with the suffix - */ - private static function endsWith(string $haystack, string $needle): bool - { - $length = strlen($needle); - if ($length === 0) { - return true; - } - - return substr($haystack, -$length) === $needle; + return Naming::slug($string); } } diff --git a/src/Taxonomy/Infrastructure/Services/TaxonomyConfiguration.php b/src/Taxonomy/Infrastructure/Services/TaxonomyConfiguration.php index c6acae9b..0a289bc8 100644 --- a/src/Taxonomy/Infrastructure/Services/TaxonomyConfiguration.php +++ b/src/Taxonomy/Infrastructure/Services/TaxonomyConfiguration.php @@ -96,6 +96,16 @@ public function getArg(string $key, mixed $default = null): mixed return $this->attributeArgs[$key] ?? $default; } + /** + * Replace every attribute argument. + * + * @param array $args The arguments to keep + */ + public function replaceArgs(array $args): void + { + $this->attributeArgs = $args; + } + /** * Merge additional arguments into the configuration. * diff --git a/src/Taxonomy/Infrastructure/Services/TaxonomyDiscovery.php b/src/Taxonomy/Infrastructure/Services/TaxonomyDiscovery.php index 2cb6ca82..c5f4880e 100644 --- a/src/Taxonomy/Infrastructure/Services/TaxonomyDiscovery.php +++ b/src/Taxonomy/Infrastructure/Services/TaxonomyDiscovery.php @@ -5,6 +5,9 @@ namespace Pollora\Taxonomy\Infrastructure\Services; use Illuminate\Support\Str; +use Pollora\Attributes\Contracts\ConfiguresArguments; +use Pollora\Attributes\Contracts\ConfiguresArgumentsWithMethod; +use Pollora\Attributes\Contracts\HandlesAttributes; use Pollora\Attributes\Taxonomy; use Pollora\Discovery\Domain\Contracts\ConfigurableDiscoveryInterface; use Pollora\Discovery\Domain\Contracts\DiscoveryInterface; @@ -307,8 +310,13 @@ private function processClassAttribute(ReflectionClass $reflectionClass, \Reflec { $attributeInstance = $attribute->newInstance(); - // Check if the attribute has a handle method and call it - if (method_exists($attributeInstance, 'handle')) { + if ($attributeInstance instanceof ConfiguresArguments) { + $config->replaceArgs($attributeInstance->configureArguments($config->withArgs())); + + return; + } + + if ($attributeInstance instanceof HandlesAttributes) { $attributeInstance->handle(app(), $config, $reflectionClass, $attributeInstance); } } @@ -330,8 +338,13 @@ private function processMethodAttribute( ): void { $attributeInstance = $attribute->newInstance(); - // Check if the attribute has a handle method and call it - if (method_exists($attributeInstance, 'handle')) { + if ($attributeInstance instanceof ConfiguresArgumentsWithMethod) { + $config->replaceArgs($attributeInstance->configureArguments($config->withArgs(), [new $method->class, $method->getName()])); + + return; + } + + if ($attributeInstance instanceof HandlesAttributes) { $attributeInstance->handle(app(), $config, $method, $attributeInstance); } } diff --git a/src/WpRest/Infrastructure/Services/WpRestDiscovery.php b/src/WpRest/Infrastructure/Services/WpRestDiscovery.php index 62e89c78..f8781370 100644 --- a/src/WpRest/Infrastructure/Services/WpRestDiscovery.php +++ b/src/WpRest/Infrastructure/Services/WpRestDiscovery.php @@ -4,7 +4,9 @@ namespace Pollora\WpRest\Infrastructure\Services; +use Pollora\Attributes\Contracts\HandlesAttributes; use Pollora\Attributes\WpRestRoute; +use Pollora\Attributes\WpRestRoute\Method; use Pollora\Discovery\Domain\Contracts\DiscoveryInterface; use Pollora\Discovery\Domain\Contracts\DiscoveryLocationInterface; use Pollora\Discovery\Domain\Contracts\ReflectionCacheInterface; @@ -33,7 +35,8 @@ final class WpRestDiscovery implements DiscoveryInterface private array $wrapperCache = []; public function __construct( - private readonly ?LoggerInterface $logger = null + private readonly ?LoggerInterface $logger = null, + private readonly WpRestRouteRegistrar $registrar = new WpRestRouteRegistrar, ) {} /** @@ -205,15 +208,19 @@ private function processMethodAttribute( try { $attributeInstance = $attribute->newInstance(); - // Check if the attribute has a handle method and call it - if (! method_exists($attributeInstance, 'handle')) { + if ($attributeInstance instanceof Method) { + add_action('rest_api_init', function () use ($attributeInstance, $attributableWrapper, $method): void { + $this->registrar->register($attributableWrapper, $method, $attributeInstance); + }); + return; } - // Let the attribute handle the registration - add_action('rest_api_init', function () use ($attributeInstance, $attributableWrapper, $method): void { - $attributeInstance->handle(app(), $attributableWrapper, $method, $attributeInstance); - }); + if ($attributeInstance instanceof HandlesAttributes) { + add_action('rest_api_init', function () use ($attributeInstance, $attributableWrapper, $method): void { + $attributeInstance->handle(app(), $attributableWrapper, $method, $attributeInstance); + }); + } } catch (\Throwable $throwable) { $className = $method->getDeclaringClass()->getName(); $this->logger?->error(sprintf('Failed to process method attribute for %s::%s: ', $className, $method->getName()).$throwable->getMessage()); diff --git a/src/WpRest/Infrastructure/Services/WpRestRouteRegistrar.php b/src/WpRest/Infrastructure/Services/WpRestRouteRegistrar.php new file mode 100644 index 00000000..ef519021 --- /dev/null +++ b/src/WpRest/Infrastructure/Services/WpRestRouteRegistrar.php @@ -0,0 +1,133 @@ +permissionCallback; + $permissionCallback = $methodPermission ?? $instance->classPermission; + + register_rest_route( + $instance->namespace, + $instance->route, + [ + 'methods' => $attribute->getMethods(), + 'callback' => WpGlobals::wrap( + fn (WP_REST_Request $request): mixed => $this->handleRequest($instance, $context, $request) + ), + 'args' => $this->extractArgsFromRoute($instance->route), + 'permission_callback' => $this->resolvePermissionCallback($permissionCallback), + ] + ); + } + + /** + * Executes the class method associated with the REST request. + * + * @param WpRestAttributableWrapper $instance The route class wrapper + * @param ReflectionMethod $method The method to invoke + * @param WP_REST_Request $request The REST request instance + * @return mixed The result of the method invocation + */ + private function handleRequest(WpRestAttributableWrapper $instance, ReflectionMethod $method, WP_REST_Request $request): mixed + { + $args = []; + + foreach ($method->getParameters() as $param) { + // A WP_REST_Request type hint gets the request itself. Looking its + // name up among the route parameters found nothing — no route + // declares one called "request" — so the handler was invoked with + // null and a controller declaring the type fataled before running. + if ($this->expectsTheRequest($param)) { + $args[] = $request; + + continue; + } + + $args[] = $request->get_param($param->getName()); + } + + return $method->invoke($instance->getRealInstance(), ...$args); + } + + /** + * Whether a parameter asks for the request object rather than a route value. + */ + private function expectsTheRequest(\ReflectionParameter $param): bool + { + $type = $param->getType(); + + if (! $type instanceof \ReflectionNamedType || $type->isBuiltin()) { + return false; + } + + $name = $type->getName(); + + // The name comparison stands on its own: ::class resolves whether or + // not WordPress is loaded, so this holds outside a WordPress runtime. + return $name === WP_REST_Request::class + || is_a($name, WP_REST_Request::class, true); + } + + /** + * Extracts dynamic arguments from a WordPress route. + * + * @param string $route The route pattern + * @return array The extracted arguments + */ + private function extractArgsFromRoute(string $route): array + { + preg_match_all('/\(\?P<(\w+)>/', $route, $matches); + + return array_fill_keys($matches[1], [ + 'validate_callback' => fn ($param): bool => is_string($param) || is_numeric($param), + ]); + } + + /** + * Resolves and executes the permission callback. + * + * @param class-string|Permission|null $permissionCallback The permission class or instance to use + * @return callable The permission function + */ + private function resolvePermissionCallback(string|Permission|null $permissionCallback): callable + { + if ($permissionCallback === null) { + return '__return_true'; + } + + if (is_string($permissionCallback) && (! class_exists($permissionCallback) || ! is_subclass_of($permissionCallback, Permission::class))) { + return fn (): WP_Error => new WP_Error('rest_forbidden', __('Invalid permission handler.'), ['status' => 403]); + } + + return WpGlobals::wrap(function (WP_REST_Request $request) use ($permissionCallback): bool|WP_Error { + $permissionInstance = is_string($permissionCallback) ? new $permissionCallback : $permissionCallback; + + return $permissionInstance->allow($request); + }); + } +} diff --git a/tests/Feature/Hook/Async/AsyncAttributeTest.php b/tests/Feature/Hook/Async/AsyncAttributeTest.php index 8506b5bb..96b402da 100644 --- a/tests/Feature/Hook/Async/AsyncAttributeTest.php +++ b/tests/Feature/Hook/Async/AsyncAttributeTest.php @@ -11,7 +11,6 @@ use Pollora\Hook\Async\Async; use Pollora\Hook\Async\AsyncPayload; use Pollora\Hook\Async\QueuedHandler; -use Pollora\Hook\Domain\Contract\Action as ActionContract; use Pollora\Hook\Infrastructure\Services\AsyncAttributeRegistrar; use Pollora\Hook\Infrastructure\Services\HookDiscovery; use Psr\Log\AbstractLogger; @@ -149,42 +148,3 @@ function registeredCallback(Action $actions, string $hook): mixed ->and($this->logger->errors)->toContain(sprintf('#[Async] on %s::orphan() is ignored: the method has no #[Action] to make asynchronous.', AsyncAttributeHooks::class)); }); }); - -describe('Action::handle()', function (): void { - it('registers through the registrar, asynchronously when #[Async] says so', function (): void { - $locator = new readonly class($this->actions, $this->logger) - { - public function __construct(private ActionContract $actions, private AbstractLogger $logger) {} - - public function get(string $id): mixed - { - return $id === ActionContract::class ? $this->actions : $this->logger; - } - }; - $method = new ReflectionMethod(AsyncAttributeHooks::class, 'notify'); - $attribute = new ActionAttribute('transition_post_status'); - - $attribute->handle($locator, new AsyncAttributeHooks, $method, $attribute); - - expect(registeredCallback($this->actions, 'transition_post_status'))->toBeInstanceOf(QueuedHandler::class); - }); - - it('works without a logger in the service locator, and ignores a class context', function (): void { - $locator = new readonly class($this->actions) - { - public function __construct(private ActionContract $actions) {} - - public function get(string $id): mixed - { - return $id === ActionContract::class ? $this->actions : throw new RuntimeException('Not bound'); - } - }; - $attribute = new ActionAttribute('save_post'); - - $attribute->handle($locator, new AsyncAttributeInvalidHooks, new ReflectionMethod(AsyncAttributeInvalidHooks::class, 'typo'), $attribute); - $attribute->handle($locator, new AsyncAttributeHooks, new ReflectionClass(AsyncAttributeHooks::class), new ActionAttribute('init')); - - expect(registeredCallback($this->actions, 'save_post'))->toBeArray() - ->and($this->actions->callbacks('init'))->toBeNull(); - }); -}); diff --git a/tests/Feature/Hook/HookDiscoveryTest.php b/tests/Feature/Hook/HookDiscoveryTest.php new file mode 100644 index 00000000..15c40a00 --- /dev/null +++ b/tests/Feature/Hook/HookDiscoveryTest.php @@ -0,0 +1,94 @@ + */ + public array $calls = []; + + #[ActionAttribute('init')] + public function boot(): void + { + $this->calls[] = 'boot'; + } + + #[ActionAttribute('save_post', priority: 99)] + #[ActionAttribute('edit_post', priority: 20)] + public function syncPost(int $postId, object $post): void + { + $this->calls[] = 'syncPost'; + } + + #[FilterAttribute('the_title')] + public function title(string $title): string + { + return 'modified_'.$title; + } + + #[FilterAttribute('the_content', priority: 5)] + public function content(string $content): string + { + return $content; + } + + public function notAHook(): void + { + $this->calls[] = 'notAHook'; + } +} + +beforeEach(function (): void { + $this->actions = new Action; + $this->filters = new Filter; + $discovery = new HookDiscovery($this->actions, $this->filters); + $discovery->discover(new DiscoveryLocation('', __DIR__), DiscoveredClass::fromReflection(new ReflectionClass(DiscoveredHooks::class)), new ReflectionCache); + $discovery->apply(); +}); + +/** + * The registration made on a hook: its method, priority and accepted arguments. + * + * @return array{method: string, priority: int, args: int} + */ +function hookRegistration(Action|Filter $hooks, string $hook): array +{ + $registration = $hooks->callbacks($hook)[0]; + + return ['method' => $registration['callback'][1], 'priority' => $registration['priority'], 'args' => $registration['args']]; +} + +it('registers each #[Action] with its priority, 10 by default', function (): void { + expect(hookRegistration($this->actions, 'init'))->toBe(['method' => 'boot', 'priority' => 10, 'args' => 0]) + ->and(hookRegistration($this->actions, 'save_post'))->toBe(['method' => 'syncPost', 'priority' => 99, 'args' => 2]) + ->and(hookRegistration($this->actions, 'edit_post'))->toBe(['method' => 'syncPost', 'priority' => 20, 'args' => 2]); +}); + +it('registers each #[Filter] with its priority, 10 by default', function (): void { + expect(hookRegistration($this->filters, 'the_title'))->toBe(['method' => 'title', 'priority' => 10, 'args' => 1]) + ->and(hookRegistration($this->filters, 'the_content'))->toBe(['method' => 'content', 'priority' => 5, 'args' => 1]); +}); + +it('calls the method of an instance of the declaring class', function (): void { + $callback = $this->filters->callbacks('the_title')[0]['callback']; + + expect($callback[0])->toBeInstanceOf(DiscoveredHooks::class) + ->and($callback('Hello'))->toBe('modified_Hello'); +}); + +it('registers nothing for a method without hook attribute', function (): void { + $registrations = array_merge(...array_values($this->actions->all()), ...array_values($this->filters->all())); + + expect(array_map(fn (array $registration): string => $registration['callback'][1], $registrations)) + ->toHaveCount(5) + ->not->toContain('notAHook'); +}); diff --git a/tests/Unit/Attributes/ActionTest.php b/tests/Unit/Attributes/ActionTest.php deleted file mode 100644 index 4cd33586..00000000 --- a/tests/Unit/Attributes/ActionTest.php +++ /dev/null @@ -1,187 +0,0 @@ -mockAction = Mockery::mock(ActionService::class); - - // Create a fake service locator - $this->mockServiceLocator = new class($this->mockAction) - { - public function __construct(private $actionService) {} - - public function get($serviceClass) - { - if ($serviceClass === ActionService::class) { - return $this->actionService; - } - - return null; - } - }; -}); - -class SingleActionClass -{ - #[Action('test_action', priority: 10)] - public function actionMethod($param = null): string - { - // Test method - return $param ? 'processed_'.$param : 'processed'; - } -} - -class MultipleActionClass -{ - #[Action('test_action', priority: 10)] - public function actionMethod($param = null): string - { - // Test method - return $param ? 'processed_'.$param : 'processed'; - } - - #[Action('another_action', priority: 20)] - public function anotherActionMethod(): string - { - // Another test method - return 'another_processed'; - } -} - -class DefaultPriorityActionClass -{ - #[Action('test_action')] - public function actionMethod($param = null): string - { - // Test method with default priority - return $param ? 'processed_'.$param : 'processed'; - } -} - -class CustomPriorityActionClass -{ - #[Action('test_action', priority: 42)] - public function actionMethod($param = null): string - { - // Test method with custom priority - return $param ? 'processed_'.$param : 'processed'; - } -} - -it('registers an action hook correctly', function (): void { - // Set up expectations - $this->mockAction->shouldReceive('add') - ->once() - ->withArgs(fn ($hook, $callback, $priority, $acceptedArgs): bool => $hook === 'test_action' - && is_array($callback) - && $callback[0] instanceof SingleActionClass - && $callback[1] === 'actionMethod' - && $priority === 10 - && $acceptedArgs === 1); - - // Test the action attribute directly using handle method - $testClass = new SingleActionClass; - $actionAttribute = new Action('test_action', 10); - $methodReflection = new ReflectionMethod($testClass, 'actionMethod'); - - $actionAttribute->handle($this->mockServiceLocator, $testClass, $methodReflection, $actionAttribute); -}); - -it('registers multiple action hooks with different priorities', function (): void { - $testClass = new MultipleActionClass; - - // Test first action - $this->mockAction->shouldReceive('add') - ->once() - ->withArgs(fn ($hook, $callback, $priority, $acceptedArgs): bool => $hook === 'test_action' - && is_array($callback) - && $callback[0] instanceof MultipleActionClass - && $callback[1] === 'actionMethod' - && $priority === 10); - - $actionAttribute1 = new Action('test_action', 10); - $methodReflection1 = new ReflectionMethod($testClass, 'actionMethod'); - $actionAttribute1->handle($this->mockServiceLocator, $testClass, $methodReflection1, $actionAttribute1); - - // Test second action - $this->mockAction->shouldReceive('add') - ->once() - ->withArgs(fn ($hook, $callback, $priority, $acceptedArgs): bool => $hook === 'another_action' - && is_array($callback) - && $callback[0] instanceof MultipleActionClass - && $callback[1] === 'anotherActionMethod' - && $priority === 20); - - $actionAttribute2 = new Action('another_action', 20); - $methodReflection2 = new ReflectionMethod($testClass, 'anotherActionMethod'); - $actionAttribute2->handle($this->mockServiceLocator, $testClass, $methodReflection2, $actionAttribute2); -}); - -it('registers an action hook with default priority (10)', function (): void { - // Set up expectations - $this->mockAction->shouldReceive('add') - ->once() - ->withArgs(fn ($hook, $callback, $priority, $acceptedArgs): bool => $hook === 'test_action' - && is_array($callback) - && $callback[0] instanceof DefaultPriorityActionClass - && $callback[1] === 'actionMethod' - && $priority === 10 // Default priority should be 10 - && $acceptedArgs === 1); - - // Test with default priority - $testClass = new DefaultPriorityActionClass; - $actionAttribute = new Action('test_action'); // No priority specified, should default to 10 - $methodReflection = new ReflectionMethod($testClass, 'actionMethod'); - - $actionAttribute->handle($this->mockServiceLocator, $testClass, $methodReflection, $actionAttribute); -}); - -it('registers an action hook with custom priority', function (): void { - // Set up expectations - $this->mockAction->shouldReceive('add') - ->once() - ->withArgs(fn ($hook, $callback, $priority, $acceptedArgs): bool => $hook === 'test_action' - && is_array($callback) - && $callback[0] instanceof CustomPriorityActionClass - && $callback[1] === 'actionMethod' - && $priority === 42 // Custom priority - && $acceptedArgs === 1); - - // Test with custom priority - $testClass = new CustomPriorityActionClass; - $actionAttribute = new Action('test_action', 42); - $methodReflection = new ReflectionMethod($testClass, 'actionMethod'); - - $actionAttribute->handle($this->mockServiceLocator, $testClass, $methodReflection, $actionAttribute); -}); - -it('handles null service locator resolution gracefully', function (): void { - // Create a service locator that returns null for the service - $mockServiceLocator = new class - { - public function get($serviceClass): null - { - return null; - } - }; - - $testClass = new SingleActionClass; - $actionAttribute = new Action('test_action', 10); - $methodReflection = new ReflectionMethod($testClass, 'actionMethod'); - - // This should not throw an exception - $actionAttribute->handle($mockServiceLocator, $testClass, $methodReflection, $actionAttribute); - - // No assertions needed - we're just checking that it doesn't throw - expect(true)->toBeTrue(); -}); - -afterEach(function (): void { - Mockery::close(); - Facade::clearResolvedInstances(); -}); diff --git a/tests/Unit/Attributes/ArchitectureTest.php b/tests/Unit/Attributes/ArchitectureTest.php new file mode 100644 index 00000000..c3e01bfe --- /dev/null +++ b/tests/Unit/Attributes/ArchitectureTest.php @@ -0,0 +1,31 @@ +expect('Pollora\Attributes') + ->toOnlyUse([ + 'Pollora\Attributes', + \Pollora\Ajax\Domain\Model\AjaxAccess::class, + \Pollora\Abilities\Domain\Model\Behaviour::class, + \Pollora\BlockBinding\Domain\Enums\BindingFieldType::class, + \Pollora\Meta\Domain\Enums\Control::class, + \Pollora\Role\Domain\Enums\Access::class, + \Pollora\Schedule\Every::class, + \Pollora\Schedule\Interval::class, + ]) + // Permission::allow() names WordPress's request and error types in its + // signature only, so the attributes never load them. + ->ignoring([PostTypeAttribute::class, TaxonomyAttribute::class, 'WP_REST_Request', 'WP_Error']); diff --git a/tests/Unit/Attributes/FilterTest.php b/tests/Unit/Attributes/FilterTest.php deleted file mode 100644 index abf6cd87..00000000 --- a/tests/Unit/Attributes/FilterTest.php +++ /dev/null @@ -1,209 +0,0 @@ -mockFilter = Mockery::mock(FilterService::class); - - // Create a fake service locator - $this->mockServiceLocator = new class($this->mockFilter) - { - public function __construct(private $filterService) {} - - public function get($serviceClass) - { - if ($serviceClass === FilterService::class) { - return $this->filterService; - } - - return null; - } - }; -}); - -class SingleFilterClass -{ - #[Filter('test_filter', priority: 10)] - public function filterMethod(string $value): string - { - return 'modified_'.$value; - } -} - -class MultipleFilterClass -{ - #[Filter('test_filter', priority: 10)] - public function filterMethod(string $value): string - { - return 'modified_'.$value; - } - - #[Filter('another_filter', priority: 20)] - public function anotherFilterMethod(array $data): array - { - $data['modified'] = true; - - return $data; - } -} - -class DefaultPriorityFilterClass -{ - #[Filter('test_filter')] - public function filterMethod(string $value): string - { - // Test method with default priority - return 'modified_'.$value; - } -} - -class CustomPriorityFilterClass -{ - #[Filter('test_filter', priority: 99)] - public function filterMethod(string $value): string - { - // Test method with custom priority - return 'modified_'.$value; - } -} - -it('registers a filter hook correctly', function (): void { - // Set up expectations - $this->mockFilter->shouldReceive('add') - ->once() - ->withArgs(fn ($hook, $callback, $priority, $acceptedArgs): bool => $hook === 'test_filter' - && is_array($callback) - && $callback[0] instanceof SingleFilterClass - && $callback[1] === 'filterMethod' - && $priority === 10 - && $acceptedArgs === 1); - - // Test the filter attribute directly using handle method - $testClass = new SingleFilterClass; - $filterAttribute = new Filter('test_filter', 10); - $methodReflection = new ReflectionMethod($testClass, 'filterMethod'); - - $filterAttribute->handle($this->mockServiceLocator, $testClass, $methodReflection, $filterAttribute); -}); - -it('registers multiple filter hooks with different priorities', function (): void { - $testClass = new MultipleFilterClass; - - // Test first filter - $this->mockFilter->shouldReceive('add') - ->once() - ->withArgs(fn ($hook, $callback, $priority, $acceptedArgs): bool => $hook === 'test_filter' - && is_array($callback) - && $callback[0] instanceof MultipleFilterClass - && $callback[1] === 'filterMethod' - && $priority === 10); - - $filterAttribute1 = new Filter('test_filter', 10); - $methodReflection1 = new ReflectionMethod($testClass, 'filterMethod'); - $filterAttribute1->handle($this->mockServiceLocator, $testClass, $methodReflection1, $filterAttribute1); - - // Test second filter - $this->mockFilter->shouldReceive('add') - ->once() - ->withArgs(fn ($hook, $callback, $priority, $acceptedArgs): bool => $hook === 'another_filter' - && is_array($callback) - && $callback[0] instanceof MultipleFilterClass - && $callback[1] === 'anotherFilterMethod' - && $priority === 20); - - $filterAttribute2 = new Filter('another_filter', 20); - $methodReflection2 = new ReflectionMethod($testClass, 'anotherFilterMethod'); - $filterAttribute2->handle($this->mockServiceLocator, $testClass, $methodReflection2, $filterAttribute2); -}); - -it('registers a filter hook with default priority (10)', function (): void { - // Set up expectations - $this->mockFilter->shouldReceive('add') - ->once() - ->withArgs(fn ($hook, $callback, $priority, $acceptedArgs): bool => $hook === 'test_filter' - && is_array($callback) - && $callback[0] instanceof DefaultPriorityFilterClass - && $callback[1] === 'filterMethod' - && $priority === 10 // Default priority should be 10 - && $acceptedArgs === 1); - - // Test with default priority - $testClass = new DefaultPriorityFilterClass; - $filterAttribute = new Filter('test_filter'); // No priority specified, should default to 10 - $methodReflection = new ReflectionMethod($testClass, 'filterMethod'); - - $filterAttribute->handle($this->mockServiceLocator, $testClass, $methodReflection, $filterAttribute); -}); - -it('registers a filter hook with custom priority', function (): void { - // Set up expectations - $this->mockFilter->shouldReceive('add') - ->once() - ->withArgs(fn ($hook, $callback, $priority, $acceptedArgs): bool => $hook === 'test_filter' - && is_array($callback) - && $callback[0] instanceof CustomPriorityFilterClass - && $callback[1] === 'filterMethod' - && $priority === 99 // Custom priority - && $acceptedArgs === 1); - - // Test with custom priority - $testClass = new CustomPriorityFilterClass; - $filterAttribute = new Filter('test_filter', 99); - $methodReflection = new ReflectionMethod($testClass, 'filterMethod'); - - $filterAttribute->handle($this->mockServiceLocator, $testClass, $methodReflection, $filterAttribute); -}); - -it('executes filter and returns modified value', function (): void { - // Set up expectations for the add method - $this->mockFilter->shouldReceive('add') - ->once() - ->withAnyArgs(); - - // Set up expectations for the apply method - $this->mockFilter->shouldReceive('apply') - ->once() - ->with('test_filter', 'original') - ->andReturn('modified_original'); - - // Test filter attribute directly - $testClass = new SingleFilterClass; - $filterAttribute = new Filter('test_filter', 10); - $methodReflection = new ReflectionMethod($testClass, 'filterMethod'); - $filterAttribute->handle($this->mockServiceLocator, $testClass, $methodReflection, $filterAttribute); - - // Test the apply method - $result = $this->mockFilter->apply('test_filter', 'original'); - expect($result)->toBe('modified_original'); -}); - -it('handles null service locator resolution gracefully', function (): void { - // Create a service locator that returns null for the service - $mockServiceLocator = new class - { - public function get($serviceClass): null - { - return null; - } - }; - - $testClass = new SingleFilterClass; - $filterAttribute = new Filter('test_filter', 10); - $methodReflection = new ReflectionMethod($testClass, 'filterMethod'); - - // This should not throw an exception - $filterAttribute->handle($mockServiceLocator, $testClass, $methodReflection, $filterAttribute); - - // No assertions needed - we're just checking that it doesn't throw - expect(true)->toBeTrue(); -}); - -afterEach(function (): void { - Mockery::close(); - Facade::clearResolvedInstances(); -}); diff --git a/tests/Unit/Attributes/HookTest.php b/tests/Unit/Attributes/HookTest.php index 72b02c9c..c153b323 100644 --- a/tests/Unit/Attributes/HookTest.php +++ b/tests/Unit/Attributes/HookTest.php @@ -12,17 +12,7 @@ */ // Create a concrete implementation of the abstract Hook class for testing -class ConcreteHook extends Hook -{ - public function handle( - $serviceLocator, - object $instance, - ReflectionClass|ReflectionMethod $context, - object $attribute - ): void { - // Implementation for testing - } -} +class ConcreteHook extends Hook {} it('initializes with default priority', function (): void { $hook = new ConcreteHook('test_hook'); diff --git a/tests/Unit/Attributes/LabelsAttributeTest.php b/tests/Unit/Attributes/LabelsAttributeTest.php index 84bde720..2f6b4af1 100644 --- a/tests/Unit/Attributes/LabelsAttributeTest.php +++ b/tests/Unit/Attributes/LabelsAttributeTest.php @@ -56,9 +56,7 @@ class GenreWithLabels {} $reflection = new ReflectionClass(ProjectWithLabels::class); $attr = $reflection->getAttributes(PostTypeLabels::class)[0]->newInstance(); - $attr->handle(null, $config, $reflection, $attr); - - $labels = $config->attributeArgs['labels']; + $labels = $attr->configureArguments($config->attributeArgs)['labels']; // Overridden by #[Labels] expect($labels['all_items'])->toBe('All Projects'); @@ -88,10 +86,7 @@ class GenreWithLabels {} ); $config = new PostTypeConfiguration('item', 'Item', 'Items'); - $reflection = new ReflectionClass(ArticleWithoutLabels::class); - $labels->handle(null, $config, $reflection, $labels); - - $result = $config->attributeArgs['labels']; + $result = $labels->configureArguments($config->attributeArgs)['labels']; expect($result)->toBe([ 'name' => 'My Items', @@ -126,9 +121,7 @@ class GenreWithLabels {} $reflection = new ReflectionClass(GenreWithLabels::class); $attr = $reflection->getAttributes(TaxonomyLabels::class)[0]->newInstance(); - $attr->handle(null, $config, $reflection, $attr); - - $labels = $config->attributeArgs['labels']; + $labels = $attr->configureArguments($config->attributeArgs)['labels']; // Overridden expect($labels['all_items'])->toBe('All Genres'); diff --git a/tests/Unit/Attributes/NamingTest.php b/tests/Unit/Attributes/NamingTest.php new file mode 100644 index 00000000..c0d9c143 --- /dev/null +++ b/tests/Unit/Attributes/NamingTest.php @@ -0,0 +1,49 @@ + 'SyntheseDePresse', + 'single word' => 'Book', + 'already lower' => 'book', + 'camelCase property' => 'eventDate', + 'acronym' => 'HTMLParser', + 'digits' => 'Top10Books', + 'underscore' => 'event_date', + 'spaces' => 'event date time', + 'accented capital' => 'ÉvénementPublic', + 'trailing capital' => 'PostA', +]); + +it('writes snake case as Str::snake() does', function (string $name): void { + expect(Naming::snake($name))->toBe(Str::snake($name)); +})->with('names'); + +it('writes kebab case as Str::kebab() does', function (string $name): void { + expect(Naming::kebab($name))->toBe(Str::kebab($name)); +})->with('names'); + +it('strips the namespace as class_basename() does', function (): void { + expect(Naming::classBasename('App\\Cms\\PostTypes\\Book'))->toBe(class_basename('App\\Cms\\PostTypes\\Book')) + ->and(Naming::classBasename('Book'))->toBe('Book'); +}); + +it('derives command slugs, the Command suffix dropped', function (): void { + expect(Naming::commandFromClassName('App\\Cli\\UserManagementCommand'))->toBe('user-management') + ->and(Naming::commandFromClassName('Cache'))->toBe('cache') + ->and(Naming::slug('deleteById'))->toBe('delete-by-id') + ->and(Naming::slug('HTMLParser'))->toBe('htmlparser') + ->and(Slug::fromClassName('App\\Cli\\UserManagementCommand'))->toBe('user-management') + ->and(Slug::fromMethodName('listItems'))->toBe('list-items'); +}); diff --git a/tests/Unit/Attributes/TaxonomyAttributeTest.php b/tests/Unit/Attributes/TaxonomyAttributeTest.php index 66edb2fb..52a346d5 100644 --- a/tests/Unit/Attributes/TaxonomyAttributeTest.php +++ b/tests/Unit/Attributes/TaxonomyAttributeTest.php @@ -5,6 +5,7 @@ use Illuminate\Container\Container; use Illuminate\Support\Facades\Facade; use Mockery as m; +use Pollora\Attributes\Contracts\ConfiguresArguments; use Pollora\Attributes\Taxonomy; use Pollora\Attributes\Taxonomy\AllowHierarchy; use Pollora\Attributes\Taxonomy\Args; @@ -161,8 +162,8 @@ function testBooleanAttribute(string $attributeName, string $argName): void $reflectionClass = new ReflectionClass($taxonomy); foreach ($reflectionClass->getAttributes() as $attribute) { $attributeInstance = $attribute->newInstance(); - if (method_exists($attributeInstance, 'handle')) { - $attributeInstance->handle(app(), $taxonomy, $reflectionClass, $attributeInstance); + if ($attributeInstance instanceof ConfiguresArguments) { + $taxonomy->attributeArgs = $attributeInstance->configureArguments($taxonomy->attributeArgs); } } @@ -180,8 +181,8 @@ function testValueAttribute(string $attributeName, string $argName, mixed $expec $reflectionClass = new ReflectionClass($taxonomy); foreach ($reflectionClass->getAttributes() as $attribute) { $attributeInstance = $attribute->newInstance(); - if (method_exists($attributeInstance, 'handle')) { - $attributeInstance->handle(app(), $taxonomy, $reflectionClass, $attributeInstance); + if ($attributeInstance instanceof ConfiguresArguments) { + $taxonomy->attributeArgs = $attributeInstance->configureArguments($taxonomy->attributeArgs); } } @@ -206,7 +207,7 @@ function testMethodAttribute(string $attributeName, string $argName, string $met $attributes = $method->getAttributes($attributeClass); foreach ($attributes as $attribute) { $attributeInstance = $attribute->newInstance(); - $attributeInstance->handle(null, $taxonomy, $method, $attributeInstance); + $taxonomy->attributeArgs = $attributeInstance->configureArguments($taxonomy->attributeArgs, [$taxonomy, $method->getName()]); } } @@ -259,8 +260,8 @@ function testMethodAttribute(string $attributeName, string $argName, string $met $reflectionClass = new ReflectionClass($taxonomy); foreach ($reflectionClass->getAttributes() as $attribute) { $attributeInstance = $attribute->newInstance(); - if (method_exists($attributeInstance, 'handle')) { - $attributeInstance->handle(app(), $taxonomy, $reflectionClass, $attributeInstance); + if ($attributeInstance instanceof ConfiguresArguments) { + $taxonomy->attributeArgs = $attributeInstance->configureArguments($taxonomy->attributeArgs); } } diff --git a/tests/Unit/PostType/PostTypeDiscoveryTest.php b/tests/Unit/PostType/PostTypeDiscoveryTest.php new file mode 100644 index 00000000..be45aed2 --- /dev/null +++ b/tests/Unit/PostType/PostTypeDiscoveryTest.php @@ -0,0 +1,114 @@ + */ + public array $calls = []; + + #[AdminCol('isbn', 'ISBN', sortable: true)] + public function isbnColumn(): string + { + return '978'; + } + + #[RegisterMetaBoxCb] + public function metaBoxes(): void + { + $this->calls[] = 'metaBoxes'; + } + + #[Action('init')] + public function onInit(): void + { + $this->calls[] = 'onInit'; + } +} + +/** + * Run the discovery over the given classes and return what reached the post type service. + * + * @return array{slug: string, singular: string, plural: string, args: array, priority: int} + */ +function registeredPostType(string $class): array +{ + $registered = []; + $service = Mockery::mock(PostTypeServiceInterface::class); + $service->shouldReceive('register')->once()->andReturnUsing( + function (string $slug, string $singular, string $plural, array $args, int $priority) use (&$registered): object { + $registered = ['slug' => $slug, 'singular' => $singular, 'plural' => $plural, 'args' => $args, 'priority' => $priority]; + + return new stdClass; + } + ); + + $discovery = new PostTypeDiscovery($service); + $discovery->discover(new DiscoveryLocation('Tests\\', __DIR__), DiscoveredClass::fromReflection(new ReflectionClass($class)), new ReflectionCache); + $discovery->apply(); + + return $registered; +} + +it('registers the arguments its attributes declare', function (): void { + $registered = registeredPostType(DiscoveredBook::class); + + expect($registered['slug'])->toBe('fixture_book') + ->and($registered['priority'])->toBe(7) + ->and($registered['args'])->toMatchArray([ + 'has_archive' => 'library', + 'supports' => ['title', 'excerpt'], + 'orderby' => 'date', + 'order' => 'DESC', + 'text_domain' => 'pollora', + ]) + ->and($registered['args'])->not->toHaveKey('priority'); +}); + +it('merges declared labels into the generated ones', function (): void { + $labels = registeredPostType(DiscoveredBook::class)['args']['labels']; + + expect($labels['menu_name'])->toBe('Library') + ->and($labels['singular_name'])->toBe('Book'); +}); + +it('turns method attributes into callbacks on the declaring class', function (): void { + $args = registeredPostType(DiscoveredBook::class)['args']; + + expect($args['admin_cols']['isbn'])->toMatchArray(['title' => 'ISBN', 'sortable' => true]) + ->and($args['admin_cols']['isbn']['function'][0])->toBeInstanceOf(DiscoveredBook::class) + ->and($args['admin_cols']['isbn']['function'][1])->toBe('isbnColumn') + ->and($args['register_meta_box_cb'][0])->toBeInstanceOf(DiscoveredBook::class) + ->and($args['register_meta_box_cb'][1])->toBe('metaBoxes'); +}); + +it('leaves the actions of a post type class to the hook discovery', function (): void { + $actions = Mockery::mock(ActionService::class); + $actions->shouldNotReceive('add'); + + app()->instance(ActionService::class, $actions); + + registeredPostType(DiscoveredBook::class); +}); diff --git a/tests/Unit/Taxonomy/TaxonomyDiscoveryTest.php b/tests/Unit/Taxonomy/TaxonomyDiscoveryTest.php new file mode 100644 index 00000000..c025d307 --- /dev/null +++ b/tests/Unit/Taxonomy/TaxonomyDiscoveryTest.php @@ -0,0 +1,86 @@ + */ + public array $calls = []; + + #[MetaBoxCb] + public function metaBox(): void + { + $this->calls[] = 'metaBox'; + } + + #[Action('init')] + public function onInit(): void + { + $this->calls[] = 'onInit'; + } +} + +/** + * Run the discovery over the class and return the arguments it registered. + * + * @return array + */ +function registeredTaxonomyArgs(string $class): array +{ + $registered = []; + $service = Mockery::mock(TaxonomyServiceInterface::class); + $service->shouldReceive('register')->once()->andReturnUsing( + function (string $slug, string|array $objectType, string $singular, string $plural, array $args) use (&$registered): object { + $registered = $args; + + return new stdClass; + } + ); + + $discovery = new TaxonomyDiscovery($service); + $discovery->discover(new DiscoveryLocation('Tests\\', __DIR__), DiscoveredClass::fromReflection(new ReflectionClass($class)), new ReflectionCache); + $discovery->apply(); + + return $registered; +} + +it('registers the arguments its attributes declare, labels merged', function (): void { + $args = registeredTaxonomyArgs(DiscoveredGenre::class); + + expect($args)->toMatchArray(['hierarchical' => true, 'show_in_rest' => true]) + ->and($args['labels']['menu_name'])->toBe('Shelves') + ->and($args['labels']['singular_name'])->toBe('Genre'); +}); + +it('turns a method attribute into a callback on the declaring class', function (): void { + $callback = registeredTaxonomyArgs(DiscoveredGenre::class)['meta_box_cb']; + + expect($callback[0])->toBeInstanceOf(DiscoveredGenre::class) + ->and($callback[1])->toBe('metaBox'); +}); + +it('leaves the actions of a taxonomy class to the hook discovery', function (): void { + $actions = Mockery::mock(ActionService::class); + $actions->shouldNotReceive('add'); + + app()->instance(ActionService::class, $actions); + + registeredTaxonomyArgs(DiscoveredGenre::class); +}); diff --git a/tests/Unit/WpRest/CanPermissionTest.php b/tests/Unit/WpRest/CanPermissionTest.php index e2c2b1e9..d977c375 100644 --- a/tests/Unit/WpRest/CanPermissionTest.php +++ b/tests/Unit/WpRest/CanPermissionTest.php @@ -3,7 +3,7 @@ declare(strict_types=1); use Brain\Monkey\Functions; -use Pollora\Attributes\WpRestRoute\Method; +use Pollora\WpRest\Infrastructure\Services\WpRestRouteRegistrar; use Pollora\WpRest\Permissions\Can; use Tests\Unit\Role\Fixtures\EventCap; @@ -37,9 +37,9 @@ it('is accepted as an instance by #[Method] and #[WpRestRoute]', function (): void { Functions\when('current_user_can')->justReturn(true); - $resolve = new ReflectionMethod(Method::class, 'resolvePermissionCallback'); + $resolve = new ReflectionMethod(WpRestRouteRegistrar::class, 'resolvePermissionCallback'); - $callback = $resolve->invoke(new Method('GET', permissionCallback: new Can('edit_posts')), new Can('edit_posts')); + $callback = $resolve->invoke(new WpRestRouteRegistrar, new Can('edit_posts')); expect($callback(Mockery::mock(WP_REST_Request::class)))->toBeTrue(); }); diff --git a/tests/Unit/WpRest/RestRequestInjectionTest.php b/tests/Unit/WpRest/RestRequestInjectionTest.php index d69922c1..56684741 100644 --- a/tests/Unit/WpRest/RestRequestInjectionTest.php +++ b/tests/Unit/WpRest/RestRequestInjectionTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use Pollora\Attributes\WpRestRoute\Method; +use Pollora\WpRest\Infrastructure\Services\WpRestRouteRegistrar; /** * Which arguments a REST handler is invoked with. @@ -27,12 +27,12 @@ public function withBuiltin(int $page): void {} $param = (new ReflectionMethod($probe, $signatureMethod))->getParameters()[0]; - $method = (new ReflectionClass(Method::class))->getMethod('expectsTheRequest'); + $method = (new ReflectionClass(WpRestRouteRegistrar::class))->getMethod('expectsTheRequest'); - return $method->invoke(new Method('GET'), $param); + return $method->invoke(new WpRestRouteRegistrar, $param); } -describe('Method::expectsTheRequest()', function (): void { +describe('WpRestRouteRegistrar::expectsTheRequest()', function (): void { it('gives the request to a parameter that asks for it by type', function (): void { expect(expectsRequest('withRequest'))->toBeTrue(); }); From d857a9b847125857a591c901187c928566af0f15 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Mon, 12 Oct 2026 14:57:48 +0200 Subject: [PATCH 04/27] style: import the enumerations the architecture test allows --- tests/Unit/Attributes/ArchitectureTest.php | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/tests/Unit/Attributes/ArchitectureTest.php b/tests/Unit/Attributes/ArchitectureTest.php index c3e01bfe..2cb2cff2 100644 --- a/tests/Unit/Attributes/ArchitectureTest.php +++ b/tests/Unit/Attributes/ArchitectureTest.php @@ -2,8 +2,15 @@ declare(strict_types=1); +use Pollora\Abilities\Domain\Model\Behaviour; +use Pollora\Ajax\Domain\Model\AjaxAccess; use Pollora\Attributes\PostType\PostTypeAttribute; use Pollora\Attributes\Taxonomy\TaxonomyAttribute; +use Pollora\BlockBinding\Domain\Enums\BindingFieldType; +use Pollora\Meta\Domain\Enums\Control; +use Pollora\Role\Domain\Enums\Access; +use Pollora\Schedule\Every; +use Pollora\Schedule\Interval; /** * Attributes are declarations: a plugin writes them without the framework, an @@ -18,13 +25,13 @@ ->expect('Pollora\Attributes') ->toOnlyUse([ 'Pollora\Attributes', - \Pollora\Ajax\Domain\Model\AjaxAccess::class, - \Pollora\Abilities\Domain\Model\Behaviour::class, - \Pollora\BlockBinding\Domain\Enums\BindingFieldType::class, - \Pollora\Meta\Domain\Enums\Control::class, - \Pollora\Role\Domain\Enums\Access::class, - \Pollora\Schedule\Every::class, - \Pollora\Schedule\Interval::class, + AjaxAccess::class, + Behaviour::class, + BindingFieldType::class, + Control::class, + Access::class, + Every::class, + Interval::class, ]) // Permission::allow() names WordPress's request and error types in its // signature only, so the attributes never load them. From d4e7a8567e4f4bb7f121448d9fad1e9bc14b9f3a Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Mon, 12 Oct 2026 15:12:21 +0200 Subject: [PATCH 05/27] test: use the parameters of the hook fixture so Rector keeps them --- tests/Feature/Hook/HookDiscoveryTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Feature/Hook/HookDiscoveryTest.php b/tests/Feature/Hook/HookDiscoveryTest.php index 15c40a00..487f9a15 100644 --- a/tests/Feature/Hook/HookDiscoveryTest.php +++ b/tests/Feature/Hook/HookDiscoveryTest.php @@ -26,7 +26,7 @@ public function boot(): void #[ActionAttribute('edit_post', priority: 20)] public function syncPost(int $postId, object $post): void { - $this->calls[] = 'syncPost'; + $this->calls[] = sprintf('syncPost(%d, %s)', $postId, $post::class); } #[FilterAttribute('the_title')] From 155fc336f1d80824f9ea2cc692b075265c9e887c Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Mon, 12 Oct 2026 16:58:11 +0200 Subject: [PATCH 06/27] refactor: require pollora/attributes instead of shipping the attributes --- CHANGELOG.md | 1 + composer.json | 1 + src/Attributes/Ability.php | 56 --- src/Attributes/Action.php | 16 - src/Attributes/Ajax.php | 38 -- src/Attributes/Async.php | 53 --- src/Attributes/Attributable.php | 19 - src/Attributes/BlockBinding.php | 50 --- src/Attributes/BlockBinding/BindingField.php | 33 -- src/Attributes/CapabilitySet.php | 28 -- src/Attributes/CommentMeta.php | 25 -- .../Contracts/ConfiguresArguments.php | 24 -- .../ConfiguresArgumentsWithMethod.php | 24 -- .../Contracts/HandlesAttributes.php | 14 - src/Attributes/Filter.php | 16 - src/Attributes/Hook.php | 25 -- src/Attributes/Meta.php | 76 ---- src/Attributes/ModifyRole.php | 34 -- src/Attributes/PostMeta.php | 37 -- src/Attributes/PostType.php | 56 --- src/Attributes/PostType/AdminCol.php | 125 ------- src/Attributes/PostType/AdminCols.php | 42 --- src/Attributes/PostType/AdminFilters.php | 42 --- src/Attributes/PostType/Archive.php | 42 --- src/Attributes/PostType/BlockEditor.php | 42 --- src/Attributes/PostType/CanExport.php | 42 --- src/Attributes/PostType/Capabilities.php | 42 --- src/Attributes/PostType/CapabilityType.php | 47 --- src/Attributes/PostType/Chronological.php | 47 --- src/Attributes/PostType/DashboardActivity.php | 43 --- src/Attributes/PostType/DashboardGlance.php | 41 --- src/Attributes/PostType/DeleteWithUser.php | 43 --- src/Attributes/PostType/Description.php | 42 --- src/Attributes/PostType/ExcludeFromSearch.php | 42 --- src/Attributes/PostType/FeaturedImage.php | 42 --- src/Attributes/PostType/HasArchive.php | 42 --- src/Attributes/PostType/Hierarchical.php | 42 --- src/Attributes/PostType/Label.php | 42 --- src/Attributes/PostType/Labels.php | 102 ------ src/Attributes/PostType/MapMetaCap.php | 42 --- src/Attributes/PostType/MenuIcon.php | 42 --- src/Attributes/PostType/MenuPosition.php | 53 --- src/Attributes/PostType/Priority.php | 37 -- src/Attributes/PostType/PublicPostType.php | 42 --- src/Attributes/PostType/PubliclyQueryable.php | 41 --- src/Attributes/PostType/QueryVar.php | 44 --- src/Attributes/PostType/QuickEdit.php | 43 --- src/Attributes/PostType/RegisterMetaBoxCb.php | 36 -- src/Attributes/PostType/RestBase.php | 42 --- .../PostType/RestControllerClass.php | 42 --- src/Attributes/PostType/RestNamespace.php | 42 --- src/Attributes/PostType/Rewrite.php | 44 --- src/Attributes/PostType/ShowInAdminBar.php | 42 --- src/Attributes/PostType/ShowInFeed.php | 42 --- src/Attributes/PostType/ShowInMenu.php | 44 --- src/Attributes/PostType/ShowInNavMenus.php | 42 --- src/Attributes/PostType/ShowInRest.php | 43 --- src/Attributes/PostType/ShowUI.php | 42 --- src/Attributes/PostType/SiteFilters.php | 42 --- src/Attributes/PostType/SiteSortables.php | 42 --- src/Attributes/PostType/Supports.php | 41 --- src/Attributes/PostType/Taxonomies.php | 42 --- src/Attributes/PostType/Template.php | 42 --- src/Attributes/PostType/TemplateLock.php | 44 --- src/Attributes/PostType/TitlePlaceholder.php | 42 --- src/Attributes/Role.php | 42 --- src/Attributes/Role/Grants.php | 26 -- src/Attributes/Role/GrantsPostType.php | 27 -- src/Attributes/Role/GrantsTaxonomy.php | 24 -- src/Attributes/Role/Without.php | 28 -- src/Attributes/Schedule.php | 46 --- src/Attributes/Support/Naming.php | 73 ---- src/Attributes/Taxonomy.php | 66 ---- src/Attributes/Taxonomy/AllowHierarchy.php | 42 --- src/Attributes/Taxonomy/Args.php | 39 -- src/Attributes/Taxonomy/Capabilities.php | 48 --- src/Attributes/Taxonomy/CheckedOntop.php | 39 -- src/Attributes/Taxonomy/DefaultTerm.php | 39 -- src/Attributes/Taxonomy/Description.php | 39 -- src/Attributes/Taxonomy/Exclusive.php | 42 --- src/Attributes/Taxonomy/Hierarchical.php | 39 -- src/Attributes/Taxonomy/Label.php | 39 -- src/Attributes/Taxonomy/Labels.php | 89 ----- src/Attributes/Taxonomy/MetaBoxCb.php | 34 -- src/Attributes/Taxonomy/MetaBoxSanitizeCb.php | 34 -- src/Attributes/Taxonomy/ObjectType.php | 39 -- src/Attributes/Taxonomy/Priority.php | 37 -- src/Attributes/Taxonomy/PublicTaxonomy.php | 42 --- src/Attributes/Taxonomy/QueryVar.php | 39 -- src/Attributes/Taxonomy/RestBase.php | 39 -- .../Taxonomy/RestControllerClass.php | 39 -- src/Attributes/Taxonomy/RestNamespace.php | 39 -- src/Attributes/Taxonomy/Rewrite.php | 39 -- src/Attributes/Taxonomy/ShowAdminColumn.php | 39 -- src/Attributes/Taxonomy/ShowInMenu.php | 44 --- src/Attributes/Taxonomy/ShowInNavMenus.php | 39 -- src/Attributes/Taxonomy/ShowInQuickEdit.php | 39 -- src/Attributes/Taxonomy/ShowInRest.php | 39 -- src/Attributes/Taxonomy/ShowTagcloud.php | 39 -- src/Attributes/Taxonomy/ShowUI.php | 39 -- src/Attributes/Taxonomy/Sort.php | 39 -- .../Taxonomy/UpdateCountCallback.php | 34 -- src/Attributes/TermMeta.php | 37 -- src/Attributes/UserMeta.php | 25 -- src/Attributes/WpCli.php | 44 --- src/Attributes/WpCli/AfterInvoke.php | 24 -- src/Attributes/WpCli/BeforeInvoke.php | 24 -- src/Attributes/WpCli/Command.php | 47 --- src/Attributes/WpCli/IsDeferred.php | 25 -- src/Attributes/WpCli/Synopsis.php | 26 -- src/Attributes/WpCli/When.php | 26 -- src/Attributes/WpRestRoute.php | 28 -- src/Attributes/WpRestRoute/Method.php | 60 --- src/Attributes/WpRestRoute/Permission.php | 20 - .../Domain/Enums/BindingFieldType.php | 24 -- src/Meta/Domain/Enums/Control.php | 25 -- src/Role/Domain/Enums/Access.php | 40 -- src/Schedule/Every.php | 44 --- src/Schedule/Interval.php | 34 -- tests/Unit/Attributes/ArchitectureTest.php | 38 -- tests/Unit/Attributes/HookTest.php | 56 --- tests/Unit/Attributes/NamingTest.php | 49 --- .../Unit/Attributes/PostTypeAttributeTest.php | 344 ------------------ .../Attributes/PostTypeSubAttributesTest.php | 271 -------------- tests/Unit/Attributes/ScheduleTest.php | 158 -------- .../Attributes/TaxonomySubAttributesTest.php | 313 ---------------- tests/Unit/Schedule/EveryTest.php | 64 ---- tests/Unit/Schedule/IntervalTest.php | 67 ---- tests/Unit/Support/SlugTest.php | 20 + 129 files changed, 22 insertions(+), 6108 deletions(-) delete mode 100644 src/Attributes/Ability.php delete mode 100755 src/Attributes/Action.php delete mode 100644 src/Attributes/Ajax.php delete mode 100644 src/Attributes/Async.php delete mode 100755 src/Attributes/Attributable.php delete mode 100644 src/Attributes/BlockBinding.php delete mode 100644 src/Attributes/BlockBinding/BindingField.php delete mode 100644 src/Attributes/CapabilitySet.php delete mode 100644 src/Attributes/CommentMeta.php delete mode 100644 src/Attributes/Contracts/ConfiguresArguments.php delete mode 100644 src/Attributes/Contracts/ConfiguresArgumentsWithMethod.php delete mode 100755 src/Attributes/Contracts/HandlesAttributes.php delete mode 100755 src/Attributes/Filter.php delete mode 100755 src/Attributes/Hook.php delete mode 100644 src/Attributes/Meta.php delete mode 100644 src/Attributes/ModifyRole.php delete mode 100644 src/Attributes/PostMeta.php delete mode 100644 src/Attributes/PostType.php delete mode 100755 src/Attributes/PostType/AdminCol.php delete mode 100755 src/Attributes/PostType/AdminCols.php delete mode 100755 src/Attributes/PostType/AdminFilters.php delete mode 100755 src/Attributes/PostType/Archive.php delete mode 100755 src/Attributes/PostType/BlockEditor.php delete mode 100755 src/Attributes/PostType/CanExport.php delete mode 100755 src/Attributes/PostType/Capabilities.php delete mode 100755 src/Attributes/PostType/CapabilityType.php delete mode 100755 src/Attributes/PostType/Chronological.php delete mode 100755 src/Attributes/PostType/DashboardActivity.php delete mode 100755 src/Attributes/PostType/DashboardGlance.php delete mode 100755 src/Attributes/PostType/DeleteWithUser.php delete mode 100755 src/Attributes/PostType/Description.php delete mode 100755 src/Attributes/PostType/ExcludeFromSearch.php delete mode 100755 src/Attributes/PostType/FeaturedImage.php delete mode 100755 src/Attributes/PostType/HasArchive.php delete mode 100755 src/Attributes/PostType/Hierarchical.php delete mode 100755 src/Attributes/PostType/Label.php delete mode 100755 src/Attributes/PostType/Labels.php delete mode 100755 src/Attributes/PostType/MapMetaCap.php delete mode 100755 src/Attributes/PostType/MenuIcon.php delete mode 100755 src/Attributes/PostType/MenuPosition.php delete mode 100755 src/Attributes/PostType/Priority.php delete mode 100755 src/Attributes/PostType/PublicPostType.php delete mode 100755 src/Attributes/PostType/PubliclyQueryable.php delete mode 100755 src/Attributes/PostType/QueryVar.php delete mode 100755 src/Attributes/PostType/QuickEdit.php delete mode 100755 src/Attributes/PostType/RegisterMetaBoxCb.php delete mode 100755 src/Attributes/PostType/RestBase.php delete mode 100755 src/Attributes/PostType/RestControllerClass.php delete mode 100755 src/Attributes/PostType/RestNamespace.php delete mode 100755 src/Attributes/PostType/Rewrite.php delete mode 100755 src/Attributes/PostType/ShowInAdminBar.php delete mode 100755 src/Attributes/PostType/ShowInFeed.php delete mode 100755 src/Attributes/PostType/ShowInMenu.php delete mode 100755 src/Attributes/PostType/ShowInNavMenus.php delete mode 100755 src/Attributes/PostType/ShowInRest.php delete mode 100755 src/Attributes/PostType/ShowUI.php delete mode 100755 src/Attributes/PostType/SiteFilters.php delete mode 100755 src/Attributes/PostType/SiteSortables.php delete mode 100755 src/Attributes/PostType/Supports.php delete mode 100755 src/Attributes/PostType/Taxonomies.php delete mode 100755 src/Attributes/PostType/Template.php delete mode 100755 src/Attributes/PostType/TemplateLock.php delete mode 100755 src/Attributes/PostType/TitlePlaceholder.php delete mode 100644 src/Attributes/Role.php delete mode 100644 src/Attributes/Role/Grants.php delete mode 100644 src/Attributes/Role/GrantsPostType.php delete mode 100644 src/Attributes/Role/GrantsTaxonomy.php delete mode 100644 src/Attributes/Role/Without.php delete mode 100755 src/Attributes/Schedule.php delete mode 100644 src/Attributes/Support/Naming.php delete mode 100644 src/Attributes/Taxonomy.php delete mode 100755 src/Attributes/Taxonomy/AllowHierarchy.php delete mode 100755 src/Attributes/Taxonomy/Args.php delete mode 100644 src/Attributes/Taxonomy/Capabilities.php delete mode 100755 src/Attributes/Taxonomy/CheckedOntop.php delete mode 100755 src/Attributes/Taxonomy/DefaultTerm.php delete mode 100755 src/Attributes/Taxonomy/Description.php delete mode 100755 src/Attributes/Taxonomy/Exclusive.php delete mode 100755 src/Attributes/Taxonomy/Hierarchical.php delete mode 100755 src/Attributes/Taxonomy/Label.php delete mode 100755 src/Attributes/Taxonomy/Labels.php delete mode 100755 src/Attributes/Taxonomy/MetaBoxCb.php delete mode 100755 src/Attributes/Taxonomy/MetaBoxSanitizeCb.php delete mode 100755 src/Attributes/Taxonomy/ObjectType.php delete mode 100755 src/Attributes/Taxonomy/Priority.php delete mode 100755 src/Attributes/Taxonomy/PublicTaxonomy.php delete mode 100755 src/Attributes/Taxonomy/QueryVar.php delete mode 100755 src/Attributes/Taxonomy/RestBase.php delete mode 100755 src/Attributes/Taxonomy/RestControllerClass.php delete mode 100755 src/Attributes/Taxonomy/RestNamespace.php delete mode 100755 src/Attributes/Taxonomy/Rewrite.php delete mode 100755 src/Attributes/Taxonomy/ShowAdminColumn.php delete mode 100755 src/Attributes/Taxonomy/ShowInMenu.php delete mode 100755 src/Attributes/Taxonomy/ShowInNavMenus.php delete mode 100755 src/Attributes/Taxonomy/ShowInQuickEdit.php delete mode 100755 src/Attributes/Taxonomy/ShowInRest.php delete mode 100755 src/Attributes/Taxonomy/ShowTagcloud.php delete mode 100755 src/Attributes/Taxonomy/ShowUI.php delete mode 100755 src/Attributes/Taxonomy/Sort.php delete mode 100755 src/Attributes/Taxonomy/UpdateCountCallback.php delete mode 100644 src/Attributes/TermMeta.php delete mode 100644 src/Attributes/UserMeta.php delete mode 100644 src/Attributes/WpCli.php delete mode 100644 src/Attributes/WpCli/AfterInvoke.php delete mode 100644 src/Attributes/WpCli/BeforeInvoke.php delete mode 100644 src/Attributes/WpCli/Command.php delete mode 100644 src/Attributes/WpCli/IsDeferred.php delete mode 100644 src/Attributes/WpCli/Synopsis.php delete mode 100644 src/Attributes/WpCli/When.php delete mode 100755 src/Attributes/WpRestRoute.php delete mode 100755 src/Attributes/WpRestRoute/Method.php delete mode 100755 src/Attributes/WpRestRoute/Permission.php delete mode 100644 src/BlockBinding/Domain/Enums/BindingFieldType.php delete mode 100644 src/Meta/Domain/Enums/Control.php delete mode 100644 src/Role/Domain/Enums/Access.php delete mode 100644 src/Schedule/Every.php delete mode 100644 src/Schedule/Interval.php delete mode 100644 tests/Unit/Attributes/ArchitectureTest.php delete mode 100644 tests/Unit/Attributes/HookTest.php delete mode 100644 tests/Unit/Attributes/NamingTest.php delete mode 100644 tests/Unit/Attributes/PostTypeAttributeTest.php delete mode 100644 tests/Unit/Attributes/PostTypeSubAttributesTest.php delete mode 100644 tests/Unit/Attributes/ScheduleTest.php delete mode 100644 tests/Unit/Attributes/TaxonomySubAttributesTest.php delete mode 100644 tests/Unit/Schedule/EveryTest.php delete mode 100644 tests/Unit/Schedule/IntervalTest.php create mode 100644 tests/Unit/Support/SlugTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 92321442..93b92baf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Discovery lives in its own package, [`pollora/discovery`](https://github.com/Pollora/discovery), which the framework requires: the engine, its contracts, `DiscoveryManager`, the cache and the `#[SkipDiscovery]` attribute, with their tests and benchmarks. Class names do not change. The framework keeps what needs Laravel: the service provider, `discovery:run` / `discovery:clear`, `DiscoveryRegistrar`, `ServiceProviderDiscovery`, the Laravel adapters, and `HasConfiguringSupport`, which builds post types and taxonomies - Discovery can run without Laravel's application, the first step towards `pollora/discovery`: `new DiscoveryEngine()` works with no argument. Two contracts let an environment plug in — `InstanceResolverInterface` builds the classes discovery needs (Laravel's container in a Pollora application, `ReflectionInstanceResolver` otherwise) and `DiscoveryRegistrarInterface` adds the environment's discoveries before each run (`DiscoveryRegistrar` keeps scanning the container's `*Discovery` bindings). `Psr16DiscoverCacheDriver` caches scans in any PSR-16 cache - Post type and taxonomy attributes say what they register: each implements `ConfiguresArguments`, a pure function that returns the registration arguments with its own contribution (`#[HasArchive('library')]` sets `has_archive`, `#[Labels]` merges into the generated labels), and the method attributes (`#[AdminCol]`, `#[RegisterMetaBoxCb]`, `#[MetaBoxCb]`, `#[MetaBoxSanitizeCb]`, `#[UpdateCountCallback]`) implement `ConfiguresArgumentsWithMethod`, the discovery handing them the method as a callback. The `Pollora\Attributes` namespace no longer reaches Laravel or a framework service, which an architecture test keeps: the first step towards `pollora/attributes`, a package to declare attributes without the framework +- The attributes live in their own package, [`pollora/attributes`](https://github.com/Pollora/attributes), which the framework requires: every attribute and its contracts, with the value types they take (`Every`, `Interval`, `Access`, `Control`, `BindingFieldType`). Class names do not change. The framework keeps the two deprecated bases, `PostTypeAttribute` and `TaxonomyAttribute`, and the code that reads the attributes ### Changed - `DiscoveryEngine`, `DiscoveryCacheManager`, `InstancePool` and `ReflectionCache` take their dependencies instead of reaching for `app()` and `config()`; reading `structure-discoverer.cache` moved to `LaravelDiscoveryCacheManagerFactory`. `DiscoveryRegistrar::registerFromContainer()` is now `registerInto()`. These are infrastructure classes, outside the public contracts (`Pollora\Discovery\Domain\*`), which do not change diff --git a/composer.json b/composer.json index a5f9f69b..af63004e 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,7 @@ "illuminate/console": "^13.35", "pollora/abilities": "^1.0", "pollora/ajax": "^1.1", + "pollora/attributes": "^0.1 || ^1.0", "pollora/discovery": "^0.1 || ^1.0", "pollora/entity": "^1.2", "pollora/hook": "^1.5", diff --git a/src/Attributes/Ability.php b/src/Attributes/Ability.php deleted file mode 100644 index 1367df84..00000000 --- a/src/Attributes/Ability.php +++ /dev/null @@ -1,56 +0,0 @@ -async(): #[Action] keeps the hook and - * the priority, #[Async] carries the deferred-execution options. - * - * #[Action('save_post_event')] - * #[Async(delay: 60, unique: true, tries: 3)] - * public function syncToCrm(int $postId): void {} - * - * On a class, it applies to every #[Action] method; an #[Async] on a method - * replaces the class options for that method. With several #[Action] on a - * method, it applies to all of them, except the hooks listed in $except. - */ -#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] -final readonly class Async -{ - /** - * @param int|\DateInterval|null $delay Minimum delay before execution, in seconds - * @param string|null $via Driver: queue, action-scheduler, wp-cron, sync - * @param string|null $onQueue Queue name, the group with Action Scheduler - * @param bool|int $unique Merge identical triggers until the first one runs; an integer sets how long the lock lasts, in seconds - * @param int|null $tries Attempts when the handler throws - * @param int|list|null $backoff Seconds before each retry - * @param bool|null $asUser Run as the user who fired the hook - * @param string|null $capture Public method of the class recording values at trigger time - * @param string|null $when Public method of the class deciding, at trigger time, whether to queue - * @param bool $keepMissing Run with null in place of a referenced object deleted in the meantime - * @param string|list $except Hooks of the method that stay synchronous - */ - public function __construct( - public int|\DateInterval|null $delay = null, - public ?string $via = null, - public ?string $onQueue = null, - public bool|int $unique = false, - public ?int $tries = null, - public int|array|null $backoff = null, - public ?bool $asUser = null, - public ?string $capture = null, - public ?string $when = null, - public bool $keepMissing = false, - public string|array $except = [], - ) {} -} diff --git a/src/Attributes/Attributable.php b/src/Attributes/Attributable.php deleted file mode 100755 index 942df62d..00000000 --- a/src/Attributes/Attributable.php +++ /dev/null @@ -1,19 +0,0 @@ -|Permission|null $classPermission - * @property string $namespace - * @property string $route - */ -interface Attributable {} diff --git a/src/Attributes/BlockBinding.php b/src/Attributes/BlockBinding.php deleted file mode 100644 index e9da9805..00000000 --- a/src/Attributes/BlockBinding.php +++ /dev/null @@ -1,50 +0,0 @@ - */ - public array $usesContext; - - /** @var list */ - public array $postTypes; - - /** - * @param string $name Source name, `namespace/name` in lowercase, as WordPress requires - * @param string|null $label Label shown in the editor. Defaults to the class name, headlined - * @param list $usesContext Block context the source reads - * @param string|list $postTypes Post types the source answers for; empty for all - */ - public function __construct( - public string $name, - public ?string $label = null, - array $usesContext = ['postId', 'postType'], - string|array $postTypes = [], - ) { - $this->usesContext = array_values($usesContext); - $this->postTypes = array_values((array) $postTypes); - } -} diff --git a/src/Attributes/BlockBinding/BindingField.php b/src/Attributes/BlockBinding/BindingField.php deleted file mode 100644 index 4d8f436f..00000000 --- a/src/Attributes/BlockBinding/BindingField.php +++ /dev/null @@ -1,33 +0,0 @@ - $arguments The arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array; -} diff --git a/src/Attributes/Contracts/ConfiguresArgumentsWithMethod.php b/src/Attributes/Contracts/ConfiguresArgumentsWithMethod.php deleted file mode 100644 index 934c04ce..00000000 --- a/src/Attributes/Contracts/ConfiguresArgumentsWithMethod.php +++ /dev/null @@ -1,24 +0,0 @@ - $arguments The arguments gathered so far - * @param callable $method The decorated method, bound to an instance of its class - * @return array - */ - public function configureArguments(array $arguments, callable $method): array; -} diff --git a/src/Attributes/Contracts/HandlesAttributes.php b/src/Attributes/Contracts/HandlesAttributes.php deleted file mode 100755 index b334807a..00000000 --- a/src/Attributes/Contracts/HandlesAttributes.php +++ /dev/null @@ -1,14 +0,0 @@ - $rules Laravel validation rules, checked on writes from PHP and REST - * @param bool $single On an `array` property, false stores one row per item instead of one serialized array - * @param string|null $items On an `array` property, the item type: `'string'`, `'int'`, `'float'`, `'bool'` or a class. Defaults to the `@var list<…>` docblock - * @param Control|null $control The input a UI driver should build. Defaults to one derived from the type - * @param string|null $group The group of fields a UI driver puts the meta in - * @param array $hints Options passed as they are to UI drivers, by driver: `['acf' => ['wrapper' => ['width' => 50]]]` - * @param bool $media On an `int` property, the value is an attachment ID: a block binding gives its URL, alt text or caption - * @param bool $public The value may be shown to anyone: required for a user meta to be read by the `pollora/author-meta` block binding - */ - public function __construct( - public ?string $key = null, - public bool $showInRest = false, - public ?string $label = null, - public ?string $description = null, - public string|array|null $sanitize = null, - public ?string $capability = null, - public bool $revisions = false, - public array $rules = [], - public bool $single = true, - public ?string $items = null, - public ?Control $control = null, - public ?string $group = null, - public array $hints = [], - public bool $media = false, - public bool $public = false, - ) {} - - /** - * The key the meta is stored under for the given property. - */ - public function resolveKey(string $property): string - { - return $this->key ?? Naming::snake($property); - } -} diff --git a/src/Attributes/ModifyRole.php b/src/Attributes/ModifyRole.php deleted file mode 100644 index accf608b..00000000 --- a/src/Attributes/ModifyRole.php +++ /dev/null @@ -1,34 +0,0 @@ - */ - public array $postTypes; - - /** - * @param string|list $postTypes The post type slugs the meta belong to - */ - public function __construct(string|array $postTypes) - { - $this->postTypes = array_values((array) $postTypes); - } -} diff --git a/src/Attributes/PostType.php b/src/Attributes/PostType.php deleted file mode 100644 index 94fac8ed..00000000 --- a/src/Attributes/PostType.php +++ /dev/null @@ -1,56 +0,0 @@ -slug ?? substr(Naming::kebab(Naming::classBasename($className)), 0, 20); - } -} diff --git a/src/Attributes/PostType/AdminCol.php b/src/Attributes/PostType/AdminCol.php deleted file mode 100755 index d5696bd3..00000000 --- a/src/Attributes/PostType/AdminCol.php +++ /dev/null @@ -1,125 +0,0 @@ - $arguments The registration arguments gathered so far - * @param callable $method The decorated method - * @return array - */ - public function configureArguments(array $arguments, callable $method): array - { - // Build column configuration based on extended-cpts options - $columnConfig = [ - 'title' => $this->title, - 'function' => $method, - ]; - - // Add optional configuration based on extended-cpts features - if ($this->titleIcon !== null) { - $columnConfig['title_icon'] = $this->titleIcon; - } - - if ($this->dateFormat !== null) { - $columnConfig['date_format'] = $this->dateFormat; - } - - if ($this->link !== null) { - $columnConfig['link'] = $this->link; - } - - if ($this->cap !== null) { - $columnConfig['cap'] = $this->cap; - } - - if ($this->postCap !== null) { - $columnConfig['post_cap'] = $this->postCap; - } - - if ($this->default !== null) { - $columnConfig['default'] = $this->default; - } - - if ($this->metaKey !== null) { - $columnConfig['meta_key'] = $this->metaKey; - } - - if ($this->taxonomy !== null) { - $columnConfig['taxonomy'] = $this->taxonomy; - } - - if ($this->featuredImage !== null) { - $columnConfig['featured_image'] = $this->featuredImage; - } - - if ($this->postField !== null) { - $columnConfig['post_field'] = $this->postField; - } - - if ($this->width !== null) { - $columnConfig['width'] = $this->width; - } - - // Handle sortable configuration - if ($this->sortable !== false) { - $columnConfig['sortable'] = $this->sortable; - } - - $arguments['admin_cols'] = [...$arguments['admin_cols'] ?? [], $this->key => $columnConfig]; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/AdminCols.php b/src/Attributes/PostType/AdminCols.php deleted file mode 100755 index 2b43b9e2..00000000 --- a/src/Attributes/PostType/AdminCols.php +++ /dev/null @@ -1,42 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['admin_cols'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/AdminFilters.php b/src/Attributes/PostType/AdminFilters.php deleted file mode 100755 index 4fbf2818..00000000 --- a/src/Attributes/PostType/AdminFilters.php +++ /dev/null @@ -1,42 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['admin_filters'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/Archive.php b/src/Attributes/PostType/Archive.php deleted file mode 100755 index 3d081ac4..00000000 --- a/src/Attributes/PostType/Archive.php +++ /dev/null @@ -1,42 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['archive'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/BlockEditor.php b/src/Attributes/PostType/BlockEditor.php deleted file mode 100755 index cdfc9f2b..00000000 --- a/src/Attributes/PostType/BlockEditor.php +++ /dev/null @@ -1,42 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['show_in_rest'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/CanExport.php b/src/Attributes/PostType/CanExport.php deleted file mode 100755 index 7f1f5d76..00000000 --- a/src/Attributes/PostType/CanExport.php +++ /dev/null @@ -1,42 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['can_export'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/Capabilities.php b/src/Attributes/PostType/Capabilities.php deleted file mode 100755 index b3459422..00000000 --- a/src/Attributes/PostType/Capabilities.php +++ /dev/null @@ -1,42 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['capabilities'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/CapabilityType.php b/src/Attributes/PostType/CapabilityType.php deleted file mode 100755 index e7196eca..00000000 --- a/src/Attributes/PostType/CapabilityType.php +++ /dev/null @@ -1,47 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['capability_type'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/Chronological.php b/src/Attributes/PostType/Chronological.php deleted file mode 100755 index 728fe084..00000000 --- a/src/Attributes/PostType/Chronological.php +++ /dev/null @@ -1,47 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - if ($this->value) { - // Set default ordering to date, descending - $arguments['orderby'] = 'date'; - $arguments['order'] = 'DESC'; - } - - return $arguments; - } -} diff --git a/src/Attributes/PostType/DashboardActivity.php b/src/Attributes/PostType/DashboardActivity.php deleted file mode 100755 index a808f98a..00000000 --- a/src/Attributes/PostType/DashboardActivity.php +++ /dev/null @@ -1,43 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['dashboard_activity'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/DashboardGlance.php b/src/Attributes/PostType/DashboardGlance.php deleted file mode 100755 index 81df2cfa..00000000 --- a/src/Attributes/PostType/DashboardGlance.php +++ /dev/null @@ -1,41 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['dashboard_glance'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/DeleteWithUser.php b/src/Attributes/PostType/DeleteWithUser.php deleted file mode 100755 index 2abeb23c..00000000 --- a/src/Attributes/PostType/DeleteWithUser.php +++ /dev/null @@ -1,43 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['delete_with_user'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/Description.php b/src/Attributes/PostType/Description.php deleted file mode 100755 index 6654747f..00000000 --- a/src/Attributes/PostType/Description.php +++ /dev/null @@ -1,42 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['description'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/ExcludeFromSearch.php b/src/Attributes/PostType/ExcludeFromSearch.php deleted file mode 100755 index a6cb5d53..00000000 --- a/src/Attributes/PostType/ExcludeFromSearch.php +++ /dev/null @@ -1,42 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['exclude_from_search'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/FeaturedImage.php b/src/Attributes/PostType/FeaturedImage.php deleted file mode 100755 index d2af7006..00000000 --- a/src/Attributes/PostType/FeaturedImage.php +++ /dev/null @@ -1,42 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['featured_image'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/HasArchive.php b/src/Attributes/PostType/HasArchive.php deleted file mode 100755 index 59befcda..00000000 --- a/src/Attributes/PostType/HasArchive.php +++ /dev/null @@ -1,42 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['has_archive'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/Hierarchical.php b/src/Attributes/PostType/Hierarchical.php deleted file mode 100755 index b4e085b8..00000000 --- a/src/Attributes/PostType/Hierarchical.php +++ /dev/null @@ -1,42 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['hierarchical'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/Label.php b/src/Attributes/PostType/Label.php deleted file mode 100755 index b9bbb22a..00000000 --- a/src/Attributes/PostType/Label.php +++ /dev/null @@ -1,42 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['label'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/Labels.php b/src/Attributes/PostType/Labels.php deleted file mode 100755 index 0ac367ff..00000000 --- a/src/Attributes/PostType/Labels.php +++ /dev/null @@ -1,102 +0,0 @@ - */ - private readonly array $labels; - - public function __construct( - ?string $name = null, - ?string $singularName = null, - ?string $menuName = null, - ?string $allItems = null, - ?string $addNew = null, - ?string $addNewItem = null, - ?string $editItem = null, - ?string $newItem = null, - ?string $viewItem = null, - ?string $viewItems = null, - ?string $searchItems = null, - ?string $notFound = null, - ?string $notFoundInTrash = null, - ?string $parentItemColon = null, - ?string $archives = null, - ?string $attributes = null, - ?string $insertIntoItem = null, - ?string $uploadedToThisItem = null, - ?string $featuredImage = null, - ?string $setFeaturedImage = null, - ?string $removeFeaturedImage = null, - ?string $useFeaturedImage = null, - ?string $filterItemsList = null, - ?string $itemsListNavigation = null, - ?string $itemsList = null, - ?string $itemPublished = null, - ?string $itemUpdated = null, - ?string $itemScheduled = null, - ?string $itemReverted = null, - ) { - $this->labels = array_filter([ - 'name' => $name, - 'singular_name' => $singularName, - 'menu_name' => $menuName, - 'all_items' => $allItems, - 'add_new' => $addNew, - 'add_new_item' => $addNewItem, - 'edit_item' => $editItem, - 'new_item' => $newItem, - 'view_item' => $viewItem, - 'view_items' => $viewItems, - 'search_items' => $searchItems, - 'not_found' => $notFound, - 'not_found_in_trash' => $notFoundInTrash, - 'parent_item_colon' => $parentItemColon, - 'archives' => $archives, - 'attributes' => $attributes, - 'insert_into_item' => $insertIntoItem, - 'uploaded_to_this_item' => $uploadedToThisItem, - 'featured_image' => $featuredImage, - 'set_featured_image' => $setFeaturedImage, - 'remove_featured_image' => $removeFeaturedImage, - 'use_featured_image' => $useFeaturedImage, - 'filter_items_list' => $filterItemsList, - 'items_list_navigation' => $itemsListNavigation, - 'items_list' => $itemsList, - 'item_published' => $itemPublished, - 'item_updated' => $itemUpdated, - 'item_scheduled' => $itemScheduled, - 'item_reverted_to_draft' => $itemReverted, - ], fn (?string $v): bool => $v !== null); - } - - public function configureArguments(array $arguments): array - { - $existing = ($arguments['labels'] ?? []); - $arguments['labels'] = array_merge($existing, $this->labels); - - return $arguments; - } -} diff --git a/src/Attributes/PostType/MapMetaCap.php b/src/Attributes/PostType/MapMetaCap.php deleted file mode 100755 index 04b1ff39..00000000 --- a/src/Attributes/PostType/MapMetaCap.php +++ /dev/null @@ -1,42 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['map_meta_cap'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/MenuIcon.php b/src/Attributes/PostType/MenuIcon.php deleted file mode 100755 index f2218cdc..00000000 --- a/src/Attributes/PostType/MenuIcon.php +++ /dev/null @@ -1,42 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['menu_icon'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/MenuPosition.php b/src/Attributes/PostType/MenuPosition.php deleted file mode 100755 index 0952d950..00000000 --- a/src/Attributes/PostType/MenuPosition.php +++ /dev/null @@ -1,53 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['menu_position'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/Priority.php b/src/Attributes/PostType/Priority.php deleted file mode 100755 index f416ca56..00000000 --- a/src/Attributes/PostType/Priority.php +++ /dev/null @@ -1,37 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['priority'] = $this->priority; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/PublicPostType.php b/src/Attributes/PostType/PublicPostType.php deleted file mode 100755 index 53b905c4..00000000 --- a/src/Attributes/PostType/PublicPostType.php +++ /dev/null @@ -1,42 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['public'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/PubliclyQueryable.php b/src/Attributes/PostType/PubliclyQueryable.php deleted file mode 100755 index 42c5b3e5..00000000 --- a/src/Attributes/PostType/PubliclyQueryable.php +++ /dev/null @@ -1,41 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['publicly_queryable'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/QueryVar.php b/src/Attributes/PostType/QueryVar.php deleted file mode 100755 index 54091c73..00000000 --- a/src/Attributes/PostType/QueryVar.php +++ /dev/null @@ -1,44 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['query_var'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/QuickEdit.php b/src/Attributes/PostType/QuickEdit.php deleted file mode 100755 index 9a7cbe77..00000000 --- a/src/Attributes/PostType/QuickEdit.php +++ /dev/null @@ -1,43 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['quick_edit'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/RegisterMetaBoxCb.php b/src/Attributes/PostType/RegisterMetaBoxCb.php deleted file mode 100755 index 8daff699..00000000 --- a/src/Attributes/PostType/RegisterMetaBoxCb.php +++ /dev/null @@ -1,36 +0,0 @@ - $arguments The registration arguments gathered so far - * @param callable $method The decorated method - * @return array - */ - public function configureArguments(array $arguments, callable $method): array - { - $arguments['register_meta_box_cb'] = $method; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/RestBase.php b/src/Attributes/PostType/RestBase.php deleted file mode 100755 index 8ca0e7fd..00000000 --- a/src/Attributes/PostType/RestBase.php +++ /dev/null @@ -1,42 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['rest_base'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/RestControllerClass.php b/src/Attributes/PostType/RestControllerClass.php deleted file mode 100755 index d3d9004c..00000000 --- a/src/Attributes/PostType/RestControllerClass.php +++ /dev/null @@ -1,42 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['rest_controller_class'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/RestNamespace.php b/src/Attributes/PostType/RestNamespace.php deleted file mode 100755 index 1eae5ba9..00000000 --- a/src/Attributes/PostType/RestNamespace.php +++ /dev/null @@ -1,42 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['rest_namespace'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/Rewrite.php b/src/Attributes/PostType/Rewrite.php deleted file mode 100755 index 1fa15cfb..00000000 --- a/src/Attributes/PostType/Rewrite.php +++ /dev/null @@ -1,44 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['rewrite'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/ShowInAdminBar.php b/src/Attributes/PostType/ShowInAdminBar.php deleted file mode 100755 index cdcf1aa8..00000000 --- a/src/Attributes/PostType/ShowInAdminBar.php +++ /dev/null @@ -1,42 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['show_in_admin_bar'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/ShowInFeed.php b/src/Attributes/PostType/ShowInFeed.php deleted file mode 100755 index 129b9f2b..00000000 --- a/src/Attributes/PostType/ShowInFeed.php +++ /dev/null @@ -1,42 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['show_in_feed'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/ShowInMenu.php b/src/Attributes/PostType/ShowInMenu.php deleted file mode 100755 index b9724ac2..00000000 --- a/src/Attributes/PostType/ShowInMenu.php +++ /dev/null @@ -1,44 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['show_in_menu'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/ShowInNavMenus.php b/src/Attributes/PostType/ShowInNavMenus.php deleted file mode 100755 index 6dd21be9..00000000 --- a/src/Attributes/PostType/ShowInNavMenus.php +++ /dev/null @@ -1,42 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['show_in_nav_menus'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/ShowInRest.php b/src/Attributes/PostType/ShowInRest.php deleted file mode 100755 index f83f97f2..00000000 --- a/src/Attributes/PostType/ShowInRest.php +++ /dev/null @@ -1,43 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['show_in_rest'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/ShowUI.php b/src/Attributes/PostType/ShowUI.php deleted file mode 100755 index 74c8eb82..00000000 --- a/src/Attributes/PostType/ShowUI.php +++ /dev/null @@ -1,42 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['show_ui'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/SiteFilters.php b/src/Attributes/PostType/SiteFilters.php deleted file mode 100755 index a8b52288..00000000 --- a/src/Attributes/PostType/SiteFilters.php +++ /dev/null @@ -1,42 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['site_filters'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/SiteSortables.php b/src/Attributes/PostType/SiteSortables.php deleted file mode 100755 index 772c99c3..00000000 --- a/src/Attributes/PostType/SiteSortables.php +++ /dev/null @@ -1,42 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['site_sortables'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/Supports.php b/src/Attributes/PostType/Supports.php deleted file mode 100755 index 9fe01558..00000000 --- a/src/Attributes/PostType/Supports.php +++ /dev/null @@ -1,41 +0,0 @@ - $features Array of features the post type supports - */ - public function __construct( - public readonly array $features = ['title', 'editor'] - ) {} - - /** - * Configure the post type with the supports parameter. - * - * @param array $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['supports'] = $this->features; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/Taxonomies.php b/src/Attributes/PostType/Taxonomies.php deleted file mode 100755 index a4908587..00000000 --- a/src/Attributes/PostType/Taxonomies.php +++ /dev/null @@ -1,42 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['taxonomies'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/Template.php b/src/Attributes/PostType/Template.php deleted file mode 100755 index ac33e809..00000000 --- a/src/Attributes/PostType/Template.php +++ /dev/null @@ -1,42 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['template'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/TemplateLock.php b/src/Attributes/PostType/TemplateLock.php deleted file mode 100755 index 442afb56..00000000 --- a/src/Attributes/PostType/TemplateLock.php +++ /dev/null @@ -1,44 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['template_lock'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/PostType/TitlePlaceholder.php b/src/Attributes/PostType/TitlePlaceholder.php deleted file mode 100755 index c3ccd2b0..00000000 --- a/src/Attributes/PostType/TitlePlaceholder.php +++ /dev/null @@ -1,42 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['title_placeholder'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/Role.php b/src/Attributes/Role.php deleted file mode 100644 index f4909c2e..00000000 --- a/src/Attributes/Role.php +++ /dev/null @@ -1,42 +0,0 @@ - - */ - public array $capabilities; - - public function __construct(string|BackedEnum ...$capabilities) - { - $this->capabilities = array_values($capabilities); - } -} diff --git a/src/Attributes/Role/GrantsPostType.php b/src/Attributes/Role/GrantsPostType.php deleted file mode 100644 index b16c96da..00000000 --- a/src/Attributes/Role/GrantsPostType.php +++ /dev/null @@ -1,27 +0,0 @@ - - */ - public array $capabilities; - - public function __construct(string|BackedEnum ...$capabilities) - { - $this->capabilities = array_values($capabilities); - } -} diff --git a/src/Attributes/Schedule.php b/src/Attributes/Schedule.php deleted file mode 100755 index fc72a85d..00000000 --- a/src/Attributes/Schedule.php +++ /dev/null @@ -1,46 +0,0 @@ -|Every|Interval $recurrence The schedule recurrence definition - * - string: WordPress built-in schedule ('hourly', 'daily', 'weekly', 'twicedaily') - * - array: Custom schedule with 'interval' (seconds) and 'display' (name) keys - * - Every: Enum for predefined intervals (Every::HOUR, Every::DAY, etc.) - * - Interval: Custom interval instance with precise timing control - * @param string|null $hook Optional custom hook name. If null, generated from class and method names - * @param array $args Optional arguments to pass to the scheduled method - */ - public function __construct( - public readonly string|array|Every|Interval $recurrence, - public readonly ?string $hook = null, - public readonly array $args = [] - ) { - // No validation here - will be handled by ScheduleDiscovery - } -} diff --git a/src/Attributes/Support/Naming.php b/src/Attributes/Support/Naming.php deleted file mode 100644 index 0640640e..00000000 --- a/src/Attributes/Support/Naming.php +++ /dev/null @@ -1,73 +0,0 @@ -|string|null $objectType The post types this taxonomy applies to. If null, defaults to ['post']. - */ - public function __construct( - public ?string $slug = null, - public ?string $singular = null, - public ?string $plural = null, - public array|string|null $objectType = null, - public ?string $textDomain = null - ) {} - - /** - * The slug WordPress registers the taxonomy under. - * - * The explicit slug when the attribute has one; otherwise the class name in - * kebab-case, cut to 20 characters. Discovery registers with it and - * `pollora:status` reports it, so the two cannot disagree. - * - * @param string $className The class carrying the attribute - */ - public function resolveSlug(string $className): string - { - return $this->slug ?? substr(Naming::kebab(Naming::classBasename($className)), 0, 20); - } -} diff --git a/src/Attributes/Taxonomy/AllowHierarchy.php b/src/Attributes/Taxonomy/AllowHierarchy.php deleted file mode 100755 index ebdee900..00000000 --- a/src/Attributes/Taxonomy/AllowHierarchy.php +++ /dev/null @@ -1,42 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['allow_hierarchy'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/Taxonomy/Args.php b/src/Attributes/Taxonomy/Args.php deleted file mode 100755 index 90740947..00000000 --- a/src/Attributes/Taxonomy/Args.php +++ /dev/null @@ -1,39 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['args'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/Taxonomy/Capabilities.php b/src/Attributes/Taxonomy/Capabilities.php deleted file mode 100644 index f7fea2ce..00000000 --- a/src/Attributes/Taxonomy/Capabilities.php +++ /dev/null @@ -1,48 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['capabilities'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/Taxonomy/CheckedOntop.php b/src/Attributes/Taxonomy/CheckedOntop.php deleted file mode 100755 index 72cb1d68..00000000 --- a/src/Attributes/Taxonomy/CheckedOntop.php +++ /dev/null @@ -1,39 +0,0 @@ -value; - - return $arguments; - } -} diff --git a/src/Attributes/Taxonomy/DefaultTerm.php b/src/Attributes/Taxonomy/DefaultTerm.php deleted file mode 100755 index 8e7d553e..00000000 --- a/src/Attributes/Taxonomy/DefaultTerm.php +++ /dev/null @@ -1,39 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['default_term'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/Taxonomy/Description.php b/src/Attributes/Taxonomy/Description.php deleted file mode 100755 index bc613822..00000000 --- a/src/Attributes/Taxonomy/Description.php +++ /dev/null @@ -1,39 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['description'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/Taxonomy/Exclusive.php b/src/Attributes/Taxonomy/Exclusive.php deleted file mode 100755 index d34334f0..00000000 --- a/src/Attributes/Taxonomy/Exclusive.php +++ /dev/null @@ -1,42 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['exclusive'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/Taxonomy/Hierarchical.php b/src/Attributes/Taxonomy/Hierarchical.php deleted file mode 100755 index afea0792..00000000 --- a/src/Attributes/Taxonomy/Hierarchical.php +++ /dev/null @@ -1,39 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['hierarchical'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/Taxonomy/Label.php b/src/Attributes/Taxonomy/Label.php deleted file mode 100755 index 5f8ec4c3..00000000 --- a/src/Attributes/Taxonomy/Label.php +++ /dev/null @@ -1,39 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['label'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/Taxonomy/Labels.php b/src/Attributes/Taxonomy/Labels.php deleted file mode 100755 index 09efa745..00000000 --- a/src/Attributes/Taxonomy/Labels.php +++ /dev/null @@ -1,89 +0,0 @@ - */ - private readonly array $labels; - - public function __construct( - ?string $name = null, - ?string $singularName = null, - ?string $menuName = null, - ?string $allItems = null, - ?string $editItem = null, - ?string $viewItem = null, - ?string $updateItem = null, - ?string $addNewItem = null, - ?string $newItemName = null, - ?string $searchItems = null, - ?string $popularItems = null, - ?string $separateItemsWithCommas = null, - ?string $addOrRemoveItems = null, - ?string $chooseFromMostUsed = null, - ?string $notFound = null, - ?string $parentItem = null, - ?string $parentItemColon = null, - ?string $noTerms = null, - ?string $filterByItem = null, - ?string $itemsListNavigation = null, - ?string $itemsList = null, - ?string $backToItems = null, - ) { - $this->labels = array_filter([ - 'name' => $name, - 'singular_name' => $singularName, - 'menu_name' => $menuName, - 'all_items' => $allItems, - 'edit_item' => $editItem, - 'view_item' => $viewItem, - 'update_item' => $updateItem, - 'add_new_item' => $addNewItem, - 'new_item_name' => $newItemName, - 'search_items' => $searchItems, - 'popular_items' => $popularItems, - 'separate_items_with_commas' => $separateItemsWithCommas, - 'add_or_remove_items' => $addOrRemoveItems, - 'choose_from_most_used' => $chooseFromMostUsed, - 'not_found' => $notFound, - 'parent_item' => $parentItem, - 'parent_item_colon' => $parentItemColon, - 'no_terms' => $noTerms, - 'filter_by_item' => $filterByItem, - 'items_list_navigation' => $itemsListNavigation, - 'items_list' => $itemsList, - 'back_to_items' => $backToItems, - ], fn (?string $v): bool => $v !== null); - } - - public function configureArguments(array $arguments): array - { - $existing = ($arguments['labels'] ?? []); - $arguments['labels'] = array_merge($existing, $this->labels); - - return $arguments; - } -} diff --git a/src/Attributes/Taxonomy/MetaBoxCb.php b/src/Attributes/Taxonomy/MetaBoxCb.php deleted file mode 100755 index 6fe8045c..00000000 --- a/src/Attributes/Taxonomy/MetaBoxCb.php +++ /dev/null @@ -1,34 +0,0 @@ - $arguments The registration arguments gathered so far - * @param callable $method The decorated method - * @return array - */ - public function configureArguments(array $arguments, callable $method): array - { - $arguments['meta_box_cb'] = $method; - - return $arguments; - } -} diff --git a/src/Attributes/Taxonomy/MetaBoxSanitizeCb.php b/src/Attributes/Taxonomy/MetaBoxSanitizeCb.php deleted file mode 100755 index 994a2fc8..00000000 --- a/src/Attributes/Taxonomy/MetaBoxSanitizeCb.php +++ /dev/null @@ -1,34 +0,0 @@ - $arguments The registration arguments gathered so far - * @param callable $method The decorated method - * @return array - */ - public function configureArguments(array $arguments, callable $method): array - { - $arguments['meta_box_sanitize_cb'] = $method; - - return $arguments; - } -} diff --git a/src/Attributes/Taxonomy/ObjectType.php b/src/Attributes/Taxonomy/ObjectType.php deleted file mode 100755 index 7c39b7f3..00000000 --- a/src/Attributes/Taxonomy/ObjectType.php +++ /dev/null @@ -1,39 +0,0 @@ - $value The post type(s) this taxonomy is associated with - */ - public function __construct( - public readonly string|array $value - ) {} - - /** - * Configure the taxonomy with the object_type parameter. - * - * @param array $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['object_type'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/Taxonomy/Priority.php b/src/Attributes/Taxonomy/Priority.php deleted file mode 100755 index 7a37cbc4..00000000 --- a/src/Attributes/Taxonomy/Priority.php +++ /dev/null @@ -1,37 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['priority'] = $this->priority; - - return $arguments; - } -} diff --git a/src/Attributes/Taxonomy/PublicTaxonomy.php b/src/Attributes/Taxonomy/PublicTaxonomy.php deleted file mode 100755 index 29da08b8..00000000 --- a/src/Attributes/Taxonomy/PublicTaxonomy.php +++ /dev/null @@ -1,42 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['public'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/Taxonomy/QueryVar.php b/src/Attributes/Taxonomy/QueryVar.php deleted file mode 100755 index 4fbd686f..00000000 --- a/src/Attributes/Taxonomy/QueryVar.php +++ /dev/null @@ -1,39 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['query_var'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/Taxonomy/RestBase.php b/src/Attributes/Taxonomy/RestBase.php deleted file mode 100755 index b7548aed..00000000 --- a/src/Attributes/Taxonomy/RestBase.php +++ /dev/null @@ -1,39 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['rest_base'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/Taxonomy/RestControllerClass.php b/src/Attributes/Taxonomy/RestControllerClass.php deleted file mode 100755 index fcc5aaa4..00000000 --- a/src/Attributes/Taxonomy/RestControllerClass.php +++ /dev/null @@ -1,39 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['rest_controller_class'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/Taxonomy/RestNamespace.php b/src/Attributes/Taxonomy/RestNamespace.php deleted file mode 100755 index 9bf20d6e..00000000 --- a/src/Attributes/Taxonomy/RestNamespace.php +++ /dev/null @@ -1,39 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['rest_namespace'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/Taxonomy/Rewrite.php b/src/Attributes/Taxonomy/Rewrite.php deleted file mode 100755 index 687946d7..00000000 --- a/src/Attributes/Taxonomy/Rewrite.php +++ /dev/null @@ -1,39 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['rewrite'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/Taxonomy/ShowAdminColumn.php b/src/Attributes/Taxonomy/ShowAdminColumn.php deleted file mode 100755 index b5521ef6..00000000 --- a/src/Attributes/Taxonomy/ShowAdminColumn.php +++ /dev/null @@ -1,39 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['show_admin_column'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/Taxonomy/ShowInMenu.php b/src/Attributes/Taxonomy/ShowInMenu.php deleted file mode 100755 index 6e40e179..00000000 --- a/src/Attributes/Taxonomy/ShowInMenu.php +++ /dev/null @@ -1,44 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['show_in_menu'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/Taxonomy/ShowInNavMenus.php b/src/Attributes/Taxonomy/ShowInNavMenus.php deleted file mode 100755 index e9a1a1ec..00000000 --- a/src/Attributes/Taxonomy/ShowInNavMenus.php +++ /dev/null @@ -1,39 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['show_in_nav_menus'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/Taxonomy/ShowInQuickEdit.php b/src/Attributes/Taxonomy/ShowInQuickEdit.php deleted file mode 100755 index 49e29153..00000000 --- a/src/Attributes/Taxonomy/ShowInQuickEdit.php +++ /dev/null @@ -1,39 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['show_in_quick_edit'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/Taxonomy/ShowInRest.php b/src/Attributes/Taxonomy/ShowInRest.php deleted file mode 100755 index 0cbaf204..00000000 --- a/src/Attributes/Taxonomy/ShowInRest.php +++ /dev/null @@ -1,39 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['show_in_rest'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/Taxonomy/ShowTagcloud.php b/src/Attributes/Taxonomy/ShowTagcloud.php deleted file mode 100755 index c915f2a1..00000000 --- a/src/Attributes/Taxonomy/ShowTagcloud.php +++ /dev/null @@ -1,39 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['show_tagcloud'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/Taxonomy/ShowUI.php b/src/Attributes/Taxonomy/ShowUI.php deleted file mode 100755 index 908e13a1..00000000 --- a/src/Attributes/Taxonomy/ShowUI.php +++ /dev/null @@ -1,39 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['show_ui'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/Taxonomy/Sort.php b/src/Attributes/Taxonomy/Sort.php deleted file mode 100755 index bc65b152..00000000 --- a/src/Attributes/Taxonomy/Sort.php +++ /dev/null @@ -1,39 +0,0 @@ - $arguments The registration arguments gathered so far - * @return array - */ - public function configureArguments(array $arguments): array - { - $arguments['sort'] = $this->value; - - return $arguments; - } -} diff --git a/src/Attributes/Taxonomy/UpdateCountCallback.php b/src/Attributes/Taxonomy/UpdateCountCallback.php deleted file mode 100755 index b289db34..00000000 --- a/src/Attributes/Taxonomy/UpdateCountCallback.php +++ /dev/null @@ -1,34 +0,0 @@ - $arguments The registration arguments gathered so far - * @param callable $method The decorated method - * @return array - */ - public function configureArguments(array $arguments, callable $method): array - { - $arguments['update_count_callback'] = $method; - - return $arguments; - } -} diff --git a/src/Attributes/TermMeta.php b/src/Attributes/TermMeta.php deleted file mode 100644 index 8bdc3d09..00000000 --- a/src/Attributes/TermMeta.php +++ /dev/null @@ -1,37 +0,0 @@ - */ - public array $taxonomies; - - /** - * @param string|list $taxonomies The taxonomy slugs the meta belong to - */ - public function __construct(string|array $taxonomies) - { - $this->taxonomies = array_values((array) $taxonomies); - } -} diff --git a/src/Attributes/UserMeta.php b/src/Attributes/UserMeta.php deleted file mode 100644 index 25a70858..00000000 --- a/src/Attributes/UserMeta.php +++ /dev/null @@ -1,25 +0,0 @@ -newsletterOptIn; - * - * @experimental The API may still change before it is declared stable. - */ -#[Attribute(Attribute::TARGET_CLASS)] -final readonly class UserMeta {} diff --git a/src/Attributes/WpCli.php b/src/Attributes/WpCli.php deleted file mode 100644 index df7ff388..00000000 --- a/src/Attributes/WpCli.php +++ /dev/null @@ -1,44 +0,0 @@ -commandName ?? Naming::commandFromClassName($className); - } -} diff --git a/src/Attributes/WpCli/AfterInvoke.php b/src/Attributes/WpCli/AfterInvoke.php deleted file mode 100644 index aaa226fa..00000000 --- a/src/Attributes/WpCli/AfterInvoke.php +++ /dev/null @@ -1,24 +0,0 @@ -commandName ?? Naming::slug($methodName); - } -} diff --git a/src/Attributes/WpCli/IsDeferred.php b/src/Attributes/WpCli/IsDeferred.php deleted file mode 100644 index bcaeaea6..00000000 --- a/src/Attributes/WpCli/IsDeferred.php +++ /dev/null @@ -1,25 +0,0 @@ -|Permission|null $permissionCallback The permission for the route: a Permission class, or an instance such as `new Can('edit_posts')`. - */ - public function __construct( - public readonly string $namespace, - public readonly string $route, - public readonly string|Permission|null $permissionCallback = null - ) {} -} diff --git a/src/Attributes/WpRestRoute/Method.php b/src/Attributes/WpRestRoute/Method.php deleted file mode 100755 index 53d4a066..00000000 --- a/src/Attributes/WpRestRoute/Method.php +++ /dev/null @@ -1,60 +0,0 @@ -|Permission|null $permissionCallback The permission for this method, replacing the route's: a Permission class, or an instance such as `new Can('edit_posts')`. - * - * @throws InvalidArgumentException If an invalid HTTP method is provided. - */ - public function __construct( - public array|string $methods, - public string|Permission|null $permissionCallback = null - ) { - $this->methods = is_array($methods) ? $methods : [$methods]; - $this->validateMethods(); - } - - /** - * Validates the provided HTTP methods. - * - * @throws InvalidArgumentException If an invalid HTTP method is found. - */ - private function validateMethods(): void - { - foreach ($this->methods as $method) { - if (! in_array(strtoupper((string) $method), self::ALLOWED_METHODS, true)) { - throw new InvalidArgumentException( - sprintf( - 'Invalid HTTP method "%s". Allowed methods are: %s', - $method, - implode(', ', self::ALLOWED_METHODS) - ) - ); - } - } - } - - /** - * Retrieves the list of HTTP methods. - * - * @return array The list of allowed HTTP methods. - */ - public function getMethods(): array - { - return $this->methods; - } -} diff --git a/src/Attributes/WpRestRoute/Permission.php b/src/Attributes/WpRestRoute/Permission.php deleted file mode 100755 index f535a20a..00000000 --- a/src/Attributes/WpRestRoute/Permission.php +++ /dev/null @@ -1,20 +0,0 @@ - - */ - public function genericCapabilities(): array - { - $contributor = ['edit_posts', 'delete_posts']; - $author = ['publish_posts', 'edit_published_posts', 'delete_published_posts']; - $editor = ['edit_others_posts', 'delete_others_posts', 'read_private_posts', 'edit_private_posts', 'delete_private_posts']; - - return match ($this) { - self::Contributor => $contributor, - self::Author => [...$contributor, ...$author], - self::Editor => [...$contributor, ...$author, ...$editor], - }; - } -} diff --git a/src/Schedule/Every.php b/src/Schedule/Every.php deleted file mode 100644 index 73f89cef..00000000 --- a/src/Schedule/Every.php +++ /dev/null @@ -1,44 +0,0 @@ - 'hourly', - self::TWICE_DAILY => 'twicedaily', - self::DAY => 'daily', - self::WEEK => 'weekly', - self::MONTH => 'monthly', - self::YEAR => 'yearly', - }; - } - - public function isCustom(): bool - { - return in_array($this, [self::MONTH, self::YEAR], true); - } - - public function toInterval(): Interval - { - return match ($this) { - self::HOUR => new Interval(hours: 1, display: 'Once Hourly'), - self::TWICE_DAILY => new Interval(hours: 12, display: 'Twice Daily'), - self::DAY => new Interval(days: 1, display: 'Once Daily'), - self::WEEK => new Interval(weeks: 1, display: 'Once Weekly'), - self::MONTH => new Interval(days: 30, display: 'Once Monthly'), - self::YEAR => new Interval(days: 365, display: 'Once Yearly'), - }; - } -} diff --git a/src/Schedule/Interval.php b/src/Schedule/Interval.php deleted file mode 100644 index 448c2c91..00000000 --- a/src/Schedule/Interval.php +++ /dev/null @@ -1,34 +0,0 @@ - $this->totalSeconds(), - 'display' => $this->display, - ]; - } - - public function totalSeconds(): int - { - return $this->seconds - + $this->minutes * 60 - + $this->hours * 3600 - + $this->days * 86400 - + $this->weeks * 604800; - } -} diff --git a/tests/Unit/Attributes/ArchitectureTest.php b/tests/Unit/Attributes/ArchitectureTest.php deleted file mode 100644 index 2cb2cff2..00000000 --- a/tests/Unit/Attributes/ArchitectureTest.php +++ /dev/null @@ -1,38 +0,0 @@ -expect('Pollora\Attributes') - ->toOnlyUse([ - 'Pollora\Attributes', - AjaxAccess::class, - Behaviour::class, - BindingFieldType::class, - Control::class, - Access::class, - Every::class, - Interval::class, - ]) - // Permission::allow() names WordPress's request and error types in its - // signature only, so the attributes never load them. - ->ignoring([PostTypeAttribute::class, TaxonomyAttribute::class, 'WP_REST_Request', 'WP_Error']); diff --git a/tests/Unit/Attributes/HookTest.php b/tests/Unit/Attributes/HookTest.php deleted file mode 100644 index c153b323..00000000 --- a/tests/Unit/Attributes/HookTest.php +++ /dev/null @@ -1,56 +0,0 @@ -hook)->toBe('test_hook'); - expect($hook->priority)->toBe(10); -}); - -it('initializes with custom priority', function (): void { - $hook = new ConcreteHook('test_hook', 20); - expect($hook->hook)->toBe('test_hook'); - expect($hook->priority)->toBe(20); -}); - -it('stores hook name correctly', function (): void { - $hookName = 'custom_hook_name'; - $hook = new ConcreteHook($hookName); - expect($hook->hook)->toBe($hookName); -}); - -it('allows priority to be a negative number', function (): void { - $priority = -1; - $hook = new ConcreteHook('test_hook', $priority); - expect($hook->priority)->toBe($priority); -}); - -it('allows priority to be zero', function (): void { - $priority = 0; - $hook = new ConcreteHook('test_hook', $priority); - expect($hook->priority)->toBe($priority); -}); - -it('allows priority to be a large number', function (): void { - $priority = 9999; - $hook = new ConcreteHook('test_hook', $priority); - expect($hook->priority)->toBe($priority); -}); - -afterEach(function (): void { - Mockery::close(); - Facade::clearResolvedInstances(); -}); diff --git a/tests/Unit/Attributes/NamingTest.php b/tests/Unit/Attributes/NamingTest.php deleted file mode 100644 index c0d9c143..00000000 --- a/tests/Unit/Attributes/NamingTest.php +++ /dev/null @@ -1,49 +0,0 @@ - 'SyntheseDePresse', - 'single word' => 'Book', - 'already lower' => 'book', - 'camelCase property' => 'eventDate', - 'acronym' => 'HTMLParser', - 'digits' => 'Top10Books', - 'underscore' => 'event_date', - 'spaces' => 'event date time', - 'accented capital' => 'ÉvénementPublic', - 'trailing capital' => 'PostA', -]); - -it('writes snake case as Str::snake() does', function (string $name): void { - expect(Naming::snake($name))->toBe(Str::snake($name)); -})->with('names'); - -it('writes kebab case as Str::kebab() does', function (string $name): void { - expect(Naming::kebab($name))->toBe(Str::kebab($name)); -})->with('names'); - -it('strips the namespace as class_basename() does', function (): void { - expect(Naming::classBasename('App\\Cms\\PostTypes\\Book'))->toBe(class_basename('App\\Cms\\PostTypes\\Book')) - ->and(Naming::classBasename('Book'))->toBe('Book'); -}); - -it('derives command slugs, the Command suffix dropped', function (): void { - expect(Naming::commandFromClassName('App\\Cli\\UserManagementCommand'))->toBe('user-management') - ->and(Naming::commandFromClassName('Cache'))->toBe('cache') - ->and(Naming::slug('deleteById'))->toBe('delete-by-id') - ->and(Naming::slug('HTMLParser'))->toBe('htmlparser') - ->and(Slug::fromClassName('App\\Cli\\UserManagementCommand'))->toBe('user-management') - ->and(Slug::fromMethodName('listItems'))->toBe('list-items'); -}); diff --git a/tests/Unit/Attributes/PostTypeAttributeTest.php b/tests/Unit/Attributes/PostTypeAttributeTest.php deleted file mode 100644 index 2c0cd6b9..00000000 --- a/tests/Unit/Attributes/PostTypeAttributeTest.php +++ /dev/null @@ -1,344 +0,0 @@ - 'Test Product']; - } -} - -it('creates PostType attribute with all parameters', function (): void { - $postType = new PostType('custom-slug', 'Custom Type', 'Custom Types'); - - expect($postType->slug)->toBe('custom-slug'); - expect($postType->singular)->toBe('Custom Type'); - expect($postType->plural)->toBe('Custom Types'); -}); - -it('creates PostType attribute with only slug', function (): void { - $postType = new PostType('events'); - - expect($postType->slug)->toBe('events'); - expect($postType->singular)->toBeNull(); - expect($postType->plural)->toBeNull(); -}); - -it('creates PostType attribute with no parameters', function (): void { - $postType = new PostType; - - expect($postType->slug)->toBeNull(); - expect($postType->singular)->toBeNull(); - expect($postType->plural)->toBeNull(); -}); - -it('stores PostType properties as readonly', function (): void { - $postType = new PostType('test'); - - expect($postType->slug)->toBe('test'); - - // Verify properties are readonly - $reflection = new ReflectionClass($postType); - $slugProperty = $reflection->getProperty('slug'); - $singularProperty = $reflection->getProperty('singular'); - $pluralProperty = $reflection->getProperty('plural'); - - expect($slugProperty->isReadOnly())->toBeTrue(); - expect($singularProperty->isReadOnly())->toBeTrue(); - expect($pluralProperty->isReadOnly())->toBeTrue(); -}); - -it('creates HasArchive attribute with default value', function (): void { - $hasArchive = new HasArchive; - - expect($hasArchive->value)->toBeTrue(); -}); - -it('creates HasArchive attribute with custom slug', function (): void { - $hasArchive = new HasArchive('custom-archive'); - - expect($hasArchive->value)->toBe('custom-archive'); -}); - -it('creates HasArchive attribute disabled', function (): void { - $hasArchive = new HasArchive(false); - - expect($hasArchive->value)->toBeFalse(); -}); - -it('creates Supports attribute with default features', function (): void { - $supports = new Supports; - - expect($supports->features)->toBe(['title', 'editor']); -}); - -it('creates Supports attribute with custom features', function (): void { - $features = ['title', 'editor', 'thumbnail', 'excerpt']; - $supports = new Supports($features); - - expect($supports->features)->toBe($features); -}); - -it('creates MenuIcon attribute with dashicon', function (): void { - $menuIcon = new MenuIcon('dashicons-admin-post'); - - expect($menuIcon->value)->toBe('dashicons-admin-post'); -}); - -it('creates MenuIcon attribute with custom URL', function (): void { - $menuIcon = new MenuIcon('https://example.com/icon.png'); - - expect($menuIcon->value)->toBe('https://example.com/icon.png'); -}); - -it('creates PubliclyQueryable attribute with default value', function (): void { - $publiclyQueryable = new PubliclyQueryable; - - expect($publiclyQueryable->value)->toBeTrue(); -}); - -it('creates PubliclyQueryable attribute disabled', function (): void { - $publiclyQueryable = new PubliclyQueryable(false); - - expect($publiclyQueryable->value)->toBeFalse(); -}); - -it('creates AdminCol attribute with all parameters', function (): void { - $adminCol = new AdminCol( - 'price', - 'Product Price', - sortable: true, - titleIcon: 'dashicons-money', - dateFormat: 'd/m/Y', - link: 'edit', - cap: 'edit_posts', - default: 'ASC', - width: 120 - ); - - expect($adminCol->key)->toBe('price'); - expect($adminCol->title)->toBe('Product Price'); - expect($adminCol->width)->toBe(120); - expect($adminCol->sortable)->toBeTrue(); - expect($adminCol->titleIcon)->toBe('dashicons-money'); - expect($adminCol->dateFormat)->toBe('d/m/Y'); - expect($adminCol->link)->toBe('edit'); - expect($adminCol->cap)->toBe('edit_posts'); - expect($adminCol->default)->toBe('ASC'); -}); - -it('creates AdminCol attribute with minimal parameters', function (): void { - $adminCol = new AdminCol('title', 'Title'); - - expect($adminCol->key)->toBe('title'); - expect($adminCol->title)->toBe('Title'); - expect($adminCol->width)->toBeNull(); - expect($adminCol->sortable)->toBeFalse(); - expect($adminCol->titleIcon)->toBeNull(); - expect($adminCol->dateFormat)->toBeNull(); - expect($adminCol->link)->toBeNull(); - expect($adminCol->cap)->toBeNull(); - expect($adminCol->default)->toBeNull(); -}); - -it('creates AdminCol attribute for meta fields', function (): void { - $adminCol = new AdminCol( - 'custom_price', - 'Price', - sortable: 'meta_value_num', - metaKey: 'product_price' - ); - - expect($adminCol->key)->toBe('custom_price'); - expect($adminCol->title)->toBe('Price'); - expect($adminCol->sortable)->toBe('meta_value_num'); - expect($adminCol->metaKey)->toBe('product_price'); -}); - -it('creates AdminCol attribute for taxonomy fields', function (): void { - $adminCol = new AdminCol( - 'categories', - 'Categories', - taxonomy: 'product_category' - ); - - expect($adminCol->key)->toBe('categories'); - expect($adminCol->title)->toBe('Categories'); - expect($adminCol->taxonomy)->toBe('product_category'); -}); - -it('creates AdminCol attribute for featured image', function (): void { - $adminCol = new AdminCol( - 'image', - 'Featured Image', - featuredImage: 'thumbnail', - width: 80 - ); - - expect($adminCol->key)->toBe('image'); - expect($adminCol->title)->toBe('Featured Image'); - expect($adminCol->featuredImage)->toBe('thumbnail'); - expect($adminCol->width)->toBe(80); -}); - -it('creates RegisterMetaBoxCb attribute', function (): void { - $registerMetaBoxCb = new RegisterMetaBoxCb; - - expect($registerMetaBoxCb)->toBeInstanceOf(RegisterMetaBoxCb::class); -}); - -it('has correct PHP attribute configurations', function (): void { - // Test PostType attribute configuration - $postTypeReflection = new ReflectionClass(PostType::class); - $postTypeAttributes = $postTypeReflection->getAttributes(Attribute::class); - - expect($postTypeAttributes)->toHaveCount(1); - $postTypeAttribute = $postTypeAttributes[0]->newInstance(); - expect($postTypeAttribute->flags)->toBe(Attribute::TARGET_CLASS); - - // Test AdminCol attribute configuration - $adminColReflection = new ReflectionClass(AdminCol::class); - $adminColAttributes = $adminColReflection->getAttributes(Attribute::class); - - expect($adminColAttributes)->toHaveCount(1); - $adminColAttribute = $adminColAttributes[0]->newInstance(); - expect($adminColAttribute->flags)->toBe(Attribute::TARGET_METHOD); - - // Test RegisterMetaBoxCb attribute configuration - $registerMetaBoxCbReflection = new ReflectionClass(RegisterMetaBoxCb::class); - $registerMetaBoxCbAttributes = $registerMetaBoxCbReflection->getAttributes(Attribute::class); - - expect($registerMetaBoxCbAttributes)->toHaveCount(1); - $registerMetaBoxCbAttribute = $registerMetaBoxCbAttributes[0]->newInstance(); - expect($registerMetaBoxCbAttribute->flags)->toBe(Attribute::TARGET_METHOD); -}); - -it('test class has correct attributes applied', function (): void { - $reflection = new ReflectionClass(TestProduct::class); - - // Check class-level attributes - $postTypeAttrs = $reflection->getAttributes(PostType::class); - expect($postTypeAttrs)->toHaveCount(1); - - $hasArchiveAttrs = $reflection->getAttributes(HasArchive::class); - expect($hasArchiveAttrs)->toHaveCount(1); - - $supportsAttrs = $reflection->getAttributes(Supports::class); - expect($supportsAttrs)->toHaveCount(1); - - // Check method-level attributes - $priceMethod = $reflection->getMethod('getPriceColumn'); - $priceAdminColAttrs = $priceMethod->getAttributes(AdminCol::class); - expect($priceAdminColAttrs)->toHaveCount(1); - - $stockMethod = $reflection->getMethod('getStockColumn'); - $stockAdminColAttrs = $stockMethod->getAttributes(AdminCol::class); - expect($stockAdminColAttrs)->toHaveCount(1); - - $metaBoxMethod = $reflection->getMethod('registerProductMetaBoxes'); - $metaBoxAttrs = $metaBoxMethod->getAttributes(RegisterMetaBoxCb::class); - expect($metaBoxAttrs)->toHaveCount(1); -}); - -it('attribute values can be extracted correctly', function (): void { - $reflection = new ReflectionClass(TestProduct::class); - - // Extract PostType attribute values - $postTypeAttr = $reflection->getAttributes(PostType::class)[0]; - $postType = $postTypeAttr->newInstance(); - expect($postType->slug)->toBe('test-product'); - expect($postType->singular)->toBe('Product'); - expect($postType->plural)->toBe('Products'); - - // Extract HasArchive attribute value - $hasArchiveAttr = $reflection->getAttributes(HasArchive::class)[0]; - $hasArchive = $hasArchiveAttr->newInstance(); - expect($hasArchive->value)->toBeTrue(); - - // Extract Supports attribute values - $supportsAttr = $reflection->getAttributes(Supports::class)[0]; - $supports = $supportsAttr->newInstance(); - expect($supports->features)->toBe(['title', 'editor', 'thumbnail']); - - // Extract AdminCol attribute values - $priceMethod = $reflection->getMethod('getPriceColumn'); - $adminColAttr = $priceMethod->getAttributes(AdminCol::class)[0]; - $adminCol = $adminColAttr->newInstance(); - expect($adminCol->key)->toBe('price'); - expect($adminCol->title)->toBe('Price'); -}); - -it('accepts all types without validation', function (): void { - // No validation should happen in attribute constructors - // All validation is now handled by PostTypeDiscovery - - // PostType with any values - expect(fn (): PostType => new PostType('', '', ''))->not->toThrow(Exception::class); - expect(fn (): PostType => new PostType('invalid slug', 'invalid name', 'invalid plural'))->not->toThrow(Exception::class); - - // HasArchive with any values - expect(fn (): HasArchive => new HasArchive(''))->not->toThrow(Exception::class); - expect(fn (): HasArchive => new HasArchive('invalid-archive'))->not->toThrow(Exception::class); - - // Supports with any array - expect(fn (): Supports => new Supports([]))->not->toThrow(Exception::class); - expect(fn (): Supports => new Supports(['invalid-feature']))->not->toThrow(Exception::class); - - // MenuIcon with any string - expect(fn (): MenuIcon => new MenuIcon(''))->not->toThrow(Exception::class); - expect(fn (): MenuIcon => new MenuIcon('invalid-icon'))->not->toThrow(Exception::class); - - // AdminCol with any values - expect(fn (): AdminCol => new AdminCol('', ''))->not->toThrow(Exception::class); -}); - -test('resolveSlug returns the explicit slug', function (): void { - expect((new PostType('synthese-presse'))->resolveSlug('App\\Cms\\PostTypes\\SyntheseDePresse')) - ->toBe('synthese-presse'); -}); - -test('resolveSlug derives the slug from the class name, cut to 20 characters', function (): void { - expect((new PostType)->resolveSlug('App\\Cms\\PostTypes\\SyntheseDePresse'))->toBe('synthese-de-presse') - ->and((new PostType)->resolveSlug('App\\VeryLongPostTypeClassName'))->toBe('very-long-post-type-'); -}); diff --git a/tests/Unit/Attributes/PostTypeSubAttributesTest.php b/tests/Unit/Attributes/PostTypeSubAttributesTest.php deleted file mode 100644 index ae2e589c..00000000 --- a/tests/Unit/Attributes/PostTypeSubAttributesTest.php +++ /dev/null @@ -1,271 +0,0 @@ -getAttributes(PostType::class); - expect($postTypeAttrs)->toHaveCount(1); - - // Should detect HasArchive sub-attribute - $hasArchiveAttrs = $reflection->getAttributes(HasArchive::class); - expect($hasArchiveAttrs)->toHaveCount(1); - - // Should detect Supports sub-attribute - $supportsAttrs = $reflection->getAttributes(Supports::class); - expect($supportsAttrs)->toHaveCount(1); - - // Should detect MenuIcon sub-attribute - $menuIconAttrs = $reflection->getAttributes(MenuIcon::class); - expect($menuIconAttrs)->toHaveCount(1); -}); - -it('detects all method-level AdminCol attributes', function (): void { - $reflection = new ReflectionClass(ProductWithSubAttributes::class); - - // Check price method - $priceMethod = $reflection->getMethod('getPriceColumn'); - $priceAttrs = $priceMethod->getAttributes(AdminCol::class); - expect($priceAttrs)->toHaveCount(1); - - // Check stock method - $stockMethod = $reflection->getMethod('getStockColumn'); - $stockAttrs = $stockMethod->getAttributes(AdminCol::class); - expect($stockAttrs)->toHaveCount(1); - - // Check category method - $categoryMethod = $reflection->getMethod('getCategoryColumn'); - $categoryAttrs = $categoryMethod->getAttributes(AdminCol::class); - expect($categoryAttrs)->toHaveCount(1); - - // Check that method without attribute has no AdminCol attributes - $nameMethod = $reflection->getMethod('getProductName'); - $nameAttrs = $nameMethod->getAttributes(AdminCol::class); - expect($nameAttrs)->toHaveCount(0); -}); - -it('extracts correct values from class-level attributes', function (): void { - $reflection = new ReflectionClass(ProductWithSubAttributes::class); - - // Extract PostType attribute - $postTypeAttr = $reflection->getAttributes(PostType::class)[0]; - $postType = $postTypeAttr->newInstance(); - expect($postType->slug)->toBe('product'); - expect($postType->singular)->toBeNull(); - expect($postType->plural)->toBeNull(); - - // Extract HasArchive attribute - $hasArchiveAttr = $reflection->getAttributes(HasArchive::class)[0]; - $hasArchive = $hasArchiveAttr->newInstance(); - expect($hasArchive->value)->toBe('products-archive'); - - // Extract Supports attribute - $supportsAttr = $reflection->getAttributes(Supports::class)[0]; - $supports = $supportsAttr->newInstance(); - expect($supports->features)->toBe(['title', 'editor', 'thumbnail', 'excerpt']); - - // Extract MenuIcon attribute - $menuIconAttr = $reflection->getAttributes(MenuIcon::class)[0]; - $menuIcon = $menuIconAttr->newInstance(); - expect($menuIcon->value)->toBe('dashicons-cart'); -}); - -it('extracts correct values from method-level AdminCol attributes', function (): void { - $reflection = new ReflectionClass(ProductWithSubAttributes::class); - - // Extract price AdminCol - $priceMethod = $reflection->getMethod('getPriceColumn'); - $priceAttr = $priceMethod->getAttributes(AdminCol::class)[0]; - $priceAdminCol = $priceAttr->newInstance(); - expect($priceAdminCol->key)->toBe('price'); - expect($priceAdminCol->title)->toBe('Price'); - expect($priceAdminCol->width)->toBeNull(); // Default value - expect($priceAdminCol->sortable)->toBeFalse(); // Default value - - // Extract stock AdminCol - $stockMethod = $reflection->getMethod('getStockColumn'); - $stockAttr = $stockMethod->getAttributes(AdminCol::class)[0]; - $stockAdminCol = $stockAttr->newInstance(); - expect($stockAdminCol->key)->toBe('stock'); - expect($stockAdminCol->title)->toBe('Stock Status'); - expect($stockAdminCol->width)->toBeNull(); // Default value - expect($stockAdminCol->sortable)->toBeFalse(); // Default value - - // Extract category AdminCol - $categoryMethod = $reflection->getMethod('getCategoryColumn'); - $categoryAttr = $categoryMethod->getAttributes(AdminCol::class)[0]; - $categoryAdminCol = $categoryAttr->newInstance(); - expect($categoryAdminCol->key)->toBe('category'); - expect($categoryAdminCol->title)->toBe('Category'); - expect($categoryAdminCol->width)->toBeNull(); // Default value - expect($categoryAdminCol->sortable)->toBeFalse(); // Default value -}); - -it('supports multiple AdminCol attributes on different methods', function (): void { - $reflection = new ReflectionClass(ProductWithSubAttributes::class); - $adminColMethods = []; - - foreach ($reflection->getMethods() as $method) { - $adminColAttrs = $method->getAttributes(AdminCol::class); - if (! empty($adminColAttrs)) { - $adminColMethods[$method->getName()] = $adminColAttrs[0]->newInstance(); - } - } - - expect($adminColMethods)->toHaveCount(3); - expect($adminColMethods)->toHaveKeys(['getPriceColumn', 'getStockColumn', 'getCategoryColumn']); - - // Verify each column has unique key - $keys = array_map(fn (AdminCol $adminCol): string => $adminCol->key, $adminColMethods); - expect($keys)->toBe(['getPriceColumn' => 'price', 'getStockColumn' => 'stock', 'getCategoryColumn' => 'category']); - expect(array_unique($keys))->toHaveCount(3); // All keys are unique -}); - -it('demonstrates attribute composition pattern', function (): void { - // This test demonstrates how the new system works: - // 1. PostType attribute defines the main post type - // 2. Sub-attributes (HasArchive, Supports, MenuIcon) configure post type settings - // 3. Method-level attributes (AdminCol) define callbacks and additional functionality - - $reflection = new ReflectionClass(ProductWithSubAttributes::class); - - // Main post type definition - $postTypeAttr = $reflection->getAttributes(PostType::class)[0]->newInstance(); - expect($postTypeAttr->slug)->toBe('product'); - - // Archive configuration - $hasArchiveAttr = $reflection->getAttributes(HasArchive::class)[0]->newInstance(); - expect($hasArchiveAttr->value)->toBe('products-archive'); - - // Features configuration - $supportsAttr = $reflection->getAttributes(Supports::class)[0]->newInstance(); - expect($supportsAttr->features)->toContain('title', 'editor', 'thumbnail', 'excerpt'); - - // Admin UI configuration - $menuIconAttr = $reflection->getAttributes(MenuIcon::class)[0]->newInstance(); - expect($menuIconAttr->value)->toBe('dashicons-cart'); - - // Admin columns configuration (method-level) - $columnMethods = []; - foreach ($reflection->getMethods() as $method) { - $adminColAttrs = $method->getAttributes(AdminCol::class); - if (! empty($adminColAttrs)) { - $columnMethods[] = [ - 'method' => $method->getName(), - 'column' => $adminColAttrs[0]->newInstance(), - ]; - } - } - - expect($columnMethods)->toHaveCount(3); - - // Verify the composition creates a complete post type configuration - $allClassAttributes = $reflection->getAttributes(); - $allMethodAttributes = []; - foreach ($reflection->getMethods() as $method) { - $allMethodAttributes = array_merge($allMethodAttributes, $method->getAttributes()); - } - - // Should have class-level attributes for configuration - expect($allClassAttributes)->toHaveCount(4); // PostType + 3 sub-attributes - - // Should have method-level attributes for callbacks - expect($allMethodAttributes)->toHaveCount(3); // 3 AdminCol attributes -}); - -it('attributes have correct target configurations', function (): void { - // Verify class-level attributes target classes - $classLevelAttributes = [ - PostType::class, - HasArchive::class, - Supports::class, - MenuIcon::class, - ]; - - foreach ($classLevelAttributes as $attributeClass) { - $reflection = new ReflectionClass($attributeClass); - $attributes = $reflection->getAttributes(Attribute::class); - expect($attributes)->toHaveCount(1); - - $attribute = $attributes[0]->newInstance(); - expect($attribute->flags)->toBe(Attribute::TARGET_CLASS); - } - - // Verify method-level attributes target methods - $methodLevelAttributes = [ - AdminCol::class, - ]; - - foreach ($methodLevelAttributes as $attributeClass) { - $reflection = new ReflectionClass($attributeClass); - $attributes = $reflection->getAttributes(Attribute::class); - expect($attributes)->toHaveCount(1); - - $attribute = $attributes[0]->newInstance(); - expect($attribute->flags)->toBe(Attribute::TARGET_METHOD); - } -}); - -it('demonstrates no validation in attributes', function (): void { - // All attributes should accept any values without validation - // Validation will be handled by PostTypeDiscovery - - expect(fn (): PostType => new PostType('', '', ''))->not->toThrow(Exception::class); - expect(fn (): HasArchive => new HasArchive(''))->not->toThrow(Exception::class); - expect(fn (): Supports => new Supports([]))->not->toThrow(Exception::class); - expect(fn (): MenuIcon => new MenuIcon(''))->not->toThrow(Exception::class); - expect(fn (): AdminCol => new AdminCol('', ''))->not->toThrow(Exception::class); - - // Even completely invalid values should not throw - expect(fn (): PostType => new PostType('invalid slug with spaces', 'inv@lid', 'pl{ur}al'))->not->toThrow(Exception::class); - expect(fn (): HasArchive => new HasArchive(123))->not->toThrow(Exception::class); // Wrong type - expect(fn (): Supports => new Supports(['invalid-feature', '', null]))->not->toThrow(Exception::class); - expect(fn (): MenuIcon => new MenuIcon(null))->not->toThrow(Exception::class); // Wrong type -}); diff --git a/tests/Unit/Attributes/ScheduleTest.php b/tests/Unit/Attributes/ScheduleTest.php deleted file mode 100644 index 97843335..00000000 --- a/tests/Unit/Attributes/ScheduleTest.php +++ /dev/null @@ -1,158 +0,0 @@ -toBeInstanceOf(Schedule::class); - expect($schedule->recurrence)->toBe('daily'); - expect($schedule->hook)->toBeNull(); - expect($schedule->args)->toBe([]); -}); - -it('creates Schedule attribute with custom hook name', function (): void { - $schedule = new Schedule('hourly', 'custom_hook_name'); - - expect($schedule->recurrence)->toBe('hourly'); - expect($schedule->hook)->toBe('custom_hook_name'); - expect($schedule->args)->toBe([]); -}); - -it('creates Schedule attribute with arguments', function (): void { - $args = ['type' => 'full', 'force' => true]; - $schedule = new Schedule('daily', null, $args); - - expect($schedule->recurrence)->toBe('daily'); - expect($schedule->hook)->toBeNull(); - expect($schedule->args)->toBe($args); -}); - -it('creates Schedule attribute with all parameters', function (): void { - $args = ['batch_size' => 100]; - $schedule = new Schedule('weekly', 'weekly_cleanup', $args); - - expect($schedule->recurrence)->toBe('weekly'); - expect($schedule->hook)->toBe('weekly_cleanup'); - expect($schedule->args)->toBe($args); -}); - -it('creates Schedule attribute with array recurrence', function (): void { - $recurrence = ['interval' => 3600, 'display' => 'Every Hour']; - $schedule = new Schedule($recurrence); - - expect($schedule->recurrence)->toBe($recurrence); - expect($schedule->hook)->toBeNull(); - expect($schedule->args)->toBe([]); -}); - -it('creates Schedule attribute with Every enum', function (): void { - $schedule = new Schedule(Every::DAY); - - expect($schedule->recurrence)->toBe(Every::DAY); - expect($schedule->hook)->toBeNull(); - expect($schedule->args)->toBe([]); -}); - -it('creates Schedule attribute with Interval instance', function (): void { - $interval = new Interval(minutes: 30, hours: 2); - $schedule = new Schedule($interval); - - expect($schedule->recurrence)->toBe($interval); - expect($schedule->hook)->toBeNull(); - expect($schedule->args)->toBe([]); -}); - -it('creates Schedule attribute with complex Every enum and parameters', function (): void { - $args = ['source' => 'api', 'limit' => 50]; - $schedule = new Schedule(Every::MONTH, 'monthly_sync', $args); - - expect($schedule->recurrence)->toBe(Every::MONTH); - expect($schedule->hook)->toBe('monthly_sync'); - expect($schedule->args)->toBe($args); -}); - -it('creates Schedule attribute with complex Interval and parameters', function (): void { - $interval = new Interval(minutes: 30, hours: 12, days: 1); - $args = ['cleanup_type' => 'deep']; - $schedule = new Schedule($interval, 'complex_cleanup', $args); - - expect($schedule->recurrence)->toBe($interval); - expect($schedule->hook)->toBe('complex_cleanup'); - expect($schedule->args)->toBe($args); -}); - -it('stores recurrence as readonly property', function (): void { - $schedule = new Schedule('daily'); - - expect($schedule->recurrence)->toBe('daily'); - - // Verify property is readonly by checking it cannot be modified - $reflection = new ReflectionClass($schedule); - $property = $reflection->getProperty('recurrence'); - expect($property->isReadOnly())->toBeTrue(); -}); - -it('stores hook as readonly property', function (): void { - $schedule = new Schedule('daily', 'test_hook'); - - expect($schedule->hook)->toBe('test_hook'); - - // Verify property is readonly - $reflection = new ReflectionClass($schedule); - $property = $reflection->getProperty('hook'); - expect($property->isReadOnly())->toBeTrue(); -}); - -it('stores args as readonly property', function (): void { - $args = ['key' => 'value']; - $schedule = new Schedule('daily', null, $args); - - expect($schedule->args)->toBe($args); - - // Verify property is readonly - $reflection = new ReflectionClass($schedule); - $property = $reflection->getProperty('args'); - expect($property->isReadOnly())->toBeTrue(); -}); - -it('has correct PHP attribute configuration', function (): void { - $reflection = new ReflectionClass(Schedule::class); - $attributes = $reflection->getAttributes(Attribute::class); - - expect($attributes)->toHaveCount(1); - - $attribute = $attributes[0]->newInstance(); - expect($attribute->flags)->toBe(Attribute::TARGET_METHOD); -}); - -it('accepts all supported recurrence types without validation', function (): void { - // No validation should happen in the attribute constructor - // All validation is now handled by ScheduleDiscovery - - // String recurrence - expect(fn (): Schedule => new Schedule('daily'))->not->toThrow(Exception::class); - expect(fn (): Schedule => new Schedule('invalid_schedule'))->not->toThrow(Exception::class); // No validation - - // Array recurrence - expect(fn (): Schedule => new Schedule(['interval' => 3600, 'display' => 'Valid']))->not->toThrow(Exception::class); - expect(fn (): Schedule => new Schedule(['invalid' => 'array']))->not->toThrow(Exception::class); // No validation - - // Every enum - expect(fn (): Schedule => new Schedule(Every::HOUR))->not->toThrow(Exception::class); - expect(fn (): Schedule => new Schedule(Every::MONTH))->not->toThrow(Exception::class); - - // Interval instance - $interval = new Interval(minutes: 30); - expect(fn (): Schedule => new Schedule($interval))->not->toThrow(Exception::class); -}); diff --git a/tests/Unit/Attributes/TaxonomySubAttributesTest.php b/tests/Unit/Attributes/TaxonomySubAttributesTest.php deleted file mode 100644 index 3838bb40..00000000 --- a/tests/Unit/Attributes/TaxonomySubAttributesTest.php +++ /dev/null @@ -1,313 +0,0 @@ -Custom taxonomy meta box

'; - } - - #[UpdateCountCallback] - public function updateTermCount($terms, $taxonomy): void - { - // Custom count update logic - foreach ($terms as $term) { - // Update count logic here - } - } - - #[MetaBoxSanitizeCb] - public function sanitizeTerms($terms): array - { - // Sanitize the terms - return array_map(sanitize_text_field(...), (array) $terms); - } - - public function getTaxonomyName(): string - { - return 'Product Category'; - } -} - -it('detects all class-level Taxonomy attributes', function (): void { - $reflection = new ReflectionClass(ProductCategoryWithSubAttributes::class); - - // Should detect Taxonomy main attribute - $taxonomyAttrs = $reflection->getAttributes(Taxonomy::class); - expect($taxonomyAttrs)->toHaveCount(1); - - // Should detect Hierarchical sub-attribute - $hierarchicalAttrs = $reflection->getAttributes(Hierarchical::class); - expect($hierarchicalAttrs)->toHaveCount(1); - - // Should detect PublicTaxonomy sub-attribute - $publicAttrs = $reflection->getAttributes(PublicTaxonomy::class); - expect($publicAttrs)->toHaveCount(1); - - // Should detect ShowUI sub-attribute - $showUIAttrs = $reflection->getAttributes(ShowUI::class); - expect($showUIAttrs)->toHaveCount(1); - - // Should detect ShowInRest sub-attribute - $showInRestAttrs = $reflection->getAttributes(ShowInRest::class); - expect($showInRestAttrs)->toHaveCount(1); - - // Should detect ObjectType sub-attribute - $objectTypeAttrs = $reflection->getAttributes(ObjectType::class); - expect($objectTypeAttrs)->toHaveCount(1); -}); - -it('detects all method-level callback attributes', function (): void { - $reflection = new ReflectionClass(ProductCategoryWithSubAttributes::class); - - // Check custom meta box method - $metaBoxMethod = $reflection->getMethod('customMetaBox'); - $metaBoxAttrs = $metaBoxMethod->getAttributes(MetaBoxCb::class); - expect($metaBoxAttrs)->toHaveCount(1); - - // Check update count method - $updateCountMethod = $reflection->getMethod('updateTermCount'); - $updateCountAttrs = $updateCountMethod->getAttributes(UpdateCountCallback::class); - expect($updateCountAttrs)->toHaveCount(1); - - // Check sanitize method - $sanitizeMethod = $reflection->getMethod('sanitizeTerms'); - $sanitizeAttrs = $sanitizeMethod->getAttributes(MetaBoxSanitizeCb::class); - expect($sanitizeAttrs)->toHaveCount(1); - - // Check that method without attribute has no callback attributes - $nameMethod = $reflection->getMethod('getTaxonomyName'); - $nameCallbackAttrs = array_merge( - $nameMethod->getAttributes(MetaBoxCb::class), - $nameMethod->getAttributes(UpdateCountCallback::class), - $nameMethod->getAttributes(MetaBoxSanitizeCb::class) - ); - expect($nameCallbackAttrs)->toHaveCount(0); -}); - -it('extracts correct values from class-level attributes', function (): void { - $reflection = new ReflectionClass(ProductCategoryWithSubAttributes::class); - - // Extract Taxonomy attribute - $taxonomyAttr = $reflection->getAttributes(Taxonomy::class)[0]; - $taxonomy = $taxonomyAttr->newInstance(); - expect($taxonomy->slug)->toBe('product-category'); - expect($taxonomy->singular)->toBeNull(); - expect($taxonomy->plural)->toBeNull(); - expect($taxonomy->objectType)->toBeNull(); - - // Test that attributes can be instantiated successfully - $hierarchicalAttr = $reflection->getAttributes(Hierarchical::class)[0]; - $hierarchical = $hierarchicalAttr->newInstance(); - expect($hierarchical)->toBeInstanceOf(Hierarchical::class); - - $publicAttr = $reflection->getAttributes(PublicTaxonomy::class)[0]; - $public = $publicAttr->newInstance(); - expect($public)->toBeInstanceOf(PublicTaxonomy::class); - - $showUIAttr = $reflection->getAttributes(ShowUI::class)[0]; - $showUI = $showUIAttr->newInstance(); - expect($showUI)->toBeInstanceOf(ShowUI::class); - - $showInRestAttr = $reflection->getAttributes(ShowInRest::class)[0]; - $showInRest = $showInRestAttr->newInstance(); - expect($showInRest)->toBeInstanceOf(ShowInRest::class); - - $objectTypeAttr = $reflection->getAttributes(ObjectType::class)[0]; - $objectType = $objectTypeAttr->newInstance(); - expect($objectType)->toBeInstanceOf(ObjectType::class); -}); - -it('extracts correct values from method-level callback attributes', function (): void { - $reflection = new ReflectionClass(ProductCategoryWithSubAttributes::class); - - // Extract MetaBoxCb - $metaBoxMethod = $reflection->getMethod('customMetaBox'); - $metaBoxAttr = $metaBoxMethod->getAttributes(MetaBoxCb::class)[0]; - $metaBoxCb = $metaBoxAttr->newInstance(); - expect($metaBoxCb)->toBeInstanceOf(MetaBoxCb::class); - - // Extract UpdateCountCallback - $updateCountMethod = $reflection->getMethod('updateTermCount'); - $updateCountAttr = $updateCountMethod->getAttributes(UpdateCountCallback::class)[0]; - $updateCountCb = $updateCountAttr->newInstance(); - expect($updateCountCb)->toBeInstanceOf(UpdateCountCallback::class); - - // Extract MetaBoxSanitizeCb - $sanitizeMethod = $reflection->getMethod('sanitizeTerms'); - $sanitizeAttr = $sanitizeMethod->getAttributes(MetaBoxSanitizeCb::class)[0]; - $sanitizeCb = $sanitizeAttr->newInstance(); - expect($sanitizeCb)->toBeInstanceOf(MetaBoxSanitizeCb::class); -}); - -it('supports multiple callback attributes on different methods', function (): void { - $reflection = new ReflectionClass(ProductCategoryWithSubAttributes::class); - $callbackMethods = []; - - foreach ($reflection->getMethods() as $method) { - $callbackAttrs = array_merge( - $method->getAttributes(MetaBoxCb::class), - $method->getAttributes(UpdateCountCallback::class), - $method->getAttributes(MetaBoxSanitizeCb::class) - ); - - if ($callbackAttrs !== []) { - $callbackMethods[$method->getName()] = $callbackAttrs[0]->newInstance(); - } - } - - expect($callbackMethods)->toHaveCount(3); - expect($callbackMethods)->toHaveKeys(['customMetaBox', 'updateTermCount', 'sanitizeTerms']); - - // Verify each callback has correct type - expect($callbackMethods['customMetaBox'])->toBeInstanceOf(MetaBoxCb::class); - expect($callbackMethods['updateTermCount'])->toBeInstanceOf(UpdateCountCallback::class); - expect($callbackMethods['sanitizeTerms'])->toBeInstanceOf(MetaBoxSanitizeCb::class); -}); - -it('demonstrates attribute composition pattern for taxonomies', function (): void { - // This test demonstrates how the taxonomy attribute system works: - // 1. Taxonomy attribute defines the main taxonomy - // 2. Sub-attributes (Hierarchical, PublicTaxonomy, ShowUI, etc.) configure taxonomy settings - // 3. Method-level attributes (MetaBoxCb, UpdateCountCallback, etc.) define callbacks - - $reflection = new ReflectionClass(ProductCategoryWithSubAttributes::class); - - // Main taxonomy definition - $taxonomyAttr = $reflection->getAttributes(Taxonomy::class)[0]->newInstance(); - expect($taxonomyAttr->slug)->toBe('product-category'); - - // Test attribute instantiation - $hierarchicalAttr = $reflection->getAttributes(Hierarchical::class)[0]->newInstance(); - expect($hierarchicalAttr)->toBeInstanceOf(Hierarchical::class); - - $publicAttr = $reflection->getAttributes(PublicTaxonomy::class)[0]->newInstance(); - expect($publicAttr)->toBeInstanceOf(PublicTaxonomy::class); - - $showUIAttr = $reflection->getAttributes(ShowUI::class)[0]->newInstance(); - expect($showUIAttr)->toBeInstanceOf(ShowUI::class); - - $showInRestAttr = $reflection->getAttributes(ShowInRest::class)[0]->newInstance(); - expect($showInRestAttr)->toBeInstanceOf(ShowInRest::class); - - $objectTypeAttr = $reflection->getAttributes(ObjectType::class)[0]->newInstance(); - expect($objectTypeAttr)->toBeInstanceOf(ObjectType::class); - - // Callback methods configuration (method-level) - $callbackMethods = []; - foreach ($reflection->getMethods() as $method) { - $callbackAttrs = array_merge( - $method->getAttributes(MetaBoxCb::class), - $method->getAttributes(UpdateCountCallback::class), - $method->getAttributes(MetaBoxSanitizeCb::class) - ); - - if ($callbackAttrs !== []) { - $callbackMethods[] = [ - 'method' => $method->getName(), - 'attribute' => $callbackAttrs[0]->newInstance(), - ]; - } - } - - expect($callbackMethods)->toHaveCount(3); - - // Verify the composition creates a complete taxonomy configuration - $allClassAttributes = $reflection->getAttributes(); - $allMethodAttributes = []; - foreach ($reflection->getMethods() as $method) { - $allMethodAttributes = array_merge($allMethodAttributes, $method->getAttributes()); - } - - // Should have class-level attributes for configuration - expect($allClassAttributes)->toHaveCount(6); // Taxonomy + 5 sub-attributes - - // Should have method-level attributes for callbacks - expect($allMethodAttributes)->toHaveCount(3); // 3 callback attributes -}); - -it('attributes have correct target configurations', function (): void { - // Verify class-level attributes target classes - $classLevelAttributes = [ - Taxonomy::class, - Hierarchical::class, - PublicTaxonomy::class, - ShowUI::class, - ShowInRest::class, - ObjectType::class, - ]; - - foreach ($classLevelAttributes as $attributeClass) { - $reflection = new ReflectionClass($attributeClass); - $attributes = $reflection->getAttributes(Attribute::class); - expect($attributes)->toHaveCount(1); - - $attribute = $attributes[0]->newInstance(); - expect($attribute->flags)->toBe(Attribute::TARGET_CLASS); - } - - // Verify method-level attributes target methods - $methodLevelAttributes = [ - MetaBoxCb::class, - UpdateCountCallback::class, - MetaBoxSanitizeCb::class, - ]; - - foreach ($methodLevelAttributes as $attributeClass) { - $reflection = new ReflectionClass($attributeClass); - $attributes = $reflection->getAttributes(Attribute::class); - expect($attributes)->toHaveCount(1); - - $attribute = $attributes[0]->newInstance(); - expect($attribute->flags)->toBe(Attribute::TARGET_METHOD); - } -}); - -it('demonstrates no validation in attributes', function (): void { - // All attributes should accept any values without validation - // Validation will be handled by TaxonomyDiscovery - - expect(fn (): Taxonomy => new Taxonomy('', '', '', []))->not->toThrow(Throwable::class); - expect(fn (): Hierarchical => new Hierarchical(false))->not->toThrow(Throwable::class); - expect(fn (): PublicTaxonomy => new PublicTaxonomy(false))->not->toThrow(Throwable::class); - expect(fn (): ShowUI => new ShowUI(false))->not->toThrow(Throwable::class); - expect(fn (): ShowInRest => new ShowInRest(false))->not->toThrow(Throwable::class); - expect(fn (): ObjectType => new ObjectType([]))->not->toThrow(Throwable::class); - expect(fn (): MetaBoxCb => new MetaBoxCb)->not->toThrow(Throwable::class); - expect(fn (): UpdateCountCallback => new UpdateCountCallback)->not->toThrow(Throwable::class); - expect(fn (): MetaBoxSanitizeCb => new MetaBoxSanitizeCb)->not->toThrow(Throwable::class); - - // Even completely invalid values should not throw - expect(fn (): Taxonomy => new Taxonomy('invalid slug with spaces', 'inv@lid', 'pl{ur}al', 'invalid'))->not->toThrow(Throwable::class); - expect(fn (): Hierarchical => new Hierarchical('not-boolean'))->not->toThrow(Throwable::class); // Wrong type - expect(fn (): ObjectType => new ObjectType('not-array'))->not->toThrow(Throwable::class); // Wrong type -}); diff --git a/tests/Unit/Schedule/EveryTest.php b/tests/Unit/Schedule/EveryTest.php deleted file mode 100644 index 68386522..00000000 --- a/tests/Unit/Schedule/EveryTest.php +++ /dev/null @@ -1,64 +0,0 @@ -toScheduleKey())->toBe('hourly'); - expect(Every::TWICE_DAILY->toScheduleKey())->toBe('twicedaily'); - expect(Every::DAY->toScheduleKey())->toBe('daily'); - expect(Every::WEEK->toScheduleKey())->toBe('weekly'); - expect(Every::MONTH->toScheduleKey())->toBe('monthly'); - expect(Every::YEAR->toScheduleKey())->toBe('yearly'); - }); - - it('identifies custom schedules correctly', function (): void { - expect(Every::HOUR->isCustom())->toBeFalse(); - expect(Every::TWICE_DAILY->isCustom())->toBeFalse(); - expect(Every::DAY->isCustom())->toBeFalse(); - expect(Every::WEEK->isCustom())->toBeFalse(); - expect(Every::MONTH->isCustom())->toBeTrue(); - expect(Every::YEAR->isCustom())->toBeTrue(); - }); - - it('converts to correct Interval instances', function (): void { - $hourInterval = Every::HOUR->toInterval(); - expect($hourInterval)->toBeInstanceOf(Interval::class); - expect($hourInterval->totalSeconds())->toBe(3600); - - $twiceDailyInterval = Every::TWICE_DAILY->toInterval(); - expect($twiceDailyInterval->totalSeconds())->toBe(43200); - - $dayInterval = Every::DAY->toInterval(); - expect($dayInterval->totalSeconds())->toBe(86400); - - $weekInterval = Every::WEEK->toInterval(); - expect($weekInterval->totalSeconds())->toBe(604800); - - $monthInterval = Every::MONTH->toInterval(); - expect($monthInterval->totalSeconds())->toBe(2592000); - - $yearInterval = Every::YEAR->toInterval(); - expect($yearInterval->totalSeconds())->toBe(31536000); - }); - - it('provides meaningful display text in intervals', function (): void { - expect(Every::HOUR->toInterval()->display)->toBe('Once Hourly'); - expect(Every::DAY->toInterval()->display)->toBe('Once Daily'); - expect(Every::WEEK->toInterval()->display)->toBe('Once Weekly'); - expect(Every::MONTH->toInterval()->display)->toBe('Once Monthly'); - expect(Every::YEAR->toInterval()->display)->toBe('Once Yearly'); - }); - - it('generates valid schedule arrays for custom intervals', function (): void { - $monthArray = Every::MONTH->toInterval()->toScheduleArray(); - - expect($monthArray)->toHaveKeys(['interval', 'display']); - expect($monthArray['interval'])->toBeInt(); - expect($monthArray['interval'])->toBeGreaterThan(0); - expect($monthArray['display'])->toBeString(); - }); -}); diff --git a/tests/Unit/Schedule/IntervalTest.php b/tests/Unit/Schedule/IntervalTest.php deleted file mode 100644 index 5630b804..00000000 --- a/tests/Unit/Schedule/IntervalTest.php +++ /dev/null @@ -1,67 +0,0 @@ -totalSeconds())->toBe(30 + 300 + 7200 + 86400 + 604800); - }); - - it('returns zero for empty interval', function (): void { - $interval = new Interval; - - expect($interval->totalSeconds())->toBe(0); - }); - - it('calculates seconds from hours only', function (): void { - $interval = new Interval(hours: 1); - - expect($interval->totalSeconds())->toBe(3600); - }); - - it('calculates seconds from days only', function (): void { - $interval = new Interval(days: 30); - - expect($interval->totalSeconds())->toBe(2592000); - }); - - it('calculates seconds from weeks only', function (): void { - $interval = new Interval(weeks: 1); - - expect($interval->totalSeconds())->toBe(604800); - }); - - it('returns schedule array with interval and display', function (): void { - $interval = new Interval(hours: 12, display: 'Twice Daily'); - - $array = $interval->toScheduleArray(); - - expect($array)->toBe([ - 'interval' => 43200, - 'display' => 'Twice Daily', - ]); - }); - - it('uses default display text', function (): void { - $interval = new Interval(minutes: 30); - - expect($interval->display)->toBe('Custom schedule'); - }); - - it('is readonly', function (): void { - $reflection = new ReflectionClass(Interval::class); - - expect($reflection->isReadOnly())->toBeTrue(); - }); -}); diff --git a/tests/Unit/Support/SlugTest.php b/tests/Unit/Support/SlugTest.php new file mode 100644 index 00000000..3b662ca5 --- /dev/null +++ b/tests/Unit/Support/SlugTest.php @@ -0,0 +1,20 @@ +toBe('user-management') + ->and(Slug::fromClassName('Cache'))->toBe('cache'); +}); + +it('derives a subcommand from a method name', function (): void { + expect(Slug::fromMethodName('deleteById'))->toBe('delete-by-id') + ->and(Slug::toKebabCase('listItems'))->toBe('list-items'); +}); From bc3d39e1f4f3e5b89a42bb63cb3a6dc4f12cf72e Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Mon, 12 Oct 2026 17:30:54 +0200 Subject: [PATCH 07/27] refactor: let the WpRest module run without Laravel's application WpRestDiscovery resolves controllers through the discovery's instance pool and hands custom HandlesAttributes attributes the PSR-11 container it was given, instead of app(); the wrapper takes the instance instead of building it with resolve() for its logger. An architecture test keeps the module to attributes, discovery, PSR contracts and WordPress. Fixes a REST controller with a constructor dependency never being built. --- CHANGELOG.md | 2 + .../Services/WpRestAttributableWrapper.php | 39 ++----------- .../Services/WpRestDiscovery.php | 11 +++- tests/Unit/WpRest/PackageBoundaryTest.php | 31 ++++++++++ tests/Unit/WpRest/WpRestDiscoveryTest.php | 58 +++++++++++++++++++ 5 files changed, 104 insertions(+), 37 deletions(-) create mode 100644 tests/Unit/WpRest/PackageBoundaryTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 93b92baf..489ebf97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - One `DiscoveryCacheManager` is shared by the engine and the `pollora:doctor` discovery cache check, which built its own - The discovery benchmarks measure memory as PHP allocates it, from a reset peak: they read 2 MB whatever the load - `#[WpRestRoute\Method]` no longer registers its route: `WpRestRouteRegistrar`, in the WpRest module, does, with the same arguments, permissions and request injection. Default slugs, meta keys and command names come from `Pollora\Attributes\Support\Naming`, which gives the same results as `Str::kebab()` and `Str::snake()`; `Pollora\Support\Slug` uses it +- The WpRest module no longer reaches for Laravel's application, the first step towards `pollora/wp-rest`: custom `HandlesAttributes` attributes receive the PSR-11 container given to `WpRestDiscovery`, and `WpRestAttributableWrapper` takes the controller instance instead of building it. An architecture test keeps the module to the attributes, discovery, PSR contracts and WordPress ### Deprecated - `DiscoveryCacheInterface` and `LaravelDiscoveryCache`, removed in 15.0: nothing reads this cache, scans are cached by the Spatie driver given to `DiscoveryCacheManager` @@ -28,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - A public `#[Action]` or `#[Filter]` method in a `#[PostType]` or `#[Taxonomy]` class is registered once: the post type and taxonomy discoveries also handed it to `Action::handle()`, which registered it a second time on the discovery's configuration object, so the page firing that hook failed with `call_user_func(): Argument #1 ($callback) must be a valid callback, class Pollora\PostType\Infrastructure\Services\PostTypeConfiguration does not have a method …` +- A REST controller declared with `#[WpRestRoute]` gets its constructor dependencies: it is resolved through the discovery's instance pool (Laravel's container in a Pollora application) instead of `new` with no argument, so a controller with a required dependency answered every request with `Trying to invoke non static method … without an object` ## [v13.35.6](https://github.com/Pollora/framework/compare/v13.35.5...v13.35.6) - 2026-10-12 diff --git a/src/WpRest/Infrastructure/Services/WpRestAttributableWrapper.php b/src/WpRest/Infrastructure/Services/WpRestAttributableWrapper.php index cca261ca..3d276ab2 100644 --- a/src/WpRest/Infrastructure/Services/WpRestAttributableWrapper.php +++ b/src/WpRest/Infrastructure/Services/WpRestAttributableWrapper.php @@ -6,57 +6,28 @@ use Pollora\Attributes\Attributable; use Pollora\Attributes\WpRestRoute\Permission; -use Pollora\Discovery\Domain\Contracts\ReflectionCacheInterface; -use Psr\Log\LoggerInterface; /** * Wrapper class to make any class compatible with Attributable interface. * * This class acts as a bridge between regular classes that use WpRestRoute attributes - * and the Attributable interface required by the Method attribute handlers. + * and the Attributable interface required by the Method attribute handlers. It carries + * the controller instance the discovery resolved, dependencies included. */ final readonly class WpRestAttributableWrapper implements Attributable { - private mixed $realInstance; - public function __construct( - private string $className, + private object $realInstance, public string $namespace, public string $route, public string|Permission|null $classPermission = null, - private ?ReflectionCacheInterface $reflectionCache = null - ) { - $this->realInstance = $this->createRealInstance(); - } + ) {} /** * Get the real instance of the wrapped class. */ - public function getRealInstance(): mixed + public function getRealInstance(): object { return $this->realInstance; } - - /** - * Create an instance of the real class. - */ - private function createRealInstance(): mixed - { - try { - $reflectionClass = $this->reflectionCache->getClassReflection($this->className); - - if ($reflectionClass->isInstantiable()) { - return $reflectionClass->newInstance(); - } - } catch (\Throwable $throwable) { - try { - resolve(LoggerInterface::class)->error( - sprintf('Failed to create instance of %s: ', $this->className).$throwable->getMessage() - ); - } catch (\Throwable) { - } - } - - return null; - } } diff --git a/src/WpRest/Infrastructure/Services/WpRestDiscovery.php b/src/WpRest/Infrastructure/Services/WpRestDiscovery.php index f8781370..0f69a7e1 100644 --- a/src/WpRest/Infrastructure/Services/WpRestDiscovery.php +++ b/src/WpRest/Infrastructure/Services/WpRestDiscovery.php @@ -12,6 +12,7 @@ use Pollora\Discovery\Domain\Contracts\ReflectionCacheInterface; use Pollora\Discovery\Domain\Services\HasInstancePool; use Pollora\Discovery\Domain\Services\IsDiscovery; +use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use ReflectionMethod; use Spatie\StructureDiscoverer\Data\DiscoveredClass; @@ -34,9 +35,13 @@ final class WpRestDiscovery implements DiscoveryInterface */ private array $wrapperCache = []; + /** + * @param ContainerInterface|null $container Handed to custom attributes implementing HandlesAttributes + */ public function __construct( private readonly ?LoggerInterface $logger = null, private readonly WpRestRouteRegistrar $registrar = new WpRestRouteRegistrar, + private readonly ?ContainerInterface $container = null, ) {} /** @@ -137,12 +142,12 @@ private function processWpRestRoute(string $className, ?ReflectionCacheInterface // Create wrapper once for the class (use cache to avoid recreating) $wrapperKey = md5($className.$wpRestRoute->namespace.$wpRestRoute->route); + // The controller comes from the instance pool, which gives it its dependencies $this->wrapperCache[$wrapperKey] ??= new WpRestAttributableWrapper( - $className, + $this->getInstanceFromPool($className), $wpRestRoute->namespace, $wpRestRoute->route, $wpRestRoute->permissionCallback, - $reflectionCache ); $attributableWrapper = $this->wrapperCache[$wrapperKey]; @@ -218,7 +223,7 @@ private function processMethodAttribute( if ($attributeInstance instanceof HandlesAttributes) { add_action('rest_api_init', function () use ($attributeInstance, $attributableWrapper, $method): void { - $attributeInstance->handle(app(), $attributableWrapper, $method, $attributeInstance); + $attributeInstance->handle($this->container, $attributableWrapper, $method, $attributeInstance); }); } } catch (\Throwable $throwable) { diff --git a/tests/Unit/WpRest/PackageBoundaryTest.php b/tests/Unit/WpRest/PackageBoundaryTest.php new file mode 100644 index 00000000..2486a4fc --- /dev/null +++ b/tests/Unit/WpRest/PackageBoundaryTest.php @@ -0,0 +1,31 @@ +expect(['Pollora\WpRest', WpGlobals::class]) + ->toOnlyUse([ + 'Pollora\WpRest', + 'Pollora\Attributes', + 'Pollora\Discovery', + WpGlobals::class, + 'Psr\Container', + 'Psr\Log', + 'Spatie\StructureDiscoverer', + // WordPress + '__', + 'add_action', + 'current_user_can', + 'is_user_logged_in', + 'register_rest_route', + 'rest_authorization_required_code', + ]) + ->ignoring([WpRestAttributeServiceProvider::class, 'WP_REST_Request', 'WP_Error']); diff --git a/tests/Unit/WpRest/WpRestDiscoveryTest.php b/tests/Unit/WpRest/WpRestDiscoveryTest.php index ed292d5b..5f0a4ff5 100644 --- a/tests/Unit/WpRest/WpRestDiscoveryTest.php +++ b/tests/Unit/WpRest/WpRestDiscoveryTest.php @@ -2,12 +2,14 @@ declare(strict_types=1); +use Brain\Monkey\Functions; use Pollora\Attributes\Attributable; use Pollora\Attributes\WpRestRoute; use Pollora\Attributes\WpRestRoute\Method; use Pollora\Discovery\Domain\Models\DiscoveryLocation; use Pollora\Discovery\Infrastructure\Services\ReflectionCache; use Pollora\WpRest\Infrastructure\Services\WpRestDiscovery; +use Spatie\StructureDiscoverer\Data\DiscoveredClass; // Mock WordPress functions if they don't exist if (! function_exists('register_rest_route')) { @@ -30,6 +32,31 @@ function add_action($hook, $callback, $priority = 10, $accepted_args = 1): bool } } +if (! class_exists('WP_REST_Request')) { + eval('class WP_REST_Request {}'); +} + +final class DocumentRepository +{ + public function title(): string + { + return 'Annual report'; + } +} + +// A controller with a dependency, given by the container +#[WpRestRoute('api/v1', '/reports')] +final readonly class ReportsController +{ + public function __construct(private DocumentRepository $documents) {} + + #[Method('GET')] + public function index(): array + { + return ['title' => $this->documents->title()]; + } +} + // Test class that doesn't implement Attributable (real-world scenario) #[WpRestRoute('api/v1', '/documents/(?P\\d+)')] class TestDocumentAPI @@ -186,6 +213,37 @@ class: WpRestRoute::class, expect(fn () => $discovery->apply())->not->toThrow(Exception::class); }); + test('registers each #[Method] on rest_api_init and calls the controller with its dependencies', function (): void { + $hooks = []; + $routes = []; + Functions\when('add_action')->alias(function (string $hook, callable $callback) use (&$hooks): bool { + $hooks[$hook][] = $callback; + + return true; + }); + Functions\when('register_rest_route')->alias(function (string $namespace, string $route, array $args) use (&$routes): bool { + $routes[] = ['namespace' => $namespace, 'route' => $route, 'args' => $args]; + + return true; + }); + + $discovery = new WpRestDiscovery; + $discovery->discover(new DiscoveryLocation('', __DIR__), DiscoveredClass::fromReflection(new ReflectionClass(ReportsController::class)), new ReflectionCache); + $discovery->apply(); + + expect($routes)->toBe([]); + + foreach ($hooks['rest_api_init'] ?? [] as $callback) { + $callback(); + } + + expect($routes)->toHaveCount(1) + ->and($routes[0]['namespace'])->toBe('api/v1') + ->and($routes[0]['route'])->toBe('/reports') + ->and($routes[0]['args']['methods'])->toBe(['GET']) + ->and($routes[0]['args']['callback'](Mockery::mock(WP_REST_Request::class)))->toBe(['title' => 'Annual report']); + }); + test('returns correct identifier', function (): void { $discovery = new WpRestDiscovery; expect($discovery->getIdentifier())->toBe('wp_rest_routes'); From dfa210dfc7152ddc1e786d379bb2017b240b0f39 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Mon, 12 Oct 2026 17:33:34 +0200 Subject: [PATCH 08/27] refactor: let the WpCli module run without Laravel's application WpCliDiscovery builds commands through the discovery's instance pool instead of resolve(). An architecture test keeps the module, provider and generator aside, to the attributes, discovery, PSR logging and WP-CLI. --- CHANGELOG.md | 1 + .../Services/WpCliDiscovery.php | 9 ++------ tests/Unit/WpCli/PackageBoundaryTest.php | 23 +++++++++++++++++++ 3 files changed, 26 insertions(+), 7 deletions(-) create mode 100644 tests/Unit/WpCli/PackageBoundaryTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 489ebf97..59f08d30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The discovery benchmarks measure memory as PHP allocates it, from a reset peak: they read 2 MB whatever the load - `#[WpRestRoute\Method]` no longer registers its route: `WpRestRouteRegistrar`, in the WpRest module, does, with the same arguments, permissions and request injection. Default slugs, meta keys and command names come from `Pollora\Attributes\Support\Naming`, which gives the same results as `Str::kebab()` and `Str::snake()`; `Pollora\Support\Slug` uses it - The WpRest module no longer reaches for Laravel's application, the first step towards `pollora/wp-rest`: custom `HandlesAttributes` attributes receive the PSR-11 container given to `WpRestDiscovery`, and `WpRestAttributableWrapper` takes the controller instance instead of building it. An architecture test keeps the module to the attributes, discovery, PSR contracts and WordPress +- The WpCli module no longer reaches for Laravel's application, the first step towards `pollora/wp-cli`: `WpCliDiscovery` builds commands through the discovery's instance pool instead of `resolve()`. Its service provider and `pollora:make:wp-cli` stay in the framework; an architecture test keeps the rest to the attributes, discovery, PSR logging and WP-CLI ### Deprecated - `DiscoveryCacheInterface` and `LaravelDiscoveryCache`, removed in 15.0: nothing reads this cache, scans are cached by the Spatie driver given to `DiscoveryCacheManager` diff --git a/src/WpCli/Infrastructure/Services/WpCliDiscovery.php b/src/WpCli/Infrastructure/Services/WpCliDiscovery.php index 36c9baeb..17d8ba33 100644 --- a/src/WpCli/Infrastructure/Services/WpCliDiscovery.php +++ b/src/WpCli/Infrastructure/Services/WpCliDiscovery.php @@ -129,13 +129,8 @@ private function processWpCliCommand(string $className, ?ReflectionCacheInterfac */ private function getCommandInstance(string $className): object { - // Use instance pool if available, otherwise fallback to local cache - return $this->getInstanceFromPool($className, function () use ($className): object { - // On laisse le container gérer la construction - $this->commandInstances[$className] ??= resolve($className); - - return $this->commandInstances[$className]; - }); + // The instance pool builds it with its dependencies; one instance per class + return $this->commandInstances[$className] ??= $this->getInstanceFromPool($className); } /** diff --git a/tests/Unit/WpCli/PackageBoundaryTest.php b/tests/Unit/WpCli/PackageBoundaryTest.php new file mode 100644 index 00000000..8fb39eb2 --- /dev/null +++ b/tests/Unit/WpCli/PackageBoundaryTest.php @@ -0,0 +1,23 @@ +expect('Pollora\WpCli') + ->toOnlyUse([ + 'Pollora\WpCli', + 'Pollora\Attributes', + 'Pollora\Discovery', + 'Psr\Log', + 'Spatie\StructureDiscoverer', + ]) + ->ignoring([WpCliServiceProvider::class, WpCliMakeCommand::class, 'WP_CLI', 'WP_CLI_Command']); From 19bf600f914acc56d0a4d2b5dace4f10786112c8 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Mon, 12 Oct 2026 17:45:59 +0200 Subject: [PATCH 09/27] refactor: let #[Schedule] run without Laravel ScheduleDiscovery derives default hook names itself, matching Str::snake() so scheduled hooks keep their name. Adds the first tests that run the discovery, a parity test and an architecture test; the bridge to Laravel's scheduler stays in the framework. --- CHANGELOG.md | 1 + .../Services/ScheduleDiscovery.php | 20 +++- tests/Unit/Schedule/PackageBoundaryTest.php | 28 +++++ tests/Unit/Schedule/ScheduleDiscoveryTest.php | 103 ++++++++++++++++++ 4 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/Schedule/PackageBoundaryTest.php create mode 100644 tests/Unit/Schedule/ScheduleDiscoveryTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 59f08d30..b6d9367b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `#[WpRestRoute\Method]` no longer registers its route: `WpRestRouteRegistrar`, in the WpRest module, does, with the same arguments, permissions and request injection. Default slugs, meta keys and command names come from `Pollora\Attributes\Support\Naming`, which gives the same results as `Str::kebab()` and `Str::snake()`; `Pollora\Support\Slug` uses it - The WpRest module no longer reaches for Laravel's application, the first step towards `pollora/wp-rest`: custom `HandlesAttributes` attributes receive the PSR-11 container given to `WpRestDiscovery`, and `WpRestAttributableWrapper` takes the controller instance instead of building it. An architecture test keeps the module to the attributes, discovery, PSR contracts and WordPress - The WpCli module no longer reaches for Laravel's application, the first step towards `pollora/wp-cli`: `WpCliDiscovery` builds commands through the discovery's instance pool instead of `resolve()`. Its service provider and `pollora:make:wp-cli` stay in the framework; an architecture test keeps the rest to the attributes, discovery, PSR logging and WP-CLI +- `#[Schedule]`, which runs on WP-Cron alone, no longer reaches for Laravel, the first step towards `pollora/schedule`: `ScheduleDiscovery` derives default hook names itself, with the same results as `Str::snake()` (a parity test keeps them), so hooks already scheduled keep their name. The bridge to Laravel's scheduler (`use_laravel_scheduler`) stays in the framework ### Deprecated - `DiscoveryCacheInterface` and `LaravelDiscoveryCache`, removed in 15.0: nothing reads this cache, scans are cached by the Spatie driver given to `DiscoveryCacheManager` diff --git a/src/Schedule/Infrastructure/Services/ScheduleDiscovery.php b/src/Schedule/Infrastructure/Services/ScheduleDiscovery.php index b01db376..ffb32491 100644 --- a/src/Schedule/Infrastructure/Services/ScheduleDiscovery.php +++ b/src/Schedule/Infrastructure/Services/ScheduleDiscovery.php @@ -4,7 +4,6 @@ namespace Pollora\Schedule\Infrastructure\Services; -use Illuminate\Support\Str; use InvalidArgumentException; use Pollora\Attributes\Schedule; use Pollora\Discovery\Domain\Contracts\DiscoveryInterface; @@ -162,10 +161,25 @@ public function apply(): void private function generateHookName(string $className, string $methodName): string { // Extract just the class name without namespace - $shortClassName = class_basename($className); + $shortClassName = basename(str_replace('\\', '/', $className)); // Convert to snake_case following WordPress conventions - return Str::snake($shortClassName).'_'.Str::snake($methodName); + return $this->snake($shortClassName).'_'.$this->snake($methodName); + } + + /** + * The value in snake case, as Laravel's Str::snake() writes it: hooks + * already scheduled under a derived name keep that name. + */ + private function snake(string $value): string + { + if (ctype_lower($value)) { + return $value; + } + + $value = (string) preg_replace('/\s+/u', '', ucwords($value)); + + return mb_strtolower((string) preg_replace('/(.)(?=[A-Z])/u', '$1_', $value), 'UTF-8'); } /** diff --git a/tests/Unit/Schedule/PackageBoundaryTest.php b/tests/Unit/Schedule/PackageBoundaryTest.php new file mode 100644 index 00000000..dd31d761 --- /dev/null +++ b/tests/Unit/Schedule/PackageBoundaryTest.php @@ -0,0 +1,28 @@ +expect(ScheduleDiscovery::class) + ->toOnlyUse([ + 'Pollora\Attributes', + 'Pollora\Discovery', + Every::class, + Interval::class, + 'Psr\Log', + 'Spatie\StructureDiscoverer', + // WordPress + 'add_action', + 'add_filter', + 'wp_next_scheduled', + 'wp_schedule_event', + ]); diff --git a/tests/Unit/Schedule/ScheduleDiscoveryTest.php b/tests/Unit/Schedule/ScheduleDiscoveryTest.php new file mode 100644 index 00000000..ad16b49a --- /dev/null +++ b/tests/Unit/Schedule/ScheduleDiscoveryTest.php @@ -0,0 +1,103 @@ + */ + public static array $calls = []; + + #[Schedule(Every::DAY)] + public function compile(): void + { + self::$calls[] = 'compile'; + } + + #[Schedule('hourly', hook: 'reports_ping', args: ['site' => 1])] + public function ping(array $args): void + { + self::$calls[] = 'ping:'.$args['site']; + } + + #[Schedule(Every::MONTH)] + public function archiveOldReports(): void + { + self::$calls[] = 'archiveOldReports'; + } + + #[Schedule(new Interval(minutes: 15, display: 'Every quarter hour'))] + public function refresh(): void + { + self::$calls[] = 'refresh'; + } +} + +beforeEach(function (): void { + NightlyReports::$calls = []; + $this->hooks = []; + $this->filters = []; + $this->events = []; + + Functions\when('add_action')->alias(function (string $hook, callable $callback): bool { + $this->hooks[$hook] = $callback; + + return true; + }); + Functions\when('add_filter')->alias(function (string $hook, callable $callback): bool { + $this->filters[$hook][] = $callback; + + return true; + }); + Functions\when('wp_next_scheduled')->justReturn(false); + Functions\when('wp_schedule_event')->alias(function (int $timestamp, string $recurrence, string $hook, array $args = []): bool { + $this->events[$hook] = [$recurrence, $args]; + + return true; + }); + + $discovery = new ScheduleDiscovery; + $discovery->discover(new DiscoveryLocation('', __DIR__), DiscoveredClass::fromReflection(new ReflectionClass(NightlyReports::class)), new ReflectionCache); + $discovery->apply(); +}); + +it('schedules each method on its hook, named after the class and method by default', function (): void { + expect($this->events)->toHaveKeys(['nightly_reports_compile', 'reports_ping', 'nightly_reports_archive_old_reports']) + ->and($this->events['nightly_reports_compile'])->toBe(['daily', []]) + ->and($this->events['reports_ping'])->toBe(['hourly', ['site' => 1]]) + ->and($this->events['nightly_reports_archive_old_reports'])->toBe(['every_month', []]); +}); + +it('registers the schedules WordPress does not know', function (): void { + $schedules = array_reduce($this->filters['cron_schedules'], fn (array $schedules, callable $filter): array => $filter($schedules), []); + + expect($schedules['every_month'])->toBe(['interval' => 30 * 86400, 'display' => 'Once Monthly']) + ->and($schedules)->toContain(['interval' => 900, 'display' => 'Every quarter hour']); +}); + +it('runs the method, with its arguments, when the hook fires', function (): void { + ($this->hooks['nightly_reports_compile'])(); + ($this->hooks['reports_ping'])(); + + expect(NightlyReports::$calls)->toBe(['compile', 'ping:1']); +}); + +it('derives the hook name as Str::snake() does, so scheduled hooks keep their name', function (string $class, string $method): void { + $hookName = (new ReflectionMethod(ScheduleDiscovery::class, 'generateHookName'))->invoke(new ScheduleDiscovery, $class, $method); + + expect($hookName)->toBe(Str::snake(class_basename($class)).'_'.Str::snake($method)); +})->with([ + ['App\\Cron\\NightlyReports', 'compile'], + ['App\\Cron\\HTMLSitemap', 'rebuildXMLIndex'], + ['Reports', 'sendTop10'], + ['App\\Cron\\ÉvénementsPublics', 'purge'], +]); From 26cad69ca6fec20c5b21282df2417887231f4d5a Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 13 Oct 2026 09:43:55 +0200 Subject: [PATCH 10/27] refactor: require pollora/wp-rest instead of shipping the WpRest module --- CHANGELOG.md | 1 + composer.json | 1 + src/Support/WpGlobals.php | 90 ------ .../Services/WpRestAttributableWrapper.php | 33 -- .../Services/WpRestDiscovery.php | 242 --------------- .../Services/WpRestRouteRegistrar.php | 133 -------- src/WpRest/Permissions/Can.php | 43 --- src/WpRest/Permissions/IsAdmin.php | 29 -- src/WpRest/Permissions/IsLoggedIn.php | 29 -- tests/Unit/WpRest/CanPermissionTest.php | 45 --- tests/Unit/WpRest/PackageBoundaryTest.php | 31 -- .../Unit/WpRest/RestRequestInjectionTest.php | 52 ---- tests/Unit/WpRest/WpRestDiscoveryTest.php | 288 ------------------ 13 files changed, 2 insertions(+), 1015 deletions(-) delete mode 100755 src/Support/WpGlobals.php delete mode 100644 src/WpRest/Infrastructure/Services/WpRestAttributableWrapper.php delete mode 100644 src/WpRest/Infrastructure/Services/WpRestDiscovery.php delete mode 100644 src/WpRest/Infrastructure/Services/WpRestRouteRegistrar.php delete mode 100644 src/WpRest/Permissions/Can.php delete mode 100755 src/WpRest/Permissions/IsAdmin.php delete mode 100755 src/WpRest/Permissions/IsLoggedIn.php delete mode 100644 tests/Unit/WpRest/CanPermissionTest.php delete mode 100644 tests/Unit/WpRest/PackageBoundaryTest.php delete mode 100644 tests/Unit/WpRest/RestRequestInjectionTest.php delete mode 100644 tests/Unit/WpRest/WpRestDiscoveryTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b6d9367b..7aafff9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Discovery can run without Laravel's application, the first step towards `pollora/discovery`: `new DiscoveryEngine()` works with no argument. Two contracts let an environment plug in — `InstanceResolverInterface` builds the classes discovery needs (Laravel's container in a Pollora application, `ReflectionInstanceResolver` otherwise) and `DiscoveryRegistrarInterface` adds the environment's discoveries before each run (`DiscoveryRegistrar` keeps scanning the container's `*Discovery` bindings). `Psr16DiscoverCacheDriver` caches scans in any PSR-16 cache - Post type and taxonomy attributes say what they register: each implements `ConfiguresArguments`, a pure function that returns the registration arguments with its own contribution (`#[HasArchive('library')]` sets `has_archive`, `#[Labels]` merges into the generated labels), and the method attributes (`#[AdminCol]`, `#[RegisterMetaBoxCb]`, `#[MetaBoxCb]`, `#[MetaBoxSanitizeCb]`, `#[UpdateCountCallback]`) implement `ConfiguresArgumentsWithMethod`, the discovery handing them the method as a callback. The `Pollora\Attributes` namespace no longer reaches Laravel or a framework service, which an architecture test keeps: the first step towards `pollora/attributes`, a package to declare attributes without the framework - The attributes live in their own package, [`pollora/attributes`](https://github.com/Pollora/attributes), which the framework requires: every attribute and its contracts, with the value types they take (`Every`, `Interval`, `Access`, `Control`, `BindingFieldType`). Class names do not change. The framework keeps the two deprecated bases, `PostTypeAttribute` and `TaxonomyAttribute`, and the code that reads the attributes +- REST routes live in their own package, [`pollora/wp-rest`](https://github.com/Pollora/wp-rest), which the framework requires: `WpRestDiscovery`, the route registrar, the permissions (`IsLoggedIn`, `IsAdmin`, `Can`) and `Pollora\Support\WpGlobals`. Class names do not change. The framework keeps `WpRestAttributeServiceProvider` ### Changed - `DiscoveryEngine`, `DiscoveryCacheManager`, `InstancePool` and `ReflectionCache` take their dependencies instead of reaching for `app()` and `config()`; reading `structure-discoverer.cache` moved to `LaravelDiscoveryCacheManagerFactory`. `DiscoveryRegistrar::registerFromContainer()` is now `registerInto()`. These are infrastructure classes, outside the public contracts (`Pollora\Discovery\Domain\*`), which do not change diff --git a/composer.json b/composer.json index af63004e..6601c689 100644 --- a/composer.json +++ b/composer.json @@ -36,6 +36,7 @@ "pollora/entity": "^1.2", "pollora/hook": "^1.5", "pollora/option": "^1.1", + "pollora/wp-rest": "^0.1 || ^1.0", "watson/rememberable": "^7.1", "cweagans/composer-patches": "^2.0", "pollora/colt": "^10.0.1", diff --git a/src/Support/WpGlobals.php b/src/Support/WpGlobals.php deleted file mode 100755 index 7f4c22d1..00000000 --- a/src/Support/WpGlobals.php +++ /dev/null @@ -1,90 +0,0 @@ - - */ - private static array $essentialGlobals = [ - 'current_user', - 'wpdb', - 'wp_roles', - 'wp', - 'wp_query', - 'post', - 'pagenow', - 'hook_suffix', - 'wp_filter', - 'wp_actions', - 'wp_current_filter', - ]; - - /** - * Wraps a callback function with WordPress globals. - * - * @param callable $callback The function to wrap - * @param array|null $globals Optional specific list of globals to preserve - * @return callable The wrapped function with access to WordPress globals - */ - public static function wrap(callable $callback, ?array $globals = null): callable - { - // If $globals is null, use the default list - $globalKeys = $globals ?? self::$essentialGlobals; - - // Capture the global variables - $capturedGlobals = array_intersect_key($GLOBALS, array_flip($globalKeys)); - - return static function (...$args) use ($callback, $capturedGlobals) { - // Reinject the globals into the context - foreach ($capturedGlobals as $key => $value) { - $GLOBALS[$key] = $value; - } - - // Execute the callback - return $callback(...$args); - }; - } - - /** - * Adds a global to the list of essential globals. - * - * @param string $globalName Name of the global variable to add - */ - public static function addEssentialGlobal(string $globalName): void - { - if (! in_array($globalName, self::$essentialGlobals)) { - self::$essentialGlobals[] = $globalName; - } - } - - /** - * Sets multiple essential globals at once. - * - * @param array $globalNames An array of global variable names - */ - public static function setEssentialGlobals(array $globalNames): void - { - self::$essentialGlobals = $globalNames; - } - - /** - * Gets the current list of essential globals. - * - * @return array The list of essential global variable names - */ - public static function getEssentialGlobals(): array - { - return self::$essentialGlobals; - } -} diff --git a/src/WpRest/Infrastructure/Services/WpRestAttributableWrapper.php b/src/WpRest/Infrastructure/Services/WpRestAttributableWrapper.php deleted file mode 100644 index 3d276ab2..00000000 --- a/src/WpRest/Infrastructure/Services/WpRestAttributableWrapper.php +++ /dev/null @@ -1,33 +0,0 @@ -realInstance; - } -} diff --git a/src/WpRest/Infrastructure/Services/WpRestDiscovery.php b/src/WpRest/Infrastructure/Services/WpRestDiscovery.php deleted file mode 100644 index 0f69a7e1..00000000 --- a/src/WpRest/Infrastructure/Services/WpRestDiscovery.php +++ /dev/null @@ -1,242 +0,0 @@ -isAbstract) { - return; - } - - // Check if class has WpRestRoute attribute - $wpRestRouteAttribute = $this->findWpRestRouteAttribute($structure->attributes); - if ($wpRestRouteAttribute === null) { - return; - } - - // Collect the class for registration - $this->getItems()->add($location, [ - 'class' => $structure->namespace.'\\'.$structure->name, - 'attribute' => $wpRestRouteAttribute, - 'structure' => $structure, - 'reflection_cache' => $reflectionCache, - ]); - } - - /** - * {@inheritDoc} - * - * Applies discovered WpRestRoute classes by registering them as REST API endpoints. - * Each discovered class is processed for method-level attributes. - */ - public function apply(): void - { - foreach ($this->getItems() as $discoveredItem) { - [ - 'class' => $className, - 'attribute' => $restRouteAttribute, - 'structure' => $structure - ] = $discoveredItem; - - try { - // Process the complete WP REST route configuration - $reflectionCache = $discoveredItem['reflection_cache'] ?? null; - $this->processWpRestRoute($className, $reflectionCache); - } catch (\Throwable $e) { - // Log the error but continue with other REST routes - $this->logger?->error(sprintf('Failed to register WP REST route from class %s: ', $className).$e->getMessage()); - } - } - } - - /** - * Find WpRestRoute attribute in the given attributes array. - * - * @param array $attributes The attributes to search through - * @return object|null The WpRestRoute attribute or null if not found - */ - private function findWpRestRouteAttribute(array $attributes): ?object - { - foreach ($attributes as $attribute) { - if ($attribute->class === WpRestRoute::class) { - return $attribute; - } - } - - return null; - } - - /** - * Process a complete WP REST route configuration from its class and attributes. - * - * @param string $className The fully qualified class name - * @param ReflectionCacheInterface|null $reflectionCache Optional reflection cache - */ - private function processWpRestRoute(string $className, ?ReflectionCacheInterface $reflectionCache = null): void - { - try { - $reflectionClass = $reflectionCache->getClassReflection($className); - $wpRestRouteAttributes = $reflectionCache->getClassAttributes($className, WpRestRoute::class); - - if ($wpRestRouteAttributes === []) { - return; - } - - /** @var WpRestRoute $wpRestRoute */ - $wpRestRoute = $wpRestRouteAttributes[0]->newInstance(); - - // Create wrapper once for the class (use cache to avoid recreating) - $wrapperKey = md5($className.$wpRestRoute->namespace.$wpRestRoute->route); - - // The controller comes from the instance pool, which gives it its dependencies - $this->wrapperCache[$wrapperKey] ??= new WpRestAttributableWrapper( - $this->getInstanceFromPool($className), - $wpRestRoute->namespace, - $wpRestRoute->route, - $wpRestRoute->permissionCallback, - ); - - $attributableWrapper = $this->wrapperCache[$wrapperKey]; - - // Process all method-level attributes - $this->processMethodLevelAttributes($className, $attributableWrapper, $reflectionCache); - - } catch (\ReflectionException $reflectionException) { - $this->logger?->error(sprintf('Failed to process WP REST route for class %s: ', $className).$reflectionException->getMessage()); - } - } - - /** - * Process method-level attributes to register REST endpoints. - * - * @param string $className The fully qualified class name - * @param WpRestAttributableWrapper $attributableWrapper The wrapper instance - * @param ReflectionCacheInterface|null $reflectionCache Optional reflection cache - */ - private function processMethodLevelAttributes(string $className, WpRestAttributableWrapper $attributableWrapper, ?ReflectionCacheInterface $reflectionCache = null): void - { - try { - $methods = $reflectionCache->getPublicMethods($className); - - foreach ($methods as $method) { - $this->processMethodAttributes($method, $attributableWrapper); - } - } catch (\ReflectionException $reflectionException) { - $this->logger?->error(sprintf('Failed to process method-level attributes for %s: ', $className).$reflectionException->getMessage()); - } - } - - /** - * Process all attributes for a single method. - * - * @param ReflectionMethod $method The method to process - * @param WpRestAttributableWrapper $attributableWrapper The wrapper instance - */ - private function processMethodAttributes(ReflectionMethod $method, WpRestAttributableWrapper $attributableWrapper): void - { - foreach ($method->getAttributes() as $attribute) { - // Process attributes in the WpRestRoute namespace - if (! str_contains($attribute->getName(), 'Pollora\\Attributes\\WpRestRoute\\')) { - continue; - } - - $this->processMethodAttribute($method, $attribute, $attributableWrapper); - } - } - - /** - * Process a single method-level attribute for REST route registration. - * - * @param ReflectionMethod $method The method with the attribute - * @param \ReflectionAttribute $attribute The attribute to process - * @param WpRestAttributableWrapper $attributableWrapper The wrapper instance - */ - private function processMethodAttribute( - ReflectionMethod $method, - \ReflectionAttribute $attribute, - WpRestAttributableWrapper $attributableWrapper - ): void { - try { - $attributeInstance = $attribute->newInstance(); - - if ($attributeInstance instanceof Method) { - add_action('rest_api_init', function () use ($attributeInstance, $attributableWrapper, $method): void { - $this->registrar->register($attributableWrapper, $method, $attributeInstance); - }); - - return; - } - - if ($attributeInstance instanceof HandlesAttributes) { - add_action('rest_api_init', function () use ($attributeInstance, $attributableWrapper, $method): void { - $attributeInstance->handle($this->container, $attributableWrapper, $method, $attributeInstance); - }); - } - } catch (\Throwable $throwable) { - $className = $method->getDeclaringClass()->getName(); - $this->logger?->error(sprintf('Failed to process method attribute for %s::%s: ', $className, $method->getName()).$throwable->getMessage()); - } - } - - /** - * {@inheritDoc} - */ - public function getIdentifier(): string - { - return 'wp_rest_routes'; - } -} diff --git a/src/WpRest/Infrastructure/Services/WpRestRouteRegistrar.php b/src/WpRest/Infrastructure/Services/WpRestRouteRegistrar.php deleted file mode 100644 index ef519021..00000000 --- a/src/WpRest/Infrastructure/Services/WpRestRouteRegistrar.php +++ /dev/null @@ -1,133 +0,0 @@ -permissionCallback; - $permissionCallback = $methodPermission ?? $instance->classPermission; - - register_rest_route( - $instance->namespace, - $instance->route, - [ - 'methods' => $attribute->getMethods(), - 'callback' => WpGlobals::wrap( - fn (WP_REST_Request $request): mixed => $this->handleRequest($instance, $context, $request) - ), - 'args' => $this->extractArgsFromRoute($instance->route), - 'permission_callback' => $this->resolvePermissionCallback($permissionCallback), - ] - ); - } - - /** - * Executes the class method associated with the REST request. - * - * @param WpRestAttributableWrapper $instance The route class wrapper - * @param ReflectionMethod $method The method to invoke - * @param WP_REST_Request $request The REST request instance - * @return mixed The result of the method invocation - */ - private function handleRequest(WpRestAttributableWrapper $instance, ReflectionMethod $method, WP_REST_Request $request): mixed - { - $args = []; - - foreach ($method->getParameters() as $param) { - // A WP_REST_Request type hint gets the request itself. Looking its - // name up among the route parameters found nothing — no route - // declares one called "request" — so the handler was invoked with - // null and a controller declaring the type fataled before running. - if ($this->expectsTheRequest($param)) { - $args[] = $request; - - continue; - } - - $args[] = $request->get_param($param->getName()); - } - - return $method->invoke($instance->getRealInstance(), ...$args); - } - - /** - * Whether a parameter asks for the request object rather than a route value. - */ - private function expectsTheRequest(\ReflectionParameter $param): bool - { - $type = $param->getType(); - - if (! $type instanceof \ReflectionNamedType || $type->isBuiltin()) { - return false; - } - - $name = $type->getName(); - - // The name comparison stands on its own: ::class resolves whether or - // not WordPress is loaded, so this holds outside a WordPress runtime. - return $name === WP_REST_Request::class - || is_a($name, WP_REST_Request::class, true); - } - - /** - * Extracts dynamic arguments from a WordPress route. - * - * @param string $route The route pattern - * @return array The extracted arguments - */ - private function extractArgsFromRoute(string $route): array - { - preg_match_all('/\(\?P<(\w+)>/', $route, $matches); - - return array_fill_keys($matches[1], [ - 'validate_callback' => fn ($param): bool => is_string($param) || is_numeric($param), - ]); - } - - /** - * Resolves and executes the permission callback. - * - * @param class-string|Permission|null $permissionCallback The permission class or instance to use - * @return callable The permission function - */ - private function resolvePermissionCallback(string|Permission|null $permissionCallback): callable - { - if ($permissionCallback === null) { - return '__return_true'; - } - - if (is_string($permissionCallback) && (! class_exists($permissionCallback) || ! is_subclass_of($permissionCallback, Permission::class))) { - return fn (): WP_Error => new WP_Error('rest_forbidden', __('Invalid permission handler.'), ['status' => 403]); - } - - return WpGlobals::wrap(function (WP_REST_Request $request) use ($permissionCallback): bool|WP_Error { - $permissionInstance = is_string($permissionCallback) ? new $permissionCallback : $permissionCallback; - - return $permissionInstance->allow($request); - }); - } -} diff --git a/src/WpRest/Permissions/Can.php b/src/WpRest/Permissions/Can.php deleted file mode 100644 index ef9418dd..00000000 --- a/src/WpRest/Permissions/Can.php +++ /dev/null @@ -1,43 +0,0 @@ -capability instanceof BackedEnum ? (string) $this->capability->value : $this->capability; - $arguments = $this->parameter === null ? [] : [$request->get_param($this->parameter)]; - - return current_user_can($capability, ...$arguments) ?: new WP_Error( - 'rest_forbidden', - __('You do not have permission to access this endpoint.'), - ['status' => rest_authorization_required_code()] - ); - } -} diff --git a/src/WpRest/Permissions/IsAdmin.php b/src/WpRest/Permissions/IsAdmin.php deleted file mode 100755 index faa42e05..00000000 --- a/src/WpRest/Permissions/IsAdmin.php +++ /dev/null @@ -1,29 +0,0 @@ - 403] - ); - } -} diff --git a/src/WpRest/Permissions/IsLoggedIn.php b/src/WpRest/Permissions/IsLoggedIn.php deleted file mode 100755 index 43c34274..00000000 --- a/src/WpRest/Permissions/IsLoggedIn.php +++ /dev/null @@ -1,29 +0,0 @@ - 403] - ); - } -} diff --git a/tests/Unit/WpRest/CanPermissionTest.php b/tests/Unit/WpRest/CanPermissionTest.php deleted file mode 100644 index d977c375..00000000 --- a/tests/Unit/WpRest/CanPermissionTest.php +++ /dev/null @@ -1,45 +0,0 @@ -justReturn(401); -}); - -it('allows a user who has the capability', function (): void { - Functions\expect('current_user_can')->once()->with('export_attendees')->andReturn(true); - - expect((new Can(EventCap::ExportAttendees))->allow(Mockery::mock(WP_REST_Request::class)))->toBeTrue(); -}); - -it('passes the value of a request parameter, for a meta capability', function (): void { - $request = Mockery::mock(WP_REST_Request::class); - $request->shouldReceive('get_param')->with('id')->andReturn('42'); - Functions\expect('current_user_can')->once()->with('edit_post', '42')->andReturn(true); - - expect((new Can('edit_post', parameter: 'id'))->allow($request))->toBeTrue(); -}); - -it("refuses with WordPress's authorization status", function (): void { - Functions\when('current_user_can')->justReturn(false); - - expect((new Can('manage_options'))->allow(Mockery::mock(WP_REST_Request::class)))->toBeInstanceOf(WP_Error::class); -}); - -it('is accepted as an instance by #[Method] and #[WpRestRoute]', function (): void { - Functions\when('current_user_can')->justReturn(true); - $resolve = new ReflectionMethod(WpRestRouteRegistrar::class, 'resolvePermissionCallback'); - - $callback = $resolve->invoke(new WpRestRouteRegistrar, new Can('edit_posts')); - - expect($callback(Mockery::mock(WP_REST_Request::class)))->toBeTrue(); -}); diff --git a/tests/Unit/WpRest/PackageBoundaryTest.php b/tests/Unit/WpRest/PackageBoundaryTest.php deleted file mode 100644 index 2486a4fc..00000000 --- a/tests/Unit/WpRest/PackageBoundaryTest.php +++ /dev/null @@ -1,31 +0,0 @@ -expect(['Pollora\WpRest', WpGlobals::class]) - ->toOnlyUse([ - 'Pollora\WpRest', - 'Pollora\Attributes', - 'Pollora\Discovery', - WpGlobals::class, - 'Psr\Container', - 'Psr\Log', - 'Spatie\StructureDiscoverer', - // WordPress - '__', - 'add_action', - 'current_user_can', - 'is_user_logged_in', - 'register_rest_route', - 'rest_authorization_required_code', - ]) - ->ignoring([WpRestAttributeServiceProvider::class, 'WP_REST_Request', 'WP_Error']); diff --git a/tests/Unit/WpRest/RestRequestInjectionTest.php b/tests/Unit/WpRest/RestRequestInjectionTest.php deleted file mode 100644 index 56684741..00000000 --- a/tests/Unit/WpRest/RestRequestInjectionTest.php +++ /dev/null @@ -1,52 +0,0 @@ -getParameters()[0]; - - $method = (new ReflectionClass(WpRestRouteRegistrar::class))->getMethod('expectsTheRequest'); - - return $method->invoke(new WpRestRouteRegistrar, $param); -} - -describe('WpRestRouteRegistrar::expectsTheRequest()', function (): void { - it('gives the request to a parameter that asks for it by type', function (): void { - expect(expectsRequest('withRequest'))->toBeTrue(); - }); - - it('leaves a route parameter alone', function (): void { - expect(expectsRequest('withRouteParam'))->toBeFalse(); - }); - - it('leaves an untyped parameter alone', function (): void { - expect(expectsRequest('withUntyped'))->toBeFalse(); - }); - - it('leaves a builtin type alone', function (): void { - // int, string and friends name route values, never the request. - expect(expectsRequest('withBuiltin'))->toBeFalse(); - }); -}); diff --git a/tests/Unit/WpRest/WpRestDiscoveryTest.php b/tests/Unit/WpRest/WpRestDiscoveryTest.php deleted file mode 100644 index 5f0a4ff5..00000000 --- a/tests/Unit/WpRest/WpRestDiscoveryTest.php +++ /dev/null @@ -1,288 +0,0 @@ - $namespace, 'route' => $route, 'args' => $args]; - - return true; - } -} - -if (! function_exists('add_action')) { - function add_action($hook, $callback, $priority = 10, $accepted_args = 1): bool - { - global $wp_actions; - $wp_actions[$hook][] = $callback; - - return true; - } -} - -if (! class_exists('WP_REST_Request')) { - eval('class WP_REST_Request {}'); -} - -final class DocumentRepository -{ - public function title(): string - { - return 'Annual report'; - } -} - -// A controller with a dependency, given by the container -#[WpRestRoute('api/v1', '/reports')] -final readonly class ReportsController -{ - public function __construct(private DocumentRepository $documents) {} - - #[Method('GET')] - public function index(): array - { - return ['title' => $this->documents->title()]; - } -} - -// Test class that doesn't implement Attributable (real-world scenario) -#[WpRestRoute('api/v1', '/documents/(?P\\d+)')] -class TestDocumentAPI -{ - #[Method('GET')] - public function get(int $documentId): array - { - return ['id' => $documentId, 'title' => 'Test Document']; - } - - #[Method(['POST', 'DELETE'])] - public function modify(int $documentId): array - { - return ['id' => $documentId, 'modified' => true]; - } -} - -// Test class without WpRestRoute attribute -class RegularController -{ - public function someMethod(): string - { - return 'regular'; - } -} - -// Test class that is abstract -#[WpRestRoute('api/v1', '/abstract')] -abstract class AbstractController -{ - #[Method('GET')] - abstract public function get(): array; -} - -// Mock classes for testing - simplified version without Spatie dependencies -class MockDiscoveredAttribute -{ - public function __construct( - public readonly string $name, - public readonly string $class, - public readonly array $arguments = [] - ) {} - - public function newInstance() - { - return new $this->class(...$this->arguments); - } -} - -class MockDiscoveredClass -{ - public function __construct( - public readonly string $name, - public readonly string $namespace, - public readonly string $file, - public readonly array $attributes = [], - public readonly bool $isAbstract = false - ) {} -} - -describe('WpRestDiscovery', function (): void { - - beforeEach(function (): void { - global $registered_routes, $wp_actions; - $registered_routes = []; - $wp_actions = []; - }); - - test('discover method processes only DiscoveredClass instances', function (): void { - $discovery = new WpRestDiscovery; - - // Test that discovery starts empty - expect($discovery->getItems()->all())->toHaveCount(0); - - // Test that we can add items manually (simulating proper discovery) - $location = new DiscoveryLocation('Test\\', '/test/path'); - $discovery->getItems()->add($location, ['test' => 'item']); - - expect($discovery->getItems()->all())->toHaveCount(1); - }); - - test('basic discovery functionality works', function (): void { - $discovery = new WpRestDiscovery; - - // Test that we can manually add items (simulating discovery) - $location = new DiscoveryLocation('Test\\', '/test/path'); - $mockAttribute = new MockDiscoveredAttribute( - name: WpRestRoute::class, - class: WpRestRoute::class, - arguments: ['api/v1', '/test'] - ); - - $discovery->getItems()->add($location, [ - 'class' => 'TestClass', - 'attribute' => $mockAttribute, - 'structure' => new MockDiscoveredClass('TestClass', '', '/test.php'), - ]); - - expect($discovery->getItems()->all())->toHaveCount(1); - }); - - test('registers REST routes when applying discovered items', function (): void { - global $registered_routes, $wp_actions; - - $discovery = new WpRestDiscovery; - $location = new DiscoveryLocation('', '/test/path'); - $reflectionCache = new ReflectionCache; - - // Add a simple test item to verify the apply logic works - $discovery->getItems()->add($location, [ - 'class' => 'TestDocumentAPI', - 'attribute' => new MockDiscoveredAttribute( - name: WpRestRoute::class, - class: WpRestRoute::class, - arguments: ['api/v1', '/documents/(?P\\d+)'] - ), - 'structure' => new MockDiscoveredClass( - name: 'TestDocumentAPI', - namespace: '', - file: '/test/TestDocumentAPI.php' - ), - 'reflection_cache' => $reflectionCache, - ]); - - // The apply method should not throw even if reflection fails - expect(fn () => $discovery->apply())->not->toThrow(Exception::class); - - // Verify that items were processed (we don't check wp_actions as it depends on complex logic) - expect($discovery->getItems()->all())->toHaveCount(1); - }); - - test('handles reflection errors gracefully', function (): void { - $discovery = new WpRestDiscovery; - $location = new DiscoveryLocation('', '/test/path'); - $reflectionCache = new ReflectionCache; - - // Add an item with a non-existent class - $discovery->getItems()->add($location, [ - 'class' => 'NonExistentClass', - 'attribute' => new MockDiscoveredAttribute( - name: WpRestRoute::class, - class: WpRestRoute::class, - arguments: ['api/v1', '/test'] - ), - 'structure' => new MockDiscoveredClass( - name: 'NonExistentClass', - namespace: '', - file: '/test/NonExistentClass.php' - ), - 'reflection_cache' => $reflectionCache, - ]); - - // Should not throw, just log errors - expect(fn () => $discovery->apply())->not->toThrow(Exception::class); - }); - - test('registers each #[Method] on rest_api_init and calls the controller with its dependencies', function (): void { - $hooks = []; - $routes = []; - Functions\when('add_action')->alias(function (string $hook, callable $callback) use (&$hooks): bool { - $hooks[$hook][] = $callback; - - return true; - }); - Functions\when('register_rest_route')->alias(function (string $namespace, string $route, array $args) use (&$routes): bool { - $routes[] = ['namespace' => $namespace, 'route' => $route, 'args' => $args]; - - return true; - }); - - $discovery = new WpRestDiscovery; - $discovery->discover(new DiscoveryLocation('', __DIR__), DiscoveredClass::fromReflection(new ReflectionClass(ReportsController::class)), new ReflectionCache); - $discovery->apply(); - - expect($routes)->toBe([]); - - foreach ($hooks['rest_api_init'] ?? [] as $callback) { - $callback(); - } - - expect($routes)->toHaveCount(1) - ->and($routes[0]['namespace'])->toBe('api/v1') - ->and($routes[0]['route'])->toBe('/reports') - ->and($routes[0]['args']['methods'])->toBe(['GET']) - ->and($routes[0]['args']['callback'](Mockery::mock(WP_REST_Request::class)))->toBe(['title' => 'Annual report']); - }); - - test('returns correct identifier', function (): void { - $discovery = new WpRestDiscovery; - expect($discovery->getIdentifier())->toBe('wp_rest_routes'); - }); - - test('wrapper system works with non-Attributable classes', function (): void { - // Test the core wrapper functionality separately - $className = 'TestDocumentAPI'; - $namespace = 'api/v1'; - $route = '/documents/(?P\\d+)'; - - // Create wrapper like WpRestDiscovery does - $wrapper = new class($className, $namespace, $route) implements Attributable - { - private mixed $realInstance = null; - - public function __construct( - private readonly string $className, - public readonly string $namespace, - public readonly string $route, - public readonly ?string $classPermission = null - ) {} - - public function getRealInstance(): mixed - { - if ($this->realInstance === null) { - $reflectionClass = new ReflectionClass($this->className); - if ($reflectionClass->isInstantiable()) { - $this->realInstance = $reflectionClass->newInstance(); - } - } - - return $this->realInstance; - } - }; - - expect($wrapper)->toBeInstanceOf(Attributable::class); - expect($wrapper->namespace)->toBe($namespace); - expect($wrapper->route)->toBe($route); - expect($wrapper->getRealInstance())->toBeInstanceOf(TestDocumentAPI::class); - }); -}); From 5e7c7b8e4e92a6e687ef3b2b2df742936a6cbb09 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 13 Oct 2026 09:45:46 +0200 Subject: [PATCH 11/27] refactor: require pollora/wp-cli instead of shipping the WpCli module --- CHANGELOG.md | 1 + composer.json | 1 + phpstan-baseline.neon | 5 - .../Application/Services/WpCliService.php | 118 ------- .../Contracts/WpCliCommandInterface.php | 43 --- src/WpCli/Domain/Models/WordpressCommand.php | 122 ------- .../Infrastructure/Adapters/WpCliAdapter.php | 144 -------- .../Adapters/WpCliMethodWrapper.php | 81 ----- .../Services/WpCliDiscovery.php | 316 ------------------ .../Application/Services/WpCliServiceTest.php | 98 ------ .../Adapters/WpCliAdapterTest.php | 121 ------- .../Adapters/WpCliMethodWrapperTest.php | 102 ------ tests/Unit/WpCli/PackageBoundaryTest.php | 23 -- 13 files changed, 2 insertions(+), 1173 deletions(-) delete mode 100644 src/WpCli/Application/Services/WpCliService.php delete mode 100644 src/WpCli/Domain/Contracts/WpCliCommandInterface.php delete mode 100644 src/WpCli/Domain/Models/WordpressCommand.php delete mode 100644 src/WpCli/Infrastructure/Adapters/WpCliAdapter.php delete mode 100644 src/WpCli/Infrastructure/Adapters/WpCliMethodWrapper.php delete mode 100644 src/WpCli/Infrastructure/Services/WpCliDiscovery.php delete mode 100644 tests/Unit/WpCli/Application/Services/WpCliServiceTest.php delete mode 100644 tests/Unit/WpCli/Infrastructure/Adapters/WpCliAdapterTest.php delete mode 100644 tests/Unit/WpCli/Infrastructure/Adapters/WpCliMethodWrapperTest.php delete mode 100644 tests/Unit/WpCli/PackageBoundaryTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 7aafff9e..89f06b6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Post type and taxonomy attributes say what they register: each implements `ConfiguresArguments`, a pure function that returns the registration arguments with its own contribution (`#[HasArchive('library')]` sets `has_archive`, `#[Labels]` merges into the generated labels), and the method attributes (`#[AdminCol]`, `#[RegisterMetaBoxCb]`, `#[MetaBoxCb]`, `#[MetaBoxSanitizeCb]`, `#[UpdateCountCallback]`) implement `ConfiguresArgumentsWithMethod`, the discovery handing them the method as a callback. The `Pollora\Attributes` namespace no longer reaches Laravel or a framework service, which an architecture test keeps: the first step towards `pollora/attributes`, a package to declare attributes without the framework - The attributes live in their own package, [`pollora/attributes`](https://github.com/Pollora/attributes), which the framework requires: every attribute and its contracts, with the value types they take (`Every`, `Interval`, `Access`, `Control`, `BindingFieldType`). Class names do not change. The framework keeps the two deprecated bases, `PostTypeAttribute` and `TaxonomyAttribute`, and the code that reads the attributes - REST routes live in their own package, [`pollora/wp-rest`](https://github.com/Pollora/wp-rest), which the framework requires: `WpRestDiscovery`, the route registrar, the permissions (`IsLoggedIn`, `IsAdmin`, `Can`) and `Pollora\Support\WpGlobals`. Class names do not change. The framework keeps `WpRestAttributeServiceProvider` +- WP-CLI commands live in their own package, [`pollora/wp-cli`](https://github.com/Pollora/wp-cli), which the framework requires: `WpCliDiscovery`, `WpCliService`, the WP-CLI adapters and `WordpressCommand`. Class names do not change. The framework keeps `WpCliServiceProvider` and `pollora:make:wp-cli` ### Changed - `DiscoveryEngine`, `DiscoveryCacheManager`, `InstancePool` and `ReflectionCache` take their dependencies instead of reaching for `app()` and `config()`; reading `structure-discoverer.cache` moved to `LaravelDiscoveryCacheManagerFactory`. `DiscoveryRegistrar::registerFromContainer()` is now `registerInto()`. These are infrastructure classes, outside the public contracts (`Pollora\Discovery\Domain\*`), which do not change diff --git a/composer.json b/composer.json index 6601c689..4b0e66f2 100644 --- a/composer.json +++ b/composer.json @@ -36,6 +36,7 @@ "pollora/entity": "^1.2", "pollora/hook": "^1.5", "pollora/option": "^1.1", + "pollora/wp-cli": "^0.1 || ^1.0", "pollora/wp-rest": "^0.1 || ^1.0", "watson/rememberable": "^7.1", "cweagans/composer-patches": "^2.0", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index c13e879f..8334026c 100755 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -210,8 +210,3 @@ parameters: count: 1 path: src/View/Application/UseCases/MakeTemplateIncludableUseCase.php - - - message: '#^Constant WP_CLI_VERSION not found\.$#' - identifier: constant.notFound - count: 1 - path: src/WpCli/Infrastructure/Adapters/WpCliAdapter.php diff --git a/src/WpCli/Application/Services/WpCliService.php b/src/WpCli/Application/Services/WpCliService.php deleted file mode 100644 index 3f52a2f0..00000000 --- a/src/WpCli/Application/Services/WpCliService.php +++ /dev/null @@ -1,118 +0,0 @@ - - */ - private array $registeredCommands = []; - - public function __construct( - private readonly WpCliAdapter $wpCliAdapter, - private readonly ?LoggerInterface $logger = null - ) {} - - /** - * Register a WP CLI command. - * - * @param string $name The command name - * @param string|array $className The command class name or callable array - * @param string $description The command description - * @param int $priority The registration priority - * @param array $args Additional WP_CLI::add_command() arguments - */ - public function register(string $name, string|array $className, string $description = '', int $priority = 0, array $args = []): void - { - $this->registeredCommands[$name] = [ - 'class' => $className, - 'description' => $description, - 'priority' => $priority, - 'args' => $args, - ]; - - // Register immediately if WP CLI is available - $this->registerWithWpCli($name, $className, $args); - } - - /** - * Register a command with WP CLI through the adapter. - * - * @param string $name The command name - * @param string|array $className The command class name or callable array - * @param array $args Additional WP_CLI::add_command() arguments - */ - private function registerWithWpCli(string $name, string|array $className, array $args = []): void - { - if (! $this->wpCliAdapter->isAvailable()) { - return; - } - - try { - // Delegate validation and registration to the adapter - $this->wpCliAdapter->addCommand($name, $className, $args); - } catch (\Throwable $throwable) { - $this->logger?->error(sprintf('Failed to register WP CLI command %s: ', $name).$throwable->getMessage()); - } - } - - /** - * Initialize all registered commands. - * This method can be called to ensure all commands are properly initialized. - */ - public function initializeCommands(): void - { - if (! $this->wpCliAdapter->isAvailable()) { - return; - } - - foreach ($this->registeredCommands as $name => $commandData) { - $this->registerWithWpCli($name, $commandData['class'], $commandData['args']); - } - } - - /** - * Get all registered commands. - * - * @return array - */ - public function getRegisteredCommands(): array - { - return $this->registeredCommands; - } - - /** - * Check if a command is registered. - * - * @param string $name The command name - */ - public function hasCommand(string $name): bool - { - return isset($this->registeredCommands[$name]); - } - - /** - * Get command information. - * - * @param string $name The command name - * @return array{class: string|array, description: string, priority: int, args: array}|null - */ - public function getCommand(string $name): ?array - { - return $this->registeredCommands[$name] ?? null; - } -} diff --git a/src/WpCli/Domain/Contracts/WpCliCommandInterface.php b/src/WpCli/Domain/Contracts/WpCliCommandInterface.php deleted file mode 100644 index eb1c2c21..00000000 --- a/src/WpCli/Domain/Contracts/WpCliCommandInterface.php +++ /dev/null @@ -1,43 +0,0 @@ - $arguments The command arguments - * @param array $options The command options/flags - */ - public function __invoke(array $arguments, array $options): void; - - /** - * Get the command priority for registration order. - * - * @return int The priority (higher numbers = higher priority) - */ - public function getPriority(): int; - - /** - * Initialize the command. - * This method is called during the discovery and registration process. - */ - public function initialize(): void; - - /** - * Get the default command name from the AsWordpressCommand attribute. - * - * @return string|null The command name or null if not found - */ - public static function getDefaultName(): ?string; -} diff --git a/src/WpCli/Domain/Models/WordpressCommand.php b/src/WpCli/Domain/Models/WordpressCommand.php deleted file mode 100644 index e9e84c35..00000000 --- a/src/WpCli/Domain/Models/WordpressCommand.php +++ /dev/null @@ -1,122 +0,0 @@ - $arguments The command arguments - * @param array $options The command options/flags - */ - abstract public function __invoke(array $arguments, array $options): void; - - /** - * Get the command priority for registration order. - * Override this method to change the registration priority. - * - * @return int The priority (higher numbers = higher priority) - */ - public function getPriority(): int - { - return 0; - } - - /** - * Get command registration data. - * Returns data needed for command registration without directly interacting with WP-CLI. - * This respects the hexagonal architecture by keeping the domain layer pure. - * - * @return array{name: string|null, description: string|null, class: string} - */ - public function getRegistrationData(): array - { - return [ - 'name' => static::getDefaultName(), - 'description' => static::getDefaultDescription(), - 'class' => static::class, - ]; - } - - /** - * Get the default command name from the WpCli attribute. - * - * @return string|null The command name or null if not found - */ - public static function getDefaultName(): ?string - { - $class = static::class; - - try { - $reflectionClass = new \ReflectionClass($class); - $attributes = $reflectionClass->getAttributes(WpCli::class); - - if ($attributes !== []) { - /** @var WpCli $attribute */ - $attribute = $attributes[0]->newInstance(); - - return $attribute->getCommandName($class); - } - } catch (\ReflectionException) { - // Reflection failure is handled by returning null - } - - return null; - } - - /** - * Get the default command description from the WpCli attribute. - * - * @return string|null The command description or null if not found - */ - public static function getDefaultDescription(): ?string - { - $class = static::class; - - try { - $reflectionClass = new \ReflectionClass($class); - $attributes = $reflectionClass->getAttributes(WpCli::class); - - if ($attributes !== []) { - /** @var WpCli $attribute */ - $attribute = $attributes[0]->newInstance(); - - return null; - } - } catch (\ReflectionException) { - // Reflection failure is handled by returning null - } - - return null; - } - - /** - * Get the command description from the WpCli attribute. - * This method is called by WP CLI to get the command description. - * - * @return string The command description - */ - public static function get_shortdesc(): string - { - return static::getDefaultDescription() ?? ''; - } -} diff --git a/src/WpCli/Infrastructure/Adapters/WpCliAdapter.php b/src/WpCli/Infrastructure/Adapters/WpCliAdapter.php deleted file mode 100644 index aae45f5c..00000000 --- a/src/WpCli/Infrastructure/Adapters/WpCliAdapter.php +++ /dev/null @@ -1,144 +0,0 @@ - $args Additional arguments for WP-CLI - * - * @throws \RuntimeException If WP-CLI is not available - * @throws \InvalidArgumentException If the handler is invalid - */ - public function addCommand(string $name, string|array|object $handler, array $args = []): void - { - if (! $this->isAvailable()) { - throw new \RuntimeException('WP-CLI is not available'); - } - - $this->validateHandler($handler); - - WP_CLI::add_command($name, $handler, $args); - } - - /** - * Validate that the handler is properly formatted. - * - * @throws \InvalidArgumentException If the handler is invalid - */ - private function validateHandler(string|array|object $handler): void - { - if (is_string($handler)) { - if (! class_exists($handler)) { - throw new \InvalidArgumentException(sprintf("Command handler class '%s' does not exist", $handler)); - } - } elseif (is_array($handler)) { - if (count($handler) !== 2) { - throw new \InvalidArgumentException('Command handler array must contain exactly 2 elements [object, method]'); - } - - [$object, $method] = $handler; - - if (! is_object($object)) { - throw new \InvalidArgumentException('First element of handler array must be an object'); - } - - if (! is_string($method)) { - throw new \InvalidArgumentException('Second element of handler array must be a method name string'); - } - - if (! method_exists($object, $method)) { - $class = $object::class; - throw new \InvalidArgumentException(sprintf("Method '%s' does not exist on class '%s'", $method, $class)); - } - } elseif (is_object($handler)) { - if (! method_exists($handler, '__invoke')) { - $class = $handler::class; - throw new \InvalidArgumentException(sprintf("Object of class '%s' must be invokable (have __invoke method)", $class)); - } - } - } - - /** - * Check if a command is already registered with WP-CLI. - * - * @param string $name The command name - * @return bool True if the command exists - */ - public function hasCommand(string $name): bool - { - if (! $this->isAvailable()) { - return false; - } - - try { - $runner = WP_CLI::get_runner(); - - return $runner->find_command_to_run(explode(' ', $name)) !== null; - } catch (\Throwable) { - return false; - } - } - - /** - * Get WP-CLI version information. - * - * @return string|null The WP-CLI version or null if not available - */ - public function getVersion(): ?string - { - if (! $this->isAvailable()) { - return null; - } - - return WP_CLI_VERSION ?? null; - } - - /** - * Log a message to WP-CLI output. - * - * @param string $message The message to log - * @param string $level The log level (debug, log, warning, error, success) - */ - public function log(string $message, string $level = 'log'): void - { - if (! $this->isAvailable()) { - return; - } - - try { - match ($level) { - 'debug' => WP_CLI::debug($message), - 'warning' => WP_CLI::warning($message), - 'error' => WP_CLI::error($message), - 'success' => WP_CLI::success($message), - default => WP_CLI::log($message), - }; - } catch (\Throwable) { - // Silently fail for logging to avoid breaking command execution - } - } -} diff --git a/src/WpCli/Infrastructure/Adapters/WpCliMethodWrapper.php b/src/WpCli/Infrastructure/Adapters/WpCliMethodWrapper.php deleted file mode 100644 index f0c8430b..00000000 --- a/src/WpCli/Infrastructure/Adapters/WpCliMethodWrapper.php +++ /dev/null @@ -1,81 +0,0 @@ -docComment = $this->originalMethod->getDocComment() ?: ''; - } - - public function __invoke(array $args, array $assocArgs): mixed - { - // Delegate to the original method - return $this->originalMethod->invoke($this->instance, $args, $assocArgs); - } - - /** - * Magic method to expose the original method name when WP-CLI reflects on this object - * This allows WP-CLI to find a method with the same name as the original. - */ - public function __call(string $name, array $arguments): mixed - { - if ($name === $this->originalMethod->getName()) { - return $this->originalMethod->invoke($this->instance, ...$arguments); - } - - throw new \BadMethodCallException(sprintf('Method %s does not exist', $name)); - } - - /** - * Creates a dynamic method with the original method's name and documentation. - * This method will have the same name and docblock as the original method, - * allowing WP-CLI to properly extract documentation. - */ - public function createProxyMethod(): ReflectionMethod - { - return $this->originalMethod; - } - - /** - * Get the original method for reflection purposes - */ - public function getOriginalMethod(): ReflectionMethod - { - return $this->originalMethod; - } - - /** - * Get the original instance - */ - public function getOriginalInstance(): object - { - return $this->instance; - } - - /** - * Get the cached docblock - */ - public function getDocComment(): string|false - { - return $this->docComment ?: false; - } -} diff --git a/src/WpCli/Infrastructure/Services/WpCliDiscovery.php b/src/WpCli/Infrastructure/Services/WpCliDiscovery.php deleted file mode 100644 index 17d8ba33..00000000 --- a/src/WpCli/Infrastructure/Services/WpCliDiscovery.php +++ /dev/null @@ -1,316 +0,0 @@ - - */ - private array $commandInstances = []; - - public function __construct( - private readonly WpCliService $wpCliService, - private readonly ?LoggerInterface $logger = null - ) {} - - /** - * {@inheritDoc} - */ - public function discover(DiscoveryLocationInterface $location, DiscoveredStructure $structure, ?ReflectionCacheInterface $reflectionCache = null): void - { - if (! $structure instanceof DiscoveredClass || $structure->isAbstract) { - return; - } - - foreach ($structure->attributes as $attribute) { - if ($attribute->class === WpCli::class) { - $this->getItems()->add($location, [ - 'class' => $structure->namespace.'\\'.$structure->name, - 'reflection_cache' => $reflectionCache, - ]); - - return; - } - } - } - - /** - * {@inheritDoc} - */ - public function apply(): void - { - foreach ($this->getItems() as $discoveredItem) { - try { - $reflectionCache = $discoveredItem['reflection_cache'] ?? null; - $this->processWpCliCommand($discoveredItem['class'], $reflectionCache); - } catch (\Throwable $e) { - $this->logger?->error(sprintf('Failed to register WP CLI command from class %s: ', $discoveredItem['class']).$e->getMessage()); - } - } - } - - /** - * Process a WP CLI command class for registration. - */ - private function processWpCliCommand(string $className, ?ReflectionCacheInterface $reflectionCache = null): void - { - $reflectionClass = $reflectionCache->getClassReflection($className); - - if (! $reflectionClass->isInstantiable()) { - return; - } - - $attributes = $reflectionClass->getAttributes(WpCli::class); - if ($attributes === []) { - return; - } - - /** @var WpCli $attribute */ - $attribute = $attributes[0]->newInstance(); - $commandName = $attribute->getCommandName($className); - - if (empty($commandName)) { - $this->logger?->error(sprintf('WP CLI command %s has no command name defined', $className)); - - return; - } - - // On garde quand même l'instance dispo pour les subcommands - $instance = $this->getCommandInstance($className); - - if ($this->hasSubcommands($reflectionClass)) { - $this->processSubcommands($reflectionClass, $className, $commandName, $instance); - } else { - // IMPORTANT : on repasse au class-string, pas à l'instance - $this->registerCommand( - $commandName, - $className, - $this->collectWpCliArguments($reflectionClass) - ); - } - } - - /** - * Retourne une instance unique de la classe de commande. - * - * @param class-string $className - */ - private function getCommandInstance(string $className): object - { - // The instance pool builds it with its dependencies; one instance per class - return $this->commandInstances[$className] ??= $this->getInstanceFromPool($className); - } - - /** - * Check if the class has subcommands (no __invoke and has #[Command] methods). - */ - private function hasSubcommands(ReflectionClass $reflectionClass): bool - { - if ($reflectionClass->hasMethod('__invoke')) { - return false; - } - - foreach ($reflectionClass->getMethods() as $method) { - if ($method->getAttributes(Command::class) !== []) { - return true; - } - } - - return false; - } - - /** - * Process and register subcommands for a class. - */ - private function processSubcommands( - ReflectionClass $reflectionClass, - string $className, - string $baseCommandName, - object $instance - ): void { - // Base command => class-string, comme avant - $this->registerCommand( - $baseCommandName, - $className, - $this->collectWpCliArguments($reflectionClass) - ); - - // Subcommands => [instance, méthode] - foreach ($reflectionClass->getMethods() as $method) { - $commandAttributes = $method->getAttributes(Command::class); - if ($commandAttributes === []) { - continue; - } - - /** @var Command $commandAttribute */ - $commandAttribute = $commandAttributes[0]->newInstance(); - $subcommandName = $commandAttribute->getSubcommandName($method->getName()); - $fullCommandName = sprintf('%s %s', $baseCommandName, $subcommandName); - - $handler = $this->createCallable($instance, $method); - - $this->registerCommand( - $fullCommandName, - $handler, - $this->collectWpCliArguments($method) - ); - } - } - - /** - * Register a command through the WP CLI service only. - * This ensures single responsibility and avoids duplication. - * - * @param array $args - */ - private function registerCommand(string $commandName, string|array|\Closure $handler, array $args = []): void - { - // Delegate to the application service which handles WP-CLI registration - $this->wpCliService->register($commandName, $handler, '', 0, $args); - } - - /** - * Create a callable for a method (handles private/protected methods). - */ - private function createCallable(object $instance, ReflectionMethod $method): array|\Closure - { - if ($method->isPublic()) { - // Cas simple : WP-CLI peut appeler directement [instance, 'methodName'] - return [$instance, $method->getName()]; - } - - // Créer un wrapper qui se comporte comme la méthode originale - $wrapper = new WpCliMethodWrapper($instance, $method); - - // Retourner un callable qui préserve l'accès à la documentation - return $wrapper->__invoke(...); - } - - /** - * Collect WP CLI arguments from reflection attributes. - */ - private function collectWpCliArguments(ReflectionClass|ReflectionMethod $reflection): array - { - $args = []; - - $attributeMap = [ - BeforeInvoke::class => ['callback', 'before_invoke'], - AfterInvoke::class => ['callback', 'after_invoke'], - Synopsis::class => ['synopsis', 'synopsis'], - When::class => ['hook', 'when'], - IsDeferred::class => ['deferred', 'is_deferred'], - ]; - - foreach ($attributeMap as $attributeClass => [$property, $wpCliKey]) { - $attributes = $reflection->getAttributes($attributeClass); - if ($attributes !== []) { - $args[$wpCliKey] = $attributes[0]->newInstance()->$property; - } - } - - // Si c'est une méthode, extraire la documentation du docblock - if ($reflection instanceof ReflectionMethod) { - $docComment = $reflection->getDocComment(); - if ($docComment) { - // Extraire la description courte et longue du docblock - $description = $this->extractMethodDescription($docComment); - $args['shortdesc'] = $description['short']; - - if (! empty($description['long'])) { - $args['longdesc'] = $description['long']; - } - } - } - - return $args; - } - - /** - * Extract description from method docblock for WP-CLI help. - */ - private function extractMethodDescription(string $docComment): array - { - // Remove /** and */ and leading asterisks - $cleaned = preg_replace('/^\/\*\*|\*\/$/', '', $docComment); - $lines = explode("\n", (string) $cleaned); - - $description = ['short' => '', 'long' => '']; - $inLongDesc = false; - $longDescLines = []; - - foreach ($lines as $line) { - $line = trim(ltrim($line, ' *')); - - // Skip empty lines at the beginning - if (($line === '' || $line === '0') && empty($description['short']) && $longDescLines === []) { - continue; - } - - // Stop at @tags - if (str_starts_with($line, '@') || str_starts_with($line, '##')) { - break; - } - - // First non-empty line is the short description - if (empty($description['short']) && ($line !== '' && $line !== '0')) { - $description['short'] = $line; - - continue; - } - - // After short description, collect long description - if ($description['short'] !== '' && $description['short'] !== '0') { - $inLongDesc = true; - if ($line !== '' && $line !== '0' || $longDescLines !== []) { - $longDescLines[] = $line; - } - } - } - - if ($longDescLines !== []) { - $description['long'] = trim(implode("\n", $longDescLines)); - } - - return $description; - } - - /** - * {@inheritDoc} - */ - public function getIdentifier(): string - { - return 'wp_cli_commands'; - } -} diff --git a/tests/Unit/WpCli/Application/Services/WpCliServiceTest.php b/tests/Unit/WpCli/Application/Services/WpCliServiceTest.php deleted file mode 100644 index a6210720..00000000 --- a/tests/Unit/WpCli/Application/Services/WpCliServiceTest.php +++ /dev/null @@ -1,98 +0,0 @@ -adapter = new WpCliAdapter; - $this->service = new WpCliService($this->adapter); - }); - - describe('register', function (): void { - it('stores command in registered commands', function (): void { - $this->service->register('pollora test', 'TestCommand', 'A test command', 10); - - expect($this->service->hasCommand('pollora test'))->toBeTrue(); - }); - - it('stores command metadata correctly', function (): void { - $this->service->register('pollora test', 'TestCommand', 'A test command', 10, ['when' => 'before_wp_load']); - - $command = $this->service->getCommand('pollora test'); - - expect($command['class'])->toBe('TestCommand'); - expect($command['description'])->toBe('A test command'); - expect($command['priority'])->toBe(10); - expect($command['args'])->toBe(['when' => 'before_wp_load']); - }); - - it('accepts array handler', function (): void { - $handler = ['SomeClass', 'someMethod']; - - $this->service->register('pollora array-cmd', $handler, 'Array handler'); - - $command = $this->service->getCommand('pollora array-cmd'); - expect($command['class'])->toBe($handler); - }); - - it('stores default values when not provided', function (): void { - $this->service->register('pollora minimal', 'MinimalCommand'); - - $command = $this->service->getCommand('pollora minimal'); - - expect($command['description'])->toBe(''); - expect($command['priority'])->toBe(0); - expect($command['args'])->toBe([]); - }); - }); - - describe('hasCommand', function (): void { - it('returns false for unregistered command', function (): void { - expect($this->service->hasCommand('nonexistent'))->toBeFalse(); - }); - - it('returns true for registered command', function (): void { - $this->service->register('pollora exists', 'ExistsCommand'); - - expect($this->service->hasCommand('pollora exists'))->toBeTrue(); - }); - }); - - describe('getCommand', function (): void { - it('returns null for unregistered command', function (): void { - expect($this->service->getCommand('nonexistent'))->toBeNull(); - }); - }); - - describe('getRegisteredCommands', function (): void { - it('returns empty array initially', function (): void { - expect($this->service->getRegisteredCommands())->toBe([]); - }); - - it('returns all registered commands', function (): void { - $this->service->register('cmd1', 'Class1'); - $this->service->register('cmd2', 'Class2'); - - $commands = $this->service->getRegisteredCommands(); - - expect($commands)->toHaveCount(2); - expect($commands)->toHaveKeys(['cmd1', 'cmd2']); - }); - }); - - describe('initializeCommands', function (): void { - it('does not throw when WP CLI is unavailable', function (): void { - $this->service->register('cmd1', 'Class1'); - - // Should not throw — WP CLI is not available, so it silently skips - $this->service->initializeCommands(); - - expect($this->service->hasCommand('cmd1'))->toBeTrue(); - }); - }); -}); diff --git a/tests/Unit/WpCli/Infrastructure/Adapters/WpCliAdapterTest.php b/tests/Unit/WpCli/Infrastructure/Adapters/WpCliAdapterTest.php deleted file mode 100644 index 9b0b1522..00000000 --- a/tests/Unit/WpCli/Infrastructure/Adapters/WpCliAdapterTest.php +++ /dev/null @@ -1,121 +0,0 @@ -adapter = new WpCliAdapter; - }); - - describe('isAvailable', function (): void { - it('returns false when WP_CLI is not defined', function (): void { - // WP_CLI is not defined in test environment - expect($this->adapter->isAvailable())->toBeFalse(); - }); - }); - - describe('addCommand', function (): void { - it('throws RuntimeException when WP_CLI is not available', function (): void { - expect(fn () => $this->adapter->addCommand('test', 'SomeClass')) - ->toThrow(RuntimeException::class, 'WP-CLI is not available'); - }); - }); - - describe('hasCommand', function (): void { - it('returns false when WP_CLI is not available', function (): void { - expect($this->adapter->hasCommand('test'))->toBeFalse(); - }); - }); - - describe('getVersion', function (): void { - it('returns null when WP_CLI is not available', function (): void { - expect($this->adapter->getVersion())->toBeNull(); - }); - }); - - describe('log', function (): void { - it('does nothing when WP_CLI is not available', function (): void { - // Should not throw - $this->adapter->log('test message', 'debug'); - $this->adapter->log('test message', 'warning'); - $this->adapter->log('test message', 'error'); - $this->adapter->log('test message', 'success'); - $this->adapter->log('test message'); - - expect(true)->toBeTrue(); // Assert no exception - }); - }); -}); - -describe('WpCliAdapter validation', function (): void { - /* - * Since validateHandler is private, we test it indirectly through addCommand. - * When WP_CLI is unavailable, RuntimeException is thrown before validation. - * We use Reflection to test validation logic directly. - */ - beforeEach(function (): void { - $this->adapter = new WpCliAdapter; - $this->validate = new ReflectionMethod(WpCliAdapter::class, 'validateHandler'); - }); - - it('accepts a valid class string', function (): void { - // stdClass exists - $this->validate->invoke($this->adapter, stdClass::class); - - expect(true)->toBeTrue(); - }); - - it('rejects a non-existent class string', function (): void { - expect(fn () => $this->validate->invoke($this->adapter, 'NonExistentClass')) - ->toThrow(InvalidArgumentException::class, 'does not exist'); - }); - - it('accepts a valid [object, method] array', function (): void { - $handler = [new class - { - public function handle(): void {} - }, 'handle']; - - $this->validate->invoke($this->adapter, $handler); - - expect(true)->toBeTrue(); - }); - - it('rejects array with wrong count', function (): void { - expect(fn () => $this->validate->invoke($this->adapter, ['only-one'])) - ->toThrow(InvalidArgumentException::class, 'exactly 2 elements'); - }); - - it('rejects array where first element is not an object', function (): void { - expect(fn () => $this->validate->invoke($this->adapter, ['string', 'method'])) - ->toThrow(InvalidArgumentException::class, 'must be an object'); - }); - - it('rejects array where second element is not a string', function (): void { - expect(fn () => $this->validate->invoke($this->adapter, [new stdClass, 42])) - ->toThrow(InvalidArgumentException::class, 'must be a method name string'); - }); - - it('rejects array where method does not exist', function (): void { - expect(fn () => $this->validate->invoke($this->adapter, [new stdClass, 'nonExistent'])) - ->toThrow(InvalidArgumentException::class, 'does not exist on class'); - }); - - it('accepts an invokable object', function (): void { - $handler = new class - { - public function __invoke(): void {} - }; - - $this->validate->invoke($this->adapter, $handler); - - expect(true)->toBeTrue(); - }); - - it('rejects a non-invokable object', function (): void { - expect(fn () => $this->validate->invoke($this->adapter, new stdClass)) - ->toThrow(InvalidArgumentException::class, 'must be invokable'); - }); -}); diff --git a/tests/Unit/WpCli/Infrastructure/Adapters/WpCliMethodWrapperTest.php b/tests/Unit/WpCli/Infrastructure/Adapters/WpCliMethodWrapperTest.php deleted file mode 100644 index d235251e..00000000 --- a/tests/Unit/WpCli/Infrastructure/Adapters/WpCliMethodWrapperTest.php +++ /dev/null @@ -1,102 +0,0 @@ -instance = new class - { - /** - * Test command documentation. - * - * ## OPTIONS - * - * : The name to greet. - */ - public function greet(array $args, array $assocArgs): string - { - return 'Hello '.$args[0]; - } - - private function secretMethod(array $args): string - { - return 'secret: '.$args[0]; - } - }; - }); - - it('invokes the original method via __invoke', function (): void { - $method = new ReflectionMethod($this->instance, 'greet'); - $wrapper = new WpCliMethodWrapper($this->instance, $method); - - $result = $wrapper(['World'], []); - - expect($result)->toBe('Hello World'); - }); - - it('delegates __call to original method by name', function (): void { - $method = new ReflectionMethod($this->instance, 'greet'); - $wrapper = new WpCliMethodWrapper($this->instance, $method); - - $result = $wrapper->greet(['World'], []); - - expect($result)->toBe('Hello World'); - }); - - it('throws BadMethodCallException for unknown method via __call', function (): void { - $method = new ReflectionMethod($this->instance, 'greet'); - $wrapper = new WpCliMethodWrapper($this->instance, $method); - - expect(fn () => $wrapper->unknownMethod()) - ->toThrow(BadMethodCallException::class, 'does not exist'); - }); - - it('caches and returns the docblock', function (): void { - $method = new ReflectionMethod($this->instance, 'greet'); - $wrapper = new WpCliMethodWrapper($this->instance, $method); - - $doc = $wrapper->getDocComment(); - - expect($doc)->toBeString(); - expect($doc)->toContain('Test command documentation'); - expect($doc)->toContain('## OPTIONS'); - }); - - it('returns false when method has no docblock', function (): void { - $obj = new class - { - public function noDoc(array $args, array $assocArgs): void {} - }; - - $method = new ReflectionMethod($obj, 'noDoc'); - $wrapper = new WpCliMethodWrapper($obj, $method); - - expect($wrapper->getDocComment())->toBeFalse(); - }); - - it('returns the original method reflection', function (): void { - $method = new ReflectionMethod($this->instance, 'greet'); - $wrapper = new WpCliMethodWrapper($this->instance, $method); - - expect($wrapper->getOriginalMethod())->toBe($method); - }); - - it('returns the original instance', function (): void { - $method = new ReflectionMethod($this->instance, 'greet'); - $wrapper = new WpCliMethodWrapper($this->instance, $method); - - expect($wrapper->getOriginalInstance())->toBe($this->instance); - }); - - it('can invoke private methods via reflection', function (): void { - $method = new ReflectionMethod($this->instance, 'secretMethod'); - - $wrapper = new WpCliMethodWrapper($this->instance, $method); - - $result = $wrapper(['data'], []); - - expect($result)->toBe('secret: data'); - }); -}); diff --git a/tests/Unit/WpCli/PackageBoundaryTest.php b/tests/Unit/WpCli/PackageBoundaryTest.php deleted file mode 100644 index 8fb39eb2..00000000 --- a/tests/Unit/WpCli/PackageBoundaryTest.php +++ /dev/null @@ -1,23 +0,0 @@ -expect('Pollora\WpCli') - ->toOnlyUse([ - 'Pollora\WpCli', - 'Pollora\Attributes', - 'Pollora\Discovery', - 'Psr\Log', - 'Spatie\StructureDiscoverer', - ]) - ->ignoring([WpCliServiceProvider::class, WpCliMakeCommand::class, 'WP_CLI', 'WP_CLI_Command']); From 32518252348979e912381e38abfea6035a1b8fd4 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 13 Oct 2026 10:58:38 +0200 Subject: [PATCH 12/27] refactor: require pollora/schedule instead of shipping ScheduleDiscovery ScheduleDiscovery moves to pollora/schedule with its FQCN; the framework gives the schedule engine its container. The bridge to Laravel's scheduler stays. --- CHANGELOG.md | 1 + composer.json | 1 + .../Services/ScheduleDiscovery.php | 365 ------------------ .../SchedulerDiscoveryServiceProvider.php | 2 + src/Schedule/SchedulerServiceProvider.php | 3 + .../Schedule/SchedulerServiceProviderTest.php | 7 + tests/Unit/Schedule/PackageBoundaryTest.php | 28 -- tests/Unit/Schedule/ScheduleDiscoveryTest.php | 103 ----- 8 files changed, 14 insertions(+), 496 deletions(-) delete mode 100644 src/Schedule/Infrastructure/Services/ScheduleDiscovery.php delete mode 100644 tests/Unit/Schedule/PackageBoundaryTest.php delete mode 100644 tests/Unit/Schedule/ScheduleDiscoveryTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 89f06b6f..ba160255 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The attributes live in their own package, [`pollora/attributes`](https://github.com/Pollora/attributes), which the framework requires: every attribute and its contracts, with the value types they take (`Every`, `Interval`, `Access`, `Control`, `BindingFieldType`). Class names do not change. The framework keeps the two deprecated bases, `PostTypeAttribute` and `TaxonomyAttribute`, and the code that reads the attributes - REST routes live in their own package, [`pollora/wp-rest`](https://github.com/Pollora/wp-rest), which the framework requires: `WpRestDiscovery`, the route registrar, the permissions (`IsLoggedIn`, `IsAdmin`, `Can`) and `Pollora\Support\WpGlobals`. Class names do not change. The framework keeps `WpRestAttributeServiceProvider` - WP-CLI commands live in their own package, [`pollora/wp-cli`](https://github.com/Pollora/wp-cli), which the framework requires: `WpCliDiscovery`, `WpCliService`, the WP-CLI adapters and `WordpressCommand`. Class names do not change. The framework keeps `WpCliServiceProvider` and `pollora:make:wp-cli` +- Scheduling lives in its own package, [`pollora/schedule`](https://github.com/Pollora/schedule), which the framework requires: one engine behind `#[Schedule]` and a fluent API (`Schedule::event()`, `Schedule::once()`, `cancel()`, `next()`, `events()`), WP-Cron as the default driver, and `Schedule::fake()` for tests. `ScheduleDiscovery` keeps its class name and moves there, with the same hook names and schedule keys. The framework gives the engine its container, so the classes passed to `call()` get their dependencies, and keeps the bridge to Laravel's scheduler (`use_laravel_scheduler`) ### Changed - `DiscoveryEngine`, `DiscoveryCacheManager`, `InstancePool` and `ReflectionCache` take their dependencies instead of reaching for `app()` and `config()`; reading `structure-discoverer.cache` moved to `LaravelDiscoveryCacheManagerFactory`. `DiscoveryRegistrar::registerFromContainer()` is now `registerInto()`. These are infrastructure classes, outside the public contracts (`Pollora\Discovery\Domain\*`), which do not change diff --git a/composer.json b/composer.json index 4b0e66f2..04e7c4a0 100644 --- a/composer.json +++ b/composer.json @@ -36,6 +36,7 @@ "pollora/entity": "^1.2", "pollora/hook": "^1.5", "pollora/option": "^1.1", + "pollora/schedule": "^0.1 || ^1.0", "pollora/wp-cli": "^0.1 || ^1.0", "pollora/wp-rest": "^0.1 || ^1.0", "watson/rememberable": "^7.1", diff --git a/src/Schedule/Infrastructure/Services/ScheduleDiscovery.php b/src/Schedule/Infrastructure/Services/ScheduleDiscovery.php deleted file mode 100644 index ffb32491..00000000 --- a/src/Schedule/Infrastructure/Services/ScheduleDiscovery.php +++ /dev/null @@ -1,365 +0,0 @@ - - */ - private const array DEFAULT_SCHEDULES = [ - 'hourly', - 'twicedaily', - 'daily', - 'weekly', - ]; - - /** - * {@inheritDoc} - * - * Discovers methods with Schedule attributes and collects them for registration. - * Only processes public methods that have the Schedule attribute. - */ - public function discover(DiscoveryLocationInterface $location, DiscoveredStructure $structure, ?ReflectionCacheInterface $reflectionCache = null): void - { - // Only process classes - if (! $structure instanceof DiscoveredClass) { - return; - } - - // Skip abstract classes - if ($structure->isAbstract) { - return; - } - - try { - $className = $structure->namespace.'\\'.$structure->name; - $methods = $reflectionCache->getPublicMethods($className); - - foreach ($methods as $method) { - $scheduleAttributes = $method->getAttributes(Schedule::class); - - if (empty($scheduleAttributes)) { - continue; - } - - foreach ($scheduleAttributes as $scheduleAttribute) { - // Collect the method for registration - $this->getItems()->add($location, [ - 'class' => $className, - 'method' => $method->getName(), - 'attribute' => $scheduleAttribute, - 'reflection_method' => $method, - ]); - } - } - } catch (\Throwable) { - // Skip classes that can't be reflected - // This might happen for classes with missing dependencies - return; - } - } - - /** - * {@inheritDoc} - * - * Applies discovered Schedule methods by registering them as WordPress cron events. - * Each discovered method is processed to extract schedule information, validate - * recurrence definitions, register custom schedules if needed, and finally - * register the scheduled task with WordPress cron system. - */ - public function apply(): void - { - foreach ($this->getItems() as $discoveredItem) { - [ - 'class' => $className, - 'method' => $methodName, - 'attribute' => $scheduleAttribute, - 'reflection_method' => $reflectionMethod - ] = $discoveredItem; - - try { - // Get the Schedule attribute instance - /** @var Schedule $schedule */ - $schedule = $scheduleAttribute->newInstance(); - - // Determine hook name (custom or generated) - $hookName = $schedule->hook ?? $this->generateHookName($className, $methodName); - - // Process the recurrence definition and get the schedule identifier - $scheduleIdentifier = $this->processRecurrence($schedule->recurrence, $hookName); - - // Register the action handler that will execute the scheduled method - add_action($hookName, function () use ($className, $methodName, $schedule): void { - // Use instance pool if available, otherwise fallback to container - $instance = $this->getInstanceFromPool($className); - - // Call method with arguments if provided - if ($schedule->args !== []) { - $instance->{$methodName}($schedule->args); - } else { - $instance->{$methodName}(); - } - }); - - // Schedule the cron event if not already scheduled - if (! wp_next_scheduled($hookName, $schedule->args)) { - $this->scheduleWordPressCron($hookName, $scheduleIdentifier, $schedule->args); - } - } catch (\Throwable $e) { - // Log the error but continue with other scheduled tasks - $this->logger?->error(sprintf('Failed to register Schedule from method %s::%s', $className, $methodName), ['exception' => $e]); - } - } - } - - /** - * Generate a hook name for a class method. - * - * Creates a human-readable hook name based on the class and method names - * using snake_case formatting, following WordPress naming conventions. - * - * @param string $className The fully qualified class name - * @param string $methodName The method name - * @return string The generated hook name in snake_case format - */ - private function generateHookName(string $className, string $methodName): string - { - // Extract just the class name without namespace - $shortClassName = basename(str_replace('\\', '/', $className)); - - // Convert to snake_case following WordPress conventions - return $this->snake($shortClassName).'_'.$this->snake($methodName); - } - - /** - * The value in snake case, as Laravel's Str::snake() writes it: hooks - * already scheduled under a derived name keep that name. - */ - private function snake(string $value): string - { - if (ctype_lower($value)) { - return $value; - } - - $value = (string) preg_replace('/\s+/u', '', ucwords($value)); - - return mb_strtolower((string) preg_replace('/(.)(?=[A-Z])/u', '$1_', $value), 'UTF-8'); - } - - /** - * Process and validate a recurrence definition. - * - * Converts various recurrence types (string, array, Every, Interval) into - * a standardized schedule identifier that can be used by WordPress cron. - * Also handles registration of custom schedules when needed. - * - * @param string|array|Every|Interval $recurrence The recurrence definition to process - * @param string $hookName The hook name for custom schedule registration - * @return string The processed schedule identifier - * - * @throws InvalidArgumentException If the recurrence type is unsupported or invalid - */ - private function processRecurrence(string|array|Every|Interval $recurrence, string $hookName): string - { - return match (true) { - // Handle string-based WordPress schedules - is_string($recurrence) => $this->validateAndReturnStringRecurrence($recurrence), - - // Handle array-based custom schedules - is_array($recurrence) => $this->processArrayRecurrence($recurrence, $hookName), - - // Handle Every enum values - $recurrence instanceof Every => $this->processEveryRecurrence($recurrence), - - // Handle Interval instances (only remaining type) - default => $this->processIntervalRecurrence($recurrence, $hookName), - }; - } - - /** - * Validate and return a string-based WordPress schedule. - * - * Ensures the provided string matches one of WordPress's built-in schedule intervals. - * - * @param string $recurrence The WordPress schedule name to validate - * @return string The validated schedule name - * - * @throws InvalidArgumentException If the schedule name is not recognized - */ - private function validateAndReturnStringRecurrence(string $recurrence): string - { - if (! in_array($recurrence, self::DEFAULT_SCHEDULES, true)) { - throw new InvalidArgumentException( - sprintf( - 'Invalid WordPress schedule "%s". Allowed schedules: %s', - $recurrence, - implode(', ', self::DEFAULT_SCHEDULES) - ) - ); - } - - return $recurrence; - } - - /** - * Process an array-based custom schedule. - * - * Validates that the array contains required 'interval' and 'display' keys, - * registers the custom schedule with WordPress, and returns the schedule identifier. - * - * @param array $recurrence The custom schedule definition array - * @param string $hookName The hook name to use as schedule identifier - * @return string The custom schedule identifier - * - * @throws InvalidArgumentException If the array structure is invalid - */ - private function processArrayRecurrence(array $recurrence, string $hookName): string - { - if (! isset($recurrence['interval']) || ! is_numeric($recurrence['interval'])) { - throw new InvalidArgumentException( - 'Custom schedule array must contain a numeric "interval" key (seconds)' - ); - } - - if (! isset($recurrence['display']) || ! is_string($recurrence['display'])) { - throw new InvalidArgumentException( - 'Custom schedule array must contain a string "display" key (human-readable name)' - ); - } - - // Register the custom schedule with WordPress - $scheduleKey = 'custom_'.md5($hookName); - $this->registerCustomSchedule($scheduleKey, $recurrence); - - return $scheduleKey; - } - - /** - * Process Every enum recurrence values. - * - * Converts Every enum values to their corresponding WordPress schedule names - * or registers custom schedules for non-standard intervals like MONTH/YEAR. - * - * @param Every $recurrence The Every enum value to process - * @return string The processed schedule identifier - */ - private function processEveryRecurrence(Every $recurrence): string - { - // For standard WordPress schedules, return the direct mapping - if (! $recurrence->isCustom()) { - return $recurrence->toScheduleKey(); - } - - // For custom schedules (MONTH, YEAR), register and return identifier - $scheduleKey = 'every_'.strtolower($recurrence->name); - $interval = $recurrence->toInterval(); - - $this->registerCustomSchedule($scheduleKey, [ - 'interval' => $interval->totalSeconds(), - 'display' => $interval->display, - ]); - - return $scheduleKey; - } - - /** - * Process Interval instance recurrence values. - * - * Registers a custom schedule for the Interval and returns its identifier. - * - * @param Interval $recurrence The Interval instance to process - * @param string $hookName The hook name for custom schedule registration - * @return string The custom schedule identifier - */ - private function processIntervalRecurrence(Interval $recurrence, string $hookName): string - { - // Generate unique identifier based on interval - $scheduleKey = 'interval_'.md5($hookName.'_'.$recurrence->totalSeconds()); - - $this->registerCustomSchedule($scheduleKey, [ - 'interval' => $recurrence->totalSeconds(), - 'display' => $recurrence->display, - ]); - - return $scheduleKey; - } - - /** - * Register a custom schedule with WordPress. - * - * Adds a new schedule interval to WordPress's cron system using the cron_schedules filter. - * This allows custom intervals beyond WordPress's built-in options. - * - * @param string $scheduleKey The unique key for the custom schedule - * @param array $schedule The schedule definition with 'interval' and 'display' keys - */ - private function registerCustomSchedule(string $scheduleKey, array $schedule): void - { - add_filter('cron_schedules', function (array $schedules) use ($scheduleKey, $schedule): array { - $schedules[$scheduleKey] = [ - 'interval' => (int) $schedule['interval'], - 'display' => (string) $schedule['display'], - ]; - - return $schedules; - }); - } - - /** - * Schedule a WordPress cron event - * - * @param string $hookName The hook name to schedule - * @param string $interval The interval (e.g., 'hourly', 'daily') - * @param array $args Optional arguments to pass to the hook - */ - private function scheduleWordPressCron(string $hookName, string $interval, array $args = []): void - { - if (wp_schedule_event(time(), $interval, $hookName, $args) === false) { - $this->logger?->error('Failed to schedule WordPress cron event for hook: '.$hookName); - } - } - - /** - * {@inheritDoc} - */ - public function getIdentifier(): string - { - return 'schedules'; - } -} diff --git a/src/Schedule/SchedulerDiscoveryServiceProvider.php b/src/Schedule/SchedulerDiscoveryServiceProvider.php index 19e1c7e0..529aae99 100755 --- a/src/Schedule/SchedulerDiscoveryServiceProvider.php +++ b/src/Schedule/SchedulerDiscoveryServiceProvider.php @@ -22,5 +22,7 @@ class SchedulerDiscoveryServiceProvider extends ServiceProvider public function register(): void { $this->app->singleton(ScheduleDiscovery::class, fn (): ScheduleDiscovery => new ScheduleDiscovery); + + Schedule::useContainer($this->app); } } diff --git a/src/Schedule/SchedulerServiceProvider.php b/src/Schedule/SchedulerServiceProvider.php index ca4f178a..2c340926 100755 --- a/src/Schedule/SchedulerServiceProvider.php +++ b/src/Schedule/SchedulerServiceProvider.php @@ -14,6 +14,7 @@ use Pollora\Schedule\Contracts\SchedulerInterface; use Pollora\Schedule\Events\RecurringEvent; use Pollora\Schedule\Infrastructure\Services\ScheduleDiscovery; +use Pollora\Schedule\Schedule as ScheduleApi; /** * Service provider for WordPress cron scheduler functionality. @@ -30,6 +31,8 @@ public function register(): void { $this->registerDomainContracts(); $this->registerUseCases(); + + ScheduleApi::useContainer($this->app); } /** diff --git a/tests/Feature/Schedule/SchedulerServiceProviderTest.php b/tests/Feature/Schedule/SchedulerServiceProviderTest.php index 023f58b3..377ad4f5 100644 --- a/tests/Feature/Schedule/SchedulerServiceProviderTest.php +++ b/tests/Feature/Schedule/SchedulerServiceProviderTest.php @@ -7,6 +7,7 @@ use Pollora\Schedule\Application\UseCases\RegisterSchedulerFiltersUseCase; use Pollora\Schedule\Contracts\SchedulerInterface; use Pollora\Schedule\Infrastructure\Services\ScheduleDiscovery; +use Pollora\Schedule\Schedule; use Pollora\Schedule\Scheduler; use Pollora\Schedule\SchedulerServiceProvider; @@ -41,4 +42,10 @@ expect($useCase)->toBeInstanceOf(RegisterSchedulerFiltersUseCase::class); }); + + it('gives the schedule engine the application, so the classes passed to call() get their dependencies', function (): void { + $container = (new ReflectionProperty(Schedule::engine(), 'container'))->getValue(Schedule::engine()); + + expect($container)->toBe($this->app); + }); }); diff --git a/tests/Unit/Schedule/PackageBoundaryTest.php b/tests/Unit/Schedule/PackageBoundaryTest.php deleted file mode 100644 index dd31d761..00000000 --- a/tests/Unit/Schedule/PackageBoundaryTest.php +++ /dev/null @@ -1,28 +0,0 @@ -expect(ScheduleDiscovery::class) - ->toOnlyUse([ - 'Pollora\Attributes', - 'Pollora\Discovery', - Every::class, - Interval::class, - 'Psr\Log', - 'Spatie\StructureDiscoverer', - // WordPress - 'add_action', - 'add_filter', - 'wp_next_scheduled', - 'wp_schedule_event', - ]); diff --git a/tests/Unit/Schedule/ScheduleDiscoveryTest.php b/tests/Unit/Schedule/ScheduleDiscoveryTest.php deleted file mode 100644 index ad16b49a..00000000 --- a/tests/Unit/Schedule/ScheduleDiscoveryTest.php +++ /dev/null @@ -1,103 +0,0 @@ - */ - public static array $calls = []; - - #[Schedule(Every::DAY)] - public function compile(): void - { - self::$calls[] = 'compile'; - } - - #[Schedule('hourly', hook: 'reports_ping', args: ['site' => 1])] - public function ping(array $args): void - { - self::$calls[] = 'ping:'.$args['site']; - } - - #[Schedule(Every::MONTH)] - public function archiveOldReports(): void - { - self::$calls[] = 'archiveOldReports'; - } - - #[Schedule(new Interval(minutes: 15, display: 'Every quarter hour'))] - public function refresh(): void - { - self::$calls[] = 'refresh'; - } -} - -beforeEach(function (): void { - NightlyReports::$calls = []; - $this->hooks = []; - $this->filters = []; - $this->events = []; - - Functions\when('add_action')->alias(function (string $hook, callable $callback): bool { - $this->hooks[$hook] = $callback; - - return true; - }); - Functions\when('add_filter')->alias(function (string $hook, callable $callback): bool { - $this->filters[$hook][] = $callback; - - return true; - }); - Functions\when('wp_next_scheduled')->justReturn(false); - Functions\when('wp_schedule_event')->alias(function (int $timestamp, string $recurrence, string $hook, array $args = []): bool { - $this->events[$hook] = [$recurrence, $args]; - - return true; - }); - - $discovery = new ScheduleDiscovery; - $discovery->discover(new DiscoveryLocation('', __DIR__), DiscoveredClass::fromReflection(new ReflectionClass(NightlyReports::class)), new ReflectionCache); - $discovery->apply(); -}); - -it('schedules each method on its hook, named after the class and method by default', function (): void { - expect($this->events)->toHaveKeys(['nightly_reports_compile', 'reports_ping', 'nightly_reports_archive_old_reports']) - ->and($this->events['nightly_reports_compile'])->toBe(['daily', []]) - ->and($this->events['reports_ping'])->toBe(['hourly', ['site' => 1]]) - ->and($this->events['nightly_reports_archive_old_reports'])->toBe(['every_month', []]); -}); - -it('registers the schedules WordPress does not know', function (): void { - $schedules = array_reduce($this->filters['cron_schedules'], fn (array $schedules, callable $filter): array => $filter($schedules), []); - - expect($schedules['every_month'])->toBe(['interval' => 30 * 86400, 'display' => 'Once Monthly']) - ->and($schedules)->toContain(['interval' => 900, 'display' => 'Every quarter hour']); -}); - -it('runs the method, with its arguments, when the hook fires', function (): void { - ($this->hooks['nightly_reports_compile'])(); - ($this->hooks['reports_ping'])(); - - expect(NightlyReports::$calls)->toBe(['compile', 'ping:1']); -}); - -it('derives the hook name as Str::snake() does, so scheduled hooks keep their name', function (string $class, string $method): void { - $hookName = (new ReflectionMethod(ScheduleDiscovery::class, 'generateHookName'))->invoke(new ScheduleDiscovery, $class, $method); - - expect($hookName)->toBe(Str::snake(class_basename($class)).'_'.Str::snake($method)); -})->with([ - ['App\\Cron\\NightlyReports', 'compile'], - ['App\\Cron\\HTMLSitemap', 'rebuildXMLIndex'], - ['Reports', 'sendTop10'], - ['App\\Cron\\ÉvénementsPublics', 'purge'], -]); From 1931e8ef832c1b624d728945ad8d7f87e0ac352f Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 13 Oct 2026 14:11:56 +0200 Subject: [PATCH 13/27] feat: choose the scheduling driver, list and migrate scheduled events config/schedule.php names the driver (POLLORA_SCHEDULE_DRIVER) and the hooks that stay on WP-Cron. pollora:schedule:list reads the cron option, which now indexes every driver's events, and shows each one's type and driver; pollora:schedule:migrate moves pending events between drivers. pollora:doctor and Site Health report an unknown or missing driver, and events a former driver still keeps. --- config/schedule.php | 44 +++ .../Application/Services/ScheduleStatus.php | 106 +++++++ .../Checks/ScheduleDriverCheck.php | 67 +++++ src/Schedule/SchedulerServiceProvider.php | 40 +++ .../UI/Console/ScheduleListCommand.php | 173 +++++++++++ .../UI/Console/ScheduleMigrateCommand.php | 99 +++++++ .../Feature/Schedule/ScheduleCommandsTest.php | 278 ++++++++++++++++++ 7 files changed, 807 insertions(+) create mode 100644 config/schedule.php create mode 100644 src/Schedule/Application/Services/ScheduleStatus.php create mode 100644 src/Schedule/Infrastructure/Checks/ScheduleDriverCheck.php create mode 100644 src/Schedule/UI/Console/ScheduleListCommand.php create mode 100644 src/Schedule/UI/Console/ScheduleMigrateCommand.php create mode 100644 tests/Feature/Schedule/ScheduleCommandsTest.php diff --git a/config/schedule.php b/config/schedule.php new file mode 100644 index 00000000..322b6e65 --- /dev/null +++ b/config/schedule.php @@ -0,0 +1,44 @@ + env('POLLORA_SCHEDULE_DRIVER', 'wp-cron'), + + /* + |-------------------------------------------------------------------------- + | Hooks that stay on WP-Cron + |-------------------------------------------------------------------------- + | + | Action Scheduler's own queue runner always does: handed to Action + | Scheduler, it would never run. Add hooks that must run by WP-Cron + | whatever the driver. The pollora/schedule/protected_hooks filter extends + | the list too. + | + */ + + 'protected_hooks' => [], + +]; diff --git a/src/Schedule/Application/Services/ScheduleStatus.php b/src/Schedule/Application/Services/ScheduleStatus.php new file mode 100644 index 00000000..1542e213 --- /dev/null +++ b/src/Schedule/Application/Services/ScheduleStatus.php @@ -0,0 +1,106 @@ +config->get('schedule.driver', CronIndex::WP_CRON); + + return is_string($driver) && $driver !== '' ? $driver : CronIndex::WP_CRON; + } + + /** + * The driver new events go to now. + */ + public function active(): string + { + return Schedule::engine()->driverName(); + } + + /** + * Whether the configured driver is one the package does not know: the site runs on WP-Cron instead. + */ + public function unknown(): bool + { + return $this->configured() !== $this->active(); + } + + /** + * Whether the driver is not there (Action Scheduler without a plugin that bundles it): events stay on WP-Cron. + */ + public function unavailable(): bool + { + return $this->active() !== CronIndex::WP_CRON && ! Schedule::engine()->driver()->available(); + } + + /** + * Events still kept by a driver other than the active one, by driver. + * + * An event on WP-Cron because its hook is protected, or because the driver + * is not there, is where it is meant to be. + * + * @return array + */ + public function toMigrate(): array + { + $counts = []; + + foreach (Schedule::engine()->index() as $event) { + if ($this->placement($event) === 'to migrate') { + $counts[(string) $event->driver] = ($counts[(string) $event->driver] ?? 0) + 1; + } + } + + return $counts; + } + + /** + * The driver of an event, with what sets it apart from the site's: "wp-cron (protected)", "wp-cron (to migrate)". + */ + public function label(Event $event): string + { + $placement = $this->placement($event); + + return (string) $event->driver.($placement === 'active' ? '' : sprintf(' (%s)', $placement)); + } + + /** + * Whether an event is where the site means it to be: "active" (the driver of the site), "protected" or "fallback" (on WP-Cron on purpose), or "to migrate". + */ + private function placement(Event $event): string + { + if ($event->driver === $this->active()) { + return 'active'; + } + + if ($event->driver === CronIndex::WP_CRON && in_array($event->hook, Schedule::engine()->protectedHooks(), true)) { + return 'protected'; + } + + if ($event->driver === CronIndex::WP_CRON && $this->unavailable()) { + return 'fallback'; + } + + return 'to migrate'; + } +} diff --git a/src/Schedule/Infrastructure/Checks/ScheduleDriverCheck.php b/src/Schedule/Infrastructure/Checks/ScheduleDriverCheck.php new file mode 100644 index 00000000..125bcf99 --- /dev/null +++ b/src/Schedule/Infrastructure/Checks/ScheduleDriverCheck.php @@ -0,0 +1,67 @@ + $problems */ + $problems = []; + $fix = null; + + if ($this->status->unknown()) { + $problems[] = sprintf('config/schedule.php asks for the driver "%s", which does not exist: every event goes to WP-Cron', $this->status->configured()); + $fix = 'Set POLLORA_SCHEDULE_DRIVER (config/schedule.php driver) to wp-cron or action-scheduler'; + } elseif ($this->status->unavailable()) { + $problems[] = sprintf('The "%s" driver is not available, so events stay on WP-Cron', $this->status->active()); + $fix = 'Activate a plugin that bundles Action Scheduler (WooCommerce does), or set the driver back to wp-cron'; + } + + foreach ($this->status->toMigrate() as $driver => $count) { + $problems[] = sprintf('%d event(s) are still kept by "%s", not by the driver of the site ("%s")', $count, $driver, $this->status->active()); + $fix ??= sprintf('php artisan pollora:schedule:migrate --from=%s --to=%s (--dry-run shows what would move)', $driver, $this->status->active()); + } + + if ($problems === []) { + return CheckResult::ok(sprintf('Scheduled events are kept by "%s".', $this->status->active())); + } + + return CheckResult::warning(sprintf('%d problem(s) with scheduled events.', count($problems)), $problems, (string) $fix); + } +} diff --git a/src/Schedule/SchedulerServiceProvider.php b/src/Schedule/SchedulerServiceProvider.php index 2c340926..2d16d748 100755 --- a/src/Schedule/SchedulerServiceProvider.php +++ b/src/Schedule/SchedulerServiceProvider.php @@ -5,16 +5,22 @@ namespace Pollora\Schedule; use Illuminate\Console\Scheduling\Schedule; +use Illuminate\Contracts\Config\Repository; use Illuminate\Contracts\Foundation\Application; use Illuminate\Database\QueryException; use Illuminate\Support\ServiceProvider; use Orchestra\Testbench\TestCase; +use Pollora\Doctor\Infrastructure\Providers\DoctorServiceProvider; use Pollora\Hook\Domain\Contract\Filter; +use Pollora\Schedule\Application\Services\ScheduleStatus; use Pollora\Schedule\Application\UseCases\RegisterSchedulerFiltersUseCase; use Pollora\Schedule\Contracts\SchedulerInterface; use Pollora\Schedule\Events\RecurringEvent; +use Pollora\Schedule\Infrastructure\Checks\ScheduleDriverCheck; use Pollora\Schedule\Infrastructure\Services\ScheduleDiscovery; use Pollora\Schedule\Schedule as ScheduleApi; +use Pollora\Schedule\UI\Console\ScheduleListCommand; +use Pollora\Schedule\UI\Console\ScheduleMigrateCommand; /** * Service provider for WordPress cron scheduler functionality. @@ -32,7 +38,15 @@ public function register(): void $this->registerDomainContracts(); $this->registerUseCases(); + $this->mergeConfigFrom(__DIR__.'/../../config/schedule.php', 'schedule'); + $this->app->singleton(ScheduleStatus::class, fn (Application $app): ScheduleStatus => new ScheduleStatus($app->make(Repository::class))); + ScheduleApi::useContainer($this->app); + $this->chooseDriver($this->app->make(Repository::class)); + + if ($this->app->runningInConsole()) { + $this->commands([ScheduleListCommand::class, ScheduleMigrateCommand::class]); + } } /** @@ -40,15 +54,41 @@ public function register(): void */ public function boot(): void { + $this->publishes([ + __DIR__.'/../../config/schedule.php' => config_path('schedule.php'), + ], 'pollora-schedule'); + + // A check of pollora:doctor and Site Health; tagged on boot, so it comes after the framework's own + $this->app->tag([ScheduleDriverCheck::class], DoctorServiceProvider::CHECKS_TAG); + if (config('wordpress.use_laravel_scheduler', false)) { $this->app->make(RegisterSchedulerFiltersUseCase::class)->execute(); } $this->app->booted(function (): void { + // The driver was chosen when the application started; WordPress can take its hooks by now + ScheduleApi::boot(); $this->scheduleRecurringEvents(); }); } + /** + * Choose the driver of the site, WP-Cron when config/schedule.php names one that does not exist (pollora:doctor says so). + */ + private function chooseDriver(Repository $config): void + { + $driver = $config->get('schedule.driver', 'wp-cron'); + + try { + ScheduleApi::driver(is_string($driver) && $driver !== '' ? $driver : 'wp-cron'); + } catch (\InvalidArgumentException) { + ScheduleApi::driver('wp-cron'); + } + + $protected = $config->get('schedule.protected_hooks', []); + ScheduleApi::engine()->protect(...array_map(strval(...), is_array($protected) ? $protected : [])); + } + /** * Register domain contracts with their infrastructure implementations. */ diff --git a/src/Schedule/UI/Console/ScheduleListCommand.php b/src/Schedule/UI/Console/ScheduleListCommand.php new file mode 100644 index 00000000..f4b536af --- /dev/null +++ b/src/Schedule/UI/Console/ScheduleListCommand.php @@ -0,0 +1,173 @@ +option('type'); + + if ($type !== null && ! in_array($type, ['once', 'recurring'], true)) { + $this->error('--type is "once" or "recurring".'); + + return self::FAILURE; + } + + if (! function_exists('_get_cron_array')) { + $this->error('WordPress is not loaded.'); + + return self::FAILURE; + } + + $rows = $this->rows(is_string($type) ? $type : null, is_string($this->option('driver')) ? $this->option('driver') : null); + + if ($this->option('json')) { + $this->line((string) json_encode($rows, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + + return self::SUCCESS; + } + + if ($rows === []) { + $this->info('No scheduled event matches.'); + + return self::SUCCESS; + } + + $network = (bool) $this->option('network'); + + $this->table( + [...($network ? ['Site'] : []), 'Hook', 'Type', 'Frequency', 'Driver', 'Next run', 'Arguments'], + array_map(static fn (array $row): array => [ + ...($network ? [$row['site']] : []), + $row['hook'], + $row['type'], + $row['frequency'], + $row['driver_label'], + $row['next_run'], + $row['args'] === [] ? '—' : (string) json_encode($row['args'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + ], $rows), + ); + + return self::SUCCESS; + } + + /** + * @return list> + */ + private function rows(?string $type, ?string $driver): array + { + $sites = $this->sites(); + $rows = []; + + foreach ($sites as $site) { + if ($site !== null) { + switch_to_blog($site); + } + + try { + foreach (Schedule::engine()->index() as $event) { + if (($type !== null && ($event->isRecurring() ? 'recurring' : 'once') !== $type) || ($driver !== null && $event->driver !== $driver)) { + continue; + } + + $rows[] = $this->row($event, $site); + } + } finally { + if ($site !== null) { + restore_current_blog(); + } + } + } + + usort($rows, static fn (array $a, array $b): int => $a['timestamp'] <=> $b['timestamp']); + + return $rows; + } + + /** + * The sites to read: the current one (null), one by ID, or the whole network. + * + * @return list + */ + private function sites(): array + { + if ($this->option('network') && function_exists('is_multisite') && is_multisite()) { + return array_map(intval(...), (array) get_sites(['fields' => 'ids', 'number' => 0])); + } + + $site = $this->option('site'); + + return is_numeric($site) && function_exists('is_multisite') && is_multisite() ? [(int) $site] : [null]; + } + + /** + * @return array + */ + private function row(Event $event, ?int $site): array + { + return [ + 'site' => $site ?? (function_exists('get_current_blog_id') ? get_current_blog_id() : 1), + 'hook' => $event->hook, + 'type' => $event->isRecurring() ? 'recurring' : 'once', + 'recurrence' => $event->recurrence, + 'frequency' => $this->frequency($event), + 'interval' => $event->interval, + 'driver' => $event->driver, + 'driver_label' => $this->status->label($event), + 'timestamp' => $event->timestamp, + 'next_run' => function_exists('wp_date') ? (string) wp_date('Y-m-d H:i', $event->timestamp) : gmdate('Y-m-d H:i', $event->timestamp), + 'args' => $event->args, + ]; + } + + private function frequency(Event $event): string + { + if ($event->recurrence === null) { + return '—'; + } + + if (in_array($event->recurrence, self::BUILT_IN, true) || $event->interval === null) { + return $event->recurrence; + } + + foreach ([86400 => ' day', 3600 => ' hour', 60 => ' min'] as $seconds => $unit) { + if ($event->interval % $seconds === 0) { + $count = intdiv($event->interval, $seconds); + + return $count.$unit.($count > 1 && $seconds !== 60 ? 's' : ''); + } + } + + return $event->interval.' s'; + } +} diff --git a/src/Schedule/UI/Console/ScheduleMigrateCommand.php b/src/Schedule/UI/Console/ScheduleMigrateCommand.php new file mode 100644 index 00000000..ae86b382 --- /dev/null +++ b/src/Schedule/UI/Console/ScheduleMigrateCommand.php @@ -0,0 +1,99 @@ +error('WordPress is not loaded.'); + + return self::FAILURE; + } + + $to = is_string($this->option('to')) && $this->option('to') !== '' ? $this->option('to') : $this->status->active(); + $from = is_string($this->option('from')) && $this->option('from') !== '' ? $this->option('from') : $this->guessSource($to); + + if ($from === null) { + $this->error('No other driver keeps events: say which one with --from.'); + + return self::FAILURE; + } + + try { + $report = (new Migrator(Schedule::engine()))->migrate($from, $to, (bool) $this->option('dry-run')); + } catch (\Throwable $throwable) { + $this->error($throwable->getMessage()); + + return self::FAILURE; + } + + $verb = $report->dryRun ? 'Would move' : 'Moved'; + + foreach ($report->moved as $event) { + $this->line(sprintf('%s %s (%s)', $verb, $event->hook, $event->recurrence ?? 'once')); + } + + foreach ($report->skipped as $skipped) { + $this->line(sprintf('Kept %s: %s', $skipped['event']->hook, $skipped['reason'])); + } + + foreach ($report->failed as $failed) { + $this->warn(sprintf('Could not move %s: %s', $failed['event']->hook, $failed['reason'])); + } + + $summary = sprintf('%s %d event(s) from %s to %s%s.', $verb, count($report->moved), $from, $to, $report->dryRun ? ' (dry run)' : ''); + + if (! $report->succeeded()) { + $this->error($summary.' Some events stayed where they were.'); + + return self::FAILURE; + } + + $this->info($summary); + + return self::SUCCESS; + } + + /** + * WP-Cron holds what a driver has not taken; when the target is WP-Cron, the driver that still holds events. + */ + private function guessSource(string $to): ?string + { + if ($to !== CronIndex::WP_CRON) { + return CronIndex::WP_CRON; + } + + foreach (Schedule::engine()->index() as $event) { + if ($event->driver !== CronIndex::WP_CRON) { + return (string) $event->driver; + } + } + + return null; + } +} diff --git a/tests/Feature/Schedule/ScheduleCommandsTest.php b/tests/Feature/Schedule/ScheduleCommandsTest.php new file mode 100644 index 00000000..87799ff2 --- /dev/null +++ b/tests/Feature/Schedule/ScheduleCommandsTest.php @@ -0,0 +1,278 @@ + */ + public array $kept = []; + + public function available(): bool + { + return self::$available; + } + + public function once(string $hook, int $timestamp, array $args = []): bool + { + $this->kept[] = new Event($hook, $timestamp, null, $args); + + return true; + } + + public function recurring(string $hook, int $timestamp, string $recurrence, array $args = []): bool + { + $this->kept[] = new Event($hook, $timestamp, $recurrence, $args, 3600); + + return true; + } + + public function cancel(string $hook, array $args = []): int + { + $before = count($this->kept); + $this->kept = array_values(array_filter($this->kept, fn (Event $event): bool => $event->hook !== $hook || $event->args !== $args)); + + return $before - count($this->kept); + } + + public function clear(string $hook): int + { + $before = count($this->kept); + $this->kept = array_values(array_filter($this->kept, fn (Event $event): bool => $event->hook !== $hook)); + + return $before - count($this->kept); + } + + public function next(string $hook, array $args = []): ?int + { + foreach ($this->kept as $event) { + if ($event->hook === $hook && $event->args === $args) { + return $event->timestamp; + } + } + + return null; + } + + public function events(): array + { + return $this->kept; + } +} + +beforeEach(function (): void { + $schedule = new ReflectionClass(Schedule::class); + + foreach (['engine', 'fake', 'driverBeforeFake', 'wanted'] as $property) { + $schedule->setStaticPropertyValue($property, null); + } + + MemoryScheduleDriver::$available = true; + $this->memory = new MemoryScheduleDriver; + $this->time = time() + 3600; + $this->cron = [ + $this->time => [ + 'reports' => [md5(serialize([])) => ['schedule' => 'daily', 'args' => [], 'interval' => 86400]], + 'wp_version_check' => [md5(serialize([])) => ['schedule' => 'twicedaily', 'args' => [], 'interval' => 43200]], + ], + $this->time + 60 => [ + 'digest' => [md5(serialize(['u' => 1])) => ['schedule' => false, 'args' => ['u' => 1], 'driver' => 'memory']], + 'action_scheduler_run_queue' => [md5(serialize([])) => ['schedule' => 'every_minute', 'args' => [], 'interval' => 60]], + ], + ]; + + Functions\when('_get_cron_array')->alias(fn (): array => $this->cron); + Functions\when('_set_cron_array')->alias(function (array $cron): bool { + $this->cron = $cron; + + return true; + }); + Functions\when('wp_get_schedules')->justReturn(['daily' => ['interval' => 86400], 'twicedaily' => ['interval' => 43200]]); + Functions\when('wp_schedule_event')->justReturn(true); + Functions\when('wp_schedule_single_event')->justReturn(true); + Functions\when('did_action')->justReturn(1); + + Schedule::extend('memory', fn (): ScheduleDriver => $this->memory); + config(['schedule.driver' => 'memory', 'schedule.protected_hooks' => []]); + app()->register(SchedulerServiceProvider::class, force: true); +}); + +afterEach(function (): void { + MemoryScheduleDriver::$available = true; +}); + +/** + * @return list> + */ +function scheduleListJson(array $options = []): array +{ + expect(Artisan::call('pollora:schedule:list', [...$options, '--json' => true]))->toBe(0); + + return json_decode(Artisan::output(), true); +} + +describe('driver choice', function (): void { + it('chooses the driver config/schedule.php names, and merges the defaults', function (): void { + expect(Schedule::engine()->driverName())->toBe('memory') + ->and(require __DIR__.'/../../../config/schedule.php')->toMatchArray(['protected_hooks' => []]) + ->and(config('schedule.driver'))->toBe('memory'); + }); + + it('falls back to WP-Cron when it names a driver that does not exist, and says so', function (): void { + config(['schedule.driver' => 'nope']); + $schedule = new ReflectionClass(Schedule::class); + $schedule->setStaticPropertyValue('engine', null); + app()->register(SchedulerServiceProvider::class, force: true); + + $status = app(ScheduleStatus::class); + + expect(Schedule::engine()->driverName())->toBe('wp-cron') + ->and($status->unknown())->toBeTrue() + ->and($status->configured())->toBe('nope'); + }); + + it('protects the hooks the configuration names', function (): void { + config(['schedule.protected_hooks' => ['my_heartbeat']]); + app()->register(SchedulerServiceProvider::class, force: true); + + expect(Schedule::engine()->protectedHooks())->toContain('my_heartbeat', 'action_scheduler_run_queue'); + }); +}); + +describe('pollora:schedule:list', function (): void { + it('lists every event with its type and its driver, soonest first, from the index alone', function (): void { + $rows = scheduleListJson(); + + expect(array_column($rows, 'hook'))->toBe(['reports', 'wp_version_check', 'digest', 'action_scheduler_run_queue']) + ->and($rows[2])->toMatchArray(['type' => 'once', 'driver' => 'memory', 'driver_label' => 'memory', 'args' => ['u' => 1]]) + ->and($rows[0])->toMatchArray(['type' => 'recurring', 'recurrence' => 'daily', 'frequency' => 'daily', 'driver' => 'wp-cron']); + }); + + it('marks what is on WP-Cron on purpose, and what is left to migrate', function (): void { + $labels = array_column(scheduleListJson(), 'driver_label', 'hook'); + + expect($labels['action_scheduler_run_queue'])->toBe('wp-cron (protected)') + ->and($labels['reports'])->toBe('wp-cron (to migrate)') + ->and($labels['digest'])->toBe('memory'); + + MemoryScheduleDriver::$available = false; + + expect(array_column(scheduleListJson(), 'driver_label', 'hook')['reports'])->toBe('wp-cron (fallback)'); + }); + + it('filters by type and by driver, and refuses an unknown type', function (): void { + expect(array_column(scheduleListJson(['--type' => 'once']), 'hook'))->toBe(['digest']) + ->and(array_column(scheduleListJson(['--driver' => 'wp-cron', '--type' => 'recurring']), 'hook'))->toBe(['reports', 'wp_version_check', 'action_scheduler_run_queue']) + ->and(Artisan::call('pollora:schedule:list', ['--type' => 'sometimes']))->toBe(1); + }); + + it('prints a table, and a line when nothing matches', function (): void { + expect(Artisan::call('pollora:schedule:list'))->toBe(0) + ->and(Artisan::output())->toContain('wp-cron (to migrate)', 'digest'); + + Artisan::call('pollora:schedule:list', ['--driver' => 'nobody']); + + expect(Artisan::output())->toContain('No scheduled event matches.'); + }); + + it('writes a frequency a person reads for a custom interval', function (): void { + $this->cron[$this->time]['fast'] = [md5(serialize([])) => ['schedule' => 'custom_x', 'args' => [], 'interval' => 900]]; + + expect(array_column(scheduleListJson(), 'frequency', 'hook')['fast'])->toBe('15 min'); + }); +}); + +describe('pollora:schedule:migrate', function (): void { + it('shows what would move, and moves nothing, with --dry-run', function (): void { + expect(Artisan::call('pollora:schedule:migrate', ['--dry-run' => true]))->toBe(0) + ->and(Artisan::output())->toContain('Would move reports', 'Kept action_scheduler_run_queue') + ->and($this->memory->kept)->toBe([]); + }); + + it('moves the events of WP-Cron to the driver of the site', function (): void { + expect(Artisan::call('pollora:schedule:migrate'))->toBe(0) + ->and(array_column($this->memory->kept, 'hook'))->toContain('reports', 'wp_version_check') + ->and(array_column(scheduleListJson(), 'driver_label', 'hook')['reports'])->toBe('memory'); + }); + + it('moves them back with --from and --to, and guesses the source when the target is WP-Cron', function (): void { + Artisan::call('pollora:schedule:migrate'); + + expect(Artisan::call('pollora:schedule:migrate', ['--to' => 'wp-cron']))->toBe(0) + ->and(array_column(scheduleListJson(), 'driver', 'hook')['reports'])->toBe('wp-cron'); + }); + + it('fails when the target is not there, or there is no other driver to take from', function (): void { + MemoryScheduleDriver::$available = false; + + expect(Artisan::call('pollora:schedule:migrate'))->toBe(1) + ->and(Artisan::output())->toContain('not available'); + + MemoryScheduleDriver::$available = true; + $this->cron = []; + + expect(Artisan::call('pollora:schedule:migrate', ['--to' => 'wp-cron']))->toBe(1) + ->and(Artisan::output())->toContain('say which one with --from'); + }); +}); + +describe('the doctor check', function (): void { + it('is ok when events are kept by the driver of the site', function (): void { + $this->cron = [$this->time => ['digest' => [md5(serialize([])) => ['schedule' => false, 'args' => [], 'driver' => 'memory']]]]; + + $result = app(ScheduleDriverCheck::class)->run(RunContext::Console); + + expect($result->status)->toBe(CheckStatus::Ok); + }); + + it('warns of events a former driver keeps, with the command that moves them', function (): void { + $result = app(ScheduleDriverCheck::class)->run(RunContext::Console); + + expect($result->status)->toBe(CheckStatus::Warning) + ->and(implode(' ', $result->details))->toContain('"wp-cron"', 'not by the driver of the site ("memory")') + ->and($result->fix)->toContain('pollora:schedule:migrate --from=wp-cron --to=memory'); + }); + + it('warns of a driver that is not there, and does not ask to migrate what falls back to WP-Cron', function (): void { + MemoryScheduleDriver::$available = false; + $this->cron = [$this->time => ['reports' => [md5(serialize([])) => ['schedule' => 'daily', 'args' => [], 'interval' => 86400]]]]; + + $result = app(ScheduleDriverCheck::class)->run(RunContext::Console); + + expect($result->status)->toBe(CheckStatus::Warning) + ->and(implode(' ', $result->details))->toContain('is not available') + ->and(implode(' ', $result->details))->not->toContain('still kept'); + }); + + it('warns of a driver the configuration names and nobody provides', function (): void { + config(['schedule.driver' => 'nope']); + (new ReflectionClass(Schedule::class))->setStaticPropertyValue('engine', null); + app()->register(SchedulerServiceProvider::class, force: true); + + $result = app(ScheduleDriverCheck::class)->run(RunContext::Console); + + expect($result->status)->toBe(CheckStatus::Warning) + ->and(implode(' ', $result->details))->toContain('"nope", which does not exist'); + }); + + it('is skipped before WordPress has initialised', function (): void { + Functions\when('did_action')->justReturn(0); + + expect(app(ScheduleDriverCheck::class)->run(RunContext::Console)->status)->toBe(CheckStatus::Skipped); + }); +}); From 2610509ac6869f32f9772cf1147f87cf946a0830 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 13 Oct 2026 14:23:23 +0200 Subject: [PATCH 14/27] feat: tell the administrator which scheduled events wait for a migration --- CHANGELOG.md | 3 + .../Application/Services/ScheduleStatus.php | 2 +- .../Services/MigrationNotice.php | 55 +++++++++++++++++ src/Schedule/SchedulerServiceProvider.php | 2 + .../Feature/Schedule/ScheduleCommandsTest.php | 60 +++++++++++++++++-- 5 files changed, 115 insertions(+), 7 deletions(-) create mode 100644 src/Schedule/Infrastructure/Services/MigrationNotice.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ba160255..1616d8b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - REST routes live in their own package, [`pollora/wp-rest`](https://github.com/Pollora/wp-rest), which the framework requires: `WpRestDiscovery`, the route registrar, the permissions (`IsLoggedIn`, `IsAdmin`, `Can`) and `Pollora\Support\WpGlobals`. Class names do not change. The framework keeps `WpRestAttributeServiceProvider` - WP-CLI commands live in their own package, [`pollora/wp-cli`](https://github.com/Pollora/wp-cli), which the framework requires: `WpCliDiscovery`, `WpCliService`, the WP-CLI adapters and `WordpressCommand`. Class names do not change. The framework keeps `WpCliServiceProvider` and `pollora:make:wp-cli` - Scheduling lives in its own package, [`pollora/schedule`](https://github.com/Pollora/schedule), which the framework requires: one engine behind `#[Schedule]` and a fluent API (`Schedule::event()`, `Schedule::once()`, `cancel()`, `next()`, `events()`), WP-Cron as the default driver, and `Schedule::fake()` for tests. `ScheduleDiscovery` keeps its class name and moves there, with the same hook names and schedule keys. The framework gives the engine its container, so the classes passed to `call()` get their dependencies, and keeps the bridge to Laravel's scheduler (`use_laravel_scheduler`) +- `config/schedule.php` (publishable with the `pollora-schedule` tag) chooses the scheduling driver, `wp-cron` (the default) or `action-scheduler`, by `POLLORA_SCHEDULE_DRIVER`, and lists the hooks that stay on WP-Cron whatever the driver. The `cron` option now indexes every driver's events, so `wp_next_scheduled()`, Site Health and WP Crontrol see the ones Action Scheduler keeps, and WP-Cron never runs them. Changing the driver moves nothing by itself +- `pollora:schedule:list` shows the scheduled events with their type, frequency and driver (`--type`, `--driver`, `--site`, `--network`, `--json`), read from the `cron` option with no query to a driver; an event on WP-Cron on purpose reads `(protected)` or `(fallback)`, one a former driver keeps reads `(to migrate)`. `pollora:schedule:migrate` moves the pending events from one driver to another (`--from`, `--to`, `--dry-run`), in both directions and safe to run again +- `pollora:doctor`, Site Health and an administration notice report a driver that does not exist or is not available (events stay on WP-Cron), and the events a former driver still keeps, with the command that moves them ### Changed - `DiscoveryEngine`, `DiscoveryCacheManager`, `InstancePool` and `ReflectionCache` take their dependencies instead of reaching for `app()` and `config()`; reading `structure-discoverer.cache` moved to `LaravelDiscoveryCacheManagerFactory`. `DiscoveryRegistrar::registerFromContainer()` is now `registerInto()`. These are infrastructure classes, outside the public contracts (`Pollora\Discovery\Domain\*`), which do not change diff --git a/src/Schedule/Application/Services/ScheduleStatus.php b/src/Schedule/Application/Services/ScheduleStatus.php index 1542e213..a2a86358 100644 --- a/src/Schedule/Application/Services/ScheduleStatus.php +++ b/src/Schedule/Application/Services/ScheduleStatus.php @@ -81,7 +81,7 @@ public function label(Event $event): string { $placement = $this->placement($event); - return (string) $event->driver.($placement === 'active' ? '' : sprintf(' (%s)', $placement)); + return $event->driver.($placement === 'active' ? '' : sprintf(' (%s)', $placement)); } /** diff --git a/src/Schedule/Infrastructure/Services/MigrationNotice.php b/src/Schedule/Infrastructure/Services/MigrationNotice.php new file mode 100644 index 00000000..efdc2c50 --- /dev/null +++ b/src/Schedule/Infrastructure/Services/MigrationNotice.php @@ -0,0 +1,55 @@ +render(...)); + add_action('network_admin_notices', $this->render(...)); + } + } + + public function render(): void + { + if (! current_user_can('manage_options')) { + return; + } + + $pending = $this->status->toMigrate(); + + if ($pending === []) { + return; + } + + $driver = array_key_first($pending); + + printf( + '

%s %s %s

', + esc_html__('Scheduled events are still kept by another driver.', 'pollora'), + esc_html(sprintf( + /* translators: 1: number of events, 2: driver name, 3: driver of the site */ + __('%1$d event(s) wait in "%2$s", while the site uses "%3$s". Move them with', 'pollora'), + array_sum($pending), + $driver, + $this->status->active(), + )), + esc_html(sprintf('php artisan pollora:schedule:migrate --from=%s --to=%s', $driver, $this->status->active())), + ); + } +} diff --git a/src/Schedule/SchedulerServiceProvider.php b/src/Schedule/SchedulerServiceProvider.php index 2d16d748..4750e57c 100755 --- a/src/Schedule/SchedulerServiceProvider.php +++ b/src/Schedule/SchedulerServiceProvider.php @@ -17,6 +17,7 @@ use Pollora\Schedule\Contracts\SchedulerInterface; use Pollora\Schedule\Events\RecurringEvent; use Pollora\Schedule\Infrastructure\Checks\ScheduleDriverCheck; +use Pollora\Schedule\Infrastructure\Services\MigrationNotice; use Pollora\Schedule\Infrastructure\Services\ScheduleDiscovery; use Pollora\Schedule\Schedule as ScheduleApi; use Pollora\Schedule\UI\Console\ScheduleListCommand; @@ -60,6 +61,7 @@ public function boot(): void // A check of pollora:doctor and Site Health; tagged on boot, so it comes after the framework's own $this->app->tag([ScheduleDriverCheck::class], DoctorServiceProvider::CHECKS_TAG); + $this->app->make(MigrationNotice::class)->register(); if (config('wordpress.use_laravel_scheduler', false)) { $this->app->make(RegisterSchedulerFiltersUseCase::class)->execute(); diff --git a/tests/Feature/Schedule/ScheduleCommandsTest.php b/tests/Feature/Schedule/ScheduleCommandsTest.php index 87799ff2..d12a95e6 100644 --- a/tests/Feature/Schedule/ScheduleCommandsTest.php +++ b/tests/Feature/Schedule/ScheduleCommandsTest.php @@ -10,6 +10,7 @@ use Pollora\Schedule\Contracts\ScheduleDriver; use Pollora\Schedule\Event; use Pollora\Schedule\Infrastructure\Checks\ScheduleDriverCheck; +use Pollora\Schedule\Infrastructure\Services\MigrationNotice; use Pollora\Schedule\Schedule; use Pollora\Schedule\SchedulerServiceProvider; @@ -106,6 +107,7 @@ public function events(): array Functions\when('wp_schedule_event')->justReturn(true); Functions\when('wp_schedule_single_event')->justReturn(true); Functions\when('did_action')->justReturn(1); + Functions\when('wp_date')->alias(fn (string $format, int $timestamp): string => gmdate($format, $timestamp)); Schedule::extend('memory', fn (): ScheduleDriver => $this->memory); config(['schedule.driver' => 'memory', 'schedule.protected_hooks' => []]); @@ -137,9 +139,10 @@ function scheduleListJson(array $options = []): array config(['schedule.driver' => 'nope']); $schedule = new ReflectionClass(Schedule::class); $schedule->setStaticPropertyValue('engine', null); + app()->register(SchedulerServiceProvider::class, force: true); - $status = app(ScheduleStatus::class); + $status = resolve(ScheduleStatus::class); expect(Schedule::engine()->driverName())->toBe('wp-cron') ->and($status->unknown())->toBeTrue() @@ -235,13 +238,13 @@ function scheduleListJson(array $options = []): array it('is ok when events are kept by the driver of the site', function (): void { $this->cron = [$this->time => ['digest' => [md5(serialize([])) => ['schedule' => false, 'args' => [], 'driver' => 'memory']]]]; - $result = app(ScheduleDriverCheck::class)->run(RunContext::Console); + $result = resolve(ScheduleDriverCheck::class)->run(RunContext::Console); expect($result->status)->toBe(CheckStatus::Ok); }); it('warns of events a former driver keeps, with the command that moves them', function (): void { - $result = app(ScheduleDriverCheck::class)->run(RunContext::Console); + $result = resolve(ScheduleDriverCheck::class)->run(RunContext::Console); expect($result->status)->toBe(CheckStatus::Warning) ->and(implode(' ', $result->details))->toContain('"wp-cron"', 'not by the driver of the site ("memory")') @@ -252,7 +255,7 @@ function scheduleListJson(array $options = []): array MemoryScheduleDriver::$available = false; $this->cron = [$this->time => ['reports' => [md5(serialize([])) => ['schedule' => 'daily', 'args' => [], 'interval' => 86400]]]]; - $result = app(ScheduleDriverCheck::class)->run(RunContext::Console); + $result = resolve(ScheduleDriverCheck::class)->run(RunContext::Console); expect($result->status)->toBe(CheckStatus::Warning) ->and(implode(' ', $result->details))->toContain('is not available') @@ -264,7 +267,7 @@ function scheduleListJson(array $options = []): array (new ReflectionClass(Schedule::class))->setStaticPropertyValue('engine', null); app()->register(SchedulerServiceProvider::class, force: true); - $result = app(ScheduleDriverCheck::class)->run(RunContext::Console); + $result = resolve(ScheduleDriverCheck::class)->run(RunContext::Console); expect($result->status)->toBe(CheckStatus::Warning) ->and(implode(' ', $result->details))->toContain('"nope", which does not exist'); @@ -273,6 +276,51 @@ function scheduleListJson(array $options = []): array it('is skipped before WordPress has initialised', function (): void { Functions\when('did_action')->justReturn(0); - expect(app(ScheduleDriverCheck::class)->run(RunContext::Console)->status)->toBe(CheckStatus::Skipped); + expect(resolve(ScheduleDriverCheck::class)->run(RunContext::Console)->status)->toBe(CheckStatus::Skipped); + }); +}); + +describe('the administration notice', function (): void { + beforeEach(function (): void { + Functions\when('esc_html__')->returnArg(); + Functions\when('esc_html')->returnArg(); + }); + + it('tells those who can run the command which events wait, and how to move them', function (): void { + Functions\when('current_user_can')->justReturn(true); + + ob_start(); + resolve(MigrationNotice::class)->render(); + $html = (string) ob_get_clean(); + + expect($html)->toContain('notice-warning', 'pollora:schedule:migrate --from=wp-cron --to=memory'); + }); + + it('says nothing to those who cannot, or when nothing waits', function (): void { + Functions\when('current_user_can')->justReturn(false); + + ob_start(); + resolve(MigrationNotice::class)->render(); + expect((string) ob_get_clean())->toBe(''); + + Functions\when('current_user_can')->justReturn(true); + $this->cron = []; + + ob_start(); + resolve(MigrationNotice::class)->render(); + expect((string) ob_get_clean())->toBe(''); + }); + + it('is shown on the administration screens, and the network ones', function (): void { + $added = []; + Functions\when('add_action')->alias(function (string $hook) use (&$added): bool { + $added[] = $hook; + + return true; + }); + + resolve(MigrationNotice::class)->register(); + + expect($added)->toBe(['admin_notices', 'network_admin_notices']); }); }); From 321d9b2ff76efa6e41eb54065199e6f4d2101a55 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 13 Oct 2026 14:33:52 +0200 Subject: [PATCH 15/27] fix: require the schedule release that has the index, and judge a driver by its name --- composer.json | 2 +- src/Schedule/Application/Services/ScheduleStatus.php | 2 +- tests/Feature/Schedule/ScheduleCommandsTest.php | 10 ++++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 04e7c4a0..7058d64c 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,7 @@ "pollora/entity": "^1.2", "pollora/hook": "^1.5", "pollora/option": "^1.1", - "pollora/schedule": "^0.1 || ^1.0", + "pollora/schedule": "^0.1.1 || ^1.0", "pollora/wp-cli": "^0.1 || ^1.0", "pollora/wp-rest": "^0.1 || ^1.0", "watson/rememberable": "^7.1", diff --git a/src/Schedule/Application/Services/ScheduleStatus.php b/src/Schedule/Application/Services/ScheduleStatus.php index a2a86358..620fefcb 100644 --- a/src/Schedule/Application/Services/ScheduleStatus.php +++ b/src/Schedule/Application/Services/ScheduleStatus.php @@ -42,7 +42,7 @@ public function active(): string */ public function unknown(): bool { - return $this->configured() !== $this->active(); + return ! Schedule::knows($this->configured()); } /** diff --git a/tests/Feature/Schedule/ScheduleCommandsTest.php b/tests/Feature/Schedule/ScheduleCommandsTest.php index d12a95e6..2d55bd3f 100644 --- a/tests/Feature/Schedule/ScheduleCommandsTest.php +++ b/tests/Feature/Schedule/ScheduleCommandsTest.php @@ -149,6 +149,16 @@ function scheduleListJson(array $options = []): array ->and($status->configured())->toBe('nope'); }); + it('does not call a driver unknown because something else switched the engine after the provider', function (): void { + Schedule::driver('wp-cron'); + + $status = app(ScheduleStatus::class); + + expect($status->configured())->toBe('memory') + ->and($status->active())->toBe('wp-cron') + ->and($status->unknown())->toBeFalse(); + }); + it('protects the hooks the configuration names', function (): void { config(['schedule.protected_hooks' => ['my_heartbeat']]); app()->register(SchedulerServiceProvider::class, force: true); From 7408de093738e94e367ef179fbf725cea9debec1 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 13 Oct 2026 14:48:51 +0200 Subject: [PATCH 16/27] chore: rerun CI against the released pollora/schedule From ef4585222faccf58a9bf2d3c71d885ee626cc112 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 13 Oct 2026 14:59:30 +0200 Subject: [PATCH 17/27] style: apply Rector to the schedule tests --- tests/Feature/Schedule/ScheduleCommandsTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Feature/Schedule/ScheduleCommandsTest.php b/tests/Feature/Schedule/ScheduleCommandsTest.php index 2d55bd3f..d07cb73e 100644 --- a/tests/Feature/Schedule/ScheduleCommandsTest.php +++ b/tests/Feature/Schedule/ScheduleCommandsTest.php @@ -152,7 +152,7 @@ function scheduleListJson(array $options = []): array it('does not call a driver unknown because something else switched the engine after the provider', function (): void { Schedule::driver('wp-cron'); - $status = app(ScheduleStatus::class); + $status = resolve(ScheduleStatus::class); expect($status->configured())->toBe('memory') ->and($status->active())->toBe('wp-cron') From 1cf53a3a8b5d49ba1380618306d17c699b6ad7c9 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 13 Oct 2026 15:34:15 +0200 Subject: [PATCH 18/27] feat: require pollora/hook 2.0, queue asynchronous actions through pollora/schedule --- CHANGELOG.md | 3 +++ composer.json | 2 +- src/Hook/Infrastructure/Services/AsyncInspector.php | 3 ++- tests/Feature/Hook/Async/AsyncInspectorTest.php | 9 +++++++++ 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1616d8b5..b49e703a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The WpCli module no longer reaches for Laravel's application, the first step towards `pollora/wp-cli`: `WpCliDiscovery` builds commands through the discovery's instance pool instead of `resolve()`. Its service provider and `pollora:make:wp-cli` stay in the framework; an architecture test keeps the rest to the attributes, discovery, PSR logging and WP-CLI - `#[Schedule]`, which runs on WP-Cron alone, no longer reaches for Laravel, the first step towards `pollora/schedule`: `ScheduleDiscovery` derives default hook names itself, with the same results as `Str::snake()` (a parity test keeps them), so hooks already scheduled keep their name. The bridge to Laravel's scheduler (`use_laravel_scheduler`) stays in the framework +- Asynchronous actions queue through the WP-Cron and Action Scheduler drivers of `pollora/schedule`, with `pollora/hook` 2.0: `->async()`, `#[Async]`, `Async::fake()`, `via()` and their options do not change, and work already queued runs once the upgrade is in. `Async\Drivers\WpCronDriver` and `ActionSchedulerDriver` are gone with no alias; the framework never used them, and the `queue` driver is unchanged + ### Deprecated - `DiscoveryCacheInterface` and `LaravelDiscoveryCache`, removed in 15.0: nothing reads this cache, scans are cached by the Spatie driver given to `DiscoveryCacheManager` - `Pollora\Attributes\PostType\PostTypeAttribute` and `Pollora\Attributes\Taxonomy\TaxonomyAttribute`, removed in 15.0: no built-in attribute extends them any more; implement `ConfiguresArguments` @@ -36,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `handle()` on `#[Action]`, `#[Filter]`, `#[WpRestRoute]` and `#[WpRestRoute\Method]`, and `Hook` no longer implements `HandlesAttributes`: `HookDiscovery` and `WpRestDiscovery` register hooks and routes themselves. Only the bug below reached `Action::handle()`, and `WpRestRoute::handle()` wrote to a read-only wrapper ### Fixed +- `pollora:doctor` no longer counts as overdue WP-Cron events the ones Action Scheduler keeps: the `cron` option indexes them too, with a `driver` key - A public `#[Action]` or `#[Filter]` method in a `#[PostType]` or `#[Taxonomy]` class is registered once: the post type and taxonomy discoveries also handed it to `Action::handle()`, which registered it a second time on the discovery's configuration object, so the page firing that hook failed with `call_user_func(): Argument #1 ($callback) must be a valid callback, class Pollora\PostType\Infrastructure\Services\PostTypeConfiguration does not have a method …` - A REST controller declared with `#[WpRestRoute]` gets its constructor dependencies: it is resolved through the discovery's instance pool (Laravel's container in a Pollora application) instead of `new` with no argument, so a controller with a required dependency answered every request with `Trying to invoke non static method … without an object` diff --git a/composer.json b/composer.json index 7058d64c..a926bd66 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,7 @@ "pollora/attributes": "^0.1 || ^1.0", "pollora/discovery": "^0.1 || ^1.0", "pollora/entity": "^1.2", - "pollora/hook": "^1.5", + "pollora/hook": "^2.0", "pollora/option": "^1.1", "pollora/schedule": "^0.1.1 || ^1.0", "pollora/wp-cli": "^0.1 || ^1.0", diff --git a/src/Hook/Infrastructure/Services/AsyncInspector.php b/src/Hook/Infrastructure/Services/AsyncInspector.php index da55592e..d05ecf3b 100644 --- a/src/Hook/Infrastructure/Services/AsyncInspector.php +++ b/src/Hook/Infrastructure/Services/AsyncInspector.php @@ -81,8 +81,9 @@ public function overdueCronEvents(int $seconds): array continue; } + // The events another driver keeps (Action Scheduler) are in the index too: this counts what only WP-Cron runs foreach ((array) $hooks as $events) { - $count += count((array) $events); + $count += count(array_filter((array) $events, static fn (mixed $event): bool => ! is_array($event) || ($event['driver'] ?? 'wp-cron') === 'wp-cron')); } $oldest = $oldest === null ? $timestamp : min($oldest, $timestamp); diff --git a/tests/Feature/Hook/Async/AsyncInspectorTest.php b/tests/Feature/Hook/Async/AsyncInspectorTest.php index 1ed06cca..f3d6d21b 100644 --- a/tests/Feature/Hook/Async/AsyncInspectorTest.php +++ b/tests/Feature/Hook/Async/AsyncInspectorTest.php @@ -93,6 +93,15 @@ public function get_results(string $query, string $output): array ->and($queued)->toBeInstanceOf(QueuedHandler::class); }); +it('does not count as WP-Cron\'s the events another driver keeps, which the cron option indexes too', function (): void { + $now = time(); + Functions\when('_get_cron_array')->justReturn([ + $now - 7200 => ['wp_version_check' => ['k1' => ['schedule' => 'daily', 'args' => []]], 'reports' => ['k2' => ['schedule' => 'daily', 'args' => [], 'driver' => 'action-scheduler']]], + ]); + + expect($this->inspector->overdueCronEvents(3600))->toBe(['count' => 1, 'oldest' => $now - 7200]); +}); + it('counts the WP-Cron events overdue for longer than the grace period', function (): void { $now = time(); Functions\when('_get_cron_array')->justReturn([ From e7d5c88f50842057c8e5b14b0778cc6e776f9290 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 13 Oct 2026 15:58:09 +0200 Subject: [PATCH 19/27] style: apply Rector to the inspector test --- tests/Feature/Hook/Async/AsyncInspectorTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Feature/Hook/Async/AsyncInspectorTest.php b/tests/Feature/Hook/Async/AsyncInspectorTest.php index f3d6d21b..4195a02e 100644 --- a/tests/Feature/Hook/Async/AsyncInspectorTest.php +++ b/tests/Feature/Hook/Async/AsyncInspectorTest.php @@ -93,7 +93,7 @@ public function get_results(string $query, string $output): array ->and($queued)->toBeInstanceOf(QueuedHandler::class); }); -it('does not count as WP-Cron\'s the events another driver keeps, which the cron option indexes too', function (): void { +it("does not count as WP-Cron's the events another driver keeps, which the cron option indexes too", function (): void { $now = time(); Functions\when('_get_cron_array')->justReturn([ $now - 7200 => ['wp_version_check' => ['k1' => ['schedule' => 'daily', 'args' => []]], 'reports' => ['k2' => ['schedule' => 'daily', 'args' => [], 'driver' => 'action-scheduler']]], From c8232724d1090ae45a8cb02dd10983f7ace5a563 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 13 Oct 2026 17:34:50 +0200 Subject: [PATCH 20/27] feat: laravel schedule driver, and remove the Laravel scheduler bridge The laravel driver queues each event as a job delayed until it is due; the cron option is its record. Removes use_laravel_scheduler, Scheduler, Events, Jobs\JobDispatcher, RegisterSchedulerFiltersUseCase and the wp_events table. Adds pollora:schedule:recover. --- CHANGELOG.md | 3 + config/schedule.php | 34 +- .../Checks/AsyncActionsCheck.php | 4 - src/Providers/PolloraServiceProvider.php | 3 - .../Application/Services/ScheduleStatus.php | 19 + .../RegisterSchedulerFiltersUseCase.php | 66 ---- src/Schedule/Contracts/EventInterface.php | 55 --- .../Contracts/JobDispatcherInterface.php | 22 -- src/Schedule/Contracts/SchedulerInterface.php | 103 ----- src/Schedule/Events/AbstractEvent.php | 139 ------- src/Schedule/Events/RecurringEvent.php | 140 ------- src/Schedule/Events/SingleEvent.php | 26 -- .../Checks/ScheduleDriverCheck.php | 20 +- .../Drivers/LaravelQueueDriver.php | 162 ++++++++ .../Infrastructure/Jobs/RunScheduledEvent.php | 193 +++++++++ .../Services/LateEventRecovery.php | 92 +++++ .../Services/ScheduleFailures.php | 46 +++ src/Schedule/Jobs/JobDispatcher.php | 35 -- src/Schedule/Scheduler.php | 370 ------------------ src/Schedule/SchedulerServiceProvider.php | 66 +--- .../UI/Console/ScheduleRecoverCommand.php | 58 +++ .../Hook/Async/AsyncActionsCheckTest.php | 8 +- tests/Feature/Schedule/LaravelDriverTest.php | 306 +++++++++++++++ .../Feature/Schedule/ScheduleCommandsTest.php | 2 + .../Schedule/SchedulerServiceProviderTest.php | 16 - .../RegisterSchedulerFiltersUseCaseTest.php | 72 ---- 26 files changed, 953 insertions(+), 1107 deletions(-) delete mode 100644 src/Schedule/Application/UseCases/RegisterSchedulerFiltersUseCase.php delete mode 100755 src/Schedule/Contracts/EventInterface.php delete mode 100755 src/Schedule/Contracts/JobDispatcherInterface.php delete mode 100755 src/Schedule/Contracts/SchedulerInterface.php delete mode 100755 src/Schedule/Events/AbstractEvent.php delete mode 100755 src/Schedule/Events/RecurringEvent.php delete mode 100755 src/Schedule/Events/SingleEvent.php create mode 100644 src/Schedule/Infrastructure/Drivers/LaravelQueueDriver.php create mode 100644 src/Schedule/Infrastructure/Jobs/RunScheduledEvent.php create mode 100644 src/Schedule/Infrastructure/Services/LateEventRecovery.php create mode 100644 src/Schedule/Infrastructure/Services/ScheduleFailures.php delete mode 100755 src/Schedule/Jobs/JobDispatcher.php delete mode 100755 src/Schedule/Scheduler.php create mode 100644 src/Schedule/UI/Console/ScheduleRecoverCommand.php create mode 100644 tests/Feature/Schedule/LaravelDriverTest.php delete mode 100644 tests/Unit/Schedule/Application/UseCases/RegisterSchedulerFiltersUseCaseTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b49e703a..b3aff6c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `config/schedule.php` (publishable with the `pollora-schedule` tag) chooses the scheduling driver, `wp-cron` (the default) or `action-scheduler`, by `POLLORA_SCHEDULE_DRIVER`, and lists the hooks that stay on WP-Cron whatever the driver. The `cron` option now indexes every driver's events, so `wp_next_scheduled()`, Site Health and WP Crontrol see the ones Action Scheduler keeps, and WP-Cron never runs them. Changing the driver moves nothing by itself - `pollora:schedule:list` shows the scheduled events with their type, frequency and driver (`--type`, `--driver`, `--site`, `--network`, `--json`), read from the `cron` option with no query to a driver; an event on WP-Cron on purpose reads `(protected)` or `(fallback)`, one a former driver keeps reads `(to migrate)`. `pollora:schedule:migrate` moves the pending events from one driver to another (`--from`, `--to`, `--dry-run`), in both directions and safe to run again - `pollora:doctor`, Site Health and an administration notice report a driver that does not exist or is not available (events stay on WP-Cron), and the events a former driver still keeps, with the command that moves them +- The `laravel` scheduling driver (`POLLORA_SCHEDULE_DRIVER=laravel`): every event is a job delayed until it is due, in the application's queue (`database`, Redis or Horizon, SQS), and a worker runs it. There is no table of Pollora's own and no `schedule:run`: the `cron` option is the record of what is scheduled, and cancelling an event takes its entry out of it. A job runs only if the entry still exists with its identifier and time, so a cancelled, moved or duplicated job stops; a recurring event queues its next occurrence once it ran or failed for good, never in the past. `config/schedule.php` gains `laravel.connection`, `queue`, `max_delay` (a job due later than the connection allows, 900 seconds on SQS, wakes and waits again) and `late_after`. On a multisite network the queue is shared, with one worker, and each job runs in the site that scheduled it +- Lost jobs (a flushed Redis, a purged queue) are found again: at most every five minutes, as a request ends and under a lock, and with `php artisan pollora:schedule:recover` (`--network`, `--dry-run`), an event more than `late_after` seconds (600) past its time is queued again with the same identifier, so it never runs twice. `pollora:doctor` and Site Health report late events (no worker), a queue connection that is `sync` or `null`, and a scheduled event that failed for good in the last week ### Changed - `DiscoveryEngine`, `DiscoveryCacheManager`, `InstancePool` and `ReflectionCache` take their dependencies instead of reaching for `app()` and `config()`; reading `structure-discoverer.cache` moved to `LaravelDiscoveryCacheManagerFactory`. `DiscoveryRegistrar::registerFromContainer()` is now `registerInto()`. These are infrastructure classes, outside the public contracts (`Pollora\Discovery\Domain\*`), which do not change @@ -35,6 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `Pollora\Attributes\PostType\PostTypeAttribute` and `Pollora\Attributes\Taxonomy\TaxonomyAttribute`, removed in 15.0: no built-in attribute extends them any more; implement `ConfiguresArguments` ### Removed +- **The Laravel scheduling bridge, with no replacement under the same name**: `wordpress.use_laravel_scheduler`, `Pollora\Schedule\Scheduler`, `SchedulerInterface`, `Events\*` (`AbstractEvent`, `SingleEvent`, `RecurringEvent`), `Jobs\JobDispatcher`, `RegisterSchedulerFiltersUseCase`, the `pre_schedule_event`, `pre_option_cron` and related filters it installed, and the `wp_events` table. Setting the option no longer does anything. To keep WP-Cron events on Laravel's queue, set `POLLORA_SCHEDULE_DRIVER=laravel` and run a worker (`php artisan queue:work`) in place of `schedule:run`; events that sat in `wp_events` are not moved (let `#[Schedule]` and your own code schedule them again, then drop the table: `DROP TABLE wp_events`, with the prefix of your database connection). `pollora:doctor` no longer reads the option - `handle()` on `#[Action]`, `#[Filter]`, `#[WpRestRoute]` and `#[WpRestRoute\Method]`, and `Hook` no longer implements `HandlesAttributes`: `HookDiscovery` and `WpRestDiscovery` register hooks and routes themselves. Only the bug below reached `Action::handle()`, and `WpRestRoute::handle()` wrote to a read-only wrapper ### Fixed diff --git a/config/schedule.php b/config/schedule.php index 322b6e65..b59c9175 100644 --- a/config/schedule.php +++ b/config/schedule.php @@ -21,12 +21,44 @@ | are until php artisan pollora:schedule:migrate moves them, and | pollora:doctor reminds you. | - | Supported: "wp-cron", "action-scheduler" + | "laravel" makes each event a job delayed until it is due, in the + | application's queue: it needs a connection that accepts delays (not + | "sync" or "null") and a worker, php artisan queue:work. Nothing else + | runs the events, so without a worker none of them fires. + | + | Supported: "wp-cron", "action-scheduler", "laravel" | */ 'driver' => env('POLLORA_SCHEDULE_DRIVER', 'wp-cron'), + /* + |-------------------------------------------------------------------------- + | The "laravel" driver + |-------------------------------------------------------------------------- + | + | connection: the queue connection of the jobs; null is the application's + | default. On a multisite network every site uses the same one, with a + | single worker. + | + | queue: the queue they wait in; null is the connection's default. + | + | max_delay: the longest delay, in seconds, the connection accepts. A job + | due later waits again when it wakes. null: 900 for SQS, none otherwise. + | + | late_after: an event this many seconds past its time is queued again + | (same identifier, so it never runs twice) when its job was lost. + | Checked every five minutes, or with php artisan pollora:schedule:recover. + | + */ + + 'laravel' => [ + 'connection' => env('POLLORA_SCHEDULE_QUEUE_CONNECTION'), + 'queue' => env('POLLORA_SCHEDULE_QUEUE'), + 'max_delay' => env('POLLORA_SCHEDULE_MAX_DELAY'), + 'late_after' => (int) env('POLLORA_SCHEDULE_LATE_AFTER', 600), + ], + /* |-------------------------------------------------------------------------- | Hooks that stay on WP-Cron diff --git a/src/Hook/Infrastructure/Checks/AsyncActionsCheck.php b/src/Hook/Infrastructure/Checks/AsyncActionsCheck.php index 1ce6440f..11857797 100644 --- a/src/Hook/Infrastructure/Checks/AsyncActionsCheck.php +++ b/src/Hook/Infrastructure/Checks/AsyncActionsCheck.php @@ -179,10 +179,6 @@ private function connectionProblems(): array */ private function cronFix(): string { - if ($this->config->get('wordpress.use_laravel_scheduler')) { - return 'WP-Cron events are Laravel jobs here (wordpress.use_laravel_scheduler): run a queue worker, php artisan queue:work'; - } - return $this->inspector->cronRunsOnPageLoad() ? 'The site gets too few visits to run WP-Cron: add a system cron that requests wp-cron.php every minute' : 'DISABLE_WP_CRON is set (Pollora sets it): add a system cron, e.g. * * * * * curl -s https://example.com/cms/wp-cron.php, or wp cron event run --due-now'; diff --git a/src/Providers/PolloraServiceProvider.php b/src/Providers/PolloraServiceProvider.php index b83b4b7a..efb6d705 100755 --- a/src/Providers/PolloraServiceProvider.php +++ b/src/Providers/PolloraServiceProvider.php @@ -4,7 +4,6 @@ namespace Pollora\Providers; -use Illuminate\Contracts\Bus\Dispatcher; use Illuminate\Support\ServiceProvider; use Log1x\SageDirectives\SageDirectivesServiceProvider; use Pollora\Ability\Infrastructure\Providers\AbilityServiceProvider; @@ -36,7 +35,6 @@ use Pollora\PostType\Infrastructure\Providers\PostTypeServiceProvider; use Pollora\Role\Infrastructure\Providers\RoleServiceProvider; use Pollora\Route\Infrastructure\Providers\RouteServiceProvider; -use Pollora\Schedule\Jobs\JobDispatcher; use Pollora\Schedule\SchedulerServiceProvider; use Pollora\Taxonomy\Infrastructure\Providers\TaxonomyServiceProvider; use Pollora\Theme\Infrastructure\Providers\ThemeServiceProvider; @@ -125,7 +123,6 @@ public function register(): void $this->app->register(RouteServiceProvider::class); $this->app->register(SchedulerServiceProvider::class); - $this->app->singleton(JobDispatcher::class, fn ($app): JobDispatcher => new JobDispatcher($app->make(Dispatcher::class))); // Authentication service provider $this->app->register(AuthServiceProvider::class); diff --git a/src/Schedule/Application/Services/ScheduleStatus.php b/src/Schedule/Application/Services/ScheduleStatus.php index 620fefcb..e6a4bb7b 100644 --- a/src/Schedule/Application/Services/ScheduleStatus.php +++ b/src/Schedule/Application/Services/ScheduleStatus.php @@ -6,6 +6,7 @@ use Illuminate\Contracts\Config\Repository; use Pollora\Schedule\Event; +use Pollora\Schedule\Infrastructure\Drivers\LaravelQueueDriver; use Pollora\Schedule\Schedule; use Pollora\Schedule\Support\CronIndex; @@ -74,6 +75,24 @@ public function toMigrate(): array return $counts; } + /** + * Seconds after which an event of the "laravel" driver counts as late. + */ + public function lateAfter(): int + { + return max(60, (int) $this->config->get('schedule.laravel.late_after', 600)); + } + + /** + * Events of the "laravel" driver that are late: a job lost, or no worker. + */ + public function late(): int + { + $limit = time() - $this->lateAfter(); + + return count(array_filter(Schedule::engine()->index(), static fn (Event $event): bool => $event->driver === LaravelQueueDriver::NAME && $event->timestamp < $limit)); + } + /** * The driver of an event, with what sets it apart from the site's: "wp-cron (protected)", "wp-cron (to migrate)". */ diff --git a/src/Schedule/Application/UseCases/RegisterSchedulerFiltersUseCase.php b/src/Schedule/Application/UseCases/RegisterSchedulerFiltersUseCase.php deleted file mode 100644 index 91784f2a..00000000 --- a/src/Schedule/Application/UseCases/RegisterSchedulerFiltersUseCase.php +++ /dev/null @@ -1,66 +0,0 @@ -registerEventQueryFilters(); - $this->registerEventManagementFilters(); - } - - /** - * Register filters for querying scheduled events. - */ - private function registerEventQueryFilters(): void - { - $filters = [ - 'pre_get_scheduled_event' => 'preGetScheduledEvent', - 'pre_get_ready_cron_jobs' => 'preGetReadyCronJobs', - ]; - - foreach ($filters as $hook => $method) { - $this->filter->add($hook, [$this->scheduler, $method], 10, 5); - } - } - - /** - * Register filters for managing scheduled events. - */ - private function registerEventManagementFilters(): void - { - $filters = [ - 'pre_update_option_cron' => 'preUpdateOptionCron', - 'pre_option_cron' => 'preOptionCron', - 'pre_schedule_event' => 'preScheduleEvent', - 'pre_reschedule_event' => 'preRescheduleEvent', - 'pre_unschedule_event' => 'preUnscheduleEvent', - 'pre_clear_scheduled_hook' => 'preClearScheduledHook', - 'pre_unschedule_hook' => 'preUnscheduleHook', - ]; - - foreach ($filters as $hook => $method) { - $this->filter->add($hook, [$this->scheduler, $method], 10, 5); - } - } -} diff --git a/src/Schedule/Contracts/EventInterface.php b/src/Schedule/Contracts/EventInterface.php deleted file mode 100755 index a3ffb23a..00000000 --- a/src/Schedule/Contracts/EventInterface.php +++ /dev/null @@ -1,55 +0,0 @@ -hook = $event->hook; - $this->timestamp = $event->timestamp; - $this->args = $event->args; - } - } - - /** - * Get the event hook name. - */ - public function getHook(): string - { - return $this->hook; - } - - /** - * Get the event timestamp. - */ - public function getTimestamp(): int - { - return $this->timestamp; - } - - /** - * Get the event arguments. - */ - public function getArgs(): array - { - return $this->args; - } - - /** - * Create and persist a new job instance. - * - * @param object $event WordPress event object - */ - public static function createJob(object $event): self - { - $job = new static($event); - $dispatcher = resolve(JobDispatcher::class); - $jobId = $dispatcher->dispatch($job); - $job->saveToDatabase($jobId); - - return $job; - } - - /** - * Save the event to the database. - * - * @param int $jobId The queue job ID - */ - protected function saveToDatabase($jobId) - { - DB::table('wp_events')->insert([ - 'hook' => $this->hook, - 'args' => json_encode($this->args), - 'schedule' => $this->schedule, - 'interval' => $this->interval, - 'is_recurring' => ! is_null($this->schedule), - 'timestamp' => $this->timestamp, - 'job_id' => $jobId, - 'created_at' => now(), - 'updated_at' => now(), - ]); - } - - /** - * Delete the event from the database. - */ - protected function deleteEvent() - { - DB::table('wp_events')->where('job_id', $this->job->getJobId())->delete(); - } - - /** - * Handle the event execution. - */ - abstract public function handle(): void; -} diff --git a/src/Schedule/Events/RecurringEvent.php b/src/Schedule/Events/RecurringEvent.php deleted file mode 100755 index cc49d9ec..00000000 --- a/src/Schedule/Events/RecurringEvent.php +++ /dev/null @@ -1,140 +0,0 @@ -schedule = $event->schedule; - $this->interval = $event->interval; - } - } - - /** - * Create and persist a new recurring job instance. - * - * @param object $event WordPress event object\n */ - public static function createJob(object $event): self - { - $job = new static($event); - $job->saveToDatabase(); - - return $job; - } - - /** - * Save the recurring job to the database. - * - * @param int|null $jobId Optional job ID - */ - protected function saveToDatabase($jobId = null): void - { - DB::table('wp_events')->insert([ - 'hook' => $this->hook, - 'args' => json_encode($this->args), - 'schedule' => $this->schedule, - 'interval' => $this->interval, - 'created_at' => now(), - 'updated_at' => now(), - ]); - } - - /** - * Schedule all recurring events in the Laravel scheduler. - * - * @param Schedule $schedule Laravel schedule instance - */ - public static function scheduleAllEvents(Schedule $schedule): void - { - $events = DB::table('wp_events')->whereNotNull('schedule')->get(); - - foreach ($events as $event) { - $schedule->call(function () use ($event): void { - do_action_ref_array($event->hook, json_decode((string) $event->args, true)); - })->cron(self::getCronExpression($event->schedule, $event->interval)); - } - } - - /** - * Convert WordPress schedule to cron expression. - * - * @param string $schedule WordPress schedule name - * @param int|null $interval Custom interval in seconds - * @return string Cron expression - */ - public static function getCronExpression(string $schedule, ?int $interval): string - { - $schedules = wp_get_schedules(); - - if (isset($schedules[$schedule])) { - $interval = $schedules[$schedule]['interval']; - } - - return match ($schedule) { - 'minutly' => '* * * * *', - 'hourly' => '0 * * * *', - 'twicedaily' => '0 */12 * * *', - 'daily' => '0 0 * * *', - 'weekly' => '0 0 * * 0', - default => self::calculateCustomInterval($interval), - }; - } - - /** - * Handle the recurring event execution. - */ - public function handle(): void - { - do_action_ref_array($this->hook, $this->args); - } - - /** - * Calculate cron expression for custom interval. - * - * @param int|null $interval Interval in seconds - * @return string Cron expression - */ - private static function calculateCustomInterval(?int $interval): string - { - if ($interval === null || $interval === 0) { - return '0 0 * * *'; - } - - $minutes = $interval / 60; - - if ($minutes < 60) { - return sprintf('*/%s * * * *', $minutes); - } - - if ($minutes == 60) { - return '0 * * * *'; - } - - if ($minutes % 60 === 0) { - $hours = $minutes / 60; - - return sprintf('0 */%s * * *', $hours); - } - - return sprintf('*/%s * * * *', $minutes); - } -} diff --git a/src/Schedule/Events/SingleEvent.php b/src/Schedule/Events/SingleEvent.php deleted file mode 100755 index 5343c16f..00000000 --- a/src/Schedule/Events/SingleEvent.php +++ /dev/null @@ -1,26 +0,0 @@ -hook, $this->args); - $this->deleteEvent(); - } -} diff --git a/src/Schedule/Infrastructure/Checks/ScheduleDriverCheck.php b/src/Schedule/Infrastructure/Checks/ScheduleDriverCheck.php index 125bcf99..20948454 100644 --- a/src/Schedule/Infrastructure/Checks/ScheduleDriverCheck.php +++ b/src/Schedule/Infrastructure/Checks/ScheduleDriverCheck.php @@ -8,6 +8,8 @@ use Pollora\Doctor\Domain\Enums\RunContext; use Pollora\Doctor\Domain\Models\CheckResult; use Pollora\Schedule\Application\Services\ScheduleStatus; +use Pollora\Schedule\Infrastructure\Drivers\LaravelQueueDriver; +use Pollora\Schedule\Infrastructure\Services\ScheduleFailures; /** * Scheduled events are where the configuration says they should be. @@ -47,10 +49,12 @@ public function run(RunContext $context): CheckResult if ($this->status->unknown()) { $problems[] = sprintf('config/schedule.php asks for the driver "%s", which does not exist: every event goes to WP-Cron', $this->status->configured()); - $fix = 'Set POLLORA_SCHEDULE_DRIVER (config/schedule.php driver) to wp-cron or action-scheduler'; + $fix = 'Set POLLORA_SCHEDULE_DRIVER (config/schedule.php driver) to wp-cron, action-scheduler or laravel'; } elseif ($this->status->unavailable()) { $problems[] = sprintf('The "%s" driver is not available, so events stay on WP-Cron', $this->status->active()); - $fix = 'Activate a plugin that bundles Action Scheduler (WooCommerce does), or set the driver back to wp-cron'; + $fix = $this->status->active() === LaravelQueueDriver::NAME + ? 'Point the queue at a connection a worker runs (database, redis, sqs…), never sync or null: QUEUE_CONNECTION or POLLORA_SCHEDULE_QUEUE_CONNECTION' + : 'Activate a plugin that bundles Action Scheduler (WooCommerce does), or set the driver back to wp-cron'; } foreach ($this->status->toMigrate() as $driver => $count) { @@ -58,6 +62,18 @@ public function run(RunContext $context): CheckResult $fix ??= sprintf('php artisan pollora:schedule:migrate --from=%s --to=%s (--dry-run shows what would move)', $driver, $this->status->active()); } + $late = $this->status->late(); + + if ($late > 0) { + $problems[] = sprintf('%d scheduled event(s) are more than %d minutes late: no queue worker is running them', $late, intdiv($this->status->lateAfter(), 60)); + $fix ??= 'Run a queue worker (php artisan queue:work), or send lost jobs again with php artisan pollora:schedule:recover'; + } + + foreach (array_slice(ScheduleFailures::recent(), -3) as $failure) { + $problems[] = sprintf('"%s" failed for good on %s: %s', $failure['hook'], gmdate('Y-m-d H:i', $failure['time']).' UTC', $failure['message']); + $fix ??= 'See php artisan queue:failed; the event is out of the schedule if it was a single one'; + } + if ($problems === []) { return CheckResult::ok(sprintf('Scheduled events are kept by "%s".', $this->status->active())); } diff --git a/src/Schedule/Infrastructure/Drivers/LaravelQueueDriver.php b/src/Schedule/Infrastructure/Drivers/LaravelQueueDriver.php new file mode 100644 index 00000000..d7c631bc --- /dev/null +++ b/src/Schedule/Infrastructure/Drivers/LaravelQueueDriver.php @@ -0,0 +1,162 @@ + 900]; + + public function __construct( + private Dispatcher $dispatcher, + private Repository $config, + ) {} + + public function available(): bool + { + $driver = $this->config->get(sprintf('queue.connections.%s.driver', $this->connection())); + + return is_string($driver) && ! in_array($driver, ['sync', 'null'], true); + } + + public function placeIdentified(Event $event): bool + { + $this->send(RunScheduledEvent::for($event)); + + return true; + } + + public function once(string $hook, int $timestamp, array $args = []): bool + { + return $this->place(new Event($hook, $timestamp, null, $args)); + } + + public function recurring(string $hook, int $timestamp, string $recurrence, array $args = []): bool + { + $interval = (int) ((function_exists('wp_get_schedules') ? wp_get_schedules() : [])[$recurrence]['interval'] ?? 0); + + return $this->place(new Event($hook, $timestamp, $recurrence, $args, $interval > 0 ? $interval : null)); + } + + /** + * What the engine removes from the index is cancelled: a job that finds no entry stops. + */ + public function cancel(string $hook, array $args = []): int + { + return count(array_filter($this->events(), static fn (Event $event): bool => $event->hook === $hook && $event->args === $args)); + } + + public function clear(string $hook): int + { + return count(array_filter($this->events(), static fn (Event $event): bool => $event->hook === $hook)); + } + + public function next(string $hook, array $args = []): ?int + { + foreach ($this->events() as $event) { + if ($event->hook === $hook && $event->args === $args) { + return $event->timestamp; + } + } + + return null; + } + + public function events(): array + { + return CronIndex::events(self::NAME); + } + + /** + * Queue the job of an event, delayed until it is due, or by the longest delay the connection accepts: the job then waits again. + */ + public function send(RunScheduledEvent $job): void + { + $delay = max(0, $job->timestamp - time()); + $longest = $this->longestDelay(); + + if ($longest !== null && $delay > $longest) { + $delay = $longest; + } + + $job->onConnection($this->connection())->onQueue($this->queue()); + + if ($delay > 0) { + $job->delay($delay); + } + + $this->dispatcher->dispatch($job); + } + + /** + * Seconds after which an event that has not run is late, and the recovery sends it again. + */ + public function lateAfter(): int + { + return max(60, (int) $this->config->get('schedule.laravel.late_after', 600)); + } + + /** + * Where the identifier of a placement is not known to the engine: record the event and queue it. + */ + private function place(Event $event): bool + { + $event = $event->withId(bin2hex(random_bytes(8))); + + CronIndex::record($event, self::NAME); + $this->placeIdentified($event); + + return true; + } + + private function connection(): string + { + $connection = $this->config->get('schedule.laravel.connection'); + + return is_string($connection) && $connection !== '' ? $connection : (string) $this->config->get('queue.default', 'sync'); + } + + private function queue(): ?string + { + $queue = $this->config->get('schedule.laravel.queue'); + + return is_string($queue) && $queue !== '' ? $queue : null; + } + + private function longestDelay(): ?int + { + $configured = $this->config->get('schedule.laravel.max_delay'); + + if (is_numeric($configured) && (int) $configured > 0) { + return (int) $configured; + } + + $driver = $this->config->get(sprintf('queue.connections.%s.driver', $this->connection())); + + return is_string($driver) ? (self::MAX_DELAYS[$driver] ?? null) : null; + } +} diff --git a/src/Schedule/Infrastructure/Jobs/RunScheduledEvent.php b/src/Schedule/Infrastructure/Jobs/RunScheduledEvent.php new file mode 100644 index 00000000..7eaa1664 --- /dev/null +++ b/src/Schedule/Infrastructure/Jobs/RunScheduledEvent.php @@ -0,0 +1,193 @@ + $args + */ + public function __construct( + public readonly string $hook, + public readonly array $args, + public readonly int $timestamp, + public readonly string $id, + public readonly ?string $recurrence = null, + public readonly ?int $interval = null, + public readonly int $site = 1, + ) {} + + public static function for(Event $event): self + { + return new self( + $event->hook, + $event->args, + $event->timestamp, + (string) $event->id, + $event->recurrence, + $event->interval, + function_exists('get_current_blog_id') ? get_current_blog_id() : 1, + ); + } + + public function handle(LaravelQueueDriver $driver): void + { + $this->inSite(function () use ($driver): void { + if (! $this->entryHolds()) { + return; + } + + // The delay was cut short for a connection with a ceiling: wait again + if ($this->timestamp - time() > 1) { + $driver->send($this->again()); + + return; + } + + // One run at a time: a copy sent again while this one is still working finds the lock taken + $lock = Cache::lock('pollora_schedule_run_'.$this->id, 3600); + + if (! $lock->get()) { + return; + } + + try { + do_action_ref_array($this->hook, $this->args); + $this->conclude($driver); + } finally { + $lock->release(); + } + }); + } + + /** + * Retries are over: a recurring event goes on with its next occurrence, a single one leaves the index, and the doctor is told. + */ + public function failed(Throwable $exception): void + { + $this->inSite(function () use ($exception): void { + if (! $this->entryHolds()) { + return; + } + + ScheduleFailures::record($this->hook, $exception->getMessage()); + $this->conclude(resolve(LaravelQueueDriver::class)); + }); + } + + /** + * Name shown by Horizon and in the failed jobs table. + */ + public function displayName(): string + { + return 'pollora/schedule '.$this->hook; + } + + /** + * Tags for Horizon. + * + * @return list + */ + public function tags(): array + { + return ['pollora/schedule', $this->hook]; + } + + /** + * Whether the index still has this event, at this time, under this identifier. + */ + private function entryHolds(): bool + { + // A worker lives across jobs: what WordPress cached in memory, the cron option included, is from before + if (function_exists('wp_cache_flush_runtime')) { + wp_cache_flush_runtime(); + } + + foreach (CronIndex::events(LaravelQueueDriver::NAME) as $event) { + if ($event->hook === $this->hook && $event->args === $this->args) { + return $event->id === $this->id && $event->timestamp === $this->timestamp; + } + } + + return false; + } + + /** + * What follows a run: the next occurrence of a recurring event (never in the past), or the end of a single one. + */ + private function conclude(LaravelQueueDriver $driver): void + { + if ($this->recurrence === null || $this->interval === null || $this->interval <= 0) { + CronIndex::forget($this->hook, $this->args, LaravelQueueDriver::NAME); + + return; + } + + $next = $this->timestamp + $this->interval; + + if ($next <= time()) { + $next += (int) ceil((time() - $next + 1) / $this->interval) * $this->interval; + } + + $event = new Event($this->hook, $next, $this->recurrence, $this->args, $this->interval, LaravelQueueDriver::NAME, bin2hex(random_bytes(8))); + + CronIndex::record($event, LaravelQueueDriver::NAME); + $driver->send(self::for($event)); + } + + /** + * The same promise as a new job: the one a worker holds carries its queue message. + */ + private function again(): self + { + return new self($this->hook, $this->args, $this->timestamp, $this->id, $this->recurrence, $this->interval, $this->site); + } + + /** + * Run in the site that scheduled the event: on a multisite network the queue and its worker are shared. + */ + private function inSite(callable $run): void + { + $switch = function_exists('is_multisite') && is_multisite() && $this->site !== get_current_blog_id(); + + if ($switch) { + switch_to_blog($this->site); + } + + try { + $run(); + } finally { + if ($switch) { + restore_current_blog(); + } + } + } +} diff --git a/src/Schedule/Infrastructure/Services/LateEventRecovery.php b/src/Schedule/Infrastructure/Services/LateEventRecovery.php new file mode 100644 index 00000000..7e06b311 --- /dev/null +++ b/src/Schedule/Infrastructure/Services/LateEventRecovery.php @@ -0,0 +1,92 @@ +get()) { + return 0; + } + + try { + $sent = 0; + + foreach ($this->late() as $event) { + if ($event->id === null) { + $event = $event->withId(bin2hex(random_bytes(8))); + CronIndex::record($event, LaravelQueueDriver::NAME); + } + + // Sent again, then left alone for as long as it takes to count as late again + if (! $force && ! Cache::add('pollora_schedule_resent_'.$event->id, 1, $this->driver->lateAfter())) { + continue; + } + + $this->driver->send(RunScheduledEvent::for($event)); + $sent++; + } + + return $sent; + } finally { + $lock->release(); + } + } + + /** + * The events of the index more than late_after seconds past their time; one without an identifier gets one when it is sent again. + * + * @return list + */ + public function late(): array + { + $late = []; + + foreach (CronIndex::events(LaravelQueueDriver::NAME) as $event) { + if ($event->timestamp >= time() - $this->driver->lateAfter()) { + break; + } + + $late[] = $event; + } + + return $late; + } +} diff --git a/src/Schedule/Infrastructure/Services/ScheduleFailures.php b/src/Schedule/Infrastructure/Services/ScheduleFailures.php new file mode 100644 index 00000000..e5ca27c4 --- /dev/null +++ b/src/Schedule/Infrastructure/Services/ScheduleFailures.php @@ -0,0 +1,46 @@ + $hook, 'time' => time(), 'message' => mb_substr($message, 0, 300)]; + + update_option(self::OPTION, array_slice($failures, -self::KEEP), false); + } + + /** + * Failures of the last week, oldest first. + * + * @return list + */ + public static function recent(): array + { + $stored = get_option(self::OPTION, []); + $recent = []; + + foreach (is_array($stored) ? $stored : [] as $failure) { + if (is_array($failure) && is_string($failure['hook'] ?? null) && is_int($failure['time'] ?? null) && $failure['time'] > time() - self::WEEK) { + $recent[] = ['hook' => $failure['hook'], 'time' => $failure['time'], 'message' => (string) ($failure['message'] ?? '')]; + } + } + + return $recent; + } +} diff --git a/src/Schedule/Jobs/JobDispatcher.php b/src/Schedule/Jobs/JobDispatcher.php deleted file mode 100755 index f3fc7fd1..00000000 --- a/src/Schedule/Jobs/JobDispatcher.php +++ /dev/null @@ -1,35 +0,0 @@ -dispatcher->dispatch($job); - } -} diff --git a/src/Schedule/Scheduler.php b/src/Schedule/Scheduler.php deleted file mode 100755 index c4c59006..00000000 --- a/src/Schedule/Scheduler.php +++ /dev/null @@ -1,370 +0,0 @@ -processCronDifferences($old_value, $value); - - return $old_value; - } - - /** - * Retrieve the cron option. - * - * @param mixed $value Current option value - * @return array The cron jobs array - */ - public function preOptionCron($value): array - { - if ($value !== false) { - return $value; - } - - return $this->generateCronArray(); - } - - /** - * Schedule a new event. - * - * @param mixed $pre Pre-filtered value - * @param object $event Event to schedule - * @param bool $wp_error Whether to return WP_Error on failure - * @return AbstractEvent|WP_Error|null The scheduled job or error - */ - public function preScheduleEvent($pre, object $event, bool $wp_error): AbstractEvent|WP_Error|null - { - if ($pre !== null) { - return $pre; - } - - try { - $job = $event->schedule ? new RecurringEvent($event) : new SingleEvent($event); - - return $job->createJob($event); - } catch (\Throwable $throwable) { - return $wp_error ? new WP_Error('schedule_error', $throwable->getMessage()) : null; - } - } - - /** - * Reschedule an event. - * - * @param mixed $pre Pre-filtered value - * @param object $event Event to reschedule - * @param bool $wp_error Whether to return WP_Error on failure - * @return AbstractEvent|WP_Error|null The rescheduled job or error - */ - public function preRescheduleEvent($pre, object $event, bool $wp_error): AbstractEvent|WP_Error|null - { - if ($pre !== null) { - return $pre; - } - - try { - $job = new RecurringEvent($event); - - return $job->createJob($event); - } catch (\Throwable $throwable) { - return $wp_error ? new WP_Error('reschedule_error', $throwable->getMessage()) : null; - } - } - - /** - * Unschedule an event. - * - * @param mixed $pre Pre-filtered value - * @param int $timestamp Event timestamp - * @param string $hook Event hook - * @param array $args Event arguments - * @param bool $wp_error Whether to return WP_Error on failure - * @return bool|WP_Error Whether the event was unscheduled - */ - public function preUnscheduleEvent($pre, int $timestamp, string $hook, array $args, bool $wp_error): bool|WP_Error - { - if ($pre !== null) { - return $pre; - } - - try { - $deleted = DB::table('wp_events') - ->where('hook', $hook) - ->where('timestamp', $timestamp) - ->where('args', json_encode($args)) - ->delete(); - - if ($deleted) { - $this->deleteAssociatedJob($hook, $timestamp, $args); - } - - return $deleted > 0; - } catch (\Throwable $throwable) { - return $wp_error ? new WP_Error('unschedule_error', $throwable->getMessage()) : false; - } - } - - /** - * Clear all scheduled hooks. - * - * @param mixed $pre Pre-filtered value - * @param string $hook Hook to clear - * @param array|null $args Arguments to match - * @param bool $wp_error Whether to return WP_Error on failure - * @return int|WP_Error The number of events cleared - */ - public function preClearScheduledHook($pre, string $hook, ?array $args, bool $wp_error): int|WP_Error - { - if ($pre !== null) { - return $pre; - } - - try { - $query = DB::table('wp_events')->where('hook', $hook); - - if ($args !== null) { - $query->where('args', json_encode($args)); - } - - $events = $query->get(); - $count = $events->count(); - - foreach ($events as $event) { - $this->deleteAssociatedJob($event->hook, $event->timestamp, json_decode((string) $event->args, true)); - } - - $query->delete(); - - return $count; - } catch (\Throwable $throwable) { - return $wp_error ? new WP_Error('clear_hook_error', $throwable->getMessage()) : 0; - } - } - - protected function deleteAssociatedJob(string $hook, int $timestamp, array $args) - { - $jobId = DB::table('wp_events') - ->where('hook', $hook) - ->where('timestamp', $timestamp) - ->where('args', json_encode($args)) - ->value('job_id'); - - if ($jobId) { - DB::table('jobs')->where('id', $jobId)->delete(); - } - } - - /** - * Unschedule all events attached to a specific hook. - * - * @param mixed $pre Pre-filtered value - * @param string $hook Hook to unschedule - * @param bool $wp_error Whether to return WP_Error on failure - * @return int|WP_Error The number of events unscheduled - */ - public function preUnscheduleHook($pre, string $hook, bool $wp_error): int|WP_Error - { - return $this->preClearScheduledHook($pre, $hook, null, $wp_error); - } - - /** - * Retrieve a scheduled event. - * - * @param mixed $pre Pre-filtered value - * @param string $hook Event hook - * @param array $args Event arguments - * @param int|null $timestamp Unix timestamp (UTC) of the event. Null to retrieve next scheduled event. - * @return object|false The event object or false if not found - */ - public function preGetScheduledEvent($pre, string $hook, array $args, ?int $timestamp): object|false - { - if ($pre !== null) { - return $pre; - } - - $query = DB::table('wp_events') - ->where('hook', $hook); - - if ($args !== []) { - $query->where('args', json_encode($args)); - } - - if ($timestamp !== null) { - $query->where('timestamp', $timestamp); - } - - $event = $query->first(); - - if (! $event) { - return false; - } - - return $this->convertDbEventToWpEvent($event); - } - - protected function convertDbEventToWpEvent(object $event): object - { - return (object) [ - 'hook' => $event->hook, - 'timestamp' => $event->schedule ? $this->getNextRunTime($event->schedule, $event->interval) : $event->timestamp, - 'schedule' => $event->schedule, - 'args' => json_decode($event->args, true), - 'interval' => $event->interval, - ]; - } - - /** - * Generate a unique job ID. - * - * @param int|null $timestamp Event timestamp - * @param string $hook Event hook - * @param array $args Event arguments - * @return string The generated job ID - */ - protected function generateJobId(?int $timestamp, string $hook, array $args): string - { - return md5(serialize([$timestamp, $hook, $args])); - } - - /** - * Convert a job to a WordPress event object. - * - * @param object $job The job to convert - * @return object The converted event object - */ - protected function convertJobToEvent(object $job): object - { - $event = new \stdClass; - $event->hook = $job->wp_hook; - $event->timestamp = $job->available_at; - $event->args = json_decode($job->wp_args, true); - $event->schedule = $job->wp_schedule ?? false; - $event->interval = $job->wp_interval ?? null; - - return $event; - } - - /** - * Process the differences between old and new cron arrays. - * - * @param array $oldCrons The old cron array - * @param array $newCrons The new cron array - */ - protected function processCronDifferences(array $oldCrons, array $newCrons): void - { - // Implementation to handle differences (add, update, delete jobs) - } - - /** - * Generate a cron array from the current queue jobs. - * - * @return array The generated cron array - */ - protected function generateCronArray(): array - { - $jobs = $this->getAllJobs(); - - return $this->convertJobsToWordPressCronArray($jobs); - } - - /** - * Get all jobs from the queue. - */ - protected function getAllJobs(): array - { - return DB::table('wp_events') - ->orderBy('timestamp', 'asc') - ->get() - ->map(fn (object $job): object => $this->convertDbEventToWpEvent($job)) - ->all(); - } - - /** - * Retrieve cron jobs ready to be run. - * - * @param mixed $pre Pre-filtered value - * @return array The array of ready cron jobs - */ - public function preGetReadyCronJobs($pre): array - { - if ($pre !== null) { - return $pre; - } - - return []; - } - - /** - * Convert an array of jobs to a WordPress cron array format. - * - * @param array $jobs The jobs to convert - * @return array The converted cron array - */ - protected function convertJobsToWordPressCronArray(array $jobs): array - { - $crons = []; - - foreach ($jobs as $job) { - if (! $job->hook) { - continue; - } - - $timestamp = $job->timestamp; - $hook = $job->hook; - $key = md5(serialize($job->args)); - - $crons[$timestamp][$hook][$key] = [ - 'schedule' => $job->schedule, - 'args' => $job->args, - ]; - - if ($job->interval) { - $crons[$timestamp][$hook][$key]['interval'] = $job->interval; - } - } - - ksort($crons, SORT_NUMERIC); - $crons['version'] = 2; - - return $crons; - } - - /** - * Get the next run time for a scheduled event. - * - * @param string $schedule WordPress schedule name - * @param int|null $interval Custom interval in seconds - * @return int Next run timestamp - */ - protected function getNextRunTime(string $schedule, ?int $interval): int - { - $cron = RecurringEvent::getCronExpression($schedule, $interval); - $cron = new CronExpression($cron); - - return $cron->getNextRunDate()->getTimestamp(); - } -} diff --git a/src/Schedule/SchedulerServiceProvider.php b/src/Schedule/SchedulerServiceProvider.php index 4750e57c..bc1e25fd 100755 --- a/src/Schedule/SchedulerServiceProvider.php +++ b/src/Schedule/SchedulerServiceProvider.php @@ -4,30 +4,24 @@ namespace Pollora\Schedule; -use Illuminate\Console\Scheduling\Schedule; use Illuminate\Contracts\Config\Repository; use Illuminate\Contracts\Foundation\Application; -use Illuminate\Database\QueryException; use Illuminate\Support\ServiceProvider; -use Orchestra\Testbench\TestCase; use Pollora\Doctor\Infrastructure\Providers\DoctorServiceProvider; -use Pollora\Hook\Domain\Contract\Filter; use Pollora\Schedule\Application\Services\ScheduleStatus; -use Pollora\Schedule\Application\UseCases\RegisterSchedulerFiltersUseCase; -use Pollora\Schedule\Contracts\SchedulerInterface; -use Pollora\Schedule\Events\RecurringEvent; use Pollora\Schedule\Infrastructure\Checks\ScheduleDriverCheck; +use Pollora\Schedule\Infrastructure\Drivers\LaravelQueueDriver; +use Pollora\Schedule\Infrastructure\Services\LateEventRecovery; use Pollora\Schedule\Infrastructure\Services\MigrationNotice; use Pollora\Schedule\Infrastructure\Services\ScheduleDiscovery; use Pollora\Schedule\Schedule as ScheduleApi; use Pollora\Schedule\UI\Console\ScheduleListCommand; use Pollora\Schedule\UI\Console\ScheduleMigrateCommand; +use Pollora\Schedule\UI\Console\ScheduleRecoverCommand; /** - * Service provider for WordPress cron scheduler functionality. - * - * Registers and bootstraps the scheduler services, including filters - * and recurring event scheduling. + * Service provider for scheduled events: the driver of the site, the commands + * and the checks around it. */ class SchedulerServiceProvider extends ServiceProvider { @@ -37,16 +31,16 @@ class SchedulerServiceProvider extends ServiceProvider public function register(): void { $this->registerDomainContracts(); - $this->registerUseCases(); $this->mergeConfigFrom(__DIR__.'/../../config/schedule.php', 'schedule'); $this->app->singleton(ScheduleStatus::class, fn (Application $app): ScheduleStatus => new ScheduleStatus($app->make(Repository::class))); ScheduleApi::useContainer($this->app); + ScheduleApi::extend(LaravelQueueDriver::NAME, fn (): LaravelQueueDriver => $this->app->make(LaravelQueueDriver::class)); $this->chooseDriver($this->app->make(Repository::class)); if ($this->app->runningInConsole()) { - $this->commands([ScheduleListCommand::class, ScheduleMigrateCommand::class]); + $this->commands([ScheduleListCommand::class, ScheduleMigrateCommand::class, ScheduleRecoverCommand::class]); } } @@ -63,14 +57,10 @@ public function boot(): void $this->app->tag([ScheduleDriverCheck::class], DoctorServiceProvider::CHECKS_TAG); $this->app->make(MigrationNotice::class)->register(); - if (config('wordpress.use_laravel_scheduler', false)) { - $this->app->make(RegisterSchedulerFiltersUseCase::class)->execute(); - } - $this->app->booted(function (): void { // The driver was chosen when the application started; WordPress can take its hooks by now ScheduleApi::boot(); - $this->scheduleRecurringEvents(); + $this->recoverLateEvents(); }); } @@ -96,44 +86,24 @@ private function chooseDriver(Repository $config): void */ private function registerDomainContracts(): void { - $this->app->singleton(SchedulerInterface::class, Scheduler::class); $this->app->singleton(ScheduleDiscovery::class, fn (): ScheduleDiscovery => new ScheduleDiscovery); + $this->app->singleton(LaravelQueueDriver::class); + $this->app->singleton(LateEventRecovery::class); } /** - * Register application use cases. - */ - private function registerUseCases(): void - { - $this->app->bind(RegisterSchedulerFiltersUseCase::class, fn (Application $app): RegisterSchedulerFiltersUseCase => new RegisterSchedulerFiltersUseCase( - $app->make(Filter::class), - $app->make(SchedulerInterface::class) - )); - - } - - /** - * Schedule all recurring events. + * Look for events of the "laravel" driver whose job was lost, when the request ends, at most every five minutes. */ - protected function scheduleRecurringEvents(): void + private function recoverLateEvents(): void { - if ($this->isOrchestraTest() || defined('WP_CLI')) { + if (! function_exists('add_action') || ScheduleApi::engine()->driverName() !== LaravelQueueDriver::NAME) { return; } - try { - $schedule = $this->app->make(Schedule::class); - RecurringEvent::scheduleAllEvents($schedule); - } catch (QueryException) { - // WordPress tables may not exist yet during initial installation - } - } - - /** - * Check if we're running in an Orchestra test environment. - */ - private function isOrchestraTest(): bool - { - return defined('LARAVEL_START') && class_exists(TestCase::class); + add_action('shutdown', function (): void { + if ($this->app->make(LaravelQueueDriver::class)->available()) { + $this->app->make(LateEventRecovery::class)->recover(); + } + }); } } diff --git a/src/Schedule/UI/Console/ScheduleRecoverCommand.php b/src/Schedule/UI/Console/ScheduleRecoverCommand.php new file mode 100644 index 00000000..ef195176 --- /dev/null +++ b/src/Schedule/UI/Console/ScheduleRecoverCommand.php @@ -0,0 +1,58 @@ +option('network') && function_exists('is_multisite') && is_multisite() + ? array_map(static fn ($site): int => (int) $site->blog_id, get_sites(['fields' => 'objects', 'number' => 0])) + : [null]; + + $total = 0; + + foreach ($sites as $site) { + if ($site !== null) { + switch_to_blog($site); + } + + try { + $total += $this->option('dry-run') ? $this->list($recovery, $site) : $recovery->recover(force: true); + } finally { + if ($site !== null) { + restore_current_blog(); + } + } + } + + $this->info(sprintf($this->option('dry-run') ? '%d late event(s).' : '%d event(s) sent again.', $total)); + + return self::SUCCESS; + } + + private function list(LateEventRecovery $recovery, ?int $site): int + { + $late = $recovery->late(); + + foreach ($late as $event) { + $this->line(sprintf('%s%s %s', $site === null ? '' : sprintf('[site %d] ', $site), gmdate('Y-m-d H:i:s', $event->timestamp), $event->hook)); + } + + return count($late); + } +} diff --git a/tests/Feature/Hook/Async/AsyncActionsCheckTest.php b/tests/Feature/Hook/Async/AsyncActionsCheckTest.php index ed96ee04..78d6e27a 100644 --- a/tests/Feature/Hook/Async/AsyncActionsCheckTest.php +++ b/tests/Feature/Hook/Async/AsyncActionsCheckTest.php @@ -170,8 +170,7 @@ public function dispatch(AsyncPayload $payload, int $delay = 0): void {} 'null' => [['jobs' => ['driver' => 'null']], 'discards jobs'], ]); -it('warns when WP-Cron events are overdue, with the fix for what runs WP-Cron', function (bool $onPageLoad, bool $laravelScheduler, string $fix): void { - config(['wordpress.use_laravel_scheduler' => $laravelScheduler]); +it('warns when WP-Cron events are overdue, with the fix for what runs WP-Cron', function (bool $onPageLoad, string $fix): void { ($this->register)('save_post'); $this->inspector->cronOnPageLoad = $onPageLoad; $this->inspector->overdue = ['count' => 3, 'oldest' => 1_800_000_000]; @@ -182,9 +181,8 @@ public function dispatch(AsyncPayload $payload, int $delay = 0): void {} ->and($result->details[0])->toBe('3 WP-Cron event(s) overdue, the oldest since 2027-01-15 08:00 UTC: WP-Cron and Action Scheduler run nothing until wp-cron.php is requested') ->and($result->fix)->toContain($fix); })->with([ - 'disabled' => [false, false, 'DISABLE_WP_CRON is set'], - 'on page load' => [true, false, 'too few visits'], - 'Laravel scheduler' => [false, true, 'run a queue worker'], + 'disabled' => [false, 'DISABLE_WP_CRON is set'], + 'on page load' => [true, 'too few visits'], ]); it('leaves overdue WP-Cron events alone when no driver in use relies on WP-Cron', function (): void { diff --git a/tests/Feature/Schedule/LaravelDriverTest.php b/tests/Feature/Schedule/LaravelDriverTest.php new file mode 100644 index 00000000..7ba7137e --- /dev/null +++ b/tests/Feature/Schedule/LaravelDriverTest.php @@ -0,0 +1,306 @@ +cron = []; + $this->fired = []; + $this->wpOptions = []; + + Functions\when('_get_cron_array')->alias(fn (): array => $this->cron); + Functions\when('_set_cron_array')->alias(function (array $cron): bool { + $this->cron = $cron; + + return true; + }); + Functions\when('wp_get_schedules')->justReturn(['hourly' => ['interval' => 3600], 'daily' => ['interval' => 86400]]); + Functions\when('add_filter')->justReturn(true); + Functions\when('add_action')->justReturn(true); + Functions\when('remove_filter')->justReturn(true); + Functions\when('do_action_ref_array')->alias(function (string $hook, array $args): void { + $this->fired[] = [$hook, $args]; + }); + Functions\when('wp_cache_flush_runtime')->justReturn(true); + Functions\when('get_current_blog_id')->justReturn(1); + Functions\when('is_multisite')->justReturn(false); + Functions\when('get_option')->alias(fn (string $name, mixed $default = false): mixed => $this->wpOptions[$name] ?? $default); + Functions\when('update_option')->alias(function (string $name, mixed $value): bool { + $this->wpOptions[$name] = $value; + + return true; + }); + Functions\when('did_action')->justReturn(1); + + config([ + 'queue.default' => 'database', + 'queue.connections.database' => ['driver' => 'database'], + 'queue.connections.sqs' => ['driver' => 'sqs'], + 'queue.connections.sync' => ['driver' => 'sync'], + 'schedule.laravel' => ['connection' => null, 'queue' => null, 'max_delay' => null, 'late_after' => 600], + ]); + + Bus::fake(); + Functions\when('wp_clear_scheduled_hook')->justReturn(0); + Functions\when('wp_unschedule_hook')->justReturn(0); + + foreach (['engine', 'fake', 'driverBeforeFake', 'wanted'] as $property) { + (new ReflectionClass(Schedule::class))->setStaticPropertyValue($property, null); + } + + app()->forgetInstance(LaravelQueueDriver::class); + app()->forgetInstance(LateEventRecovery::class); + config(['schedule.driver' => 'laravel', 'schedule.protected_hooks' => []]); + app()->register(SchedulerServiceProvider::class, force: true); + + $this->driver = resolve(LaravelQueueDriver::class); + $this->engine = Schedule::engine(); +}); + +/** + * @return list + */ +function dispatchedJobs(): array +{ + return Bus::dispatched(RunScheduledEvent::class)->all(); +} + +describe('the driver', function (): void { + it('is available on a queue a worker runs, not on sync or null', function (): void { + expect($this->driver->available())->toBeTrue(); + + config(['schedule.laravel.connection' => 'sync']); + expect($this->driver->available())->toBeFalse(); + + config(['schedule.laravel.connection' => 'missing']); + expect($this->driver->available())->toBeFalse(); + }); + + it('queues a delayed job and writes the entry, with its identifier, in the index', function (): void { + $time = time() + 7200; + $this->engine->place(new Event('digest', $time, null, ['u' => 1])); + + $job = dispatchedJobs()[0]; + + expect(dispatchedJobs())->toHaveCount(1) + ->and($job->hook)->toBe('digest') + ->and($job->timestamp)->toBe($time) + ->and($job->delay)->toBeBetween(7199, 7200) + ->and($job->connection)->toBe('database') + ->and(CronIndex::ownerOf('digest', ['u' => 1]))->toBe('laravel') + ->and(CronIndex::idOf('digest', ['u' => 1]))->toBe($job->id) + ->and($this->engine->next('digest', ['u' => 1]))->toBe($time); + }); + + it('queues a job for an event already due without a delay', function (): void { + $this->engine->place(new Event('digest', time() - 5)); + + expect(dispatchedJobs()[0]->delay)->toBeNull(); + }); + + it('waits by the longest delay a connection accepts, and by the one configured', function (): void { + config(['schedule.laravel.connection' => 'sqs']); + $this->engine->place(new Event('digest', time() + 7200)); + + config(['schedule.laravel.connection' => 'database', 'schedule.laravel.max_delay' => 60]); + $this->engine->place(new Event('other', time() + 7200)); + + expect(dispatchedJobs()[0]->delay)->toBe(900) + ->and(dispatchedJobs()[1]->delay)->toBe(60); + }); + + it('cancels by taking the entry out of the index, without reading the queue', function (): void { + $this->engine->place(new Event('digest', time() + 3600, null, ['u' => 1])); + + expect($this->engine->cancel('digest', ['u' => 1]))->toBe(1) + ->and(CronIndex::ownerOf('digest', ['u' => 1]))->toBeNull() + ->and($this->engine->next('digest', ['u' => 1]))->toBeNull(); + }); + + it('stores a single event of the WordPress functions the same way', function (): void { + $this->driver->once('digest', time() + 600, []); + $this->driver->recurring('reports', time() + 600, 'daily', []); + + expect(CronIndex::events('laravel'))->toHaveCount(2) + ->and(CronIndex::events('laravel')[0]->id)->not->toBeNull() + ->and(dispatchedJobs()[1]->interval)->toBe(86400); + }); +}); + +describe('the job', function (): void { + it('fires the hook and takes a single event out of the index', function (): void { + $this->engine->place(new Event('digest', time() - 1, null, ['u' => 1])); + + dispatchedJobs()[0]->handle($this->driver); + + expect($this->fired)->toBe([['digest', ['u' => 1]]]) + ->and(CronIndex::events('laravel'))->toBe([]); + }); + + it('queues the next occurrence of a recurring event, never in the past', function (): void { + $this->engine->place(new Event('reports', time() - 10000, 'hourly', [], 3600)); + $job = dispatchedJobs()[0]; + + $job->handle($this->driver); + + $next = CronIndex::events('laravel')[0]; + + expect($this->fired)->toHaveCount(1) + ->and($next->timestamp)->toBeGreaterThan(time()) + ->and($next->timestamp)->toBeLessThanOrEqual(time() + 3600) + ->and(($next->timestamp - $job->timestamp) % 3600)->toBe(0) + ->and($next->id)->not->toBe($job->id) + ->and(dispatchedJobs())->toHaveCount(2) + ->and(dispatchedJobs()[1]->id)->toBe($next->id); + }); + + it('stops when its event was cancelled, moved or replaced', function (): void { + $this->engine->place(new Event('digest', time() - 1)); + $this->engine->place(new Event('moved', time() - 1)); + $this->engine->place(new Event('replaced', time() - 1)); + [$cancelled, $moved, $replaced] = dispatchedJobs(); + + $this->engine->cancel('digest'); + CronIndex::record((new Event('moved', time() + 5000))->withId($moved->id), 'laravel'); + CronIndex::record((new Event('replaced', time() - 1))->withId('another'), 'laravel'); + + $cancelled->handle($this->driver); + $moved->handle($this->driver); + $replaced->handle($this->driver); + + expect($this->fired)->toBe([]); + }); + + it('waits again when it wakes before its time, and runs only once when sent twice', function (): void { + $this->engine->place(new Event('digest', time() + 3600)); + $early = dispatchedJobs()[0]; + + $early->handle($this->driver); + + expect($this->fired)->toBe([]) + ->and(dispatchedJobs())->toHaveCount(2) + ->and(dispatchedJobs()[1]->id)->toBe($early->id) + ->and(dispatchedJobs()[1]->timestamp)->toBe($early->timestamp); + + $this->cron = []; + $this->engine->place(new Event('again', time() - 1)); + $first = dispatchedJobs()[2]; + $copy = clone $first; + + $first->handle($this->driver); + $copy->handle($this->driver); + + expect($this->fired)->toBe([['again', []]]); + }); + + it('records a final failure, and goes on with a recurring event', function (): void { + $this->engine->place(new Event('reports', time() - 10, 'hourly', [], 3600)); + $this->engine->place(new Event('digest', time() - 10)); + [$recurring, $single] = dispatchedJobs(); + + $recurring->failed(new RuntimeException('boom')); + $single->failed(new RuntimeException('bang')); + + expect(array_column(ScheduleFailures::recent(), 'hook'))->toBe(['reports', 'digest']) + ->and(ScheduleFailures::recent()[1]['message'])->toBe('bang') + ->and(array_column(CronIndex::events('laravel'), 'hook'))->toBe(['reports']) + ->and(dispatchedJobs())->toHaveCount(3); + }); + + it('runs in the site that scheduled the event on a network', function (): void { + $switched = []; + Functions\when('is_multisite')->justReturn(true); + Functions\when('switch_to_blog')->alias(function (int $site) use (&$switched): bool { + $switched[] = $site; + + return true; + }); + Functions\when('restore_current_blog')->alias(function () use (&$switched): bool { + $switched[] = 'restore'; + + return true; + }); + + $this->engine->place(new Event('digest', time() - 1)); + $job = dispatchedJobs()[0]; + $job = new RunScheduledEvent($job->hook, $job->args, $job->timestamp, $job->id, null, null, 3); + + $job->handle($this->driver); + + expect($switched)->toBe([3, 'restore']); + }); +}); + +describe('the recovery', function (): void { + it('sends again the events more than late_after seconds past their time, with the same identifier, once', function (): void { + $this->engine->place(new Event('lost', time() - 1200)); + $this->engine->place(new Event('fresh', time() - 30)); + $this->engine->place(new Event('future', time() + 3000)); + + $queued = count(dispatchedJobs()); + + $recovery = resolve(LateEventRecovery::class); + $sent = $recovery->recover(); + + expect($sent)->toBe(1) + ->and(dispatchedJobs())->toHaveCount($queued + 1) + ->and(dispatchedJobs()[$queued]->hook)->toBe('lost') + ->and(dispatchedJobs()[$queued]->id)->toBe(CronIndex::idOf('lost')) + ->and($recovery->recover())->toBe(0) + ->and($recovery->recover(force: true))->toBe(1); + }); + + it('gives an event of the index that has no identifier one', function (): void { + $this->cron = [time() - 5000 => ['lost' => [md5(serialize([])) => ['schedule' => false, 'args' => [], 'driver' => 'laravel']]]]; + + expect(resolve(LateEventRecovery::class)->recover(force: true))->toBe(1) + ->and(CronIndex::idOf('lost'))->not->toBeNull(); + }); + + it('runs from the command, and lists with --dry-run', function (): void { + $this->engine->place(new Event('lost', time() - 1200)); + + expect(Artisan::call('pollora:schedule:recover', ['--dry-run' => true]))->toBe(0) + ->and(Artisan::output())->toContain('lost', '1 late event(s).') + ->and(dispatchedJobs())->toHaveCount(1) + ->and(Artisan::call('pollora:schedule:recover'))->toBe(0) + ->and(Artisan::output())->toContain('1 event(s) sent again.') + ->and(dispatchedJobs())->toHaveCount(2); + }); +}); + +describe('the doctor check', function (): void { + it('warns of late events, and of a failure that left a single event out of the schedule', function (): void { + $this->engine->place(new Event('lost', time() - 1200)); + ScheduleFailures::record('digest', 'bang'); + + $result = resolve(ScheduleDriverCheck::class)->run(RunContext::Console); + + expect($result->status)->toBe(CheckStatus::Warning) + ->and(implode(' ', $result->details))->toContain('1 scheduled event(s) are more than 10 minutes late', '"digest" failed for good') + ->and($result->fix)->toContain('queue:work'); + }); + + it('says what to change when the queue is sync', function (): void { + config(['schedule.laravel.connection' => 'sync']); + + $result = resolve(ScheduleDriverCheck::class)->run(RunContext::Console); + + expect($result->status)->toBe(CheckStatus::Warning) + ->and($result->fix)->toContain('never sync or null'); + }); +}); diff --git a/tests/Feature/Schedule/ScheduleCommandsTest.php b/tests/Feature/Schedule/ScheduleCommandsTest.php index d07cb73e..85cc56b6 100644 --- a/tests/Feature/Schedule/ScheduleCommandsTest.php +++ b/tests/Feature/Schedule/ScheduleCommandsTest.php @@ -107,6 +107,8 @@ public function events(): array Functions\when('wp_schedule_event')->justReturn(true); Functions\when('wp_schedule_single_event')->justReturn(true); Functions\when('did_action')->justReturn(1); + Functions\when('get_current_blog_id')->justReturn(1); + Functions\when('is_multisite')->justReturn(false); Functions\when('wp_date')->alias(fn (string $format, int $timestamp): string => gmdate($format, $timestamp)); Schedule::extend('memory', fn (): ScheduleDriver => $this->memory); diff --git a/tests/Feature/Schedule/SchedulerServiceProviderTest.php b/tests/Feature/Schedule/SchedulerServiceProviderTest.php index 377ad4f5..90084e35 100644 --- a/tests/Feature/Schedule/SchedulerServiceProviderTest.php +++ b/tests/Feature/Schedule/SchedulerServiceProviderTest.php @@ -4,11 +4,8 @@ use Pollora\Discovery\Domain\Contracts\DiscoveryEngineInterface; use Pollora\Hook\Domain\Contract\Filter; -use Pollora\Schedule\Application\UseCases\RegisterSchedulerFiltersUseCase; -use Pollora\Schedule\Contracts\SchedulerInterface; use Pollora\Schedule\Infrastructure\Services\ScheduleDiscovery; use Pollora\Schedule\Schedule; -use Pollora\Schedule\Scheduler; use Pollora\Schedule\SchedulerServiceProvider; beforeEach(function (): void { @@ -23,13 +20,6 @@ }); describe('SchedulerServiceProvider', function (): void { - it('binds SchedulerInterface as singleton', function (): void { - $scheduler = $this->app->make(SchedulerInterface::class); - - expect($scheduler)->toBeInstanceOf(Scheduler::class); - expect($this->app->make(SchedulerInterface::class))->toBe($scheduler); - }); - it('binds ScheduleDiscovery as singleton', function (): void { $discovery = $this->app->make(ScheduleDiscovery::class); @@ -37,12 +27,6 @@ expect($this->app->make(ScheduleDiscovery::class))->toBe($discovery); }); - it('binds RegisterSchedulerFiltersUseCase', function (): void { - $useCase = $this->app->make(RegisterSchedulerFiltersUseCase::class); - - expect($useCase)->toBeInstanceOf(RegisterSchedulerFiltersUseCase::class); - }); - it('gives the schedule engine the application, so the classes passed to call() get their dependencies', function (): void { $container = (new ReflectionProperty(Schedule::engine(), 'container'))->getValue(Schedule::engine()); diff --git a/tests/Unit/Schedule/Application/UseCases/RegisterSchedulerFiltersUseCaseTest.php b/tests/Unit/Schedule/Application/UseCases/RegisterSchedulerFiltersUseCaseTest.php deleted file mode 100644 index 27641a5a..00000000 --- a/tests/Unit/Schedule/Application/UseCases/RegisterSchedulerFiltersUseCaseTest.php +++ /dev/null @@ -1,72 +0,0 @@ -filter = m::mock(Filter::class); - $this->scheduler = m::mock(SchedulerInterface::class); - $this->useCase = new RegisterSchedulerFiltersUseCase($this->filter, $this->scheduler); -}); - -describe('RegisterSchedulerFiltersUseCase', function (): void { - - it('registers all nine WordPress cron filters', function (): void { - $this->filter->shouldReceive('add')->times(9); - - $this->useCase->execute(); - }); - - it('registers event query filters with correct hooks', function (): void { - $scheduler = $this->scheduler; - - $this->filter->shouldReceive('add') - ->with('pre_get_scheduled_event', [$scheduler, 'preGetScheduledEvent'], 10, 5) - ->once(); - $this->filter->shouldReceive('add') - ->with('pre_get_ready_cron_jobs', [$scheduler, 'preGetReadyCronJobs'], 10, 5) - ->once(); - - // Allow the management filters - $this->filter->shouldReceive('add')->times(7); - - $this->useCase->execute(); - }); - - it('registers event management filters with correct hooks', function (): void { - $scheduler = $this->scheduler; - - $expectedManagementFilters = [ - 'pre_update_option_cron' => 'preUpdateOptionCron', - 'pre_option_cron' => 'preOptionCron', - 'pre_schedule_event' => 'preScheduleEvent', - 'pre_reschedule_event' => 'preRescheduleEvent', - 'pre_unschedule_event' => 'preUnscheduleEvent', - 'pre_clear_scheduled_hook' => 'preClearScheduledHook', - 'pre_unschedule_hook' => 'preUnscheduleHook', - ]; - - foreach ($expectedManagementFilters as $hook => $method) { - $this->filter->shouldReceive('add') - ->with($hook, [$scheduler, $method], 10, 5) - ->once(); - } - - // Allow the query filters - $this->filter->shouldReceive('add')->times(2); - - $this->useCase->execute(); - }); - - it('registers all filters with priority 10 and 5 accepted args', function (): void { - $this->filter->shouldReceive('add') - ->with(m::type('string'), m::type('array'), 10, 5) - ->times(9); - - $this->useCase->execute(); - }); -}); From 65df8ce8137b25b4c374467e4c83153d4be0618a Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 13 Oct 2026 17:57:15 +0200 Subject: [PATCH 21/27] fix: build the laravel driver before the bus exists, extend before the engine is created The driver is chosen while providers register, so it resolves the bus when a job is queued. Requires pollora/schedule ^0.1.3 (Identifies). --- composer.json | 2 +- .../Infrastructure/Drivers/LaravelQueueDriver.php | 8 ++++++-- src/Schedule/SchedulerServiceProvider.php | 3 ++- tests/Feature/Schedule/LaravelDriverTest.php | 7 +++++++ 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index a926bd66..9530450e 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,7 @@ "pollora/entity": "^1.2", "pollora/hook": "^2.0", "pollora/option": "^1.1", - "pollora/schedule": "^0.1.1 || ^1.0", + "pollora/schedule": "^0.1.3 || ^1.0", "pollora/wp-cli": "^0.1 || ^1.0", "pollora/wp-rest": "^0.1 || ^1.0", "watson/rememberable": "^7.1", diff --git a/src/Schedule/Infrastructure/Drivers/LaravelQueueDriver.php b/src/Schedule/Infrastructure/Drivers/LaravelQueueDriver.php index d7c631bc..c7bca8a3 100644 --- a/src/Schedule/Infrastructure/Drivers/LaravelQueueDriver.php +++ b/src/Schedule/Infrastructure/Drivers/LaravelQueueDriver.php @@ -6,6 +6,7 @@ use Illuminate\Contracts\Bus\Dispatcher; use Illuminate\Contracts\Config\Repository; +use Illuminate\Contracts\Container\Container; use Pollora\Schedule\Contracts\Identifies; use Pollora\Schedule\Contracts\ScheduleDriver; use Pollora\Schedule\Event; @@ -31,8 +32,11 @@ */ private const array MAX_DELAYS = ['sqs' => 900]; + /** + * The bus is resolved when a job is queued: the driver is built while the application is still registering its providers. + */ public function __construct( - private Dispatcher $dispatcher, + private Container $container, private Repository $config, ) {} @@ -109,7 +113,7 @@ public function send(RunScheduledEvent $job): void $job->delay($delay); } - $this->dispatcher->dispatch($job); + $this->container->make(Dispatcher::class)->dispatch($job); } /** diff --git a/src/Schedule/SchedulerServiceProvider.php b/src/Schedule/SchedulerServiceProvider.php index bc1e25fd..87998f59 100755 --- a/src/Schedule/SchedulerServiceProvider.php +++ b/src/Schedule/SchedulerServiceProvider.php @@ -35,8 +35,9 @@ public function register(): void $this->mergeConfigFrom(__DIR__.'/../../config/schedule.php', 'schedule'); $this->app->singleton(ScheduleStatus::class, fn (Application $app): ScheduleStatus => new ScheduleStatus($app->make(Repository::class))); - ScheduleApi::useContainer($this->app); + // Before anything creates the engine: POLLORA_SCHEDULE_DRIVER may name it ScheduleApi::extend(LaravelQueueDriver::NAME, fn (): LaravelQueueDriver => $this->app->make(LaravelQueueDriver::class)); + ScheduleApi::useContainer($this->app); $this->chooseDriver($this->app->make(Repository::class)); if ($this->app->runningInConsole()) { diff --git a/tests/Feature/Schedule/LaravelDriverTest.php b/tests/Feature/Schedule/LaravelDriverTest.php index 7ba7137e..298e29b5 100644 --- a/tests/Feature/Schedule/LaravelDriverTest.php +++ b/tests/Feature/Schedule/LaravelDriverTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Brain\Monkey\Functions; +use Illuminate\Container\Container; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Bus; use Pollora\Doctor\Domain\Enums\CheckStatus; @@ -90,6 +91,12 @@ function dispatchedJobs(): array expect($this->driver->available())->toBeFalse(); }); + it('is built before the application has a bus: the driver is chosen while the providers register', function (): void { + $driver = new LaravelQueueDriver(new Container, resolve('config')); + + expect($driver->available())->toBeTrue(); + }); + it('queues a delayed job and writes the entry, with its identifier, in the index', function (): void { $time = time() + 7200; $this->engine->place(new Event('digest', $time, null, ['u' => 1])); From 830eeb614b733df05679fc9eb936f827c9015793 Mon Sep 17 00:00:00 2001 From: boulea7 Date: Tue, 13 Oct 2026 19:28:51 +0200 Subject: [PATCH 22/27] fix(wordpress): map app.env to WP_ENVIRONMENT_TYPE Map Laravel's app.env to a default WP_ENVIRONMENT_TYPE before WordPress loads, so app.env=local produces local. dev, development, testing and staging are mapped too; an explicit process environment value, a configured constant or a constant already defined still wins. Fixes #443. --- src/WordPress/Bootstrap.php | 9 + .../WordPress/BootstrapEnvironmentTest.php | 174 ++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 tests/Unit/WordPress/BootstrapEnvironmentTest.php diff --git a/src/WordPress/Bootstrap.php b/src/WordPress/Bootstrap.php index 80f908e2..f3c4554b 100644 --- a/src/WordPress/Bootstrap.php +++ b/src/WordPress/Bootstrap.php @@ -396,6 +396,15 @@ private function defineWordPressConstants(): void Constant::queue('JETPACK_DEV_DEBUG', $debugMode); + if (! function_exists('getenv') || getenv('WP_ENVIRONMENT_TYPE') === false) { + Constant::queue('WP_ENVIRONMENT_TYPE', match (config('app.env')) { + 'local' => 'local', + 'dev', 'development', 'testing' => 'development', + 'staging' => 'staging', + default => 'production', + }); + } + foreach ((array) config('wordpress.constants', []) as $key => $value) { $key = strtoupper((string) $key); Constant::queue($key, $value); diff --git a/tests/Unit/WordPress/BootstrapEnvironmentTest.php b/tests/Unit/WordPress/BootstrapEnvironmentTest.php new file mode 100644 index 00000000..599e810a --- /dev/null +++ b/tests/Unit/WordPress/BootstrapEnvironmentTest.php @@ -0,0 +1,174 @@ +registerWordPressConstants(); + + self::assertTrue(WP_DEBUG); + self::assertSame('local', constant('WP_ENVIRONMENT_TYPE')); + } + + #[DataProvider('environmentMappings')] + public function test_application_environment_is_mapped(?string $environment, string $expected): void + { + putenv('WP_ENVIRONMENT_TYPE'); + $this->registerWordPressConstants($environment); + + self::assertSame($expected, constant('WP_ENVIRONMENT_TYPE')); + } + + #[DataProvider('debugValues')] + public function test_environment_does_not_depend_on_debug_mode(bool $debug): void + { + putenv('WP_ENVIRONMENT_TYPE'); + $this->registerWordPressConstants(debug: $debug); + + self::assertSame($debug, WP_DEBUG); + self::assertSame('local', constant('WP_ENVIRONMENT_TYPE')); + } + + #[DataProvider('nativeEnvironments')] + public function test_native_environment_prevents_fallback_constant(string $environment): void + { + putenv('WP_ENVIRONMENT_TYPE='.$environment); + $this->registerWordPressConstants(); + + self::assertFalse(defined('WP_ENVIRONMENT_TYPE')); + self::assertSame($environment, getenv('WP_ENVIRONMENT_TYPE')); + } + + #[DataProvider('explicitValues')] + public function test_configured_environment_is_not_normalized(mixed $value): void + { + putenv('WP_ENVIRONMENT_TYPE'); + $this->registerWordPressConstants(constants: ['wp_environment_type' => $value]); + + self::assertSame($value, constant('WP_ENVIRONMENT_TYPE')); + } + + public function test_configured_constant_still_applies_with_native_environment(): void + { + putenv('WP_ENVIRONMENT_TYPE=production'); + $this->registerWordPressConstants(constants: ['WP_ENVIRONMENT_TYPE' => 'staging']); + + self::assertSame('staging', constant('WP_ENVIRONMENT_TYPE')); + self::assertSame('production', getenv('WP_ENVIRONMENT_TYPE')); + } + + #[DataProvider('explicitValues')] + public function test_predefined_constant_is_not_overwritten(mixed $value): void + { + putenv('WP_ENVIRONMENT_TYPE=production'); + define('WP_ENVIRONMENT_TYPE', $value); + $this->registerWordPressConstants(constants: ['WP_ENVIRONMENT_TYPE' => 'development']); + + self::assertSame($value, constant('WP_ENVIRONMENT_TYPE')); + } + + /** + * @return array + */ + public static function environmentMappings(): array + { + return [ + 'local' => ['local', 'local'], + 'dev' => ['dev', 'development'], + 'development' => ['development', 'development'], + 'testing' => ['testing', 'development'], + 'staging' => ['staging', 'staging'], + 'production' => ['production', 'production'], + 'prod' => ['prod', 'production'], + 'unknown' => ['preview', 'production'], + 'empty' => ['', 'production'], + 'missing' => [null, 'production'], + ]; + } + + /** + * @return array + */ + public static function debugValues(): array + { + return ['enabled' => [true], 'disabled' => [false]]; + } + + /** + * @return array + */ + public static function nativeEnvironments(): array + { + return [ + 'local' => ['local'], + 'development' => ['development'], + 'staging' => ['staging'], + 'production' => ['production'], + 'empty' => [''], + 'invalid' => ['preview'], + ]; + } + + /** + * @return array + */ + public static function explicitValues(): array + { + return [ + 'valid' => ['staging'], + 'empty' => [''], + 'invalid' => ['preview'], + 'false' => [false], + ]; + } + + /** + * @param array $constants + */ + private function registerWordPressConstants(?string $environment = 'local', array $constants = [], bool $debug = true): void + { + $app = new Application(dirname(__DIR__, 3)); + $app->instance('config', new Repository([ + 'app' => ['env' => $environment, 'debug' => $debug, 'url' => 'http://environment.test'], + 'wordpress' => ['constants' => $constants], + ])); + $app->instance('request', Request::create('/')); + $app->instance('constant.manager', new ConstantManager); + Facade::setFacadeApplication($app); + + $bootstrap = new Bootstrap( + new ConsoleDetectionService(new LaravelConsoleDetector($app)), + new LaravelDebugDetector($app), + \Mockery::mock(Action::class), + ); + + $bootstrap->register(); + } +} From 7d46f388ed52bef1939cba39c8f231d2bdc4acd3 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 13 Oct 2026 19:42:35 +0200 Subject: [PATCH 23/27] test: keep pings on in the E2E fixture, WordPress 7.1 disables them outside production The site now reports its environment as local, so the trackback test needs the wp_should_disable_pings_for_environment filter. The changelog says what the new environment changes for pings. --- CHANGELOG.md | 1 + tests/e2e/fixtures/plugins/e2e-features/e2e-features.php | 3 +++ 2 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3aff6c7..206fc948 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `handle()` on `#[Action]`, `#[Filter]`, `#[WpRestRoute]` and `#[WpRestRoute\Method]`, and `Hook` no longer implements `HandlesAttributes`: `HookDiscovery` and `WpRestDiscovery` register hooks and routes themselves. Only the bug below reached `Action::handle()`, and `WpRestRoute::handle()` wrote to a read-only wrapper ### Fixed +- `wp_get_environment_type()` follows `APP_ENV` instead of reading `production` on every site (#443): `local` gives `local`, `dev`, `development` and `testing` give `development`, `staging` gives `staging`, anything else `production`. A `WP_ENVIRONMENT_TYPE` environment variable, a `wordpress.constants.WP_ENVIRONMENT_TYPE` value or a constant defined earlier still wins. WordPress 7.1 turns pings (incoming and outgoing pingbacks and trackbacks) off outside `production`, so they are off on a site with `APP_ENV=local` or `staging`: `add_filter('wp_should_disable_pings_for_environment', '__return_false')` brings them back - `pollora:doctor` no longer counts as overdue WP-Cron events the ones Action Scheduler keeps: the `cron` option indexes them too, with a `driver` key - A public `#[Action]` or `#[Filter]` method in a `#[PostType]` or `#[Taxonomy]` class is registered once: the post type and taxonomy discoveries also handed it to `Action::handle()`, which registered it a second time on the discovery's configuration object, so the page firing that hook failed with `call_user_func(): Argument #1 ($callback) must be a valid callback, class Pollora\PostType\Infrastructure\Services\PostTypeConfiguration does not have a method …` - A REST controller declared with `#[WpRestRoute]` gets its constructor dependencies: it is resolved through the discovery's instance pool (Laravel's container in a Pollora application) instead of `new` with no argument, so a controller with a required dependency answered every request with `Trying to invoke non static method … without an object` diff --git a/tests/e2e/fixtures/plugins/e2e-features/e2e-features.php b/tests/e2e/fixtures/plugins/e2e-features/e2e-features.php index abcee5d3..73bd5e59 100644 --- a/tests/e2e/fixtures/plugins/e2e-features/e2e-features.php +++ b/tests/e2e/fixtures/plugins/e2e-features/e2e-features.php @@ -15,4 +15,7 @@ exit; } +// The site follows APP_ENV (local), and WordPress 7.1 refuses pings outside production: the trackback test needs them. +add_filter('wp_should_disable_pings_for_environment', '__return_false'); + pollora_register(ModuleType::Plugin, 'e2e-features', __DIR__); From 142af2ef4c3f12fde3ef99827174ab881cacf871 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 14 Oct 2026 08:55:54 +0200 Subject: [PATCH 24/27] chore(deps): require Laravel 13.36 --- CHANGELOG.md | 1 + README.md | 2 +- composer.json | 12 ++++++------ 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 206fc948..a3d8ae41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Lost jobs (a flushed Redis, a purged queue) are found again: at most every five minutes, as a request ends and under a lock, and with `php artisan pollora:schedule:recover` (`--network`, `--dry-run`), an event more than `late_after` seconds (600) past its time is queued again with the same identifier, so it never runs twice. `pollora:doctor` and Site Health report late events (no worker), a queue connection that is `sync` or `null`, and a scheduled event that failed for good in the last week ### Changed +- Requires Laravel 13.36: `illuminate/*` `^13.35` → `^13.36`. The framework version tracks Laravel's, so this release is v13.36.0 - `DiscoveryEngine`, `DiscoveryCacheManager`, `InstancePool` and `ReflectionCache` take their dependencies instead of reaching for `app()` and `config()`; reading `structure-discoverer.cache` moved to `LaravelDiscoveryCacheManagerFactory`. `DiscoveryRegistrar::registerFromContainer()` is now `registerInto()`. These are infrastructure classes, outside the public contracts (`Pollora\Discovery\Domain\*`), which do not change - One `DiscoveryCacheManager` is shared by the engine and the `pollora:doctor` discovery cache check, which built its own - The discovery benchmarks measure memory as PHP allocates it, from a reset peak: they read 2 MB whatever the load diff --git a/README.md b/README.md index d6883d3f..db6588c8 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ composer create-project pollora/pollora my-project Requirements: - PHP 8.4+ for a new project (the skeleton's lock file ships Symfony 8; this package alone accepts PHP 8.3) -- Laravel 13.35 (Pollora's version numbers follow the Laravel release it is built on) +- Laravel 13.36 (Pollora's version numbers follow the Laravel release it is built on) - WordPress 7.1+ ## Quick start diff --git a/composer.json b/composer.json index 9530450e..d63ebebb 100644 --- a/composer.json +++ b/composer.json @@ -24,11 +24,11 @@ "require": { "php": "^8.3", "pollora/helper-overrider": "^1.2", - "illuminate/auth": "^13.35", - "illuminate/support": "^13.35", - "illuminate/database": "^13.35", - "illuminate/routing": "^13.35", - "illuminate/console": "^13.35", + "illuminate/auth": "^13.36", + "illuminate/support": "^13.36", + "illuminate/database": "^13.36", + "illuminate/routing": "^13.36", + "illuminate/console": "^13.36", "pollora/abilities": "^1.0", "pollora/ajax": "^1.1", "pollora/attributes": "^0.1 || ^1.0", @@ -49,7 +49,7 @@ "coduo/php-humanizer": "5.0.3", "nwidart/laravel-modules": "^13.0", "symfony/process": "^7.4 || ^8.0", - "illuminate/config": "^13.35", + "illuminate/config": "^13.36", "laravel/prompts": "^0.3.24" }, "autoload": { From c5890c2684178b963cda6d235321c6fc842088a3 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 14 Oct 2026 10:04:08 +0200 Subject: [PATCH 25/27] fix(schedule): give a recurring event of the laravel driver its interval --- .../Drivers/LaravelQueueDriver.php | 22 +++++++++++++++---- tests/Feature/Schedule/LaravelDriverTest.php | 15 +++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/Schedule/Infrastructure/Drivers/LaravelQueueDriver.php b/src/Schedule/Infrastructure/Drivers/LaravelQueueDriver.php index c7bca8a3..db80fc6b 100644 --- a/src/Schedule/Infrastructure/Drivers/LaravelQueueDriver.php +++ b/src/Schedule/Infrastructure/Drivers/LaravelQueueDriver.php @@ -49,11 +49,27 @@ public function available(): bool public function placeIdentified(Event $event): bool { - $this->send(RunScheduledEvent::for($event)); + $this->send(RunScheduledEvent::for($this->withInterval($event))); return true; } + /** + * A recurring event runs again only when its job knows the interval: the fluent API and #[Schedule] give the name of the recurrence, WordPress's own functions give both. + */ + private function withInterval(Event $event): Event + { + if ($event->recurrence === null || $event->interval !== null) { + return $event; + } + + $interval = (int) ((function_exists('wp_get_schedules') ? wp_get_schedules() : [])[$event->recurrence]['interval'] ?? 0); + + return $interval > 0 + ? new Event($event->hook, $event->timestamp, $event->recurrence, $event->args, $interval, $event->driver, $event->id) + : $event; + } + public function once(string $hook, int $timestamp, array $args = []): bool { return $this->place(new Event($hook, $timestamp, null, $args)); @@ -61,9 +77,7 @@ public function once(string $hook, int $timestamp, array $args = []): bool public function recurring(string $hook, int $timestamp, string $recurrence, array $args = []): bool { - $interval = (int) ((function_exists('wp_get_schedules') ? wp_get_schedules() : [])[$recurrence]['interval'] ?? 0); - - return $this->place(new Event($hook, $timestamp, $recurrence, $args, $interval > 0 ? $interval : null)); + return $this->place($this->withInterval(new Event($hook, $timestamp, $recurrence, $args))); } /** diff --git a/tests/Feature/Schedule/LaravelDriverTest.php b/tests/Feature/Schedule/LaravelDriverTest.php index 298e29b5..38d56645 100644 --- a/tests/Feature/Schedule/LaravelDriverTest.php +++ b/tests/Feature/Schedule/LaravelDriverTest.php @@ -138,6 +138,21 @@ function dispatchedJobs(): array ->and($this->engine->next('digest', ['u' => 1]))->toBeNull(); }); + it('gives a recurring event the interval of its recurrence when the caller gave none: the fluent API and #[Schedule] only know the name', function (): void { + $this->engine->place(new Event('reports', time() - 5, 'daily', [])); + + expect(dispatchedJobs()[0]->interval)->toBe(86400); + + $job = dispatchedJobs()[0]; + $job->handle($this->driver); + + $next = CronIndex::events('laravel'); + + expect($next)->toHaveCount(1) + ->and($next[0]->hook)->toBe('reports') + ->and($next[0]->timestamp)->toBe($job->timestamp + 86400); + }); + it('stores a single event of the WordPress functions the same way', function (): void { $this->driver->once('digest', time() + 600, []); $this->driver->recurring('reports', time() + 600, 'daily', []); From 45592d8281a4c4abca27ef1a28b83bea4b2821d5 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 14 Oct 2026 15:26:05 +0200 Subject: [PATCH 26/27] fix: listen to queued asynchronous actions at boot --- CHANGELOG.md | 1 + src/Hook/Infrastructure/Providers/AsyncServiceProvider.php | 3 +++ 2 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3d8ae41..4774ad5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `handle()` on `#[Action]`, `#[Filter]`, `#[WpRestRoute]` and `#[WpRestRoute\Method]`, and `Hook` no longer implements `HandlesAttributes`: `HookDiscovery` and `WpRestDiscovery` register hooks and routes themselves. Only the bug below reached `Action::handle()`, and `WpRestRoute::handle()` wrote to a read-only wrapper ### Fixed +- A queued asynchronous action runs even when the request that runs it registers no hook: the framework listens to `pollora/async/run` at boot instead of waiting for an `Action` to be built. Before, a handler registered only in the request that queued it was never run, and Action Scheduler marked it failed ("no callbacks are registered") - `wp_get_environment_type()` follows `APP_ENV` instead of reading `production` on every site (#443): `local` gives `local`, `dev`, `development` and `testing` give `development`, `staging` gives `staging`, anything else `production`. A `WP_ENVIRONMENT_TYPE` environment variable, a `wordpress.constants.WP_ENVIRONMENT_TYPE` value or a constant defined earlier still wins. WordPress 7.1 turns pings (incoming and outgoing pingbacks and trackbacks) off outside `production`, so they are off on a site with `APP_ENV=local` or `staging`: `add_filter('wp_should_disable_pings_for_environment', '__return_false')` brings them back - `pollora:doctor` no longer counts as overdue WP-Cron events the ones Action Scheduler keeps: the `cron` option indexes them too, with a `driver` key - A public `#[Action]` or `#[Filter]` method in a `#[PostType]` or `#[Taxonomy]` class is registered once: the post type and taxonomy discoveries also handed it to `Action::handle()`, which registered it a second time on the discovery's configuration object, so the page firing that hook failed with `call_user_func(): Argument #1 ($callback) must be a valid callback, class Pollora\PostType\Infrastructure\Services\PostTypeConfiguration does not have a method …` diff --git a/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php b/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php index 705cbe79..42efea50 100644 --- a/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php +++ b/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php @@ -49,6 +49,9 @@ public function boot(): void $this->app->tag([AsyncActionsCheck::class], DoctorServiceProvider::CHECKS_TAG); $this->configureAsync($this->app->make(Repository::class)); + + // The run of a queued action must not wait for an Action to be built in the request: the request that runs it may register no hook at all + Async::listen(); } private function configureAsync(Repository $config): void From 9da479d47108c291b7eede0813e23efb77476f4f Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 14 Oct 2026 15:50:41 +0200 Subject: [PATCH 27/27] chore: release v13.36.0 --- CHANGELOG.md | 4 +++- composer.json | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4774ad5b..aa8c0ad5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,9 @@ All notable changes to the Pollora framework will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased](https://github.com/Pollora/framework/compare/v13.35.6...develop) +## [Unreleased](https://github.com/Pollora/framework/compare/v13.36.0...develop) + +## [v13.36.0](https://github.com/Pollora/framework/compare/v13.35.6...v13.36.0) - 2026-10-14 ### Added - Discovery lives in its own package, [`pollora/discovery`](https://github.com/Pollora/discovery), which the framework requires: the engine, its contracts, `DiscoveryManager`, the cache and the `#[SkipDiscovery]` attribute, with their tests and benchmarks. Class names do not change. The framework keeps what needs Laravel: the service provider, `discovery:run` / `discovery:clear`, `DiscoveryRegistrar`, `ServiceProviderDiscovery`, the Laravel adapters, and `HasConfiguringSupport`, which builds post types and taxonomies diff --git a/composer.json b/composer.json index d63ebebb..8034b1c6 100644 --- a/composer.json +++ b/composer.json @@ -34,9 +34,9 @@ "pollora/attributes": "^0.1 || ^1.0", "pollora/discovery": "^0.1 || ^1.0", "pollora/entity": "^1.2", - "pollora/hook": "^2.0", + "pollora/hook": "^2.0.1", "pollora/option": "^1.1", - "pollora/schedule": "^0.1.3 || ^1.0", + "pollora/schedule": "^1.0", "pollora/wp-cli": "^0.1 || ^1.0", "pollora/wp-rest": "^0.1 || ^1.0", "watson/rememberable": "^7.1",