diff --git a/.gitignore b/.gitignore index 15a8ef7215081..313d99cf0a47a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,112 +1,112 @@ -# IDE & System Related Files -.buildpath -.project -.settings -.DS_Store -.idea -.vscode -.docker -/docker-compose.yml -/nbproject -/.devcontainer -/.github -/.vscode - -# Local System Files (i.e. cache, logs, etc.) -/administrator/cache -/administrator/logs -/cache -/installation/cache -/tmp -/configuration.php -/.htaccess -/web.config -/.php-cs-fixer.cache -/media - -# Template CSS files generated by NPM -/installation/template/css - -# Test Related Files -/phpunit.xml -composer.phar -/test-install -/.phpunit.result.cache - - -# Vendor directory handling -/libraries/vendor -!libraries/vendor/.gitkeep - -/media/vendor -!media/vendor/.gitkeep - -# Stubs file holding mapped classes -/stubs.php - -# JS/CSS Build -node_modules/ -/build/assets_tmp -/scss-lint-report.xml - -# Removed in Joomla 4 -administrator/templates/isis -administrator/templates/hathor -templates/beez3 -build/generatecss.php -media/jui/less - -# CSS map files -.map - -# phpDocumentor Logs -phpdoc-* - -# Patch Tester -/administrator/components/com_patchtester -/administrator/templates/atum/html/com_patchtester -/components/com_patchtester -/media/com_patchtester - -# Languages -administrator/language/* -!administrator/language/en-GB -administrator/manifests/packages/* -!administrator/manifests/packages/pkg_en-GB.xml -!administrator/language/overrides/index.html -api/language/* -!api/language/en-GB -language/* -!language/en-GB -!language/overrides/index.html - -# OSX -._* -.Spotlight-V100 -.Trashes - -# Windows -Thumbs.db -Desktop.ini - -# Never ignore -# Only apply this rule to the main repository's gitignore files -!/.gitignore -!/build/.gitignore - -# Build related -RoboFile.ini - -# Media Manager -/media/com_media/js/mediamanager.min.js.map -/media/com_media/css/mediamanager.min.css.map - -#cypress -/tests/System/output/screenshots -!/tests/System/output/screenshots/.gitkeep -/tests/System/output/videos -!/tests/System/output/videos/.gitkeep -cypress.config.mjs - -# WebAuthn FIDO metadata cache -/plugins/system/webauthn/fido.jwt +# IDE & System Related Files +.buildpath +.project +.settings +.DS_Store +.idea +.vscode +.docker +/docker-compose.yml +/nbproject +/.devcontainer +/.github +/.vscode + +# Local System Files (i.e. cache, logs, etc.) +/administrator/cache +/administrator/logs +/cache +/installation/cache +/tmp +/configuration.php +/.htaccess +/web.config +/.php-cs-fixer.cache +/media + +# Template CSS files generated by NPM +/installation/template/css + +# Test Related Files +/phpunit.xml +composer.phar +/test-install +/.phpunit.result.cache + + +# Vendor directory handling +/libraries/vendor +!libraries/vendor/.gitkeep + +/media/vendor +!media/vendor/.gitkeep + +# Stubs file holding mapped classes +/stubs.php + +# JS/CSS Build +node_modules/ +/build/assets_tmp +/scss-lint-report.xml + +# Removed in Joomla 4 +administrator/templates/isis +administrator/templates/hathor +templates/beez3 +build/generatecss.php +media/jui/less + +# CSS map files +.map + +# phpDocumentor Logs +phpdoc-* + +# Patch Tester +/administrator/components/com_patchtester +/administrator/templates/atum/html/com_patchtester +/components/com_patchtester +/media/com_patchtester + +# Languages +administrator/language/* +!administrator/language/en-GB +administrator/manifests/packages/* +!administrator/manifests/packages/pkg_en-GB.xml +!administrator/language/overrides/index.html +api/language/* +!api/language/en-GB +language/* +!language/en-GB +!language/overrides/index.html + +# OSX +._* +.Spotlight-V100 +.Trashes + +# Windows +Thumbs.db +Desktop.ini + +# Never ignore +# Only apply this rule to the main repository's gitignore files +!/.gitignore +!/build/.gitignore + +# Build related +RoboFile.ini + +# Media Manager +/media/com_media/js/mediamanager.min.js.map +/media/com_media/css/mediamanager.min.css.map + +#cypress +/tests/System/output/screenshots +!/tests/System/output/screenshots/.gitkeep +/tests/System/output/videos +!/tests/System/output/videos/.gitkeep +cypress.config.mjs + +# WebAuthn FIDO metadata cache +/plugins/system/webauthn/fido.jwt 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 = []; + } +}