diff --git a/framework/Caching/TCacheProxy.php b/framework/Caching/TCacheProxy.php
new file mode 100644
index 000000000..05dc007cd
--- /dev/null
+++ b/framework/Caching/TCacheProxy.php
@@ -0,0 +1,384 @@
+
+ * @link https://github.com/pradosoft/prado
+ * @license https://github.com/pradosoft/prado/blob/master/LICENSE
+ */
+
+namespace Prado\Caching;
+
+use Prado\Exceptions\TConfigurationException;
+use Prado\IModuleDependency;
+use Prado\IProxy;
+use Prado\Prado;
+use Prado\TComponent;
+use Prado\TComponentProxyTrait;
+use Prado\TPropertyValue;
+use Prado\Util\TLogger;
+
+/**
+ * TCacheProxy class.
+ *
+ * TCacheProxy is a transparent proxy that delegates every {@see ICache}
+ * operation to another {@see TCache} module already registered with the
+ * application. This lets a single logical "cache slot" (e.g. the primary
+ * application cache) be hot-swapped at configuration time without changing
+ * the consumers that depend on it.
+ *
+ * **Configuration** — set {@see getBackingCacheId BackingCacheId} to the module ID of the
+ * backing cache. TCacheProxy declares that module as a required dependency via
+ * {@see \Prado\IModuleDependency} so the framework initializes it first.
+ *
+ * **Transparency** — `get`, `set`, `add`, `delete`, and `flush` are forwarded
+ * verbatim to the backing cache's public interface, preserving its key prefix,
+ * TTL semantics, dependency handling, and flush behavior exactly.
+ *
+ * **Change logging** — calling {@see setBackingCacheId} after an id has already been
+ * set logs a {@see \Prado\Util\TLogger::WARNING} message via
+ * {@see \Prado\Prado::log()} so unexpected runtime swaps are visible in the
+ * application log.
+ *
+ * Configure in application.xml:
+ * ```xml
+ *
+ *
+ *
+ * ```
+ *
+ * Or instantiate directly:
+ * ```php
+ * $proxy = new TCacheProxy();
+ * $proxy->setBackingCacheId('fileCache');
+ * $proxy->setPrimaryCache(true);
+ * $proxy->init(null);
+ * // All operations now delegate to the 'fileCache' module.
+ * ```
+ *
+ * @author Brad Anderson
+ * @since 4.4.0
+ */
+class TCacheProxy extends TCache implements IModuleDependency, IProxy
+{
+ use TComponentProxyTrait;
+
+ /** @var string Module ID of the backing cache; empty until configured. */
+ private string $_backingCacheId = '';
+
+ // ----------------------------------------------------------------- lifecycle
+
+ /**
+ * Declares a required dependency on the backing cache module so that
+ * {@see \Prado\TApplication} initializes it before this proxy.
+ *
+ * @param bool $isPreInit `true` when collecting for the dyPreInit pass,
+ * `false` when collecting for the init() pass (default).
+ * TCacheProxy requires its backing cache in all phases, so `$isPreInit` is not used.
+ * @return ?array dependency list,
+ * or null when no {@see getBackingCacheId BackingCacheId} has been set yet
+ */
+ public function getModuleDependencies(bool $isPreInit = false): ?array
+ {
+ $id = $this->getBackingCacheId();
+ if ($id === '') {
+ return null;
+ }
+ return [['id' => $id, 'required' => true]];
+ }
+
+ /**
+ * Initializes the proxy cache module. Throws when no
+ * {@see getBackingCacheId BackingCacheId} has been configured.
+ *
+ * @param ?\Prado\Xml\TXmlElement $config module configuration
+ * @throws TConfigurationException when {@see getBackingCacheId} is empty
+ */
+ public function init($config)
+ {
+ if ($this->getBackingCacheId() === '') {
+ throw new TConfigurationException('cacheproxy_backing_cache_id_required');
+ }
+ parent::init($config);
+ }
+
+ // ----------------------------------------------------------------- TComponentProxyTrait implementation
+
+ /**
+ * Returns the resolved backing cache, using the same lazy-resolution path
+ * as {@see getCache()}.
+ *
+ * @throws TConfigurationException when {@see getBackingCacheId} is empty
+ * @throws TConfigurationException when the referenced module does not exist
+ * @throws TConfigurationException when the referenced module is not a {@see TCache}
+ * @return ?TComponent the resolved backing cache
+ */
+ public function getProxyBacking(): ?TComponent
+ {
+ return $this->getCache();
+ }
+
+ /**
+ * Returns `true` when a {@see getBackingCacheId BackingCacheId} has been
+ * configured, enabling lazy resolution of the backing from the application
+ * module registry.
+ *
+ * @return bool whether lazy resolution is possible
+ */
+ protected function canResolveProxyBacking(): bool
+ {
+ return $this->getBackingCacheId() !== '';
+ }
+
+ // --------------------------------------------------------------- accessors
+
+ /**
+ * @return string the module ID of the backing cache
+ */
+ protected function getBackingCacheIdDirect(): string
+ {
+ return $this->_backingCacheId;
+ }
+
+ /**
+ * @param string $value the module ID to store directly
+ */
+ protected function setBackingCacheIdDirect(string $value): void
+ {
+ $this->_backingCacheId = $value;
+ }
+
+ /**
+ * @return string the module ID of the backing cache
+ */
+ public function getBackingCacheId(): string
+ {
+ return $this->getBackingCacheIdDirect();
+ }
+
+ /**
+ * Sets the module ID of the backing cache. When a non-empty id was already
+ * set and the new value differs, the change is logged at
+ * {@see \Prado\Util\TLogger::WARNING} level and the resolved cache
+ * reference is invalidated so the next operation re-resolves the module.
+ *
+ * @param string $value the module ID of the cache to proxy
+ */
+ public function setBackingCacheId(string $value): void
+ {
+ $value = TPropertyValue::ensureString($value);
+ $current = $this->getBackingCacheIdDirect();
+ if ($value === $current) {
+ return;
+ }
+ if ($current !== '') {
+ $this->detachProxy();
+ Prado::log(
+ sprintf(
+ "TCacheProxy.BackingCacheId changed from '%s' to '%s'.",
+ $current,
+ $value
+ ),
+ TLogger::WARNING,
+ 'prado.caching'
+ );
+ }
+ $this->setBackingCacheIdDirect($value);
+ $this->setCacheDirect(null);
+ }
+
+ /**
+ * Returns the lazily resolved backing cache reference, or null when not yet
+ * resolved. Narrows the trait's `?TComponent` storage to `?TCache`.
+ *
+ * @return ?TCache the backing cache, or null when not yet resolved
+ */
+ protected function getCacheDirect(): ?TCache
+ {
+ $b = $this->getProxyBackingDirect();
+ return $b instanceof TCache ? $b : null;
+ }
+
+ /**
+ * Stores the backing cache reference directly via the trait's backing field.
+ *
+ * @param ?TCache $cache the backing cache reference to store directly
+ */
+ protected function setCacheDirect(?TCache $cache): void
+ {
+ $this->setProxyBackingDirect($cache);
+ }
+
+ /**
+ * Returns the resolved backing {@see TCache} instance, resolving it lazily
+ * on first call via {@see \Prado\TApplication::getModule()}.
+ *
+ * @throws TConfigurationException when {@see getBackingCacheId} is empty
+ * @throws TConfigurationException when the referenced module does not exist
+ * @throws TConfigurationException when the referenced module is not a {@see TCache}
+ * @return TCache the backing cache module
+ */
+ public function getCache(): TCache
+ {
+ $cacheModule = $this->getCacheDirect();
+ if ($cacheModule === null) {
+ $id = $this->getBackingCacheId();
+ if ($id === '') {
+ throw new TConfigurationException('cacheproxy_backing_cache_id_required');
+ }
+ $cacheModule = $this->getApplication()->getModule($id);
+ if ($cacheModule === null) {
+ throw new TConfigurationException('cacheproxy_cache_not_found', $id);
+ }
+ if (!($cacheModule instanceof TCache)) {
+ throw new TConfigurationException('cacheproxy_invalid_cache_type', $id);
+ }
+ $this->setCacheDirect($cacheModule);
+ $this->attachProxy();
+ $cacheModule = $this->getCacheDirect();
+ }
+ return $cacheModule;
+ }
+
+ // ----------------------------------------------------------------- ICache
+
+ /**
+ * Retrieves a value from the backing cache with the specified key.
+ *
+ * @param string $id a key identifying the cached value
+ * @return false|mixed the value stored in cache, or false on miss / expiry
+ */
+ public function get($id)
+ {
+ return $this->getCache()->get($id);
+ }
+
+ /**
+ * Stores a value in the backing cache under the specified key.
+ *
+ * @param string $id the key identifying the value to be cached
+ * @param mixed $value the value to be cached
+ * @param int $expire TTL in seconds; 0 means never expire
+ * @param ?ICacheDependency $dependency optional invalidation dependency
+ * @return bool true on success
+ */
+ public function set($id, $value, $expire = 0, $dependency = null)
+ {
+ return $this->getCache()->set($id, $value, $expire, $dependency);
+ }
+
+ /**
+ * Stores a value in the backing cache only when no live entry exists.
+ *
+ * @param string $id the key identifying the value to be cached
+ * @param mixed $value the value to be cached
+ * @param int $expire TTL in seconds; 0 means never expire
+ * @param ?ICacheDependency $dependency optional invalidation dependency
+ * @return bool true when the entry was stored; false when it already existed
+ */
+ public function add($id, $value, $expire = 0, $dependency = null)
+ {
+ return $this->getCache()->add($id, $value, $expire, $dependency);
+ }
+
+ /**
+ * Deletes a value from the backing cache.
+ *
+ * @param string $id the key of the value to delete
+ * @return bool true on success
+ */
+ public function delete($id)
+ {
+ return $this->getCache()->delete($id);
+ }
+
+ /**
+ * Deletes all values from the backing cache.
+ *
+ * @return bool true on success
+ */
+ public function flush()
+ {
+ return $this->getCache()->flush();
+ }
+
+ // --------------------------------------------------------------- internals
+
+ /**
+ * Satisfies the abstract contract of {@see TCache}; never invoked because
+ * the public interface delegates directly to the backing cache.
+ *
+ * @param string $key the unique key
+ * @return false
+ */
+ protected function getValue($key)
+ {
+ return false; // @codeCoverageIgnore
+ }
+
+ /**
+ * Satisfies the abstract contract of {@see TCache}; never invoked because
+ * the public interface delegates directly to the backing cache.
+ *
+ * @param string $key the unique key
+ * @param mixed $value the value to store
+ * @param int $expire TTL in seconds
+ * @return false
+ */
+ protected function setValue($key, $value, $expire)
+ {
+ return false; // @codeCoverageIgnore
+ }
+
+ /**
+ * Satisfies the abstract contract of {@see TCache}; never invoked because
+ * the public interface delegates directly to the backing cache.
+ *
+ * @param string $key the unique key
+ * @param mixed $value the value to store
+ * @param int $expire TTL in seconds
+ * @return false
+ */
+ protected function addValue($key, $value, $expire)
+ {
+ return false; // @codeCoverageIgnore
+ }
+
+ /**
+ * Satisfies the abstract contract of {@see TCache}; never invoked because
+ * the public interface delegates directly to the backing cache.
+ *
+ * @param string $key the unique key
+ * @return false
+ */
+ protected function deleteValue($key)
+ {
+ return false; // @codeCoverageIgnore
+ }
+
+ // -------------------------------------------------- serialization
+
+ /**
+ * Excludes transient and default-valued fields from serialization. The
+ * resolved backing cache reference and the proxy event names are always
+ * excluded. The `_backingCacheId` is excluded only when empty.
+ *
+ * @param array $exprops excluded-properties list, passed by reference
+ */
+ protected function _getZappableSleepProps(&$exprops)
+ {
+ parent::_getZappableSleepProps($exprops);
+ $this->_addProxyEventNamesZappable($exprops);
+ $this->_addProxyBackingZappable($exprops);
+ if ($this->getBackingCacheIdDirect() === '') {
+ $exprops[] = "\0" . __CLASS__ . "\0_backingCacheId";
+ }
+ }
+}
diff --git a/framework/Data/TDataSourceConfigProxy.php b/framework/Data/TDataSourceConfigProxy.php
new file mode 100644
index 000000000..cb0d68b77
--- /dev/null
+++ b/framework/Data/TDataSourceConfigProxy.php
@@ -0,0 +1,293 @@
+
+ * @link https://github.com/pradosoft/prado
+ * @license https://github.com/pradosoft/prado/blob/master/LICENSE
+ */
+
+namespace Prado\Data;
+
+use Prado\Exceptions\TConfigurationException;
+use Prado\IModuleDependency;
+use Prado\IProxy;
+use Prado\Prado;
+use Prado\TComponent;
+use Prado\TComponentProxyTrait;
+use Prado\TPropertyValue;
+use Prado\Util\TLogger;
+
+/**
+ * TDataSourceConfigProxy class.
+ *
+ * TDataSourceConfigProxy is a transparent proxy that delegates every database
+ * connection operation to another {@see TDataSourceConfig} module already
+ * registered with the application. This lets a single logical "data source slot"
+ * be hot-swapped at configuration time without changing the consumers that depend
+ * on it.
+ *
+ * ## Configuration
+ *
+ * Set {@see getBackingDataSourceId BackingDataSourceId} to the module ID of the
+ * target data source config. TDataSourceConfigProxy declares that module as a
+ * required dependency so the framework initializes it first, guaranteeing the
+ * backing is available on first access.
+ *
+ * ## Transparency
+ *
+ * All property reads, property writes, and method calls are forwarded to the
+ * backing component. The critical override is {@see getDbConnection()}, which
+ * delegates directly to the backing data source's connection. `dy`- and
+ * `fx`-prefixed names are never forwarded — those belong exclusively to the
+ * behavior and global-event system.
+ *
+ * ## Event sharing
+ *
+ * {@see attachProxy()} reflects all public `on[A-Z]*` methods on the backing
+ * component — including those contributed by its behaviors — and shares their
+ * {@see \Prado\Collections\TWeakCallableCollection} handler lists with this proxy.
+ *
+ * Configure in `application.xml`:
+ * ```xml
+ *
+ *
+ *
+ *
+ *
+ * ```
+ *
+ * Or instantiate directly:
+ * ```php
+ * $proxy = new TDataSourceConfigProxy();
+ * $proxy->setBackingDataSourceId('realDb');
+ * $proxy->init(null);
+ * // All operations now delegate to the 'realDb' module.
+ * ```
+ *
+ * @author Brad Anderson
+ * @since 4.4.0
+ */
+class TDataSourceConfigProxy extends TDataSourceConfig implements IModuleDependency, IProxy
+{
+ use TComponentProxyTrait;
+
+ /** @var string Module ID of the backing data source config; empty until configured. */
+ private string $_backingDataSourceId = '';
+
+ // ----------------------------------------------------------------- lifecycle
+
+ /**
+ * Declares a required dependency on the backing data source module so that
+ * {@see \Prado\TApplication} initializes it before this proxy.
+ *
+ * @param bool $isPreInit `true` when collecting for the dyPreInit pass,
+ * `false` when collecting for the init() pass (default).
+ * TDataSourceConfigProxy requires its backing in all phases, so `$isPreInit` is not used.
+ * @return ?array dependency list,
+ * or null when no {@see getBackingDataSourceId BackingDataSourceId} has been set yet
+ */
+ public function getModuleDependencies(bool $isPreInit = false): ?array
+ {
+ $id = $this->getBackingDataSourceId();
+ if ($id === '') {
+ return null;
+ }
+ return [['id' => $id, 'required' => true]];
+ }
+
+ /**
+ * Initializes the proxy module. Throws when no
+ * {@see getBackingDataSourceId BackingDataSourceId} has been configured.
+ *
+ * @param ?\Prado\Xml\TXmlElement $config module configuration
+ * @throws TConfigurationException when {@see getBackingDataSourceId} is empty
+ */
+ public function init($config)
+ {
+ if ($this->getBackingDataSourceId() === '') {
+ throw new TConfigurationException('datasourceproxy_backing_data_source_id_required');
+ }
+ parent::init($config);
+ }
+
+ // ----------------------------------------------------------------- TComponentProxyTrait implementation
+
+ /**
+ * Returns the resolved backing data source, using the same lazy-resolution
+ * path as {@see getDataSource()}.
+ *
+ * @throws TConfigurationException when {@see getBackingDataSourceId} is empty
+ * @throws TConfigurationException when the referenced module does not exist
+ * @throws TConfigurationException when the referenced module is not a {@see TDataSourceConfig}
+ * @return ?TComponent the resolved backing data source config
+ */
+ public function getProxyBacking(): ?TComponent
+ {
+ return $this->getDataSource();
+ }
+
+ /**
+ * Returns `true` when a {@see getBackingDataSourceId BackingDataSourceId} has
+ * been configured, enabling lazy resolution of the backing from the application
+ * module registry.
+ *
+ * @return bool whether lazy resolution is possible
+ */
+ protected function canResolveProxyBacking(): bool
+ {
+ return $this->getBackingDataSourceId() !== '';
+ }
+
+ // --------------------------------------------------------------- accessors
+
+ /**
+ * @return string the module ID of the backing data source config
+ */
+ protected function getBackingDataSourceIdDirect(): string
+ {
+ return $this->_backingDataSourceId;
+ }
+
+ /**
+ * @param string $value the module ID to store directly
+ */
+ protected function setBackingDataSourceIdDirect(string $value): void
+ {
+ $this->_backingDataSourceId = $value;
+ }
+
+ /**
+ * @return string the module ID of the backing data source config
+ */
+ public function getBackingDataSourceId(): string
+ {
+ return $this->getBackingDataSourceIdDirect();
+ }
+
+ /**
+ * Sets the module ID of the backing data source config. When a non-empty ID was
+ * already set and the new value differs, the change is logged at
+ * {@see \Prado\Util\TLogger::WARNING} level and the resolved component reference
+ * is invalidated so the next operation re-resolves the module.
+ *
+ * @param string $value the module ID of the data source config to proxy
+ */
+ public function setBackingDataSourceId(string $value): void
+ {
+ $value = TPropertyValue::ensureString($value);
+ $current = $this->getBackingDataSourceIdDirect();
+ if ($value === $current) {
+ return;
+ }
+ if ($current !== '') {
+ $this->detachProxy();
+ Prado::log(
+ sprintf(
+ "TDataSourceConfigProxy.BackingDataSourceId changed from '%s' to '%s'.",
+ $current,
+ $value
+ ),
+ TLogger::WARNING,
+ 'prado.data'
+ );
+ }
+ $this->setBackingDataSourceIdDirect($value);
+ $this->setDataSourceDirect(null);
+ }
+
+ /**
+ * Returns the lazily resolved backing data source config reference, or null
+ * when not yet resolved. Narrows the trait's `?TComponent` storage to
+ * `?TDataSourceConfig`.
+ *
+ * @return ?TDataSourceConfig the backing data source config, or null when not yet resolved
+ */
+ protected function getDataSourceDirect(): ?TDataSourceConfig
+ {
+ $b = $this->getProxyBackingDirect();
+ return $b instanceof TDataSourceConfig ? $b : null;
+ }
+
+ /**
+ * Stores the backing data source config reference directly via the trait's
+ * backing field.
+ *
+ * @param ?TDataSourceConfig $value the backing data source config to store directly
+ */
+ protected function setDataSourceDirect(?TDataSourceConfig $value): void
+ {
+ $this->setProxyBackingDirect($value);
+ }
+
+ /**
+ * Returns the resolved backing {@see TDataSourceConfig} instance, resolving it
+ * lazily on first call via {@see \Prado\TApplication::getModule()}.
+ *
+ * @throws TConfigurationException when {@see getBackingDataSourceId} is empty
+ * @throws TConfigurationException when the referenced module does not exist
+ * @throws TConfigurationException when the referenced module is not a {@see TDataSourceConfig}
+ * @return TDataSourceConfig the backing data source config module
+ */
+ public function getDataSource(): TDataSourceConfig
+ {
+ $ds = $this->getDataSourceDirect();
+ if ($ds === null) {
+ $id = $this->getBackingDataSourceId();
+ if ($id === '') {
+ throw new TConfigurationException('datasourceproxy_backing_data_source_id_required');
+ }
+ $module = $this->getApplication()->getModule($id);
+ if ($module === null) {
+ throw new TConfigurationException('datasourceproxy_data_source_not_found', $id);
+ }
+ if (!($module instanceof TDataSourceConfig)) {
+ throw new TConfigurationException('datasourceproxy_invalid_data_source_type', $id);
+ }
+ $this->setDataSourceDirect($module);
+ $this->attachProxy();
+ $ds = $this->getDataSourceDirect();
+ }
+ return $ds;
+ }
+
+ // ----------------------------------------------------------------- TDataSourceConfig overrides
+
+ /**
+ * Returns the database connection from the backing data source config.
+ *
+ * Overrides the parent implementation so all consumers that call
+ * `getDbConnection()` through this proxy receive the backing module's
+ * connection rather than one created by the proxy itself.
+ *
+ * @throws TConfigurationException when the backing module cannot be resolved
+ * @return TDbConnection the database connection from the backing data source
+ */
+ public function getDbConnection(): TDbConnection
+ {
+ return $this->getDataSource()->getDbConnection();
+ }
+
+ // -------------------------------------------------- serialization
+
+ /**
+ * Excludes transient and default-valued fields from serialization. The
+ * resolved backing data source reference is excluded because it is re-resolved
+ * from the application module registry after deserialization.
+ *
+ * @param array $exprops excluded-properties list, passed by reference
+ */
+ protected function _getZappableSleepProps(&$exprops)
+ {
+ parent::_getZappableSleepProps($exprops);
+ $this->_addProxyEventNamesZappable($exprops);
+ $this->_addProxyBackingZappable($exprops);
+ if ($this->getBackingDataSourceIdDirect() === '') {
+ $exprops[] = "\0" . __CLASS__ . "\0_backingDataSourceId";
+ }
+ }
+}
diff --git a/framework/IProxy.php b/framework/IProxy.php
new file mode 100644
index 000000000..3b4d752cd
--- /dev/null
+++ b/framework/IProxy.php
@@ -0,0 +1,39 @@
+
+ * @link https://github.com/pradosoft/prado
+ * @license https://github.com/pradosoft/prado/blob/master/LICENSE
+ */
+
+namespace Prado;
+
+/**
+ * IProxy interface
+ *
+ * IProxy is a marker interface implemented by every transparent-proxy class in
+ * the framework. It provides a single identity for the proxy family so that
+ * consumers can test whether an object is a proxy without knowing the concrete
+ * proxy type. Every IProxy implementation also uses {@see TComponentProxyTrait},
+ * which supplies `getProxyBacking()`:
+ *
+ * ```php
+ * if ($module instanceof IProxy) {
+ * $real = $module->getProxyBacking();
+ * }
+ * ```
+ *
+ * Implementations:
+ * - {@see TComponentProxy} — wraps any {@see TComponent} set directly.
+ * - {@see TModuleProxy} — wraps any {@see TModule} registered with the application.
+ * - {@see \Prado\Caching\TCacheProxy} — wraps a {@see \Prado\Caching\TCache} module.
+ * - {@see \Prado\Data\TDataSourceConfigProxy} — wraps a {@see \Prado\Data\TDataSourceConfig} module.
+ *
+ * @author Brad Anderson
+ * @since 4.4.0
+ */
+interface IProxy
+{
+}
diff --git a/framework/TComponentProxy.php b/framework/TComponentProxy.php
new file mode 100644
index 000000000..ca31de008
--- /dev/null
+++ b/framework/TComponentProxy.php
@@ -0,0 +1,170 @@
+
+ * @link https://github.com/pradosoft/prado
+ * @license https://github.com/pradosoft/prado/blob/master/LICENSE
+ */
+
+namespace Prado;
+
+use Prado\Exceptions\TConfigurationException;
+use Prado\Util\TLogger;
+
+/**
+ * TComponentProxy class.
+ *
+ * TComponentProxy is a transparent proxy that delegates every property access,
+ * method call, and event operation to any {@see TComponent} injected directly
+ * via {@see setBackingComponent BackingComponent}. This lets a single logical
+ * "component slot" be swapped at runtime without changing the consumers that
+ * depend on it.
+ *
+ * Unlike {@see TModuleProxy}, TComponentProxy does not look up the backing by a
+ * module ID and does not extend {@see TModule} — it is a pure {@see TComponent}
+ * subclass suitable for lightweight, non-module use.
+ *
+ * ## Transparency
+ *
+ * All property reads, property writes, method calls, `isset` checks, and `unset`
+ * operations are forwarded to the backing component. The proxy is
+ * indistinguishable from the component for all practical purposes. `dy`- and
+ * `fx`-prefixed names are never forwarded — those belong exclusively to the
+ * behavior and global-event system.
+ *
+ * ## Event forwarding
+ *
+ * {@see attachProxy()} reflects all public `on[A-Z]*` methods on the backing
+ * component — including those contributed by its behaviors — and registers a
+ * forwarder closure on each backing event. When the backing raises the event, the
+ * forwarder calls through the proxy's own independent handler list. Handlers
+ * registered on the proxy survive a backing swap: {@see detachProxy()} removes
+ * the old forwarder, and {@see attachProxy()} on the new backing installs a new
+ * one pointing at the same proxy list.
+ *
+ * ## Usage
+ *
+ * ```php
+ * $proxy = new TComponentProxy();
+ * $proxy->setBackingComponent($realComponent);
+ * // All operations now delegate to $realComponent.
+ * ```
+ *
+ * To proxy a registered application module use {@see TModuleProxy} instead.
+ *
+ * @author Brad Anderson
+ * @since 4.4.0
+ */
+class TComponentProxy extends TComponent implements IProxy
+{
+ use TComponentProxyTrait;
+
+ // --------------------------------------------------------------- TComponentProxyTrait implementation
+
+ /**
+ * Returns the backing component set via {@see setBackingComponent}.
+ *
+ * @throws TConfigurationException when no backing component has been set
+ * @return ?TComponent the backing component
+ */
+ public function getProxyBacking(): ?TComponent
+ {
+ return $this->getBackingComponent();
+ }
+
+ // --------------------------------------------------------------- accessors
+
+ /**
+ * @return ?TComponent the backing component reference stored directly, or null
+ */
+ protected function getBackingComponentDirect(): ?TComponent
+ {
+ return $this->getProxyBackingDirect();
+ }
+
+ /**
+ * @param ?TComponent $value the backing component to store directly
+ */
+ protected function setBackingComponentDirect(?TComponent $value): void
+ {
+ $this->setProxyBackingDirect($value);
+ }
+
+ /**
+ * Returns the backing component. Throws when no backing has been set.
+ *
+ * @throws TConfigurationException when no backing component has been set
+ * @return TComponent the backing component
+ */
+ public function getBackingComponent(): TComponent
+ {
+ $b = $this->getBackingComponentDirect();
+ if ($b === null) {
+ throw new TConfigurationException('componentproxy_backing_component_required');
+ }
+ return $b;
+ }
+
+ /**
+ * Sets the backing component directly. When the backing is changed (a
+ * non-null backing is replaced), the existing proxy event attachment is
+ * detached and a {@see \Prado\Util\TLogger::WARNING} is logged so that
+ * unexpected runtime swaps are visible in the application log.
+ *
+ * After setting, {@see attachProxy()} must be called if event forwarding with
+ * the new backing is required.
+ *
+ * @param TComponent $value the component to use as the backing
+ */
+ public function setBackingComponent(TComponent $value): void
+ {
+ $current = $this->getBackingComponentDirect();
+ if ($current === $value) {
+ return;
+ }
+ if ($current !== null) {
+ $this->detachProxy();
+ Prado::log(
+ sprintf(
+ "TComponentProxy.BackingComponent changed from '%s' to '%s'.",
+ get_class($current),
+ get_class($value)
+ ),
+ TLogger::WARNING,
+ 'prado.component'
+ );
+ }
+ $this->setBackingComponentDirect($value);
+ }
+
+ // -------------------------------------------------- serialization
+
+ /**
+ * Excludes the `_proxyBacking` field from serialization. Delegates to
+ * {@see TComponentProxyTrait::_addProxyBackingZappable()}. Provided for
+ * subclasses whose backing is lazily resolved and must not be carried
+ * across process boundaries.
+ *
+ * @param array $exprops excluded-properties list, passed by reference
+ */
+ final protected function _zappableExcludeBackingComponent(array &$exprops): void
+ {
+ $this->_addProxyBackingZappable($exprops);
+ }
+
+ /**
+ * Excludes `_proxyEventNames` from serialization (always transient).
+ * The `_proxyBacking` reference is preserved across serialization for
+ * TComponentProxy because there is no module registry to re-resolve it from.
+ *
+ * @param array $exprops excluded-properties list, passed by reference
+ */
+ protected function _getZappableSleepProps(&$exprops)
+ {
+ parent::_getZappableSleepProps($exprops);
+ $this->_addProxyEventNamesZappable($exprops);
+ // _proxyBacking is intentionally preserved: no module registry to re-resolve from.
+ }
+}
diff --git a/framework/TComponentProxyTrait.php b/framework/TComponentProxyTrait.php
new file mode 100644
index 000000000..b2acab71f
--- /dev/null
+++ b/framework/TComponentProxyTrait.php
@@ -0,0 +1,555 @@
+
+ * @link https://github.com/pradosoft/prado
+ * @license https://github.com/pradosoft/prado/blob/master/LICENSE
+ */
+
+namespace Prado;
+
+/**
+ * TComponentProxyTrait trait
+ *
+ * TComponentProxyTrait provides the shared transparent-proxy logic used by every
+ * {@see IProxy} implementation in the framework. It supplies event-sharing
+ * ({@see attachProxy}/{@see detachProxy}), property and method dispatch
+ * ({@see __call()}, {@see __get()}, {@see __set()}, {@see __isset()},
+ * {@see __unset()}), type-transparency ({@see isa()}), and serialization helpers.
+ *
+ * ## Required abstract interface
+ *
+ * Each class using this trait must implement exactly one method:
+ *
+ * | Method | Purpose |
+ * |--------|---------|
+ * | `getProxyBacking(): ?TComponent` | Returns the backing component, performing lazy resolution when needed. May return `null` or throw {@see \Prado\Exceptions\TConfigurationException} when the backing is not configured or not found. |
+ *
+ * Optionally override:
+ *
+ * | Method | Purpose |
+ * |--------|---------|
+ * | `canResolveProxyBacking(): bool` | Returns `true` when the backing can be lazily resolved (e.g., a module ID is configured). The default returns `false`. Override in classes that support lazy resolution from the application module registry. |
+ *
+ * ## Backing storage
+ *
+ * The trait owns `$_proxyBacking` — a `?TComponent` field shared by all proxy
+ * classes. Domain-specific proxies (e.g. {@see \Prado\Caching\TCacheProxy}) wrap
+ * {@see getProxyBackingDirect()} and {@see setProxyBackingDirect()} in typed
+ * accessors that narrow the return type via `instanceof`.
+ *
+ * ## Architecture note
+ *
+ * All dispatch methods first call {@see getProxyBackingDirect()} — a zero-cost
+ * field read. Only when the result is `null` AND {@see canResolveProxyBacking()}
+ * returns `true` is the potentially expensive {@see getProxyBacking()} called.
+ * This ensures that hot-path property/method access on a fully resolved proxy
+ * incurs only one field read of overhead.
+ *
+ * @author Brad Anderson
+ * @since 4.4.0
+ */
+trait TComponentProxyTrait
+{
+ /**
+ * @var ?TComponent The lazily resolved backing component. Owned by this trait
+ * so that all proxy classes share a single field rather than each declaring
+ * their own private backing reference.
+ */
+ private ?TComponent $_proxyBacking = null;
+
+ /**
+ * @var array Map of lowercase event name to
+ * a two-element tuple `[originalName, forwarderClosure]` for each event shared
+ * with the backing component. Only events that the proxy class does not itself own
+ * are stored here. The proxy owns an independent
+ * {@see \Prado\Collections\TWeakCallableCollection} for each such event (held in
+ * `$this->_e[$lname]`); the forwarder closure is registered on the backing's
+ * event and, when the backing raises the event, iterates the proxy's own list.
+ * This lets handler registrations on the proxy survive a backing swap: on swap,
+ * the old forwarder is detached from the old backing and a new forwarder pointing
+ * to the same proxy collection is attached to the new backing.
+ */
+ private array $_proxyEventNames = [];
+
+ // ----------------------------------------------------------------- storage
+
+ /**
+ * Returns the cached backing component reference without triggering lazy
+ * resolution. Returns `null` when the backing has not yet been resolved or
+ * was cleared (e.g. after {@see __clone()}).
+ *
+ * @return ?TComponent the resolved backing, or null when not yet available
+ */
+ protected function getProxyBackingDirect(): ?TComponent
+ {
+ return $this->_proxyBacking;
+ }
+
+ /**
+ * Stores the resolved backing component reference directly.
+ *
+ * @param ?TComponent $value the backing component to store, or null to clear
+ */
+ protected function setProxyBackingDirect(?TComponent $value): void
+ {
+ $this->_proxyBacking = $value;
+ }
+
+ /**
+ * Clears the cached backing component reference. Called by {@see __clone()}
+ * so that the clone re-resolves its backing on first use.
+ */
+ protected function clearProxyBacking(): void
+ {
+ $this->_proxyBacking = null;
+ }
+
+ // ----------------------------------------------------------------- abstract interface
+
+ /**
+ * Returns the resolved backing {@see TComponent}, performing lazy resolution
+ * when the backing has not yet been resolved. Implementations may return
+ * `null` when no backing is available, or throw
+ * {@see \Prado\Exceptions\TConfigurationException} when the backing is
+ * required but missing or misconfigured.
+ *
+ * @throws \Prado\Exceptions\TConfigurationException when the backing is
+ * required but not configured or cannot be found
+ * @return ?TComponent the resolved backing component, or null when unavailable
+ */
+ abstract public function getProxyBacking(): ?TComponent;
+
+ /**
+ * Returns `true` when the backing can be lazily resolved — for example, when
+ * a backing module ID has been configured but the module has not yet been
+ * looked up. The default implementation returns `false`; override in classes
+ * that support lazy resolution from the application module registry.
+ *
+ * @return bool whether lazy resolution is possible
+ */
+ protected function canResolveProxyBacking(): bool
+ {
+ return false;
+ }
+
+ // ----------------------------------------------------------------- private helpers
+
+ /**
+ * Returns the backing component, performing lazy resolution when needed.
+ * Returns `null` when no backing is available and lazy resolution is either
+ * not supported or not yet possible. Does not throw.
+ *
+ * @return ?TComponent the resolved backing, or null
+ */
+ private function resolveProxyBacking(): ?TComponent
+ {
+ $backing = $this->getProxyBackingDirect();
+ if ($backing === null && $this->canResolveProxyBacking()) {
+ $backing = $this->getProxyBacking();
+ }
+ return $backing;
+ }
+
+ // ----------------------------------------------------------------- event sharing
+
+ /**
+ * Reflects all public `on[A-Z]*` methods on the backing component — including
+ * those contributed by behaviors attached to it — and wires a forwarder
+ * closure onto each backing event so that when the backing raises the event,
+ * the forwarder iterates the proxy's **own** independent handler list. Any
+ * previous attachment is detached first.
+ *
+ * ## Event isolation
+ *
+ * The proxy maintains its own {@see \Prado\Collections\TWeakCallableCollection}
+ * for each proxied event (stored in `$this->_e[$lname]`). A static forwarder
+ * closure — registered on the backing via
+ * {@see \Prado\TComponent::attachEventHandler} — holds a reference to that
+ * collection and calls through it when the backing fires. This means:
+ *
+ * - Handlers registered on the proxy are in the **proxy's** list, completely
+ * independent of the backing's list.
+ * - If the backing is swapped, {@see detachProxy()} removes the old forwarder,
+ * the proxy's list is preserved, and re-attaching injects a new forwarder
+ * onto the new backing — existing handler registrations survive seamlessly.
+ * - Two distinct proxy instances sharing the same backing class each have
+ * entirely separate handler lists.
+ *
+ * When the proxy class itself also owns an event that the backing exposes, the
+ * forwarder is still registered. The `$sender` argument differentiates the two
+ * origins: when the backing raises the event the forwarder passes the backing as
+ * `$sender`; when the proxy raises the event directly it passes itself as
+ * `$sender`. The same handler list in `$this->_e[$lname]` serves both paths.
+ *
+ * Discovery is a two-pass scan via {@see \Prado\TComponentReflection::getEvents()}:
+ * first the backing class itself, then every enabled behavior attached to it.
+ * Each candidate is accepted only when {@see \Prado\TComponent::hasEvent}
+ * confirms the backing exposes it. Event-name comparison is case-insensitive
+ * throughout.
+ *
+ * The backing must already be resolved (i.e. {@see getProxyBackingDirect()}
+ * must return non-null) before invoking this method. It is called
+ * automatically by {@see getProxyBacking()} on first lazy resolution.
+ */
+ public function attachProxy(): void
+ {
+ $this->detachProxy();
+ $backing = $this->getProxyBackingDirect();
+ if ($backing === null) {
+ return;
+ }
+ $candidates = [];
+ foreach (array_keys((new TComponentReflection($backing))->getEvents()) as $name) {
+ $candidates[$name] = true;
+ }
+ foreach ($backing->getBehaviors() as $behavior) {
+ if (!$behavior->getEnabled()) {
+ continue;
+ }
+ foreach (array_keys((new TComponentReflection($behavior))->getEvents()) as $name) {
+ $candidates[$name] = true;
+ }
+ }
+ foreach ($candidates as $name => $_) {
+ if (!$backing->hasEvent($name)) {
+ continue;
+ }
+ $lname = strtolower($name);
+ // Ensure the proxy has a persistent handler collection for this event.
+ // Reuse an existing collection across backing swaps so that any handlers
+ // registered while no backing was attached survive the re-attach.
+ if (!isset($this->_e[$lname])) {
+ $this->_e[$lname] = new \Prado\Collections\TWeakCallableCollection();
+ }
+ // Capture the proxy's collection object (not $this) to avoid a
+ // circular reference between the closure and _proxyEventNames.
+ $proxyList = $this->_e[$lname];
+ // Forwarder: registered on the backing; when the backing raises this
+ // event, calls through the proxy's own independent handler list.
+ $forwarder = static function ($sender, $param) use ($proxyList): void {
+ foreach ($proxyList as $handler) {
+ call_user_func($handler, $sender, $param);
+ }
+ };
+ $backing->attachEventHandler($name, $forwarder);
+ $this->_proxyEventNames[$lname] = [$name, $forwarder];
+ }
+ }
+
+ /**
+ * Detaches all forwarder closures that {@see attachProxy()} registered on the
+ * backing component, severing the event forwarding path. The proxy's own
+ * handler collections (`$this->_e[$lname]`) are intentionally preserved so
+ * that existing handler registrations survive a backing swap.
+ */
+ public function detachProxy(): void
+ {
+ $backing = $this->getProxyBackingDirect();
+ foreach ($this->_proxyEventNames as $lname => [$name, $forwarder]) {
+ if ($backing !== null && $backing->hasEvent($name)) {
+ $backing->detachEventHandler($name, $forwarder);
+ }
+ }
+ $this->_proxyEventNames = [];
+ }
+
+ /**
+ * Determines whether an event is defined on this proxy.
+ *
+ * Extends the parent check to also return `true` for event names that were
+ * injected by {@see attachProxy()} from backing-component behaviors — these
+ * events are stored in `$this->_proxyEventNames` but have no corresponding
+ * method on the proxy class itself, so the parent `method_exists` check alone
+ * would miss them.
+ *
+ * @param string $name the event name
+ * @return bool whether the event is defined
+ */
+ public function hasEvent($name): bool
+ {
+ if (parent::hasEvent($name)) {
+ return true;
+ }
+ return isset($this->_proxyEventNames[strtolower($name)]);
+ }
+
+ /**
+ * Returns the handler list for a proxy-injected event, or delegates to the
+ * parent implementation for events defined directly on this class.
+ *
+ * {@see \Prado\TComponent::getEventHandlers} gates `on*` events through
+ * `method_exists`, which misses events injected by {@see attachProxy()} from
+ * backing-component behaviors. This override intercepts those names and
+ * returns the already-shared handler list directly.
+ *
+ * @param mixed $name the event name
+ * @throws \Prado\Exceptions\TInvalidOperationException if the event is undefined
+ * @return \Prado\Collections\TWeakCallableCollection list of attached handlers
+ */
+ public function getEventHandlers($name)
+ {
+ $lname = strtolower($name);
+ if (isset($this->_proxyEventNames[$lname])) {
+ return $this->_e[$lname];
+ }
+ return parent::getEventHandlers($name);
+ }
+
+ // ----------------------------------------------------------------- isa override
+
+ /**
+ * Extends the parent {@see \Prado\TComponent::isa()} check to also return
+ * `true` when the resolved backing component is an instance of `$class`.
+ *
+ * When the backing has not yet been resolved and lazy resolution is possible
+ * ({@see canResolveProxyBacking()} returns `true`), this method resolves it
+ * via {@see getProxyBacking()} — consistent with how the dispatch methods
+ * resolve the backing on demand.
+ *
+ * @param mixed|string $class class name or object to test against
+ * @return bool `true` when this proxy or its backing component is an instance of `$class`
+ */
+ public function isa($class)
+ {
+ if (parent::isa($class)) {
+ return true;
+ }
+ $backing = $this->resolveProxyBacking();
+ return $backing !== null && $backing->isa($class);
+ }
+
+ // ----------------------------------------------------------------- dispatch
+
+ /**
+ * Forwards unrecognized method calls to the backing component.
+ *
+ * `dy`-prefixed names are never forwarded — those belong exclusively to
+ * the behavior-dispatch system. For all other names the backing component
+ * is consulted first; if it does not expose the method, the call falls
+ * through to {@see \Prado\TComponent::__call()}, which handles JS-property
+ * variants, behaviors, and raises
+ * {@see \Prado\Exceptions\TUnknownMethodException} for truly undefined
+ * methods.
+ *
+ * @param string $method the method name
+ * @param array $args the method arguments
+ * @return mixed the return value of the forwarded call
+ */
+ public function __call($method, $args)
+ {
+ $prefix = substr($method, 0, 2);
+ if ($prefix !== 'dy') {
+ $backing = $this->resolveProxyBacking();
+ if ($backing !== null && Prado::method_visible($backing, $method)) {
+ return $backing->$method(...$args);
+ }
+ }
+ return parent::__call($method, $args);
+ }
+
+ /**
+ * Forwards property-read access to the backing component when the property
+ * is not defined on the proxy itself.
+ *
+ * Properties owned by the proxy (including inherited ones), `on`-events, and
+ * `fx`-events are handled by {@see \Prado\TComponent::__get()} without
+ * consulting the backing component. Behaviors are checked last, after the
+ * backing component, via the parent fallback.
+ *
+ * @param string $name the property name
+ * @throws \Prado\Exceptions\TInvalidOperationException when neither the
+ * proxy, the backing component, nor any attached behavior defines the
+ * property
+ * @return mixed the property value
+ */
+ public function __get($name)
+ {
+ if (Prado::method_visible($this, 'get' . $name)
+ || Prado::method_visible($this, 'getjs' . $name)
+ || (strncasecmp($name, 'on', 2) === 0 && method_exists($this, $name))
+ || strncasecmp($name, 'fx', 2) === 0
+ ) {
+ return parent::__get($name);
+ }
+ // Proxy-forwarded events: return the proxy's own handler collection.
+ if (strncasecmp($name, 'on', 2) === 0 && isset($this->_proxyEventNames[strtolower($name)])) {
+ return $this->_e[strtolower($name)];
+ }
+ $backing = $this->resolveProxyBacking();
+ if ($backing !== null && Prado::method_visible($backing, 'get' . $name)) {
+ return $backing->{'get' . $name}();
+ }
+ return parent::__get($name);
+ }
+
+ /**
+ * Forwards property-write access to the backing component when the property
+ * is not defined on the proxy itself.
+ *
+ * Read-only properties defined on the proxy (a getter exists but no setter)
+ * are never forwarded — the proxy's constraint is preserved and a
+ * {@see \Prado\Exceptions\TInvalidOperationException} is raised. Behaviors
+ * are checked last, after the backing component, via the parent fallback.
+ *
+ * @param string $name the property name
+ * @param mixed $value the property value
+ * @throws \Prado\Exceptions\TInvalidOperationException when the property is
+ * read-only on the proxy, or undefined on both proxy and backing component
+ */
+ public function __set($name, $value)
+ {
+ if (Prado::method_visible($this, 'set' . $name)
+ || Prado::method_visible($this, 'setjs' . $name)
+ || Prado::method_visible($this, 'get' . $name)
+ || Prado::method_visible($this, 'getjs' . $name)
+ || (strncasecmp($name, 'on', 2) === 0 && method_exists($this, $name))
+ || strncasecmp($name, 'fx', 2) === 0
+ ) {
+ return parent::__set($name, $value);
+ }
+ // Handler lists shared via attachProxy() — attachEventHandler uses our getEventHandlers override.
+ if (strncasecmp($name, 'on', 2) === 0 && isset($this->_proxyEventNames[strtolower($name)])) {
+ return $this->attachEventHandler($name, $value);
+ }
+ $backing = $this->resolveProxyBacking();
+ if ($backing !== null && Prado::method_visible($backing, 'set' . $name)) {
+ return $backing->{'set' . $name}($value);
+ }
+ return parent::__set($name, $value);
+ }
+
+ /**
+ * Forwards `isset()` checks to the backing component when the property is
+ * not defined on the proxy itself. Returns `true` when the backing getter
+ * returns a non-null value.
+ *
+ * @param string $name the property name
+ * @return bool whether the property is considered set
+ */
+ public function __isset($name)
+ {
+ if (Prado::method_visible($this, 'get' . $name)
+ || Prado::method_visible($this, 'getjs' . $name)
+ || (strncasecmp($name, 'on', 2) === 0 && method_exists($this, $name))
+ || strncasecmp($name, 'fx', 2) === 0
+ ) {
+ return parent::__isset($name);
+ }
+ // Proxy-forwarded events: isset means at least one handler in the proxy's list.
+ if (strncasecmp($name, 'on', 2) === 0) {
+ $lname = strtolower($name);
+ if (isset($this->_proxyEventNames[$lname])) {
+ return $this->_e[$lname]->getCount() > 0;
+ }
+ }
+ $backing = $this->resolveProxyBacking();
+ if ($backing !== null && Prado::method_visible($backing, 'get' . $name)) {
+ return $backing->{'get' . $name}() !== null;
+ }
+ return parent::__isset($name);
+ }
+
+ /**
+ * Forwards `unset()` to the backing component (by calling the setter with
+ * `null`) when the property is not defined on the proxy itself.
+ *
+ * Read-only properties defined on the proxy are not forwarded.
+ *
+ * @param string $name the property name
+ * @throws \Prado\Exceptions\TInvalidOperationException when the property is
+ * read-only on the proxy
+ */
+ public function __unset($name)
+ {
+ if (Prado::method_visible($this, 'set' . $name)
+ || Prado::method_visible($this, 'setjs' . $name)
+ || Prado::method_visible($this, 'get' . $name)
+ || Prado::method_visible($this, 'getjs' . $name)
+ || (strncasecmp($name, 'on', 2) === 0 && method_exists($this, $name))
+ || strncasecmp($name, 'fx', 2) === 0
+ ) {
+ parent::__unset($name);
+ return;
+ }
+ // Proxy-forwarded events: clear the proxy's own handler collection.
+ if (strncasecmp($name, 'on', 2) === 0) {
+ $lname = strtolower($name);
+ if (isset($this->_proxyEventNames[$lname])) {
+ $this->_e[$lname]->clear();
+ return;
+ }
+ }
+ $backing = $this->resolveProxyBacking();
+ if ($backing !== null && Prado::method_visible($backing, 'set' . $name)) {
+ $backing->{'set' . $name}(null);
+ return;
+ }
+ parent::__unset($name);
+ }
+
+ // -------------------------------------------------- cloning / serialization
+
+ /**
+ * Clears the proxy attachment and the lazily resolved backing reference so
+ * that the clone re-resolves its backing on first use, then delegates to
+ * {@see \Prado\TComponent::__clone()} to re-attach behaviors.
+ */
+ public function __clone()
+ {
+ $this->detachProxy();
+ $this->clearProxyBacking();
+ parent::__clone();
+ }
+
+ /**
+ * Appends the `_proxyEventNames` field to the serialization exclusion list.
+ * Call this from {@see _getZappableSleepProps()} in each class that uses the
+ * trait:
+ *
+ * ```php
+ * protected function _getZappableSleepProps(&$exprops)
+ * {
+ * parent::_getZappableSleepProps($exprops);
+ * $this->_addProxyEventNamesZappable($exprops);
+ * // ... class-specific exclusions ...
+ * }
+ * ```
+ *
+ * @param array $exprops excluded-properties list, passed by reference
+ */
+ protected function _addProxyEventNamesZappable(array &$exprops): void
+ {
+ $exprops[] = "\0" . __CLASS__ . "\0_proxyEventNames";
+ }
+
+ /**
+ * Appends the `_proxyBacking` field to the serialization exclusion list.
+ * Call this from {@see _getZappableSleepProps()} in proxy classes whose
+ * backing is lazily re-resolved from the application module registry after
+ * deserialization (i.e. all ID-based proxies: {@see \Prado\TModuleProxy},
+ * {@see \Prado\Caching\TCacheProxy},
+ * {@see \Prado\Data\TDataSourceConfigProxy}).
+ *
+ * Do **not** call this in {@see \Prado\TComponentProxy}: its backing is
+ * injected directly and has no module-registry source to re-resolve from.
+ *
+ * ```php
+ * protected function _getZappableSleepProps(&$exprops)
+ * {
+ * parent::_getZappableSleepProps($exprops);
+ * $this->_addProxyEventNamesZappable($exprops);
+ * $this->_addProxyBackingZappable($exprops);
+ * // ... class-specific exclusions ...
+ * }
+ * ```
+ *
+ * @param array $exprops excluded-properties list, passed by reference
+ */
+ protected function _addProxyBackingZappable(array &$exprops): void
+ {
+ $exprops[] = "\0" . __CLASS__ . "\0_proxyBacking";
+ }
+}
diff --git a/framework/TModuleProxy.php b/framework/TModuleProxy.php
new file mode 100644
index 000000000..2be99aca2
--- /dev/null
+++ b/framework/TModuleProxy.php
@@ -0,0 +1,267 @@
+
+ * @link https://github.com/pradosoft/prado
+ * @license https://github.com/pradosoft/prado/blob/master/LICENSE
+ */
+
+namespace Prado;
+
+use Prado\Exceptions\TConfigurationException;
+use Prado\Util\TLogger;
+
+/**
+ * TModuleProxy class.
+ *
+ * TModuleProxy is a transparent proxy that delegates every property access,
+ * method call, and event operation to another {@see TModule} registered with
+ * the application as a module. This lets a single logical "component slot"
+ * (e.g. a service or manager identified by a fixed module ID) be hot-swapped
+ * at configuration time without changing the consumers that depend on it.
+ *
+ * TModuleProxy extends {@see TModule} directly and uses {@see TComponentProxyTrait},
+ * adding:
+ * - A {@see getBackingComponentId BackingComponentId} property that resolves
+ * the backing from the application module registry on first use.
+ * - {@see \Prado\IModuleDependency} support so the framework initializes the
+ * backing module before this proxy.
+ *
+ * ## Configuration
+ *
+ * Set {@see getBackingComponentId BackingComponentId} to the module ID of the
+ * target component. TModuleProxy declares that module as a required dependency
+ * so the framework initializes it first, guaranteeing the backing is available
+ * on first access.
+ *
+ * ## Transparency
+ *
+ * All property reads, property writes, method calls, `isset` checks, and `unset`
+ * operations are forwarded to the backing component. `dy`- and `fx`-prefixed
+ * names are never forwarded — those belong exclusively to the behavior and
+ * global-event system.
+ *
+ * ## Event sharing
+ *
+ * {@see attachProxy()} reflects all public `on[A-Z]*` methods on the backing
+ * component — including those contributed by its behaviors — and shares their
+ * {@see \Prado\Collections\TWeakCallableCollection} handler lists with this
+ * proxy.
+ *
+ * Configure in `application.xml`:
+ * ```xml
+ *
+ *
+ *
+ * ```
+ *
+ * Or instantiate directly:
+ * ```php
+ * $proxy = new TModuleProxy();
+ * $proxy->setBackingComponentId('myRealService');
+ * $proxy->init(null);
+ * // All operations now delegate to the 'myRealService' module.
+ * ```
+ *
+ * To proxy any non-module component use {@see TComponentProxy} instead.
+ *
+ * @author Brad Anderson
+ * @since 4.4.0
+ */
+class TModuleProxy extends TModule implements IModuleDependency, IProxy
+{
+ use TComponentProxyTrait;
+
+ /** @var string Module ID of the backing component; empty until configured. */
+ private string $_backingComponentId = '';
+
+ // ----------------------------------------------------------------- lifecycle
+
+ /**
+ * Declares a required dependency on the backing component module so that
+ * {@see \Prado\TApplication} initializes it before this proxy.
+ *
+ * @param bool $isPreInit `true` when collecting for the dyPreInit pass,
+ * `false` when collecting for the init() pass (default).
+ * TModuleProxy requires its backing in all phases, so `$isPreInit` is not used.
+ * @return ?array dependency list,
+ * or null when no {@see getBackingComponentId BackingComponentId} has been set yet
+ */
+ public function getModuleDependencies(bool $isPreInit = false): ?array
+ {
+ $id = $this->getBackingComponentId();
+ if ($id === '') {
+ return null;
+ }
+ return [['id' => $id, 'required' => true]];
+ }
+
+ /**
+ * Initializes the proxy module. Throws when no
+ * {@see getBackingComponentId BackingComponentId} has been configured.
+ *
+ * @param ?\Prado\Xml\TXmlElement $config module configuration
+ * @throws TConfigurationException when {@see getBackingComponentId} is empty
+ */
+ public function init($config)
+ {
+ if ($this->getBackingComponentId() === '') {
+ throw new TConfigurationException('componentproxy_backing_component_id_required');
+ }
+ parent::init($config);
+ }
+
+ // ----------------------------------------------------------------- TComponentProxyTrait implementation
+
+ /**
+ * Returns `true` when a {@see getBackingComponentId BackingComponentId} has
+ * been configured, enabling lazy resolution of the backing from the
+ * application module registry.
+ *
+ * @return bool whether lazy resolution is possible
+ */
+ protected function canResolveProxyBacking(): bool
+ {
+ return $this->_backingComponentId !== '';
+ }
+
+ /**
+ * Returns the resolved backing component, lazily resolving it from the
+ * application module registry on first call.
+ *
+ * @throws TConfigurationException when {@see getBackingComponentId} is empty
+ * @throws TConfigurationException when the referenced module does not exist
+ * @return ?TComponent the resolved backing component
+ */
+ public function getProxyBacking(): ?TComponent
+ {
+ return $this->getBackingComponent();
+ }
+
+ // --------------------------------------------------------------- accessors
+
+ /**
+ * @return ?TComponent the backing component reference stored directly, or null
+ */
+ protected function getBackingComponentDirect(): ?TComponent
+ {
+ return $this->getProxyBackingDirect();
+ }
+
+ /**
+ * @param ?TComponent $value the backing component to store directly
+ */
+ protected function setBackingComponentDirect(?TComponent $value): void
+ {
+ $this->setProxyBackingDirect($value);
+ }
+
+ /**
+ * Returns the resolved backing component, lazily resolving it from the
+ * application module registry on first call.
+ *
+ * @throws TConfigurationException when {@see getBackingComponentId} is empty
+ * @throws TConfigurationException when the referenced module does not exist
+ * @return TComponent the resolved backing component
+ */
+ public function getBackingComponent(): TComponent
+ {
+ $backing = $this->getBackingComponentDirect();
+ if ($backing === null) {
+ $id = $this->_backingComponentId;
+ if ($id === '') {
+ throw new TConfigurationException('componentproxy_backing_component_id_required');
+ }
+ $backing = $this->getApplication()->getModule($id);
+ if ($backing === null) {
+ throw new TConfigurationException('componentproxy_component_not_found', $id);
+ }
+ $this->setBackingComponentDirect($backing);
+ $this->attachProxy();
+ $backing = $this->getBackingComponentDirect();
+ }
+ return $backing;
+ }
+
+ /**
+ * @return string the module ID of the backing component
+ */
+ protected function getBackingComponentIdDirect(): string
+ {
+ return $this->_backingComponentId;
+ }
+
+ /**
+ * @param string $value the module ID to store directly
+ */
+ protected function setBackingComponentIdDirect(string $value): void
+ {
+ $this->_backingComponentId = $value;
+ }
+
+ /**
+ * @return string the module ID of the backing component
+ */
+ public function getBackingComponentId(): string
+ {
+ return $this->getBackingComponentIdDirect();
+ }
+
+ /**
+ * Sets the module ID of the backing component. When a non-empty ID was
+ * already set and the new value differs, the change is logged at
+ * {@see \Prado\Util\TLogger::WARNING} level and the resolved component
+ * reference is invalidated so the next operation re-resolves the module.
+ *
+ * @param string $value the module ID of the component to proxy
+ */
+ public function setBackingComponentId(string $value): void
+ {
+ $value = TPropertyValue::ensureString($value);
+ $current = $this->getBackingComponentIdDirect();
+ if ($value === $current) {
+ return;
+ }
+ if ($current !== '') {
+ $this->detachProxy();
+ Prado::log(
+ sprintf(
+ "TModuleProxy.BackingComponentId changed from '%s' to '%s'.",
+ $current,
+ $value
+ ),
+ TLogger::WARNING,
+ 'prado.component'
+ );
+ }
+ $this->setBackingComponentIdDirect($value);
+ $this->setBackingComponentDirect(null);
+ }
+
+ // -------------------------------------------------- serialization
+
+ /**
+ * Excludes transient and default-valued fields from serialization. The
+ * resolved backing component reference is excluded because it is re-resolved
+ * from the application module registry after deserialization. The
+ * `_proxyEventNames` list is always transient.
+ *
+ * @param array $exprops excluded-properties list, passed by reference
+ */
+ protected function _getZappableSleepProps(&$exprops)
+ {
+ parent::_getZappableSleepProps($exprops);
+ $this->_addProxyEventNamesZappable($exprops);
+ // The backing component is lazily re-resolved from the module registry
+ // after deserialization; exclude it so it re-resolves cleanly.
+ $this->_addProxyBackingZappable($exprops);
+ if ($this->_backingComponentId === '') {
+ $exprops[] = "\0" . __CLASS__ . "\0_backingComponentId";
+ }
+ }
+}
diff --git a/framework/classes.php b/framework/classes.php
index d3a049c12..abd50ddc2 100644
--- a/framework/classes.php
+++ b/framework/classes.php
@@ -19,6 +19,7 @@
'TCache' => 'Prado\Caching\TCache',
'TCacheDependency' => 'Prado\Caching\TCacheDependency',
'TCacheDependencyList' => 'Prado\Caching\TCacheDependencyList',
+'TCacheProxy' => 'Prado\Caching\TCacheProxy',
'TCacheSizeTrait' => 'Prado\Caching\TCacheSizeTrait',
'TChainedCacheDependency' => 'Prado\Caching\TChainedCacheDependency',
'TDbCache' => 'Prado\Caching\TDbCache',
@@ -192,6 +193,7 @@
'TSqlMapManager' => 'Prado\Data\SqlMap\TSqlMapManager',
'TDataCharset' => 'Prado\Data\TDataCharset',
'TDataSourceConfig' => 'Prado\Data\TDataSourceConfig',
+'TDataSourceConfigProxy' => 'Prado\Data\TDataSourceConfigProxy',
'TDbColumnCaseMode' => 'Prado\Data\TDbColumnCaseMode',
'TDbCommand' => 'Prado\Data\TDbCommand',
'TDbConnection' => 'Prado\Data\TDbConnection',
@@ -262,6 +264,7 @@
'IEventParameter' => 'Prado\IEventParameter',
'IModule' => 'Prado\IModule',
'IModuleDependency' => 'Prado\IModuleDependency',
+'IProxy' => 'Prado\IProxy',
'ITextWriter' => 'Prado\IO\ITextWriter',
'TCachedHttpClient' => 'Prado\IO\HttpClient\TCachedHttpClient',
'TCurlHttpClient' => 'Prado\IO\HttpClient\TCurlHttpClient',
@@ -319,6 +322,8 @@
'TApplicationMode' => 'Prado\TApplicationMode',
'TApplicationStatePersister' => 'Prado\TApplicationStatePersister',
'TComponent' => 'Prado\TComponent',
+'TComponentProxy' => 'Prado\TComponentProxy',
+'TComponentProxyTrait' => 'Prado\TComponentProxyTrait',
'TComponentReflection' => 'Prado\TComponentReflection',
'TEnumerable' => 'Prado\TEnumerable',
'TEventHandler' => 'Prado\TEventHandler',
@@ -326,6 +331,7 @@
'TEventResults' => 'Prado\TEventResults',
'TEventSubscription' => 'Prado\TEventSubscription',
'TModule' => 'Prado\TModule',
+'TModuleProxy' => 'Prado\TModuleProxy',
'TPropertyValue' => 'Prado\TPropertyValue',
'TService' => 'Prado\TService',
'TApplicationSignals' => 'Prado\Util\Behaviors\TApplicationSignals',
diff --git a/tests/unit/Caching/TCacheProxyTest.php b/tests/unit/Caching/TCacheProxyTest.php
new file mode 100644
index 000000000..5126efb0a
--- /dev/null
+++ b/tests/unit/Caching/TCacheProxyTest.php
@@ -0,0 +1,1062 @@
+
+ * @link https://github.com/pradosoft/prado
+ * @license https://github.com/pradosoft/prado/blob/master/LICENSE
+ */
+
+use Prado\Caching\ICache;
+use Prado\Caching\TCache;
+use Prado\Caching\TCacheProxy;
+use Prado\Collections\TWeakCallableCollection;
+use Prado\Exceptions\TConfigurationException;
+use Prado\Exceptions\TInvalidOperationException;
+use Prado\Exceptions\TUnknownMethodException;
+use Prado\IModuleDependency;
+use Prado\Prado;
+use Prado\TApplication;
+use Prado\TEventParameter;
+use Prado\TModule;
+use Prado\Util\TBehavior;
+use Prado\Util\TLogger;
+
+// ── Helper classes ─────────────────────────────────────────────────────────────
+
+/**
+ * A minimal in-memory TCache implementation used as the backing store in tests.
+ * Stores raw cache entries (as TCache serializes them) in a PHP array.
+ */
+class TCacheProxyBackend extends TCache
+{
+ /** @var array raw storage keyed by the unique (prefixed) key */
+ private array $_store = [];
+
+ /** @var int count of flush() calls */
+ public int $flushCalls = 0;
+
+ /** @var string|null used to exercise __get/__set/__isset/__unset forwarding */
+ private ?string $_customProp = null;
+
+ public function getCustomProp(): ?string
+ {
+ return $this->_customProp;
+ }
+
+ public function setCustomProp(?string $value): void
+ {
+ $this->_customProp = $value;
+ }
+
+ public function customMethod(string $arg): string
+ {
+ return 'custom:' . $arg;
+ }
+
+ public function customMultiArgMethod(string $a, int $b): string
+ {
+ return $a . ':' . $b;
+ }
+
+ public function onTestEvent(\Prado\TEventParameter $param): void
+ {
+ $this->raiseEvent('OnTestEvent', $this, $param);
+ }
+
+ protected function getValue($key)
+ {
+ return $this->_store[$key] ?? false;
+ }
+
+ protected function setValue($key, $value, $expire): bool
+ {
+ $this->_store[$key] = $value;
+ return true;
+ }
+
+ protected function addValue($key, $value, $expire): bool
+ {
+ if (array_key_exists($key, $this->_store)) {
+ return false;
+ }
+ $this->_store[$key] = $value;
+ return true;
+ }
+
+ protected function deleteValue($key): bool
+ {
+ unset($this->_store[$key]);
+ return true;
+ }
+
+ public function flush(): bool
+ {
+ $this->flushCalls++;
+ $this->_store = [];
+ return true;
+ }
+}
+
+/**
+ * A TModule that is NOT a TCache — used to test the invalid-type guard.
+ */
+class TCacheProxyNotACacheModule extends TModule
+{
+ public function init($config)
+ {
+ parent::init($config);
+ }
+}
+
+/**
+ * A TBehavior with a public on[A-Z]* event, used to verify that attachProxy()
+ * discovers events exposed by behaviors attached to the backing cache.
+ */
+class TCacheProxyBehaviorWithEvent extends TBehavior
+{
+ public function onBehaviorEvent(\Prado\TEventParameter $param): void
+ {
+ $this->raiseEvent('OnBehaviorEvent', $this->getOwner(), $param);
+ }
+}
+
+/**
+ * Exposes protected internals for direct testing.
+ */
+class TCacheProxyAccessor extends TCacheProxy
+{
+ public function pubGetZappableSleepProps(array &$exprops): void
+ {
+ $this->_getZappableSleepProps($exprops);
+ }
+
+ public function pubGetCacheDirect(): ?TCache
+ {
+ return $this->getCacheDirect();
+ }
+
+}
+
+// ── Test class ─────────────────────────────────────────────────────────────────
+
+/**
+ * TCacheProxyTest class.
+ *
+ * Tests TCacheProxy: BackingCacheId property, lazy cache resolution, init() validation,
+ * IModuleDependency, transparent ICache delegation, change logging, ArrayAccess,
+ * serialization, and edge cases.
+ *
+ * @package Prado\Tests\Unit\Caching
+ */
+class TCacheProxyTest extends PHPUnit\Framework\TestCase
+{
+ private static string $mockAppPath;
+
+ /** @var TApplication|null */
+ private ?TApplication $app = null;
+
+ /** @var TCacheProxyBackend */
+ private TCacheProxyBackend $backend;
+
+ /** @var TCacheProxyAccessor */
+ private TCacheProxyAccessor $proxy;
+
+ public static function setUpBeforeClass(): void
+ {
+ self::$mockAppPath = __DIR__ . '/mockapp';
+ }
+
+ protected function setUp(): void
+ {
+ $this->app = new TApplication(self::$mockAppPath);
+
+ // Create and register a fully initialized backing cache.
+ $this->backend = new TCacheProxyBackend();
+ $this->backend->setPrimaryCache(false);
+ $this->backend->init(null);
+ $this->app->setModule('backingCache', $this->backend);
+
+ // Build the proxy but do NOT call init() — tests that need it call it.
+ $this->proxy = new TCacheProxyAccessor();
+ $this->proxy->setPrimaryCache(false);
+ $this->proxy->setBackingCacheId('backingCache');
+ }
+
+ protected function tearDown(): void
+ {
+ $this->backend->flush();
+ $this->proxy->unlisten();
+ $this->backend->unlisten();
+ $this->app->unlisten();
+ $this->app = null;
+ }
+
+ // ── Construction / instance ──────────────────────────────────────────────────
+
+ public function testIsInstanceOfTCacheProxy(): void
+ {
+ $this->assertInstanceOf(TCacheProxy::class, $this->proxy);
+ }
+
+ public function testImplementsICache(): void
+ {
+ $this->assertInstanceOf(ICache::class, $this->proxy);
+ }
+
+ public function testImplementsIModuleDependency(): void
+ {
+ $this->assertInstanceOf(IModuleDependency::class, $this->proxy);
+ }
+
+ public function testExtendsAbstractTCache(): void
+ {
+ $this->assertInstanceOf(TCache::class, $this->proxy);
+ }
+
+ // ── Default property values ──────────────────────────────────────────────────
+
+ public function testDefaultBackingCacheIdIsEmptyString(): void
+ {
+ $fresh = new TCacheProxy();
+ $this->assertSame('', $fresh->getBackingCacheId());
+ }
+
+ // ── getBackingCacheId / setBackingCacheId ──────────────────────────────────────────────────
+
+ public function testSetGetBackingCacheId(): void
+ {
+ $proxy = new TCacheProxy();
+ $proxy->setBackingCacheId('myCache');
+ $this->assertSame('myCache', $proxy->getBackingCacheId());
+ }
+
+ public function testSetBackingCacheIdSameValueIsNoOp(): void
+ {
+ $this->proxy->setBackingCacheId('backingCache'); // same value — must not log
+ $this->assertSame('backingCache', $this->proxy->getBackingCacheId());
+ }
+
+ public function testSetBackingCacheIdFromEmptyDoesNotLog(): void
+ {
+ $cat = 'prado.caching';
+ $before = $this->countLogs(TLogger::WARNING, $cat);
+
+ $proxy = new TCacheProxy();
+ $proxy->setBackingCacheId('backingCache'); // first set from '' — no log
+
+ $this->assertSame($before, $this->countLogs(TLogger::WARNING, $cat));
+ }
+
+ public function testSetBackingCacheIdChangeLogsWarning(): void
+ {
+ $cat = 'prado.caching';
+ $before = $this->countLogs(TLogger::WARNING, $cat);
+
+ $this->proxy->setBackingCacheId('otherCache'); // changes from 'backingCache'
+
+ $this->assertSame($before + 1, $this->countLogs(TLogger::WARNING, $cat));
+ }
+
+ public function testSetBackingCacheIdChangeLogMessageContainsBothIds(): void
+ {
+ $cat = 'prado.caching';
+ $this->proxy->setBackingCacheId('replacementCache');
+
+ $logs = Prado::getLogger()->getLogs(TLogger::WARNING, $cat);
+ $msg = end($logs)[TLogger::LOG_MESSAGE];
+ $this->assertStringContainsString('backingCache', $msg);
+ $this->assertStringContainsString('replacementCache', $msg);
+ }
+
+ public function testSetBackingCacheIdInvalidatesResolvedReference(): void
+ {
+ // Force resolution of the proxy.
+ $this->proxy->init(null);
+ $first = $this->proxy->getCache();
+
+ // Change the ID — the cached reference must be cleared.
+ $secondBackend = new TCacheProxyBackend();
+ $secondBackend->setPrimaryCache(false);
+ $secondBackend->init(null);
+ $this->app->setModule('secondCache', $secondBackend);
+
+ $this->proxy->setBackingCacheId('secondCache');
+ $second = $this->proxy->getCache();
+
+ $this->assertNotSame($first, $second);
+ $this->assertSame($secondBackend, $second);
+ $secondBackend->unlisten();
+ }
+
+ // ── getModuleDependencies ────────────────────────────────────────────────────
+
+ public function testGetModuleDependenciesReturnsNullWhenBackingCacheIdEmpty(): void
+ {
+ $proxy = new TCacheProxy();
+ $this->assertNull($proxy->getModuleDependencies());
+ }
+
+ public function testGetModuleDependenciesReturnsDependencyArrayWhenBackingCacheIdSet(): void
+ {
+ $deps = $this->proxy->getModuleDependencies();
+ $this->assertIsArray($deps);
+ $this->assertCount(1, $deps);
+ $this->assertSame('backingCache', $deps[0]['id']);
+ $this->assertTrue($deps[0]['required']);
+ }
+
+ // ── init() ──────────────────────────────────────────────────────────────────
+
+ public function testInitSucceedsWhenBackingCacheIdIsSet(): void
+ {
+ $this->proxy->init(null);
+ $this->assertSame('backingCache', $this->proxy->getBackingCacheId());
+ }
+
+ public function testInitThrowsWhenBackingCacheIdIsEmpty(): void
+ {
+ $proxy = new TCacheProxy();
+ $proxy->setPrimaryCache(false);
+
+ $this->expectException(TConfigurationException::class);
+ $proxy->init(null);
+ }
+
+ // ── getCache() ──────────────────────────────────────────────────────────────
+
+ public function testGetCacheReturnsBackingCacheModule(): void
+ {
+ $this->proxy->init(null);
+ $this->assertSame($this->backend, $this->proxy->getCache());
+ }
+
+ public function testGetCacheCachesResolvedReference(): void
+ {
+ $this->proxy->init(null);
+ $first = $this->proxy->getCache();
+ $second = $this->proxy->getCache();
+ $this->assertSame($first, $second);
+ }
+
+ public function testGetCacheThrowsWhenBackingCacheIdIsEmpty(): void
+ {
+ $proxy = new TCacheProxy();
+
+ $this->expectException(TConfigurationException::class);
+ $proxy->getCache();
+ }
+
+ public function testGetCacheThrowsWhenModuleNotFound(): void
+ {
+ $proxy = new TCacheProxyAccessor();
+ $proxy->setPrimaryCache(false);
+ $proxy->setBackingCacheId('nonExistentModule');
+
+ $this->expectException(TConfigurationException::class);
+ $proxy->getCache();
+ }
+
+ public function testGetCacheThrowsWhenModuleIsNotTCache(): void
+ {
+ $notACache = new TCacheProxyNotACacheModule();
+ $this->app->setModule('notCache', $notACache);
+
+ $proxy = new TCacheProxyAccessor();
+ $proxy->setPrimaryCache(false);
+ $proxy->setBackingCacheId('notCache');
+
+ try {
+ $proxy->getCache();
+ $this->fail('Expected TConfigurationException was not thrown.');
+ } catch (TConfigurationException $e) {
+ $this->assertInstanceOf(TConfigurationException::class, $e);
+ } finally {
+ $proxy->unlisten();
+ $notACache->unlisten();
+ }
+ }
+
+ // ── ICache delegation — get / set ────────────────────────────────────────────
+
+ public function testSetAndGetDelegatesToBackingCache(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->set('key', 'hello');
+ $this->assertSame('hello', $this->proxy->get('key'));
+ }
+
+ public function testGetReturnsFalseOnCacheMiss(): void
+ {
+ $this->proxy->init(null);
+ $this->assertFalse($this->proxy->get('missing'));
+ }
+
+ public function testSetOverwritesExistingEntry(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->set('key', 'first');
+ $this->proxy->set('key', 'second');
+ $this->assertSame('second', $this->proxy->get('key'));
+ }
+
+ public function testSetVariousValueTypes(): void
+ {
+ $this->proxy->init(null);
+
+ $this->proxy->set('int', 42);
+ $this->assertSame(42, $this->proxy->get('int'));
+
+ $this->proxy->set('arr', [1, 2, 3]);
+ $this->assertSame([1, 2, 3], $this->proxy->get('arr'));
+
+ $this->proxy->set('obj', new stdClass());
+ $this->assertInstanceOf(stdClass::class, $this->proxy->get('obj'));
+ }
+
+ public function testSetEmptyValueWithZeroExpireDeletesEntry(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->set('key', 'present');
+ $this->proxy->set('key', '', 0); // empty + no expire → delete
+ $this->assertFalse($this->proxy->get('key'));
+ }
+
+ // ── ICache delegation — add ──────────────────────────────────────────────────
+
+ public function testAddStoresWhenAbsent(): void
+ {
+ $this->proxy->init(null);
+ $result = $this->proxy->add('newKey', 'value');
+ $this->assertTrue($result);
+ $this->assertSame('value', $this->proxy->get('newKey'));
+ }
+
+ public function testAddReturnsFalseWhenEntryExists(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->set('existing', 'original');
+ $result = $this->proxy->add('existing', 'new');
+ $this->assertFalse($result);
+ $this->assertSame('original', $this->proxy->get('existing'));
+ }
+
+ public function testAddEmptyValueWithZeroExpireReturnsFalse(): void
+ {
+ $this->proxy->init(null);
+ $result = $this->proxy->add('key', '', 0);
+ $this->assertFalse($result);
+ }
+
+ // ── ICache delegation — delete ───────────────────────────────────────────────
+
+ public function testDeleteRemovesExistingEntry(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->set('key', 'value');
+ $this->proxy->delete('key');
+ $this->assertFalse($this->proxy->get('key'));
+ }
+
+ public function testDeleteReturnsTrueWhenEntryAbsent(): void
+ {
+ $this->proxy->init(null);
+ $result = $this->proxy->delete('neverStored');
+ $this->assertTrue($result);
+ }
+
+ // ── ICache delegation — flush ────────────────────────────────────────────────
+
+ public function testFlushDelegatesToBackingCache(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->set('a', 1);
+ $this->proxy->set('b', 2);
+
+ $flushBefore = $this->backend->flushCalls;
+ $result = $this->proxy->flush();
+
+ $this->assertTrue($result);
+ $this->assertSame($flushBefore + 1, $this->backend->flushCalls);
+ $this->assertFalse($this->proxy->get('a'));
+ $this->assertFalse($this->proxy->get('b'));
+ }
+
+ // ── Transparency: proxy uses backing cache's key space ───────────────────────
+
+ public function testProxyAndBackingCacheShareKeySpace(): void
+ {
+ $this->proxy->init(null);
+
+ // Store via the proxy; retrieve via the backing cache directly.
+ $this->proxy->set('sharedKey', 'proxyValue');
+ $this->assertSame('proxyValue', $this->backend->get('sharedKey'));
+ }
+
+ public function testBackingCacheSetVisibleThroughProxy(): void
+ {
+ $this->proxy->init(null);
+
+ // Store directly on the backing cache; read through the proxy.
+ $this->backend->set('directKey', 'directValue');
+ $this->assertSame('directValue', $this->proxy->get('directKey'));
+ }
+
+ // ── ArrayAccess delegation ───────────────────────────────────────────────────
+
+ public function testOffsetExistsDelegatesToGet(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->set('present', 'yes');
+
+ $this->assertTrue(isset($this->proxy['present']));
+ $this->assertFalse(isset($this->proxy['absent']));
+ }
+
+ public function testOffsetGetDelegatesToGet(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->set('key', 'value');
+
+ $this->assertSame('value', $this->proxy['key']);
+ }
+
+ public function testOffsetSetDelegatesToSet(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy['key'] = 'arrayValue';
+
+ $this->assertSame('arrayValue', $this->proxy->get('key'));
+ }
+
+ public function testOffsetUnsetDelegatesToDelete(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->set('key', 'value');
+ unset($this->proxy['key']);
+
+ $this->assertFalse($this->proxy->get('key'));
+ }
+
+ // ── Primary cache registration ───────────────────────────────────────────────
+
+ public function testInitRegistersPrimaryCache(): void
+ {
+ $proxy = new TCacheProxyAccessor();
+ $proxy->setBackingCacheId('backingCache');
+ $proxy->setPrimaryCache(true);
+
+ // The app has no primary cache yet — register the proxy as primary.
+ $proxy->init(null);
+
+ $this->assertSame($proxy, $this->app->getCache());
+ }
+
+ public function testInitWithPrimaryFalseDoesNotRegisterAsAppCache(): void
+ {
+ // Proxy has PrimaryCache=false; app cache should remain null unless set elsewhere.
+ $this->proxy->init(null);
+ $this->assertNull($this->app->getCache());
+ }
+
+ // ── KeyPrefix property (inherited from TCache) ───────────────────────────────
+
+ public function testKeyPrefixGetSet(): void
+ {
+ $this->proxy->setKeyPrefix('myprefix');
+ $this->assertSame('myprefix', $this->proxy->getKeyPrefix());
+ }
+
+ // ── Multiple set/get cycles ──────────────────────────────────────────────────
+
+ public function testMultipleKeysStoredAndRetrievedIndependently(): void
+ {
+ $this->proxy->init(null);
+
+ for ($i = 0; $i < 5; $i++) {
+ $this->proxy->set("key{$i}", "val{$i}");
+ }
+ for ($i = 0; $i < 5; $i++) {
+ $this->assertSame("val{$i}", $this->proxy->get("key{$i}"));
+ }
+ }
+
+ // ── _getZappableSleepProps ───────────────────────────────────────────────────
+
+ public function testZappableAlwaysExcludesCacheReference(): void
+ {
+ $exprops = [];
+ $this->proxy->pubGetZappableSleepProps($exprops);
+
+ // _proxyBacking is declared in TComponentProxyTrait, but __CLASS__ in the
+ // trait resolves to the using class (TCacheProxy), so the key uses that.
+ $this->assertContains(
+ "\0" . TCacheProxy::class . "\0_proxyBacking",
+ $exprops
+ );
+ }
+
+ public function testZappableExcludesBackingCacheIdWhenEmpty(): void
+ {
+ $proxy = new TCacheProxyAccessor();
+ $exprops = [];
+ $proxy->pubGetZappableSleepProps($exprops);
+
+ $this->assertContains(
+ "\0" . TCacheProxy::class . "\0_backingCacheId",
+ $exprops
+ );
+ }
+
+ public function testZappableKeepsBackingCacheIdWhenNonEmpty(): void
+ {
+ $exprops = [];
+ $this->proxy->pubGetZappableSleepProps($exprops);
+
+ $this->assertNotContains(
+ "\0" . TCacheProxy::class . "\0_backingCacheId",
+ $exprops
+ );
+ }
+
+ // ── __call dispatch ──────────────────────────────────────────────────────────
+
+ public function testCallForwardsPublicMethodToBackingCache(): void
+ {
+ $this->proxy->init(null);
+ $result = $this->proxy->customMethod('hello');
+ $this->assertSame('custom:hello', $result);
+ }
+
+ public function testCallForwardsMultipleArguments(): void
+ {
+ $this->proxy->init(null);
+ $result = $this->proxy->customMultiArgMethod('key', 42);
+ $this->assertSame('key:42', $result);
+ }
+
+ public function testCallLazilyResolvesBeforeForwarding(): void
+ {
+ // init() called but getCache() never called yet — __call must still resolve.
+ $this->proxy->init(null);
+ $result = $this->proxy->customMethod('lazy');
+ $this->assertSame('custom:lazy', $result);
+ }
+
+ public function testCallDoesNotForwardDyEvent(): void
+ {
+ // dy-prefixed names belong to TComponent's behavior system, not the cache.
+ // An unimplemented dy event returns its first argument (or null).
+ $this->proxy->init(null);
+ $result = $this->proxy->dyCustomEvent('value');
+ $this->assertSame('value', $result);
+ }
+
+ public function testCallUnknownMethodThrows(): void
+ {
+ $this->proxy->init(null);
+ $this->expectException(TUnknownMethodException::class);
+ $this->proxy->totallyUnknownMethod();
+ }
+
+ // ── __get / __set / __isset / __unset passthrough ────────────────────────────
+
+ public function testGetForwardsCacheSpecificPropertyToBackingCache(): void
+ {
+ $this->proxy->init(null);
+ $this->backend->setCustomProp('hello');
+ $this->assertSame('hello', $this->proxy->CustomProp);
+ }
+
+ public function testSetForwardsCacheSpecificPropertyToBackingCache(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->CustomProp = 'world';
+ $this->assertSame('world', $this->backend->getCustomProp());
+ }
+
+ public function testIssetReturnsFalseWhenCachePropIsNull(): void
+ {
+ $this->proxy->init(null);
+ $this->assertFalse(isset($this->proxy->CustomProp));
+ }
+
+ public function testIssetReturnsTrueWhenCachePropIsSet(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->CustomProp = 'set';
+ $this->assertTrue(isset($this->proxy->CustomProp));
+ }
+
+ public function testUnsetForwardsCacheSpecificPropertyToBackingCache(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->CustomProp = 'before';
+ unset($this->proxy->CustomProp);
+ $this->assertFalse(isset($this->proxy->CustomProp));
+ }
+
+ public function testGetProxyOwnPropertyUsesProxyGetter(): void
+ {
+ // BackingCacheId is defined on TCacheProxy itself; __get must return the
+ // proxy's value, not try the backing cache.
+ $this->assertSame('backingCache', $this->proxy->BackingCacheId);
+ }
+
+ public function testSetProxyReadOnlyPropertyThrows(): void
+ {
+ // getCache() exists on the proxy but setCache() does not → read-only.
+ $this->proxy->init(null);
+ $this->expectException(TInvalidOperationException::class);
+ $this->proxy->Cache = 'anything';
+ }
+
+ public function testGetUndefinedPropertyThrows(): void
+ {
+ $this->proxy->init(null);
+ $this->expectException(TInvalidOperationException::class);
+ $_ = $this->proxy->CompletelyUndefinedProperty;
+ }
+
+ // ── __clone ──────────────────────────────────────────────────────────────────
+
+ public function testCloneClearsCacheReference(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->getCache(); // force lazy resolution
+
+ $clone = clone $this->proxy;
+
+ $this->assertNull($clone->pubGetCacheDirect());
+ }
+
+ public function testClonePreservesBackingCacheId(): void
+ {
+ $clone = clone $this->proxy;
+
+ $this->assertSame('backingCache', $clone->getBackingCacheId());
+ }
+
+ public function testCloneReresolvesBackingCacheOnFirstUse(): void
+ {
+ $this->proxy->init(null);
+
+ $clone = clone $this->proxy;
+
+ $this->assertSame($this->backend, $clone->getCache());
+ }
+
+ public function testCloneIsIndependentOfOriginal(): void
+ {
+ $this->proxy->init(null);
+
+ $clone = clone $this->proxy;
+
+ // Redirecting the clone's BackingCacheId must not affect the original.
+ $secondBackend = new TCacheProxyBackend();
+ $secondBackend->setPrimaryCache(false);
+ $secondBackend->init(null);
+ $this->app->setModule('secondCache', $secondBackend);
+
+ $clone->setBackingCacheId('secondCache');
+
+ $this->assertSame($this->backend, $this->proxy->getCache());
+ $this->assertSame($secondBackend, $clone->getCache());
+ $secondBackend->unlisten();
+ }
+
+ // ── Logging detail ───────────────────────────────────────────────────────────
+
+ public function testBackingCacheIdChangeLoggedAtWarningLevel(): void
+ {
+ $cat = 'prado.caching';
+ $before = $this->countLogs(TLogger::WARNING, $cat);
+
+ $this->proxy->setBackingCacheId('anotherModule');
+
+ $this->assertSame($before + 1, $this->countLogs(TLogger::WARNING, $cat));
+ }
+
+ public function testMultipleBackingCacheIdChangesEachLog(): void
+ {
+ $cat = 'prado.caching';
+ $before = $this->countLogs(TLogger::WARNING, $cat);
+
+ $this->proxy->setBackingCacheId('first'); // change 1
+ $this->proxy->setBackingCacheId('second'); // change 2
+
+ $this->assertSame($before + 2, $this->countLogs(TLogger::WARNING, $cat));
+ }
+
+ public function testBackingCacheIdSameValueProducesNoLog(): void
+ {
+ $cat = 'prado.caching';
+ $before = $this->countLogs(TLogger::WARNING, $cat);
+
+ $this->proxy->setBackingCacheId('backingCache'); // same value
+
+ $this->assertSame($before, $this->countLogs(TLogger::WARNING, $cat));
+ }
+
+ // ── Error-message keys ───────────────────────────────────────────────────────
+
+ public function testInitExceptionMessageContainsExpectedText(): void
+ {
+ $proxy = new TCacheProxy();
+ $proxy->setPrimaryCache(false);
+
+ try {
+ $proxy->init(null);
+ $this->fail('Expected TConfigurationException was not thrown.');
+ } catch (TConfigurationException $e) {
+ $this->assertSame('cacheproxy_backing_cache_id_required', $e->getErrorCode());
+ }
+ }
+
+ public function testGetCacheModuleNotFoundExceptionContainsId(): void
+ {
+ $proxy = new TCacheProxyAccessor();
+ $proxy->setPrimaryCache(false);
+ $proxy->setBackingCacheId('missingModule');
+
+ try {
+ $proxy->getCache();
+ $this->fail('Expected TConfigurationException was not thrown.');
+ } catch (TConfigurationException $e) {
+ $this->assertSame('cacheproxy_cache_not_found', $e->getErrorCode());
+ }
+ }
+
+ public function testGetCacheInvalidTypeExceptionContainsId(): void
+ {
+ $notACache = new TCacheProxyNotACacheModule();
+ $this->app->setModule('notCache2', $notACache);
+
+ $proxy = new TCacheProxyAccessor();
+ $proxy->setPrimaryCache(false);
+ $proxy->setBackingCacheId('notCache2');
+
+ try {
+ $proxy->getCache();
+ $this->fail('Expected TConfigurationException was not thrown.');
+ } catch (TConfigurationException $e) {
+ $this->assertSame('cacheproxy_invalid_cache_type', $e->getErrorCode());
+ } finally {
+ $proxy->unlisten();
+ $notACache->unlisten();
+ }
+ }
+
+ // ── attachProxy / detachProxy ────────────────────────────────────────────────
+
+ public function testHasEventReturnsFalseForBackingCacheEventBeforeAttach(): void
+ {
+ // Before getCache() is called, attachProxy has not run; the backing
+ // cache's OnTestEvent is not yet exposed on the proxy.
+ $this->assertFalse($this->proxy->hasEvent('OnTestEvent'));
+ }
+
+ public function testGetCacheTriggersAttachProxy(): void
+ {
+ // getCache() calls attachProxy() internally; after resolution the proxy
+ // must report OnTestEvent as a known event.
+ $this->proxy->init(null);
+ $this->proxy->getCache();
+ $this->assertTrue($this->proxy->hasEvent('OnTestEvent'));
+ }
+
+ public function testAttachProxySharesHandlerListWithBackingCache(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->getCache(); // triggers attachProxy
+
+ // Adding a handler via the proxy must place it in the backing cache's list.
+ $fired = false;
+ $this->proxy->OnTestEvent = function () use (&$fired) {
+ $fired = true;
+ };
+ $this->backend->onTestEvent(new TEventParameter());
+ $this->assertTrue($fired);
+ }
+
+ public function testHandlerAddedDirectlyOnBackingCacheVisibleViaProxy(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->getCache(); // triggers attachProxy
+
+ // A handler added directly on the backend fires when the backend raises the event.
+ // The proxy and backend each hold their own independent TWeakCallableCollection
+ // (forwarder pattern); they are NOT the same object.
+ $fired = false;
+ $this->backend->OnTestEvent = function () use (&$fired) {
+ $fired = true;
+ };
+ $this->backend->onTestEvent(new TEventParameter());
+ $this->assertTrue($fired);
+ $this->assertNotSame(
+ $this->backend->getEventHandlers('OnTestEvent'),
+ $this->proxy->getEventHandlers('OnTestEvent')
+ );
+ }
+
+ public function testGetOnEventViaPropertyAfterAttachProxy(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->getCache();
+
+ // __get for 'OnTestEvent' must return the proxy's own handler list.
+ // With the forwarder approach the proxy owns an independent collection —
+ // it is NOT the same object as the backend's collection.
+ $handlers = $this->proxy->OnTestEvent;
+ $this->assertInstanceOf(TWeakCallableCollection::class, $handlers);
+ $this->assertNotSame($this->backend->getEventHandlers('OnTestEvent'), $handlers);
+ }
+
+ public function testIssetOnEventViaPropertyAfterAttachProxy(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->getCache();
+
+ // No handlers yet → isset returns false.
+ $this->assertFalse(isset($this->proxy->OnTestEvent));
+
+ // Add one handler → isset returns true.
+ $this->proxy->OnTestEvent = function () {};
+ $this->assertTrue(isset($this->proxy->OnTestEvent));
+ }
+
+ public function testDetachProxyClearsEventSharing(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->getCache(); // triggers attachProxy
+ $this->assertTrue($this->proxy->hasEvent('OnTestEvent'));
+
+ $this->proxy->detachProxy();
+ $this->assertFalse($this->proxy->hasEvent('OnTestEvent'));
+ }
+
+ public function testBackingCacheIdChangeCallsDetachProxy(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->getCache(); // triggers attachProxy
+ $this->assertTrue($this->proxy->hasEvent('OnTestEvent'));
+
+ // Changing the BackingCacheId must detach the old proxy.
+ $this->proxy->setBackingCacheId('someOtherCache');
+ $this->assertFalse($this->proxy->hasEvent('OnTestEvent'));
+ }
+
+ public function testCloneDetachesProxy(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->getCache(); // triggers attachProxy on original
+ $this->assertTrue($this->proxy->hasEvent('OnTestEvent'));
+
+ $clone = clone $this->proxy;
+
+ // The clone must NOT retain the attached event references.
+ $this->assertFalse($clone->hasEvent('OnTestEvent'));
+ // The original is unaffected.
+ $this->assertTrue($this->proxy->hasEvent('OnTestEvent'));
+ }
+
+ public function testZappableAlwaysExcludesProxyEventNames(): void
+ {
+ $exprops = [];
+ $this->proxy->pubGetZappableSleepProps($exprops);
+
+ $this->assertContains(
+ "\0" . TCacheProxy::class . "\0_proxyEventNames",
+ $exprops
+ );
+ }
+
+ public function testAttachProxyIncludesBehaviorProvidedOnEvent(): void
+ {
+ // attachProxy() must discover on[A-Z]* events exposed by behaviors
+ // attached to the backing cache via TComponent::hasMethod(), not just
+ // events declared directly on the cache class.
+ $this->backend->attachBehavior('testBehavior', new TCacheProxyBehaviorWithEvent());
+
+ $this->proxy->init(null);
+ $this->proxy->getCache(); // triggers attachProxy
+
+ $this->assertTrue(
+ $this->proxy->hasEvent('OnBehaviorEvent'),
+ 'Proxy must expose on* events contributed by behaviors on the backing cache.'
+ );
+ }
+
+ public function testHandlerRegisteredViaProxyFiresForBehaviorEvent(): void
+ {
+ // Handlers added to the proxy's shared list must fire when the behavior
+ // raises the event on the backing cache.
+ $this->backend->attachBehavior('testBehavior', new TCacheProxyBehaviorWithEvent());
+
+ $this->proxy->init(null);
+ $this->proxy->getCache(); // triggers attachProxy
+
+ $fired = false;
+ $this->proxy->OnBehaviorEvent = function () use (&$fired) {
+ $fired = true;
+ };
+
+ $behaviors = $this->backend->getBehaviors(TCacheProxyBehaviorWithEvent::class);
+ /** @var TCacheProxyBehaviorWithEvent $beh */
+ $beh = reset($behaviors);
+ $beh->onBehaviorEvent(new TEventParameter());
+
+ $this->assertTrue($fired, 'Handler added via proxy must fire when the behavior raises its event.');
+ }
+
+ // ── isa() — backing-cache transparency ───────────────────────────────────────
+
+ public function testIsaReturnsTrueForProxyOwnClass(): void
+ {
+ // The proxy's own class and inherited hierarchy are still reported.
+ $this->assertTrue($this->proxy->isa(TCacheProxy::class));
+ $this->assertTrue($this->proxy->isa(TCache::class));
+ $this->assertTrue($this->proxy->isa(\Prado\Caching\ICache::class));
+ }
+
+ public function testIsaReturnsTrueForBackingCacheClass(): void
+ {
+ // After the backing cache is resolved, isa() must see through the proxy.
+ $this->proxy->getCache(); // force resolution
+ $this->assertTrue($this->proxy->isa(TCacheProxyBackend::class),
+ 'isa() must return true for the backing cache class once resolved');
+ }
+
+ public function testIsaReturnsFalseForUnrelatedClass(): void
+ {
+ $this->assertFalse($this->proxy->isa(TCacheProxyNotACacheModule::class));
+ }
+
+ public function testIsaLazilyResolvesBackingCacheWhenNotYetResolved(): void
+ {
+ // $this->proxy has BackingCacheId set but getCache() has NOT been called yet.
+ $this->assertNull($this->proxy->pubGetCacheDirect(), 'cache must not be resolved yet');
+
+ // isa() should trigger lazy resolution and return true for the backing class.
+ $this->assertTrue($this->proxy->isa(TCacheProxyBackend::class),
+ 'isa() must trigger lazy resolution and match the backing cache class');
+ $this->assertNotNull($this->proxy->pubGetCacheDirect(),
+ 'lazy resolution must have stored the backing cache reference');
+ }
+
+ public function testIsaReturnsFalseWhenNoBackingCacheIdSet(): void
+ {
+ // A proxy with no BackingCacheId set cannot resolve a cache; isa() for a
+ // backing-cache-only class must return false without throwing.
+ $proxy = new TCacheProxyAccessor();
+ $this->assertFalse($proxy->isa(TCacheProxyBackend::class));
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────────
+
+ private function countLogs(int $level, string $category): int
+ {
+ return count(Prado::getLogger()->getLogs($level, $category));
+ }
+}
diff --git a/tests/unit/Data/TDataSourceConfigProxyTest.php b/tests/unit/Data/TDataSourceConfigProxyTest.php
new file mode 100644
index 000000000..eda94f5c1
--- /dev/null
+++ b/tests/unit/Data/TDataSourceConfigProxyTest.php
@@ -0,0 +1,803 @@
+
+ * @link https://github.com/pradosoft/prado
+ * @license https://github.com/pradosoft/prado/blob/master/LICENSE
+ */
+
+use Prado\Collections\TWeakCallableCollection;
+use Prado\Data\TDataSourceConfig;
+use Prado\Data\TDataSourceConfigProxy;
+use Prado\Exceptions\TConfigurationException;
+use Prado\Exceptions\TInvalidOperationException;
+use Prado\Exceptions\TUnknownMethodException;
+use Prado\IModuleDependency;
+use Prado\IProxy;
+use Prado\Prado;
+use Prado\TApplication;
+use Prado\TComponent;
+use Prado\TEventParameter;
+use Prado\TModule;
+use Prado\Util\TBehavior;
+use Prado\Util\TLogger;
+
+// ── Helper classes ─────────────────────────────────────────────────────────────
+
+/**
+ * A minimal concrete TModule used to exercise the wrong-type check in
+ * {@see TDataSourceConfigProxy::getDataSource()}.
+ */
+class TDataSourceConfigProxyTestModule extends TModule
+{
+ public function init($config): void
+ {
+ parent::init($config);
+ }
+}
+
+/**
+ * A TDataSourceConfig subclass that adds a custom property, a custom method,
+ * and a custom on[A-Z]* event to exercise forwarding through the proxy.
+ */
+class TDataSourceConfigProxyBackingDs extends TDataSourceConfig
+{
+ /** @var string|null used to exercise __get/__set/__isset/__unset forwarding */
+ private ?string $_customProp = null;
+
+ public function getCustomProp(): ?string
+ {
+ return $this->_customProp;
+ }
+
+ public function setCustomProp(?string $value): void
+ {
+ $this->_customProp = $value;
+ }
+
+ public function customMethod(string $arg): string
+ {
+ return 'dsproxy:' . $arg;
+ }
+
+ public function customMultiArgMethod(string $a, int $b): string
+ {
+ return $a . ':' . $b;
+ }
+
+ public function onTestEvent(TEventParameter $param): void
+ {
+ $this->raiseEvent('OnTestEvent', $this, $param);
+ }
+}
+
+/**
+ * A TBehavior with a public on[A-Z]* event, used to verify that attachProxy()
+ * discovers events exposed by behaviors attached to the backing data source.
+ */
+class TDataSourceConfigProxyBehaviorWithEvent extends TBehavior
+{
+ public function onBehaviorEvent(TEventParameter $param): void
+ {
+ $this->raiseEvent('OnBehaviorEvent', $this->getOwner(), $param);
+ }
+}
+
+/**
+ * Exposes protected internals of TDataSourceConfigProxy for direct testing.
+ */
+class TDataSourceConfigProxyAccessor extends TDataSourceConfigProxy
+{
+ public function pubGetZappableSleepProps(array &$exprops): void
+ {
+ $this->_getZappableSleepProps($exprops);
+ }
+
+ public function pubGetDataSourceDirect(): ?TDataSourceConfig
+ {
+ return $this->getDataSourceDirect();
+ }
+
+ public function pubGetBackingComponentDirect(): ?TComponent
+ {
+ return $this->getProxyBackingDirect();
+ }
+}
+
+// ── Test class ─────────────────────────────────────────────────────────────────
+
+/**
+ * TDataSourceConfigProxyTest class.
+ *
+ * Tests TDataSourceConfigProxy: BackingDataSourceId property, lazy module
+ * resolution, init() validation, IModuleDependency, transparent delegation of
+ * getDbConnection(), event sharing, isa() transparency, change logging,
+ * serialization, and edge cases.
+ *
+ * @package Prado\Tests\Unit\Data
+ */
+class TDataSourceConfigProxyTest extends PHPUnit\Framework\TestCase
+{
+ private static string $mockAppPath;
+
+ /** @var TApplication|null */
+ private ?TApplication $app = null;
+
+ /** @var TDataSourceConfigProxyBackingDs */
+ private TDataSourceConfigProxyBackingDs $backing;
+
+ /** @var TDataSourceConfigProxyAccessor */
+ private TDataSourceConfigProxyAccessor $proxy;
+
+ public static function setUpBeforeClass(): void
+ {
+ self::$mockAppPath = __DIR__ . '/../Caching/mockapp';
+ }
+
+ protected function setUp(): void
+ {
+ $this->app = new TApplication(self::$mockAppPath);
+
+ // Create and register a fully initialized backing data source module.
+ $this->backing = new TDataSourceConfigProxyBackingDs();
+ $this->backing->init(null);
+ $this->app->setModule('backingDs', $this->backing);
+
+ // Build the proxy but do NOT call init() — tests that need it call it.
+ $this->proxy = new TDataSourceConfigProxyAccessor();
+ $this->proxy->setBackingDataSourceId('backingDs');
+ }
+
+ protected function tearDown(): void
+ {
+ $this->proxy->unlisten();
+ $this->backing->unlisten();
+ $this->app->unlisten();
+ $this->app = null;
+ }
+
+ // ── Construction / instance ──────────────────────────────────────────────────
+
+ public function testIsInstanceOfTDataSourceConfigProxy(): void
+ {
+ $this->assertInstanceOf(TDataSourceConfigProxy::class, $this->proxy);
+ }
+
+ public function testExtendsTDataSourceConfig(): void
+ {
+ $this->assertInstanceOf(TDataSourceConfig::class, $this->proxy);
+ }
+
+ public function testImplementsIModuleDependency(): void
+ {
+ $this->assertInstanceOf(IModuleDependency::class, $this->proxy);
+ }
+
+ public function testImplementsIProxy(): void
+ {
+ $this->assertInstanceOf(IProxy::class, $this->proxy);
+ }
+
+ public function testExtendsTModule(): void
+ {
+ $this->assertInstanceOf(TModule::class, $this->proxy);
+ }
+
+ // ── Default property values ──────────────────────────────────────────────────
+
+ public function testDefaultBackingDataSourceIdIsEmptyString(): void
+ {
+ $fresh = new TDataSourceConfigProxy();
+ $this->assertSame('', $fresh->getBackingDataSourceId());
+ }
+
+ // ── getBackingDataSourceId / setBackingDataSourceId ──────────────────────────
+
+ public function testSetGetBackingDataSourceId(): void
+ {
+ $proxy = new TDataSourceConfigProxy();
+ $proxy->setBackingDataSourceId('myDs');
+ $this->assertSame('myDs', $proxy->getBackingDataSourceId());
+ }
+
+ public function testSetBackingDataSourceIdSameValueIsNoOp(): void
+ {
+ $this->proxy->setBackingDataSourceId('backingDs'); // same value — must not log
+ $this->assertSame('backingDs', $this->proxy->getBackingDataSourceId());
+ }
+
+ public function testSetBackingDataSourceIdFromEmptyDoesNotLog(): void
+ {
+ $cat = 'prado.data';
+ $before = $this->countLogs(TLogger::WARNING, $cat);
+
+ $proxy = new TDataSourceConfigProxy();
+ $proxy->setBackingDataSourceId('backingDs');
+
+ $this->assertSame($before, $this->countLogs(TLogger::WARNING, $cat));
+ }
+
+ public function testSetBackingDataSourceIdChangeLogsWarning(): void
+ {
+ $cat = 'prado.data';
+ $before = $this->countLogs(TLogger::WARNING, $cat);
+
+ $this->proxy->setBackingDataSourceId('otherDs');
+
+ $this->assertSame($before + 1, $this->countLogs(TLogger::WARNING, $cat));
+ }
+
+ public function testSetBackingDataSourceIdChangeLogContainsBothIds(): void
+ {
+ $cat = 'prado.data';
+ $this->proxy->setBackingDataSourceId('replacementDs');
+
+ $logs = Prado::getLogger()->getLogs(TLogger::WARNING, $cat);
+ $msg = end($logs)[TLogger::LOG_MESSAGE];
+ $this->assertStringContainsString('backingDs', $msg);
+ $this->assertStringContainsString('replacementDs', $msg);
+ }
+
+ public function testSetBackingDataSourceIdInvalidatesResolvedReference(): void
+ {
+ $this->proxy->init(null);
+ $first = $this->proxy->getDataSource();
+
+ $secondBacking = new TDataSourceConfig();
+ $secondBacking->init(null);
+ $this->app->setModule('secondDs', $secondBacking);
+
+ $this->proxy->setBackingDataSourceId('secondDs');
+ $second = $this->proxy->getDataSource();
+
+ $this->assertNotSame($first, $second);
+ $this->assertSame($secondBacking, $second);
+ $secondBacking->unlisten();
+ }
+
+ // ── getModuleDependencies ────────────────────────────────────────────────────
+
+ public function testGetModuleDependenciesReturnsNullWhenIdEmpty(): void
+ {
+ $proxy = new TDataSourceConfigProxy();
+ $this->assertNull($proxy->getModuleDependencies());
+ }
+
+ public function testGetModuleDependenciesReturnsDependencyArrayWhenIdSet(): void
+ {
+ $deps = $this->proxy->getModuleDependencies();
+ $this->assertIsArray($deps);
+ $this->assertCount(1, $deps);
+ $this->assertSame('backingDs', $deps[0]['id']);
+ $this->assertTrue($deps[0]['required']);
+ }
+
+ public function testGetModuleDependenciesIsInitParameterIsIgnored(): void
+ {
+ $depsInit = $this->proxy->getModuleDependencies(true);
+ $depsPreInit = $this->proxy->getModuleDependencies(false);
+ $this->assertEquals($depsInit, $depsPreInit);
+ }
+
+ // ── init() ──────────────────────────────────────────────────────────────────
+
+ public function testInitSucceedsWhenBackingDataSourceIdIsSet(): void
+ {
+ $this->proxy->init(null);
+ $this->assertSame('backingDs', $this->proxy->getBackingDataSourceId());
+ }
+
+ public function testInitThrowsWhenBackingDataSourceIdIsEmpty(): void
+ {
+ $proxy = new TDataSourceConfigProxy();
+ $this->expectException(TConfigurationException::class);
+ $proxy->init(null);
+ }
+
+ public function testInitExceptionHasExpectedErrorCode(): void
+ {
+ $proxy = new TDataSourceConfigProxy();
+ try {
+ $proxy->init(null);
+ $this->fail('Expected TConfigurationException was not thrown.');
+ } catch (TConfigurationException $e) {
+ $this->assertSame('datasourceproxy_backing_data_source_id_required', $e->getErrorCode());
+ }
+ }
+
+ // ── getDataSource() — lazy resolution ────────────────────────────────────────
+
+ public function testGetDataSourceReturnsBackingModule(): void
+ {
+ $this->proxy->init(null);
+ $this->assertSame($this->backing, $this->proxy->getDataSource());
+ }
+
+ public function testGetDataSourceCachesResolvedReference(): void
+ {
+ $this->proxy->init(null);
+ $first = $this->proxy->getDataSource();
+ $second = $this->proxy->getDataSource();
+ $this->assertSame($first, $second);
+ }
+
+ public function testGetDataSourceThrowsWhenIdEmpty(): void
+ {
+ $proxy = new TDataSourceConfigProxy();
+ $this->expectException(TConfigurationException::class);
+ $proxy->getDataSource();
+ }
+
+ public function testGetDataSourceThrowsWithExpectedErrorCodeWhenIdEmpty(): void
+ {
+ $proxy = new TDataSourceConfigProxy();
+ try {
+ $proxy->getDataSource();
+ $this->fail('Expected TConfigurationException was not thrown.');
+ } catch (TConfigurationException $e) {
+ $this->assertSame('datasourceproxy_backing_data_source_id_required', $e->getErrorCode());
+ }
+ }
+
+ public function testGetDataSourceThrowsWhenModuleNotFound(): void
+ {
+ $proxy = new TDataSourceConfigProxyAccessor();
+ $proxy->setBackingDataSourceId('nonExistentDs');
+ $this->expectException(TConfigurationException::class);
+ $proxy->getDataSource();
+ }
+
+ public function testGetDataSourceModuleNotFoundHasExpectedErrorCode(): void
+ {
+ $proxy = new TDataSourceConfigProxyAccessor();
+ $proxy->setBackingDataSourceId('missingDs');
+ try {
+ $proxy->getDataSource();
+ $this->fail('Expected TConfigurationException was not thrown.');
+ } catch (TConfigurationException $e) {
+ $this->assertSame('datasourceproxy_data_source_not_found', $e->getErrorCode());
+ }
+ }
+
+ public function testGetDataSourceThrowsWhenModuleIsNotTDataSourceConfig(): void
+ {
+ // Register a non-TDataSourceConfig module under the ID.
+ $wrongModule = new TDataSourceConfigProxyTestModule();
+ $wrongModule->init(null);
+ $this->app->setModule('wrongDs', $wrongModule);
+
+ $proxy = new TDataSourceConfigProxyAccessor();
+ $proxy->setBackingDataSourceId('wrongDs');
+
+ try {
+ $proxy->getDataSource();
+ $this->fail('Expected TConfigurationException was not thrown.');
+ } catch (TConfigurationException $e) {
+ $this->assertSame('datasourceproxy_invalid_data_source_type', $e->getErrorCode());
+ } finally {
+ $proxy->unlisten();
+ $wrongModule->unlisten();
+ }
+ }
+
+ // ── getDbConnection() delegation ─────────────────────────────────────────────
+
+ public function testGetDbConnectionDelegatesToBacking(): void
+ {
+ $this->proxy->init(null);
+
+ // getDbConnection() on the proxy must return the same connection as the backing.
+ $proxyConn = $this->proxy->getDbConnection();
+ $backingConn = $this->backing->getDbConnection();
+
+ $this->assertSame($backingConn, $proxyConn);
+ }
+
+ public function testGetDatabaseDelegatesToBacking(): void
+ {
+ $this->proxy->init(null);
+
+ // getDatabase() on TDataSourceConfig calls getDbConnection(); on the proxy
+ // it must still delegate to the backing's connection.
+ $proxyConn = $this->proxy->getDatabase();
+ $backingConn = $this->backing->getDbConnection();
+
+ $this->assertSame($backingConn, $proxyConn);
+ }
+
+ // ── isa() — backing transparency ─────────────────────────────────────────────
+
+ public function testIsaReturnsTrueForProxyOwnClass(): void
+ {
+ $this->assertTrue($this->proxy->isa(TDataSourceConfigProxy::class));
+ $this->assertTrue($this->proxy->isa(TDataSourceConfig::class));
+ $this->assertTrue($this->proxy->isa(TModule::class));
+ }
+
+ public function testIsaReturnsTrueForBackingClass(): void
+ {
+ $this->proxy->getDataSource(); // force resolution
+ $this->assertTrue($this->proxy->isa(TDataSourceConfig::class));
+ }
+
+ public function testIsaReturnsFalseForUnrelatedClass(): void
+ {
+ $this->assertFalse($this->proxy->isa(\stdClass::class));
+ }
+
+ // ── __clone ──────────────────────────────────────────────────────────────────
+
+ public function testCloneClearsDataSourceReference(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->getDataSource(); // force lazy resolution
+
+ $clone = clone $this->proxy;
+
+ $this->assertNull($clone->pubGetDataSourceDirect());
+ $this->assertNull($clone->pubGetBackingComponentDirect());
+ }
+
+ public function testClonePreservesBackingDataSourceId(): void
+ {
+ $clone = clone $this->proxy;
+ $this->assertSame('backingDs', $clone->getBackingDataSourceId());
+ }
+
+ // ── _getZappableSleepProps ───────────────────────────────────────────────────
+
+ public function testZappableExcludesDataSourceReference(): void
+ {
+ $exprops = [];
+ $this->proxy->pubGetZappableSleepProps($exprops);
+
+ // _proxyBacking is declared in TComponentProxyTrait, but __CLASS__ in the
+ // trait resolves to the using class (TDataSourceConfigProxy), so the key uses that.
+ $this->assertContains(
+ "\0" . TDataSourceConfigProxy::class . "\0_proxyBacking",
+ $exprops
+ );
+ }
+
+ public function testZappableExcludesProxyEventNames(): void
+ {
+ $exprops = [];
+ $this->proxy->pubGetZappableSleepProps($exprops);
+
+ $this->assertContains(
+ "\0" . TDataSourceConfigProxy::class . "\0_proxyEventNames",
+ $exprops
+ );
+ }
+
+ public function testZappableExcludesBackingDataSourceIdWhenEmpty(): void
+ {
+ $proxy = new TDataSourceConfigProxyAccessor();
+ $exprops = [];
+ $proxy->pubGetZappableSleepProps($exprops);
+
+ $this->assertContains(
+ "\0" . TDataSourceConfigProxy::class . "\0_backingDataSourceId",
+ $exprops
+ );
+ }
+
+ public function testZappableKeepsBackingDataSourceIdWhenNonEmpty(): void
+ {
+ $exprops = [];
+ $this->proxy->pubGetZappableSleepProps($exprops);
+
+ $this->assertNotContains(
+ "\0" . TDataSourceConfigProxy::class . "\0_backingDataSourceId",
+ $exprops
+ );
+ }
+
+ // ── __call dispatch ──────────────────────────────────────────────────────────
+
+ public function testCallForwardsPublicMethodToBackingDataSource(): void
+ {
+ $this->proxy->init(null);
+ $result = $this->proxy->customMethod('hello');
+ $this->assertSame('dsproxy:hello', $result);
+ }
+
+ public function testCallForwardsMultipleArguments(): void
+ {
+ $this->proxy->init(null);
+ $result = $this->proxy->customMultiArgMethod('key', 42);
+ $this->assertSame('key:42', $result);
+ }
+
+ public function testCallLazilyResolvesBeforeForwarding(): void
+ {
+ $this->proxy->init(null);
+ $result = $this->proxy->customMethod('lazy');
+ $this->assertSame('dsproxy:lazy', $result);
+ }
+
+ public function testCallDoesNotForwardDyEvent(): void
+ {
+ $this->proxy->init(null);
+ $result = $this->proxy->dyCustomEvent('value');
+ $this->assertSame('value', $result);
+ }
+
+ public function testCallUnknownMethodThrows(): void
+ {
+ $this->proxy->init(null);
+ $this->expectException(TUnknownMethodException::class);
+ $this->proxy->totallyUnknownMethod();
+ }
+
+ // ── __get / __set / __isset / __unset passthrough ────────────────────────────
+
+ public function testGetForwardsBackingSpecificPropertyToBackingDataSource(): void
+ {
+ $this->proxy->init(null);
+ $this->backing->setCustomProp('hello');
+ $this->assertSame('hello', $this->proxy->CustomProp);
+ }
+
+ public function testSetForwardsBackingSpecificPropertyToBackingDataSource(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->CustomProp = 'world';
+ $this->assertSame('world', $this->backing->getCustomProp());
+ }
+
+ public function testIssetReturnsFalseWhenBackingPropIsNull(): void
+ {
+ $this->proxy->init(null);
+ $this->assertFalse(isset($this->proxy->CustomProp));
+ }
+
+ public function testIssetReturnsTrueWhenBackingPropIsSet(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->CustomProp = 'set';
+ $this->assertTrue(isset($this->proxy->CustomProp));
+ }
+
+ public function testUnsetForwardsBackingSpecificPropertyToBackingDataSource(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->CustomProp = 'before';
+ unset($this->proxy->CustomProp);
+ $this->assertFalse(isset($this->proxy->CustomProp));
+ }
+
+ public function testGetProxyOwnPropertyUsesProxyGetter(): void
+ {
+ $this->assertSame('backingDs', $this->proxy->BackingDataSourceId);
+ }
+
+ public function testGetUndefinedPropertyThrows(): void
+ {
+ $this->proxy->init(null);
+ $this->expectException(TInvalidOperationException::class);
+ $_ = $this->proxy->CompletelyUndefinedProperty;
+ }
+
+ // ── __clone ──────────────────────────────────────────────────────────────────
+
+ public function testCloneReresolvesDataSourceOnFirstUse(): void
+ {
+ $this->proxy->init(null);
+ $clone = clone $this->proxy;
+ $this->assertSame($this->backing, $clone->getDataSource());
+ }
+
+ public function testCloneIsIndependentOfOriginal(): void
+ {
+ $this->proxy->init(null);
+ $clone = clone $this->proxy;
+
+ $secondBacking = new TDataSourceConfigProxyBackingDs();
+ $secondBacking->init(null);
+ $this->app->setModule('secondDs2', $secondBacking);
+
+ $clone->setBackingDataSourceId('secondDs2');
+
+ $this->assertSame($this->backing, $this->proxy->getDataSource());
+ $this->assertSame($secondBacking, $clone->getDataSource());
+ $secondBacking->unlisten();
+ }
+
+ // ── attachProxy / detachProxy ────────────────────────────────────────────────
+
+ public function testHasEventReturnsFalseForBackingEventBeforeAttach(): void
+ {
+ $this->assertFalse($this->proxy->hasEvent('OnTestEvent'));
+ }
+
+ public function testGetDataSourceTriggersAttachProxy(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->getDataSource();
+ $this->assertTrue($this->proxy->hasEvent('OnTestEvent'));
+ }
+
+ public function testProxyHandlerFiresWhenBackingRaisesEvent(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->getDataSource(); // triggers attachProxy
+
+ $fired = false;
+ $this->proxy->OnTestEvent = function () use (&$fired) {
+ $fired = true;
+ };
+ $this->backing->onTestEvent(new TEventParameter());
+ $this->assertTrue($fired);
+ }
+
+ public function testProxyAndBackingHandlerListsAreIndependent(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->getDataSource(); // triggers attachProxy
+
+ $backingFired = false;
+ $this->backing->OnTestEvent = function () use (&$backingFired) {
+ $backingFired = true;
+ };
+ $proxyFired = false;
+ $this->proxy->OnTestEvent = function () use (&$proxyFired) {
+ $proxyFired = true;
+ };
+
+ $this->backing->onTestEvent(new TEventParameter());
+
+ $this->assertTrue($backingFired);
+ $this->assertTrue($proxyFired);
+ $this->assertNotSame(
+ $this->backing->getEventHandlers('OnTestEvent'),
+ $this->proxy->getEventHandlers('OnTestEvent')
+ );
+ }
+
+ public function testGetOnEventViaPropertyAfterAttachProxy(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->getDataSource();
+
+ $handlers = $this->proxy->OnTestEvent;
+ $this->assertInstanceOf(TWeakCallableCollection::class, $handlers);
+ $this->assertNotSame($this->backing->getEventHandlers('OnTestEvent'), $handlers);
+ }
+
+ public function testIssetOnEventViaPropertyAfterAttachProxy(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->getDataSource();
+
+ $this->assertFalse(isset($this->proxy->OnTestEvent));
+
+ $this->proxy->OnTestEvent = function () {};
+ $this->assertTrue(isset($this->proxy->OnTestEvent));
+ }
+
+ public function testUnsetOnEventViaPropertyClearsHandlerCollection(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->getDataSource();
+
+ $this->proxy->OnTestEvent = function () {};
+ $this->assertTrue(isset($this->proxy->OnTestEvent));
+
+ unset($this->proxy->OnTestEvent);
+ $this->assertFalse(isset($this->proxy->OnTestEvent));
+ }
+
+ public function testDetachProxyClearsEventSharing(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->getDataSource(); // triggers attachProxy
+ $this->assertTrue($this->proxy->hasEvent('OnTestEvent'));
+
+ $this->proxy->detachProxy();
+ $this->assertFalse($this->proxy->hasEvent('OnTestEvent'));
+ }
+
+ public function testBackingDataSourceIdChangeCallsDetachProxy(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->getDataSource(); // triggers attachProxy
+ $this->assertTrue($this->proxy->hasEvent('OnTestEvent'));
+
+ $this->proxy->setBackingDataSourceId('someOtherDs');
+ $this->assertFalse($this->proxy->hasEvent('OnTestEvent'));
+ }
+
+ public function testCloneDetachesProxy(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->getDataSource(); // triggers attachProxy on original
+ $this->assertTrue($this->proxy->hasEvent('OnTestEvent'));
+
+ $clone = clone $this->proxy;
+
+ $this->assertFalse($clone->hasEvent('OnTestEvent'));
+ $this->assertTrue($this->proxy->hasEvent('OnTestEvent'));
+ }
+
+ public function testAttachProxyIncludesBehaviorProvidedOnEvent(): void
+ {
+ $this->backing->attachBehavior('testBehavior', new TDataSourceConfigProxyBehaviorWithEvent());
+
+ $this->proxy->init(null);
+ $this->proxy->getDataSource(); // triggers attachProxy
+
+ $this->assertTrue(
+ $this->proxy->hasEvent('OnBehaviorEvent'),
+ 'Proxy must expose on* events contributed by behaviors on the backing data source.'
+ );
+ }
+
+ public function testHandlerRegisteredViaProxyFiresForBehaviorEvent(): void
+ {
+ $this->backing->attachBehavior('testBehavior', new TDataSourceConfigProxyBehaviorWithEvent());
+
+ $this->proxy->init(null);
+ $this->proxy->getDataSource(); // triggers attachProxy
+
+ $fired = false;
+ $this->proxy->OnBehaviorEvent = function () use (&$fired) {
+ $fired = true;
+ };
+
+ $behaviors = $this->backing->getBehaviors(TDataSourceConfigProxyBehaviorWithEvent::class);
+ /** @var TDataSourceConfigProxyBehaviorWithEvent $beh */
+ $beh = reset($behaviors);
+ $beh->onBehaviorEvent(new TEventParameter());
+
+ $this->assertTrue($fired, 'Handler added via proxy must fire when the behavior raises its event.');
+ }
+
+ // ── isa() — backing transparency ─────────────────────────────────────────────
+
+ public function testIsaLazilyResolvesBackingWhenNotYetResolved(): void
+ {
+ $this->assertNull($this->proxy->pubGetBackingComponentDirect(), 'backing must not be resolved yet');
+
+ $this->assertTrue($this->proxy->isa(TDataSourceConfigProxyBackingDs::class));
+ $this->assertNotNull($this->proxy->pubGetBackingComponentDirect());
+ }
+
+ public function testIsaReturnsFalseWhenNoBackingDataSourceIdSet(): void
+ {
+ $proxy = new TDataSourceConfigProxyAccessor();
+ $this->assertFalse($proxy->isa(TDataSourceConfigProxyBackingDs::class));
+ }
+
+ // ── Logging detail ───────────────────────────────────────────────────────────
+
+ public function testMultipleBackingDataSourceIdChangesEachLog(): void
+ {
+ $cat = 'prado.data';
+ $before = $this->countLogs(TLogger::WARNING, $cat);
+
+ $this->proxy->setBackingDataSourceId('first'); // change 1
+ $this->proxy->setBackingDataSourceId('second'); // change 2
+
+ $this->assertSame($before + 2, $this->countLogs(TLogger::WARNING, $cat));
+ }
+
+ public function testBackingDataSourceIdSameValueProducesNoLog(): void
+ {
+ $cat = 'prado.data';
+ $before = $this->countLogs(TLogger::WARNING, $cat);
+
+ $this->proxy->setBackingDataSourceId('backingDs'); // same value
+
+ $this->assertSame($before, $this->countLogs(TLogger::WARNING, $cat));
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────────
+
+ private function countLogs(int $level, string $category): int
+ {
+ return count(Prado::getLogger()->getLogs($level, $category));
+ }
+}
diff --git a/tests/unit/TComponentProxyTest.php b/tests/unit/TComponentProxyTest.php
new file mode 100644
index 000000000..0a1e7756b
--- /dev/null
+++ b/tests/unit/TComponentProxyTest.php
@@ -0,0 +1,591 @@
+
+ * @link https://github.com/pradosoft/prado
+ * @license https://github.com/pradosoft/prado/blob/master/LICENSE
+ */
+
+use Prado\Exceptions\TConfigurationException;
+use Prado\Exceptions\TInvalidOperationException;
+use Prado\Exceptions\TUnknownMethodException;
+use Prado\Collections\TWeakCallableCollection;
+use Prado\IProxy;
+use Prado\Prado;
+use Prado\TApplication;
+use Prado\TComponent;
+use Prado\TComponentProxy;
+use Prado\TEventParameter;
+use Prado\TModule;
+use Prado\Util\TBehavior;
+use Prado\Util\TLogger;
+
+// ── Helper classes ─────────────────────────────────────────────────────────────
+
+/**
+ * A concrete TComponent used as the backing in TComponentProxy tests. Exposes a
+ * custom property and method to exercise __get/__set/__call forwarding.
+ */
+class TComponentProxyBackingComponent extends TComponent
+{
+ /** @var string|null used to exercise __get/__set/__isset/__unset forwarding */
+ private ?string $_customProp = null;
+
+ /** @var int counts how many times onTestEvent has been called */
+ public int $testEventCallCount = 0;
+
+ public function getCustomProp(): ?string
+ {
+ return $this->_customProp;
+ }
+
+ public function setCustomProp(?string $value): void
+ {
+ $this->_customProp = $value;
+ }
+
+ public function customMethod(string $arg): string
+ {
+ return 'custom:' . $arg;
+ }
+
+ public function customMultiArgMethod(string $a, int $b): string
+ {
+ return $a . ':' . $b;
+ }
+
+ public function onTestEvent(TEventParameter $param): void
+ {
+ $this->testEventCallCount++;
+ $this->raiseEvent('OnTestEvent', $this, $param);
+ }
+}
+
+/**
+ * A TBehavior with a public on[A-Z]* event, used to verify that attachProxy()
+ * discovers events exposed by behaviors attached to the backing component.
+ */
+class TComponentProxyBehaviorWithEvent extends TBehavior
+{
+ public function onBehaviorEvent(TEventParameter $param): void
+ {
+ $this->raiseEvent('OnBehaviorEvent', $this->getOwner(), $param);
+ }
+}
+
+/**
+ * A second distinct backing class to test change logging.
+ */
+class TComponentProxyAltBackingComponent extends TComponent
+{
+}
+
+/**
+ * A TComponentProxy subclass that also owns OnTestEvent.
+ * Used to verify that when both the proxy and the backing expose the same
+ * on[A-Z]* event, the backing's raise still forwards through the proxy's list
+ * (with $sender = the backing), while a direct proxy raise uses $sender = proxy.
+ */
+class TComponentProxyWithOwnEvent extends TComponentProxy
+{
+ public function onTestEvent(TEventParameter $param): void
+ {
+ $this->raiseEvent('OnTestEvent', $this, $param);
+ }
+}
+
+/**
+ * Exposes protected internals of TComponentProxy for direct testing.
+ */
+class TComponentProxyAccessor extends TComponentProxy
+{
+ public function pubGetZappableSleepProps(array &$exprops): void
+ {
+ $this->_getZappableSleepProps($exprops);
+ }
+
+ public function pubGetBackingComponentDirect(): ?TComponent
+ {
+ return $this->getBackingComponentDirect();
+ }
+
+ public function pubZappableExcludeBackingComponent(array &$exprops): void
+ {
+ $this->_zappableExcludeBackingComponent($exprops);
+ }
+}
+
+// ── Test class ─────────────────────────────────────────────────────────────────
+
+/**
+ * TComponentProxyTest class.
+ *
+ * Tests TComponentProxy: direct BackingComponent injection, transparent method
+ * and property delegation, event sharing via attachProxy, isa() transparency,
+ * change logging, serialization, and edge cases.
+ *
+ * @package Prado\Tests\Unit
+ */
+class TComponentProxyTest extends PHPUnit\Framework\TestCase
+{
+ private static string $mockAppPath;
+
+ /** @var TApplication|null */
+ private ?TApplication $app = null;
+
+ /** @var TComponentProxyBackingComponent */
+ private TComponentProxyBackingComponent $backing;
+
+ /** @var TComponentProxyAccessor */
+ private TComponentProxyAccessor $proxy;
+
+ public static function setUpBeforeClass(): void
+ {
+ self::$mockAppPath = __DIR__ . '/Caching/mockapp';
+ }
+
+ protected function setUp(): void
+ {
+ $this->app = new TApplication(self::$mockAppPath);
+
+ $this->backing = new TComponentProxyBackingComponent();
+
+ // Build the proxy and inject the backing directly.
+ $this->proxy = new TComponentProxyAccessor();
+ $this->proxy->setBackingComponent($this->backing);
+ }
+
+ protected function tearDown(): void
+ {
+ $this->app->unlisten();
+ $this->app = null;
+ }
+
+ // ── Construction / instance ──────────────────────────────────────────────────
+
+ public function testIsInstanceOfTComponentProxy(): void
+ {
+ $this->assertInstanceOf(TComponentProxy::class, $this->proxy);
+ }
+
+ public function testImplementsIProxy(): void
+ {
+ $this->assertInstanceOf(IProxy::class, $this->proxy);
+ }
+
+ public function testExtendsTComponent(): void
+ {
+ $this->assertInstanceOf(TComponent::class, $this->proxy);
+ }
+
+ public function testDoesNotExtendTModule(): void
+ {
+ $this->assertNotInstanceOf(TModule::class, $this->proxy);
+ }
+
+ // ── setBackingComponent / getBackingComponent ────────────────────────────────
+
+ public function testSetGetBackingComponent(): void
+ {
+ $proxy = new TComponentProxy();
+ $backing = new TComponentProxyBackingComponent();
+ $proxy->setBackingComponent($backing);
+ $this->assertSame($backing, $proxy->getBackingComponent());
+ }
+
+ public function testGetBackingComponentThrowsWhenNotSet(): void
+ {
+ $proxy = new TComponentProxy();
+ $this->expectException(TConfigurationException::class);
+ $proxy->getBackingComponent();
+ }
+
+ public function testGetBackingComponentThrowsExpectedErrorCode(): void
+ {
+ $proxy = new TComponentProxy();
+ try {
+ $proxy->getBackingComponent();
+ $this->fail('Expected TConfigurationException was not thrown.');
+ } catch (TConfigurationException $e) {
+ $this->assertSame('componentproxy_backing_component_required', $e->getErrorCode());
+ }
+ }
+
+ public function testSetBackingComponentSameValueIsNoOp(): void
+ {
+ $cat = 'prado.component';
+ $before = $this->countLogs(TLogger::WARNING, $cat);
+
+ $this->proxy->setBackingComponent($this->backing); // same object — no log
+
+ $this->assertSame($before, $this->countLogs(TLogger::WARNING, $cat));
+ $this->assertSame($this->backing, $this->proxy->getBackingComponent());
+ }
+
+ public function testSetBackingComponentFromNullDoesNotLog(): void
+ {
+ $cat = 'prado.component';
+ $before = $this->countLogs(TLogger::WARNING, $cat);
+
+ $proxy = new TComponentProxy();
+ $proxy->setBackingComponent($this->backing); // first set from null — no log
+
+ $this->assertSame($before, $this->countLogs(TLogger::WARNING, $cat));
+ }
+
+ public function testSetBackingComponentChangeLogsWarning(): void
+ {
+ $cat = 'prado.component';
+ $before = $this->countLogs(TLogger::WARNING, $cat);
+
+ $alt = new TComponentProxyAltBackingComponent();
+ $this->proxy->setBackingComponent($alt); // changes from backing
+
+ $this->assertSame($before + 1, $this->countLogs(TLogger::WARNING, $cat));
+ }
+
+ public function testSetBackingComponentChangeLogContainsBothClassNames(): void
+ {
+ $cat = 'prado.component';
+ $alt = new TComponentProxyAltBackingComponent();
+ $this->proxy->setBackingComponent($alt);
+
+ $logs = Prado::getLogger()->getLogs(TLogger::WARNING, $cat);
+ $msg = end($logs)[TLogger::LOG_MESSAGE];
+ $this->assertStringContainsString(TComponentProxyBackingComponent::class, $msg);
+ $this->assertStringContainsString(TComponentProxyAltBackingComponent::class, $msg);
+ }
+
+ // ── __call dispatch ──────────────────────────────────────────────────────────
+
+ public function testCallForwardsPublicMethodToBacking(): void
+ {
+ $result = $this->proxy->customMethod('hello');
+ $this->assertSame('custom:hello', $result);
+ }
+
+ public function testCallForwardsMultipleArguments(): void
+ {
+ $result = $this->proxy->customMultiArgMethod('key', 42);
+ $this->assertSame('key:42', $result);
+ }
+
+ public function testCallDoesNotForwardDyEvent(): void
+ {
+ $result = $this->proxy->dyCustomEvent('value');
+ $this->assertSame('value', $result);
+ }
+
+ public function testCallDoesNotForwardFxEvent(): void
+ {
+ $this->proxy->fxCustomGlobalEvent('value');
+
+ $this->assertFalse(
+ (new \ReflectionClass($this->backing))->hasMethod('fxCustomGlobalEvent'),
+ 'fxCustomGlobalEvent must not be a real method on the backing — proxy must not forward fx events.'
+ );
+ }
+
+ public function testCallUnknownMethodThrows(): void
+ {
+ $this->expectException(TUnknownMethodException::class);
+ $this->proxy->totallyUnknownMethod();
+ }
+
+ // ── __get / __set / __isset / __unset passthrough ────────────────────────────
+
+ public function testGetForwardsBackingSpecificPropertyToBackingComponent(): void
+ {
+ $this->backing->setCustomProp('hello');
+ $this->assertSame('hello', $this->proxy->CustomProp);
+ }
+
+ public function testSetForwardsBackingSpecificPropertyToBackingComponent(): void
+ {
+ $this->proxy->CustomProp = 'world';
+ $this->assertSame('world', $this->backing->getCustomProp());
+ }
+
+ public function testIssetReturnsFalseWhenBackingPropIsNull(): void
+ {
+ $this->assertFalse(isset($this->proxy->CustomProp));
+ }
+
+ public function testIssetReturnsTrueWhenBackingPropIsSet(): void
+ {
+ $this->proxy->CustomProp = 'set';
+ $this->assertTrue(isset($this->proxy->CustomProp));
+ }
+
+ public function testUnsetForwardsBackingSpecificPropertyToBackingComponent(): void
+ {
+ $this->proxy->CustomProp = 'before';
+ unset($this->proxy->CustomProp);
+ $this->assertFalse(isset($this->proxy->CustomProp));
+ }
+
+ public function testGetUndefinedPropertyThrows(): void
+ {
+ $this->expectException(TInvalidOperationException::class);
+ $_ = $this->proxy->CompletelyUndefinedProperty;
+ }
+
+ // ── __clone ──────────────────────────────────────────────────────────────────
+
+ public function testCloneClearsBackingComponentReference(): void
+ {
+ $clone = clone $this->proxy;
+
+ $this->assertNull($clone->pubGetBackingComponentDirect());
+ }
+
+ public function testCloneIsIndependentOfOriginal(): void
+ {
+ $clone = clone $this->proxy;
+
+ $alt = new TComponentProxyAltBackingComponent();
+ $clone->setBackingComponent($alt);
+
+ $this->assertSame($this->backing, $this->proxy->getBackingComponent());
+ $this->assertSame($alt, $clone->getBackingComponent());
+ }
+
+ // ── _getZappableSleepProps ───────────────────────────────────────────────────
+
+ public function testZappableExcludesProxyEventNames(): void
+ {
+ $exprops = [];
+ $this->proxy->pubGetZappableSleepProps($exprops);
+
+ $this->assertContains(
+ "\0" . TComponentProxy::class . "\0_proxyEventNames",
+ $exprops
+ );
+ }
+
+ public function testZappablePreservesBackingComponent(): void
+ {
+ // TComponentProxy preserves _proxyBacking in serialization because
+ // there is no module registry to re-resolve it from.
+ $exprops = [];
+ $this->proxy->pubGetZappableSleepProps($exprops);
+
+ $this->assertNotContains(
+ "\0" . TComponentProxy::class . "\0_proxyBacking",
+ $exprops
+ );
+ }
+
+ public function testZappableExcludeBackingComponentHelperAddsKey(): void
+ {
+ // _zappableExcludeBackingComponent delegates to _addProxyBackingZappable(),
+ // so the key uses the using class name (TComponentProxy), not the trait name.
+ $exprops = [];
+ $this->proxy->pubZappableExcludeBackingComponent($exprops);
+
+ $this->assertContains(
+ "\0" . TComponentProxy::class . "\0_proxyBacking",
+ $exprops
+ );
+ }
+
+ // ── attachProxy / detachProxy ────────────────────────────────────────────────
+
+ public function testHasEventReturnsFalseForBackingEventBeforeAttach(): void
+ {
+ // Before attachProxy() has run, the backing's OnTestEvent is not exposed.
+ $this->assertFalse($this->proxy->hasEvent('OnTestEvent'));
+ }
+
+ public function testProxyHandlerFiresWhenBackingRaisesEvent(): void
+ {
+ // Handlers registered on the proxy must fire when the backing raises the
+ // event, because attachProxy() registers a forwarder on the backing that
+ // calls through the proxy's own independent handler list.
+ $this->proxy->attachProxy();
+
+ $fired = false;
+ $this->proxy->OnTestEvent = function () use (&$fired) {
+ $fired = true;
+ };
+ $this->backing->onTestEvent(new TEventParameter());
+ $this->assertTrue($fired);
+ }
+
+ public function testProxyAndBackingHandlerListsAreIndependent(): void
+ {
+ // The proxy has its own TWeakCallableCollection for each forwarded event —
+ // it is NOT the same object as the backing's collection.
+ $this->proxy->attachProxy();
+
+ $backingFired = false;
+ $this->backing->OnTestEvent = function () use (&$backingFired) {
+ $backingFired = true;
+ };
+ $proxyFired = false;
+ $this->proxy->OnTestEvent = function () use (&$proxyFired) {
+ $proxyFired = true;
+ };
+
+ $this->backing->onTestEvent(new TEventParameter());
+
+ // Both fire — backing's own handler fires normally, and the forwarder calls
+ // the proxy's list which fires the proxy handler.
+ $this->assertTrue($backingFired);
+ $this->assertTrue($proxyFired);
+
+ // The collections themselves are separate objects.
+ $this->assertNotSame(
+ $this->backing->getEventHandlers('OnTestEvent'),
+ $this->proxy->getEventHandlers('OnTestEvent')
+ );
+ }
+
+ public function testGetOnEventViaPropertyAfterAttachProxy(): void
+ {
+ $this->proxy->attachProxy();
+
+ $handlers = $this->proxy->OnTestEvent;
+ $this->assertInstanceOf(TWeakCallableCollection::class, $handlers);
+ // The proxy's collection is its own independent object.
+ $this->assertNotSame($this->backing->getEventHandlers('OnTestEvent'), $handlers);
+ }
+
+ public function testSenderDifferentiatesBackingRaiseFromProxyRaise(): void
+ {
+ // When the proxy itself also owns the event, the backing's raise still
+ // forwards through the proxy's list. $sender identifies the origin:
+ // backing-raise → $sender = backing; proxy-raise → $sender = proxy.
+ $proxy = new TComponentProxyWithOwnEvent();
+ $proxy->setBackingComponent($this->backing);
+ $proxy->attachProxy();
+
+ $senders = [];
+ $proxy->OnTestEvent = function ($sender) use (&$senders) {
+ $senders[] = $sender;
+ };
+
+ // Backing raises the event — forwarder fires, sender = backing.
+ $this->backing->onTestEvent(new TEventParameter());
+ // Proxy raises the event directly — sender = proxy.
+ $proxy->onTestEvent(new TEventParameter());
+
+ $this->assertCount(2, $senders);
+ $this->assertSame($this->backing, $senders[0]);
+ $this->assertSame($proxy, $senders[1]);
+ }
+
+ public function testIssetOnEventViaPropertyAfterAttachProxy(): void
+ {
+ $this->proxy->attachProxy();
+
+ $this->assertFalse(isset($this->proxy->OnTestEvent));
+
+ $this->proxy->OnTestEvent = function () {};
+ $this->assertTrue(isset($this->proxy->OnTestEvent));
+ }
+
+ public function testUnsetOnEventViaPropertyClearsHandlerCollection(): void
+ {
+ $this->proxy->attachProxy();
+
+ $this->proxy->OnTestEvent = function () {};
+ $this->assertTrue(isset($this->proxy->OnTestEvent));
+
+ unset($this->proxy->OnTestEvent);
+ $this->assertFalse(isset($this->proxy->OnTestEvent));
+ }
+
+ public function testDetachProxyClearsEventSharing(): void
+ {
+ $this->proxy->attachProxy();
+ $this->assertTrue($this->proxy->hasEvent('OnTestEvent'));
+
+ $this->proxy->detachProxy();
+ $this->assertFalse($this->proxy->hasEvent('OnTestEvent'));
+ }
+
+ public function testBackingComponentChangeCallsDetachProxy(): void
+ {
+ $this->proxy->attachProxy();
+ $this->assertTrue($this->proxy->hasEvent('OnTestEvent'));
+
+ $alt = new TComponentProxyAltBackingComponent();
+ $this->proxy->setBackingComponent($alt);
+ $this->assertFalse($this->proxy->hasEvent('OnTestEvent'));
+ }
+
+ public function testCloneDetachesProxy(): void
+ {
+ $this->proxy->attachProxy();
+ $this->assertTrue($this->proxy->hasEvent('OnTestEvent'));
+
+ $clone = clone $this->proxy;
+
+ $this->assertFalse($clone->hasEvent('OnTestEvent'));
+ $this->assertTrue($this->proxy->hasEvent('OnTestEvent'));
+ }
+
+ public function testAttachProxyIncludesBehaviorProvidedOnEvent(): void
+ {
+ $this->backing->attachBehavior('testBehavior', new TComponentProxyBehaviorWithEvent());
+
+ $this->proxy->attachProxy();
+
+ $this->assertTrue(
+ $this->proxy->hasEvent('OnBehaviorEvent'),
+ 'Proxy must expose on* events contributed by behaviors on the backing component.'
+ );
+ }
+
+ public function testHandlerRegisteredViaProxyFiresForBehaviorEvent(): void
+ {
+ $this->backing->attachBehavior('testBehavior', new TComponentProxyBehaviorWithEvent());
+ $this->proxy->attachProxy();
+
+ $fired = false;
+ $this->proxy->OnBehaviorEvent = function () use (&$fired) {
+ $fired = true;
+ };
+
+ $behaviors = $this->backing->getBehaviors(TComponentProxyBehaviorWithEvent::class);
+ /** @var TComponentProxyBehaviorWithEvent $beh */
+ $beh = reset($behaviors);
+ $beh->onBehaviorEvent(new TEventParameter());
+
+ $this->assertTrue($fired, 'Handler added via proxy must fire when the behavior raises its event.');
+ }
+
+ // ── isa() — backing transparency ─────────────────────────────────────────────
+
+ public function testIsaReturnsTrueForProxyOwnClass(): void
+ {
+ $this->assertTrue($this->proxy->isa(TComponentProxy::class));
+ $this->assertTrue($this->proxy->isa(TComponent::class));
+ }
+
+ public function testIsaReturnsTrueForBackingClass(): void
+ {
+ $this->assertTrue($this->proxy->isa(TComponentProxyBackingComponent::class));
+ }
+
+ public function testIsaReturnsFalseForUnrelatedClass(): void
+ {
+ $this->assertFalse($this->proxy->isa(\stdClass::class));
+ }
+
+ public function testIsaReturnsFalseWhenNoBackingSet(): void
+ {
+ $proxy = new TComponentProxyAccessor();
+ $this->assertFalse($proxy->isa(TComponentProxyBackingComponent::class));
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────────
+
+ private function countLogs(int $level, string $category): int
+ {
+ return count(Prado::getLogger()->getLogs($level, $category));
+ }
+}
diff --git a/tests/unit/TModuleProxyTest.php b/tests/unit/TModuleProxyTest.php
new file mode 100644
index 000000000..2c6bafb4f
--- /dev/null
+++ b/tests/unit/TModuleProxyTest.php
@@ -0,0 +1,777 @@
+
+ * @link https://github.com/pradosoft/prado
+ * @license https://github.com/pradosoft/prado/blob/master/LICENSE
+ */
+
+use Prado\Exceptions\TConfigurationException;
+use Prado\Exceptions\TInvalidOperationException;
+use Prado\Exceptions\TUnknownMethodException;
+use Prado\Collections\TWeakCallableCollection;
+use Prado\IModuleDependency;
+use Prado\IProxy;
+use Prado\Prado;
+use Prado\TApplication;
+use Prado\TComponent;
+use Prado\TEventParameter;
+use Prado\TModule;
+use Prado\TModuleProxy;
+use Prado\Util\TBehavior;
+use Prado\Util\TLogger;
+
+// ── Helper classes ─────────────────────────────────────────────────────────────
+
+/**
+ * A concrete TModule used as the backing component in TModuleProxy tests.
+ * Exposes a custom property and method to exercise forwarding through the proxy.
+ */
+class TModuleProxyBackingModule extends TModule
+{
+ /** @var string|null used to exercise __get/__set/__isset/__unset forwarding */
+ private ?string $_customProp = null;
+
+ /** @var int counts how many times onTestEvent has been called */
+ public int $testEventCallCount = 0;
+
+ public function getCustomProp(): ?string
+ {
+ return $this->_customProp;
+ }
+
+ public function setCustomProp(?string $value): void
+ {
+ $this->_customProp = $value;
+ }
+
+ public function customMethod(string $arg): string
+ {
+ return 'moduleproxy:' . $arg;
+ }
+
+ public function customMultiArgMethod(string $a, int $b): string
+ {
+ return $a . ':' . $b;
+ }
+
+ public function onTestEvent(TEventParameter $param): void
+ {
+ $this->testEventCallCount++;
+ $this->raiseEvent('OnTestEvent', $this, $param);
+ }
+
+ public function init($config): void
+ {
+ parent::init($config);
+ }
+}
+
+/**
+ * A TBehavior with a public on[A-Z]* event used to verify that attachProxy()
+ * discovers events exposed by behaviors attached to the backing module.
+ */
+class TModuleProxyBehaviorWithEvent extends TBehavior
+{
+ public function onBehaviorEvent(TEventParameter $param): void
+ {
+ $this->raiseEvent('OnBehaviorEvent', $this->getOwner(), $param);
+ }
+}
+
+/**
+ * Exposes protected internals of TModuleProxy for direct testing.
+ */
+class TModuleProxyAccessor extends TModuleProxy
+{
+ public function pubGetZappableSleepProps(array &$exprops): void
+ {
+ $this->_getZappableSleepProps($exprops);
+ }
+
+ public function pubGetBackingComponentDirect(): ?TComponent
+ {
+ return $this->getBackingComponentDirect();
+ }
+}
+
+// ── Test class ─────────────────────────────────────────────────────────────────
+
+/**
+ * TModuleProxyTest class.
+ *
+ * Tests TModuleProxy: BackingComponentId property, lazy module resolution from
+ * the application registry, init() validation, IModuleDependency, transparent
+ * method/property delegation, event sharing via attachProxy, isa() transparency,
+ * change logging, serialization, and edge cases.
+ *
+ * @package Prado\Tests\Unit
+ */
+class TModuleProxyTest extends PHPUnit\Framework\TestCase
+{
+ private static string $mockAppPath;
+
+ /** @var TApplication|null */
+ private ?TApplication $app = null;
+
+ /** @var TModuleProxyBackingModule */
+ private TModuleProxyBackingModule $backing;
+
+ /** @var TModuleProxyAccessor */
+ private TModuleProxyAccessor $proxy;
+
+ public static function setUpBeforeClass(): void
+ {
+ self::$mockAppPath = __DIR__ . '/Caching/mockapp';
+ }
+
+ protected function setUp(): void
+ {
+ $this->app = new TApplication(self::$mockAppPath);
+
+ // Create and register a fully initialized backing module.
+ $this->backing = new TModuleProxyBackingModule();
+ $this->backing->init(null);
+ $this->app->setModule('backingModule', $this->backing);
+
+ // Build the proxy but do NOT call init() — tests that need it call it.
+ $this->proxy = new TModuleProxyAccessor();
+ $this->proxy->setBackingComponentId('backingModule');
+ }
+
+ protected function tearDown(): void
+ {
+ $this->proxy->unlisten();
+ $this->backing->unlisten();
+ $this->app->unlisten();
+ $this->app = null;
+ }
+
+ // ── Construction / instance ──────────────────────────────────────────────────
+
+ public function testIsInstanceOfTModuleProxy(): void
+ {
+ $this->assertInstanceOf(TModuleProxy::class, $this->proxy);
+ }
+
+ public function testImplementsIModuleDependency(): void
+ {
+ $this->assertInstanceOf(IModuleDependency::class, $this->proxy);
+ }
+
+ public function testImplementsIProxy(): void
+ {
+ $this->assertInstanceOf(IProxy::class, $this->proxy);
+ }
+
+ public function testExtendsTModule(): void
+ {
+ $this->assertInstanceOf(TModule::class, $this->proxy);
+ }
+
+ // ── Default property values ──────────────────────────────────────────────────
+
+ public function testDefaultBackingComponentIdIsEmptyString(): void
+ {
+ $fresh = new TModuleProxy();
+ $this->assertSame('', $fresh->getBackingComponentId());
+ }
+
+ // ── getBackingComponentId / setBackingComponentId ────────────────────────────
+
+ public function testSetGetBackingComponentId(): void
+ {
+ $proxy = new TModuleProxy();
+ $proxy->setBackingComponentId('myModule');
+ $this->assertSame('myModule', $proxy->getBackingComponentId());
+ }
+
+ public function testSetBackingComponentIdSameValueIsNoOp(): void
+ {
+ $this->proxy->setBackingComponentId('backingModule'); // same value — must not log
+ $this->assertSame('backingModule', $this->proxy->getBackingComponentId());
+ }
+
+ public function testSetBackingComponentIdFromEmptyDoesNotLog(): void
+ {
+ $cat = 'prado.component';
+ $before = $this->countLogs(TLogger::WARNING, $cat);
+
+ $proxy = new TModuleProxy();
+ $proxy->setBackingComponentId('backingModule'); // first set from '' — no log
+
+ $this->assertSame($before, $this->countLogs(TLogger::WARNING, $cat));
+ }
+
+ public function testSetBackingComponentIdChangeLogsWarning(): void
+ {
+ $cat = 'prado.component';
+ $before = $this->countLogs(TLogger::WARNING, $cat);
+
+ $this->proxy->setBackingComponentId('otherModule'); // changes from 'backingModule'
+
+ $this->assertSame($before + 1, $this->countLogs(TLogger::WARNING, $cat));
+ }
+
+ public function testSetBackingComponentIdChangeLogMessageContainsBothIds(): void
+ {
+ $cat = 'prado.component';
+ $this->proxy->setBackingComponentId('replacementModule');
+
+ $logs = Prado::getLogger()->getLogs(TLogger::WARNING, $cat);
+ $msg = end($logs)[TLogger::LOG_MESSAGE];
+ $this->assertStringContainsString('backingModule', $msg);
+ $this->assertStringContainsString('replacementModule', $msg);
+ }
+
+ public function testSetBackingComponentIdInvalidatesResolvedReference(): void
+ {
+ // Force resolution of the proxy.
+ $this->proxy->init(null);
+ $first = $this->proxy->getBackingComponent();
+
+ // Change the ID — the cached reference must be cleared.
+ $secondBacking = new TModuleProxyBackingModule();
+ $secondBacking->init(null);
+ $this->app->setModule('secondModule', $secondBacking);
+
+ $this->proxy->setBackingComponentId('secondModule');
+ $second = $this->proxy->getBackingComponent();
+
+ $this->assertNotSame($first, $second);
+ $this->assertSame($secondBacking, $second);
+ $secondBacking->unlisten();
+ }
+
+ public function testSetBackingComponentIdAcceptsStringFromXmlConfig(): void
+ {
+ $proxy = new TModuleProxy();
+ $proxy->setBackingComponentId('someId');
+ $this->assertSame('someId', $proxy->getBackingComponentId());
+ }
+
+ // ── getModuleDependencies ────────────────────────────────────────────────────
+
+ public function testGetModuleDependenciesReturnsNullWhenBackingComponentIdEmpty(): void
+ {
+ $proxy = new TModuleProxy();
+ $this->assertNull($proxy->getModuleDependencies());
+ }
+
+ public function testGetModuleDependenciesReturnsDependencyArrayWhenIdSet(): void
+ {
+ $deps = $this->proxy->getModuleDependencies();
+ $this->assertIsArray($deps);
+ $this->assertCount(1, $deps);
+ $this->assertSame('backingModule', $deps[0]['id']);
+ $this->assertTrue($deps[0]['required']);
+ }
+
+ public function testGetModuleDependenciesIsInitParameterIsIgnored(): void
+ {
+ $depsInit = $this->proxy->getModuleDependencies(true);
+ $depsPreInit = $this->proxy->getModuleDependencies(false);
+ $this->assertEquals($depsInit, $depsPreInit);
+ }
+
+ // ── init() ──────────────────────────────────────────────────────────────────
+
+ public function testInitSucceedsWhenBackingComponentIdIsSet(): void
+ {
+ $this->proxy->init(null);
+ $this->assertSame('backingModule', $this->proxy->getBackingComponentId());
+ }
+
+ public function testInitThrowsWhenBackingComponentIdIsEmpty(): void
+ {
+ $proxy = new TModuleProxy();
+ $this->expectException(TConfigurationException::class);
+ $proxy->init(null);
+ }
+
+ public function testInitExceptionHasExpectedErrorCode(): void
+ {
+ $proxy = new TModuleProxy();
+ try {
+ $proxy->init(null);
+ $this->fail('Expected TConfigurationException was not thrown.');
+ } catch (TConfigurationException $e) {
+ $this->assertSame('componentproxy_backing_component_id_required', $e->getErrorCode());
+ }
+ }
+
+ // ── getBackingComponent() — lazy resolution ──────────────────────────────────
+
+ public function testGetBackingComponentReturnsBackingModule(): void
+ {
+ $this->proxy->init(null);
+ $this->assertSame($this->backing, $this->proxy->getBackingComponent());
+ }
+
+ public function testGetBackingComponentCachesResolvedReference(): void
+ {
+ $this->proxy->init(null);
+ $first = $this->proxy->getBackingComponent();
+ $second = $this->proxy->getBackingComponent();
+ $this->assertSame($first, $second);
+ }
+
+ public function testGetBackingComponentThrowsWhenBackingComponentIdIsEmpty(): void
+ {
+ $proxy = new TModuleProxy();
+ $this->expectException(TConfigurationException::class);
+ $proxy->getBackingComponent();
+ }
+
+ public function testGetBackingComponentThrowsWithExpectedErrorCodeWhenIdEmpty(): void
+ {
+ $proxy = new TModuleProxy();
+ try {
+ $proxy->getBackingComponent();
+ $this->fail('Expected TConfigurationException was not thrown.');
+ } catch (TConfigurationException $e) {
+ $this->assertSame('componentproxy_backing_component_id_required', $e->getErrorCode());
+ }
+ }
+
+ public function testGetBackingComponentThrowsWhenModuleNotFound(): void
+ {
+ $proxy = new TModuleProxyAccessor();
+ $proxy->setBackingComponentId('nonExistentModule');
+ $this->expectException(TConfigurationException::class);
+ $proxy->getBackingComponent();
+ }
+
+ public function testGetBackingComponentModuleNotFoundHasExpectedErrorCode(): void
+ {
+ $proxy = new TModuleProxyAccessor();
+ $proxy->setBackingComponentId('missingModule');
+ try {
+ $proxy->getBackingComponent();
+ $this->fail('Expected TConfigurationException was not thrown.');
+ } catch (TConfigurationException $e) {
+ $this->assertSame('componentproxy_component_not_found', $e->getErrorCode());
+ }
+ }
+
+ // ── __call dispatch ──────────────────────────────────────────────────────────
+
+ public function testCallForwardsPublicMethodToBackingModule(): void
+ {
+ $this->proxy->init(null);
+ $result = $this->proxy->customMethod('hello');
+ $this->assertSame('moduleproxy:hello', $result);
+ }
+
+ public function testCallForwardsMultipleArguments(): void
+ {
+ $this->proxy->init(null);
+ $result = $this->proxy->customMultiArgMethod('key', 42);
+ $this->assertSame('key:42', $result);
+ }
+
+ public function testCallLazilyResolvesBeforeForwarding(): void
+ {
+ $this->proxy->init(null);
+ $result = $this->proxy->customMethod('lazy');
+ $this->assertSame('moduleproxy:lazy', $result);
+ }
+
+ public function testCallDoesNotForwardDyEvent(): void
+ {
+ $this->proxy->init(null);
+ $result = $this->proxy->dyCustomEvent('value');
+ $this->assertSame('value', $result);
+ }
+
+ public function testCallDoesNotForwardFxEvent(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->fxCustomGlobalEvent('value');
+
+ $this->assertFalse(
+ (new \ReflectionClass($this->backing))->hasMethod('fxCustomGlobalEvent'),
+ 'fxCustomGlobalEvent must not be a real method on the backing — proxy must not forward fx events.'
+ );
+ }
+
+ public function testCallUnknownMethodThrows(): void
+ {
+ $this->proxy->init(null);
+ $this->expectException(TUnknownMethodException::class);
+ $this->proxy->totallyUnknownMethod();
+ }
+
+ // ── __get / __set / __isset / __unset passthrough ────────────────────────────
+
+ public function testGetForwardsModuleSpecificPropertyToBackingModule(): void
+ {
+ $this->proxy->init(null);
+ $this->backing->setCustomProp('hello');
+ $this->assertSame('hello', $this->proxy->CustomProp);
+ }
+
+ public function testSetForwardsModuleSpecificPropertyToBackingModule(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->CustomProp = 'world';
+ $this->assertSame('world', $this->backing->getCustomProp());
+ }
+
+ public function testIssetReturnsFalseWhenModulePropIsNull(): void
+ {
+ $this->proxy->init(null);
+ $this->assertFalse(isset($this->proxy->CustomProp));
+ }
+
+ public function testIssetReturnsTrueWhenModulePropIsSet(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->CustomProp = 'set';
+ $this->assertTrue(isset($this->proxy->CustomProp));
+ }
+
+ public function testUnsetForwardsModuleSpecificPropertyToBackingModule(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->CustomProp = 'before';
+ unset($this->proxy->CustomProp);
+ $this->assertFalse(isset($this->proxy->CustomProp));
+ }
+
+ public function testGetProxyOwnPropertyUsesProxyGetter(): void
+ {
+ $this->assertSame('backingModule', $this->proxy->BackingComponentId);
+ }
+
+ public function testGetUndefinedPropertyThrows(): void
+ {
+ $this->proxy->init(null);
+ $this->expectException(TInvalidOperationException::class);
+ $_ = $this->proxy->CompletelyUndefinedProperty;
+ }
+
+ // ── __clone ──────────────────────────────────────────────────────────────────
+
+ public function testCloneClearsComponentReference(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->getBackingComponent(); // force lazy resolution
+
+ $clone = clone $this->proxy;
+
+ $this->assertNull($clone->pubGetBackingComponentDirect());
+ }
+
+ public function testClonePreservesBackingComponentId(): void
+ {
+ $clone = clone $this->proxy;
+ $this->assertSame('backingModule', $clone->getBackingComponentId());
+ }
+
+ public function testCloneReresolvesBackingModuleOnFirstUse(): void
+ {
+ $this->proxy->init(null);
+ $clone = clone $this->proxy;
+ $this->assertSame($this->backing, $clone->getBackingComponent());
+ }
+
+ public function testCloneIsIndependentOfOriginal(): void
+ {
+ $this->proxy->init(null);
+ $clone = clone $this->proxy;
+
+ $secondBacking = new TModuleProxyBackingModule();
+ $secondBacking->init(null);
+ $this->app->setModule('secondModuleClone', $secondBacking);
+
+ $clone->setBackingComponentId('secondModuleClone');
+
+ $this->assertSame($this->backing, $this->proxy->getBackingComponent());
+ $this->assertSame($secondBacking, $clone->getBackingComponent());
+ $secondBacking->unlisten();
+ }
+
+ // ── _getZappableSleepProps ───────────────────────────────────────────────────
+
+ public function testZappableExcludesBackingComponentReference(): void
+ {
+ $exprops = [];
+ $this->proxy->pubGetZappableSleepProps($exprops);
+
+ // _proxyBacking is declared in TComponentProxyTrait, but __CLASS__ in the
+ // trait resolves to the using class (TModuleProxy), so the key uses that.
+ $this->assertContains(
+ "\0" . TModuleProxy::class . "\0_proxyBacking",
+ $exprops
+ );
+ }
+
+ public function testZappableAlwaysExcludesProxyEventNames(): void
+ {
+ $exprops = [];
+ $this->proxy->pubGetZappableSleepProps($exprops);
+
+ // _proxyEventNames is declared in TComponentProxyTrait which is used
+ // directly by TModuleProxy, so __CLASS__ in the trait = TModuleProxy.
+ $this->assertContains(
+ "\0" . TModuleProxy::class . "\0_proxyEventNames",
+ $exprops
+ );
+ }
+
+ public function testZappableExcludesBackingComponentIdWhenEmpty(): void
+ {
+ $proxy = new TModuleProxyAccessor();
+ $exprops = [];
+ $proxy->pubGetZappableSleepProps($exprops);
+
+ $this->assertContains(
+ "\0" . TModuleProxy::class . "\0_backingComponentId",
+ $exprops
+ );
+ }
+
+ public function testZappableKeepsBackingComponentIdWhenNonEmpty(): void
+ {
+ $exprops = [];
+ $this->proxy->pubGetZappableSleepProps($exprops);
+
+ $this->assertNotContains(
+ "\0" . TModuleProxy::class . "\0_backingComponentId",
+ $exprops
+ );
+ }
+
+ // ── attachProxy / detachProxy ────────────────────────────────────────────────
+
+ public function testHasEventReturnsFalseForBackingModuleEventBeforeAttach(): void
+ {
+ $this->assertFalse($this->proxy->hasEvent('OnTestEvent'));
+ }
+
+ public function testGetBackingComponentTriggersAttachProxy(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->getBackingComponent();
+ $this->assertTrue($this->proxy->hasEvent('OnTestEvent'));
+ }
+
+ public function testProxyHandlerFiresWhenBackingModuleRaisesEvent(): void
+ {
+ // Handlers registered on the proxy must fire when the backing raises the
+ // event, because attachProxy() registers a forwarder on the backing that
+ // calls through the proxy's own independent handler list.
+ $this->proxy->init(null);
+ $this->proxy->getBackingComponent(); // triggers attachProxy
+
+ $fired = false;
+ $this->proxy->OnTestEvent = function () use (&$fired) {
+ $fired = true;
+ };
+ $this->backing->onTestEvent(new TEventParameter());
+ $this->assertTrue($fired);
+ }
+
+ public function testProxyAndBackingModuleHandlerListsAreIndependent(): void
+ {
+ // The proxy owns its own TWeakCallableCollection for each forwarded event —
+ // it is NOT the same object as the backing module's collection.
+ $this->proxy->init(null);
+ $this->proxy->getBackingComponent(); // triggers attachProxy
+
+ $backingFired = false;
+ $this->backing->OnTestEvent = function () use (&$backingFired) {
+ $backingFired = true;
+ };
+ $proxyFired = false;
+ $this->proxy->OnTestEvent = function () use (&$proxyFired) {
+ $proxyFired = true;
+ };
+
+ $this->backing->onTestEvent(new TEventParameter());
+
+ $this->assertTrue($backingFired);
+ $this->assertTrue($proxyFired);
+
+ $this->assertNotSame(
+ $this->backing->getEventHandlers('OnTestEvent'),
+ $this->proxy->getEventHandlers('OnTestEvent')
+ );
+ }
+
+ public function testGetOnEventViaPropertyAfterAttachProxy(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->getBackingComponent(); // triggers attachProxy
+
+ $handlers = $this->proxy->OnTestEvent;
+ $this->assertInstanceOf(TWeakCallableCollection::class, $handlers);
+ // The proxy's collection is its own independent object.
+ $this->assertNotSame($this->backing->getEventHandlers('OnTestEvent'), $handlers);
+ }
+
+ public function testIssetOnEventViaPropertyAfterAttachProxy(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->getBackingComponent(); // triggers attachProxy
+
+ $this->assertFalse(isset($this->proxy->OnTestEvent));
+
+ $this->proxy->OnTestEvent = function () {};
+ $this->assertTrue(isset($this->proxy->OnTestEvent));
+ }
+
+ public function testUnsetOnEventViaPropertyClearsHandlerCollection(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->getBackingComponent(); // triggers attachProxy
+
+ $this->proxy->OnTestEvent = function () {};
+ $this->assertTrue(isset($this->proxy->OnTestEvent));
+
+ unset($this->proxy->OnTestEvent);
+ $this->assertFalse(isset($this->proxy->OnTestEvent));
+ }
+
+ public function testDetachProxyClearsEventSharing(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->getBackingComponent(); // triggers attachProxy
+ $this->assertTrue($this->proxy->hasEvent('OnTestEvent'));
+
+ $this->proxy->detachProxy();
+ $this->assertFalse($this->proxy->hasEvent('OnTestEvent'));
+ }
+
+ public function testBackingComponentIdChangeCallsDetachProxy(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->getBackingComponent(); // triggers attachProxy
+ $this->assertTrue($this->proxy->hasEvent('OnTestEvent'));
+
+ $this->proxy->setBackingComponentId('someOtherModule');
+ $this->assertFalse($this->proxy->hasEvent('OnTestEvent'));
+ }
+
+ public function testCloneDetachesProxy(): void
+ {
+ $this->proxy->init(null);
+ $this->proxy->getBackingComponent(); // triggers attachProxy on original
+ $this->assertTrue($this->proxy->hasEvent('OnTestEvent'));
+
+ $clone = clone $this->proxy;
+
+ $this->assertFalse($clone->hasEvent('OnTestEvent'));
+ $this->assertTrue($this->proxy->hasEvent('OnTestEvent'));
+ }
+
+ public function testAttachProxyIncludesBehaviorProvidedOnEvent(): void
+ {
+ $this->backing->attachBehavior('testBehavior', new TModuleProxyBehaviorWithEvent());
+
+ $this->proxy->init(null);
+ $this->proxy->getBackingComponent(); // triggers attachProxy
+
+ $this->assertTrue(
+ $this->proxy->hasEvent('OnBehaviorEvent'),
+ 'Proxy must expose on* events contributed by behaviors on the backing module.'
+ );
+ }
+
+ public function testHandlerRegisteredViaProxyFiresForBehaviorEvent(): void
+ {
+ $this->backing->attachBehavior('testBehavior', new TModuleProxyBehaviorWithEvent());
+
+ $this->proxy->init(null);
+ $this->proxy->getBackingComponent(); // triggers attachProxy
+
+ $fired = false;
+ $this->proxy->OnBehaviorEvent = function () use (&$fired) {
+ $fired = true;
+ };
+
+ $behaviors = $this->backing->getBehaviors(TModuleProxyBehaviorWithEvent::class);
+ /** @var TModuleProxyBehaviorWithEvent $beh */
+ $beh = reset($behaviors);
+ $beh->onBehaviorEvent(new TEventParameter());
+
+ $this->assertTrue($fired, 'Handler added via proxy must fire when the behavior raises its event.');
+ }
+
+ // ── isa() — backing-component transparency ────────────────────────────────────
+
+ public function testIsaReturnsTrueForProxyOwnClass(): void
+ {
+ $this->assertTrue($this->proxy->isa(TModuleProxy::class));
+ $this->assertTrue($this->proxy->isa(TModule::class));
+ $this->assertTrue($this->proxy->isa(TComponent::class));
+ }
+
+ public function testIsaReturnsTrueForBackingModuleClass(): void
+ {
+ $this->proxy->getBackingComponent(); // force resolution
+ $this->assertTrue($this->proxy->isa(TModuleProxyBackingModule::class));
+ }
+
+ public function testIsaReturnsFalseForUnrelatedClass(): void
+ {
+ $this->assertFalse($this->proxy->isa(\stdClass::class));
+ }
+
+ public function testIsaLazilyResolvesBackingModuleWhenNotYetResolved(): void
+ {
+ $this->assertNull($this->proxy->pubGetBackingComponentDirect(), 'backing must not be resolved yet');
+
+ $this->assertTrue($this->proxy->isa(TModuleProxyBackingModule::class));
+ $this->assertNotNull($this->proxy->pubGetBackingComponentDirect());
+ }
+
+ public function testIsaReturnsFalseWhenNoBackingComponentIdSet(): void
+ {
+ $proxy = new TModuleProxyAccessor();
+ $this->assertFalse($proxy->isa(TModuleProxyBackingModule::class));
+ }
+
+ // ── Logging detail ───────────────────────────────────────────────────────────
+
+ public function testBackingComponentIdChangeLoggedAtWarningLevel(): void
+ {
+ $cat = 'prado.component';
+ $before = $this->countLogs(TLogger::WARNING, $cat);
+
+ $this->proxy->setBackingComponentId('anotherModule');
+
+ $this->assertSame($before + 1, $this->countLogs(TLogger::WARNING, $cat));
+ }
+
+ public function testMultipleBackingComponentIdChangesEachLog(): void
+ {
+ $cat = 'prado.component';
+ $before = $this->countLogs(TLogger::WARNING, $cat);
+
+ $this->proxy->setBackingComponentId('first'); // change 1
+ $this->proxy->setBackingComponentId('second'); // change 2
+
+ $this->assertSame($before + 2, $this->countLogs(TLogger::WARNING, $cat));
+ }
+
+ public function testBackingComponentIdSameValueProducesNoLog(): void
+ {
+ $cat = 'prado.component';
+ $before = $this->countLogs(TLogger::WARNING, $cat);
+
+ $this->proxy->setBackingComponentId('backingModule'); // same value
+
+ $this->assertSame($before, $this->countLogs(TLogger::WARNING, $cat));
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────────
+
+ private function countLogs(int $level, string $category): int
+ {
+ return count(Prado::getLogger()->getLogs($level, $category));
+ }
+}