Skip to content
Open
Binary file modified .gitignore
Binary file not shown.
96 changes: 96 additions & 0 deletions libraries/src/Form/CachingFormFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

/**
* Joomla! Content Management System
*
* @copyright (C) 2025 Open Source Matters, Inc. <https://www.joomla.org>
* @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<string, Form> */
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;
}
}
86 changes: 86 additions & 0 deletions libraries/src/Service/CachingFactoriesProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

/**
* Joomla! Content Management System
*
* @copyright (C) 2025 Open Source Matters, Inc. <https://www.joomla.org>
* @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);
});
}
}
}
98 changes: 98 additions & 0 deletions libraries/src/User/CachingUserFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

/**
* Joomla! Content Management System
*
* @copyright (C) 2025 Open Source Matters, Inc. <https://www.joomla.org>
* @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<int, User> */
private array $byId = [];

/** @var array<string, User> */
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 = [];
}
}