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)); + } +}