diff --git a/.gitignore b/.gitignore index b936fdbaa9b6e..47c422bfd2da7 100644 Binary files a/.gitignore and b/.gitignore differ diff --git a/libraries/src/Form/CachingFormFactory.php b/libraries/src/Form/CachingFormFactory.php new file mode 100644 index 0000000000000..d9499b661970a --- /dev/null +++ b/libraries/src/Form/CachingFormFactory.php @@ -0,0 +1,96 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +declare(strict_types=1); + +namespace Joomla\CMS\Form; + +/** + * Caching decorator for the Form factory. + * + * - No interface/signature changes (BC-safe). + * - Reuses the same Form instance for a given (name, options) key within a request. + * - Allows bypassing cache via ['fresh' => true] in $options. + * + * Register via DI to override the default binding of FormFactoryInterface: + * $container->share(FormFactoryInterface::class, function (\Joomla\DI\Container $c) { + * $inner = $c->get('core.form.factory'); // whatever concrete is bound as + * return new CachingFormFactory($inner); + * }); + */ +final class CachingFormFactory implements FormFactoryInterface +{ + public function __construct(private FormFactoryInterface $inner) + { + } + + /** @var array */ + private array $cache = []; + /** + * {@inheritdoc} + */ + public function createForm(string $name, array $options = []): Form + { + // Allow callers to opt out of caching explicitly. + if (!empty($options['fresh'])) { + // Do not store in cache when 'fresh' is requested. + $opts = $options; + unset($opts['fresh']); + return $this->inner->createForm($name, $opts); + } + + $key = $this->makeKey($name, $options); + return $this->cache[$key] ??= $this->inner->createForm($name, $this->normalizedOptions($options)); + } + + /** + * Removes a cached Form for the given name/options combination. + * Useful when a caller knows the underlying XML or dynamic fields changed mid-request. + */ + public function invalidate(string $name, array $options = []): void + { + $key = $this->makeKey($name, $options); + unset($this->cache[$key]); + } + + /** + * Clears all cached Form instances (per-request scope). + */ + public function invalidateAll(): void + { + $this->cache = []; + } + + /** + * Build a stable cache key from name + options. + * Excludes volatile/nonce-like options that shouldn't affect identity. + */ + private function makeKey(string $name, array $options): string + { + $opts = $this->normalizedOptions($options); + // Remove flags that should not influence identity: + unset( + $opts['fresh'], // our local bypass flag + $opts['debug'], // debugging shouldn't split cache entries + $opts['timestamp'] // any time-based hint + ); + // Sort for deterministic encoding. + ksort($opts); + return $name . '|' . md5(json_encode($opts, JSON_THROW_ON_ERROR)); + } + + /** + * Normalize options to ensure deterministic keys and pass-through. + */ + private function normalizedOptions(array $options): array + { + // Ensure consistent types/casing if needed. Adjust as your concrete factory expects. + return $options; + } +} diff --git a/libraries/src/Service/CachingFactoriesProvider.php b/libraries/src/Service/CachingFactoriesProvider.php new file mode 100644 index 0000000000000..bfc07724e676d --- /dev/null +++ b/libraries/src/Service/CachingFactoriesProvider.php @@ -0,0 +1,86 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +declare(strict_types=1); + +namespace Joomla\CMS\Service; + +use Joomla\CMS\Form\CachingFormFactory; +use Joomla\CMS\Form\FormFactoryInterface; +use Joomla\CMS\User\CachingUserFactory; +use Joomla\CMS\User\UserFactoryInterface; +use Joomla\DI\Container; +use Joomla\DI\ServiceProviderInterface; + +/** + * Registers caching decorator factories for Forms and Users. + * + * Default behavior (BC-safe): + * - Adds opt-in services 'caching.form.factory' and 'caching.user.factory' + * without changing existing bindings. + * + * Optional behavior: + * - If $replaceDefaults is true, replaces the default bindings of + * FormFactoryInterface and UserFactoryInterface with the caching decorators. + * + * Usage: + * // BC-safe, opt-in only: + * $container->registerServiceProvider(new CachingFactoriesProvider()); + * + * // Replace defaults globally (still no interface changes): + * $container->registerServiceProvider(new CachingFactoriesProvider(true)); + */ +final class CachingFactoriesProvider implements ServiceProviderInterface +{ + public function __construct(private bool $replaceDefaults = false) + { + } + + public function register(Container $container): void + { + // ---- Opt-in services (always provided) ------------------------------ + + // caching.form.factory: a CachingFormFactory that wraps the current default form factory + $container->share('caching.form.factory', function (Container $c) { + + // Resolve whatever is currently bound for the interface + $inner = $c->get(FormFactoryInterface::class); + return new CachingFormFactory($inner); + }); + // caching.user.factory: a CachingUserFactory that wraps the current default user factory + $container->share('caching.user.factory', function (Container $c) { + + $inner = $c->get(UserFactoryInterface::class); + return new CachingUserFactory($inner); + }); + // ---- Optional: replace defaults (no BC break; interfaces unchanged) -- + + if ($this->replaceDefaults) { + // Override the interface bindings with the caching decorators + $container->share(FormFactoryInterface::class, function (Container $c) { + + // Wrap the original concrete (obtain via previous binding) + $inner = $c->get('core.form.factory') ?? $c->get('caching.form.factory'); + // If 'core.form.factory' is not registered, fall back to opt-in service + if ($inner instanceof CachingFormFactory) { + return $inner; + } + return new CachingFormFactory($inner); + }); + $container->share(UserFactoryInterface::class, function (Container $c) { + + $inner = $c->get('core.user.factory') ?? $c->get('caching.user.factory'); + if ($inner instanceof CachingUserFactory) { + return $inner; + } + return new CachingUserFactory($inner); + }); + } + } +} diff --git a/libraries/src/User/CachingUserFactory.php b/libraries/src/User/CachingUserFactory.php new file mode 100644 index 0000000000000..d5e04b59dd709 --- /dev/null +++ b/libraries/src/User/CachingUserFactory.php @@ -0,0 +1,98 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +declare(strict_types=1); + +namespace Joomla\CMS\User; + +/** + * Caching decorator for the User factory. + * + * - BC-safe: implements UserFactoryInterface without changing signatures. + * - Adds per-request identity maps for id and username. + */ +final class CachingUserFactory implements UserFactoryInterface +{ + /** @var array */ + private array $byId = []; + + /** @var array */ + private array $byUsername = []; + + public function __construct(private UserFactoryInterface $inner) + { + } + + /** + * {@inheritdoc} + */ + public function loadUserById(int $id): User + { + if (isset($this->byId[$id])) { + return $this->byId[$id]; + } + + $user = $this->inner->loadUserById($id); + + // Keep maps in sync + $this->byId[$user->id] = $user; + + if (isset($user->username) && \is_string($user->username) && $user->username !== '') { + $this->byUsername[$user->username] = $user; + } + + return $user; + } + + /** + * {@inheritdoc} + */ + public function loadUserByUsername(string $username): User + { + if (isset($this->byUsername[$username])) { + return $this->byUsername[$username]; + } + + $user = $this->inner->loadUserByUsername($username); + + if (\is_int($user->id)) { + $this->byId[$user->id] = $user; + } + + $this->byUsername[$username] = $user; + + return $user; + } + + /** + * Invalidate a single cached user by id, if needed. + */ + public function invalidateById(int $id): void + { + if (!isset($this->byId[$id])) { + return; + } + + $user = $this->byId[$id]; + unset($this->byId[$id]); + + if (isset($user->username)) { + unset($this->byUsername[(string) $user->username]); + } + } + + /** + * Clear all cached User instances (per-request scope). + */ + public function invalidateAll(): void + { + $this->byId = []; + $this->byUsername = []; + } +}