diff --git a/framework/Collections/IWeakCollection.php b/framework/Collections/IWeakCollection.php index 114155853..5e7cbd85b 100644 --- a/framework/Collections/IWeakCollection.php +++ b/framework/Collections/IWeakCollection.php @@ -13,8 +13,8 @@ /** * IWeakCollection interface * - * This is implemented by Weak Collection classes such as {@see \Prado\Collections\TWeakCallableCollection} - * and {@see \Prado\Collections\TWeakList} for providing weak collections. + * This is implemented by Weak Collection classes such as {@see \Prado\Collections\TWeakCallableCollection}, + * {@see \Prado\Collections\TWeakList}, and {@see \Prado\Collections\TWeakMap} for providing weak collections. * * @author Brad Anderson * @since 4.3.0 diff --git a/framework/Collections/TWeakCallableCollection.php b/framework/Collections/TWeakCallableCollection.php index 02d5ce67f..aa349dc23 100644 --- a/framework/Collections/TWeakCallableCollection.php +++ b/framework/Collections/TWeakCallableCollection.php @@ -58,12 +58,6 @@ class TWeakCallableCollection extends TPriorityList implements IWeakCollection, { use TWeakCollectionTrait; - /** @var ?bool Should invalid WeakReferences automatically be deleted from the list */ - private ?bool $_discardInvalid = null; - - /** @var int The number of TEventHandlers in the list */ - private int $_eventHandlerCount = 0; - /** * Constructor. * Initializes the list with an array or an iterable object. @@ -226,44 +220,56 @@ public static function filterItemForInput(&$handler, $validate = false): void * have lost their object. * All invalid WeakReference[s] are optionally removed from the list when {@see * getDiscardInvalid} is true. + * + * This method is re-entrancy safe via the {@see isScrubbing} guard. PHP's cyclic + * garbage collector can fire between opcodes and invoke object destructors + * ({@see TComponent::__destruct} → `unlisten()` → `remove()`) that call back into + * this method while the outer iteration is still running over `_d[$priority]`. The + * guard ensures that nested calls return immediately, preventing the inner call from + * modifying the array that the outer loop is currently scrubbing. * @since 4.3.0 */ protected function scrubWeakReferences() { - if (!$this->getDiscardInvalid() || !$this->weakChanged()) { + if ($this->isScrubbing() || !$this->getDiscardInvalid() || !$this->weakChanged()) { return; } - foreach (array_keys($this->_d) as $priority) { - for ($c = $i = count($this->_d[$priority]), $i--; $i >= 0; $i--) { - $a = is_array($this->_d[$priority][$i]); - $isEventHandler = $weakRefInvalid = false; - $arrayInvalid = $a && is_object($this->_d[$priority][$i][0]) && ($this->_d[$priority][$i][0] instanceof WeakReference) && $this->_d[$priority][$i][0]->get() === null; - if (is_object($this->_d[$priority][$i])) { - $object = $this->_d[$priority][$i]; - if ($isEventHandler = ($object instanceof TEventHandler)) { - $object = $object->getHandlerObject(true); - } - $weakRefInvalid = ($object instanceof WeakReference) && $object->get() === null; - } - if ($arrayInvalid || $weakRefInvalid) { - $c--; - $this->_c--; - if ($i === $c) { - array_pop($this->_d[$priority]); - } else { - array_splice($this->_d[$priority], $i, 1); + $this->setScrubbing(true); + try { + foreach (array_keys($this->_d) as $priority) { + for ($c = $i = count($this->_d[$priority]), $i--; $i >= 0; $i--) { + $a = is_array($this->_d[$priority][$i]); + $isEventHandler = $weakRefInvalid = false; + $arrayInvalid = $a && is_object($this->_d[$priority][$i][0]) && ($this->_d[$priority][$i][0] instanceof WeakReference) && $this->_d[$priority][$i][0]->get() === null; + if (is_object($this->_d[$priority][$i])) { + $object = $this->_d[$priority][$i]; + if ($isEventHandler = ($object instanceof TEventHandler)) { + $object = $object->getHandlerObject(true); + } + $weakRefInvalid = ($object instanceof WeakReference) && $object->get() === null; } - if ($isEventHandler) { - $this->_eventHandlerCount--; + if ($arrayInvalid || $weakRefInvalid) { + $c--; + $this->_c--; + if ($i === $c) { + array_pop($this->_d[$priority]); + } else { + array_splice($this->_d[$priority], $i, 1); + } + if ($isEventHandler) { + $this->_eventHandlerCount--; + } } } + if (!$c) { + unset($this->_d[$priority]); + } } - if (!$c) { - unset($this->_d[$priority]); - } + $this->_fd = null; + $this->weakResetCount(); + } finally { + $this->setScrubbing(false); } - $this->_fd = null; - $this->weakResetCount(); } /** @@ -1089,8 +1095,5 @@ protected function _getZappableSleepProps(&$exprops) $this->_c = $c; $this->_weakZappableSleepProps($exprops); - if ($this->_discardInvalid === null) { - $exprops[] = "\0" . __CLASS__ . "\0_discardInvalid"; - } } } diff --git a/framework/Collections/TWeakCollectionTrait.php b/framework/Collections/TWeakCollectionTrait.php index 33684d70c..1ff73b991 100644 --- a/framework/Collections/TWeakCollectionTrait.php +++ b/framework/Collections/TWeakCollectionTrait.php @@ -43,6 +43,37 @@ trait TWeakCollectionTrait /** @var int Number of known objects in the collection. */ private int $_weakCount = 0; + /** @var bool Re-entrancy guard for {@see scrubWeakReferences}: true while executing. */ + private bool $_scrubbing = false; + + /** @var ?bool Whether GC'd entries are automatically discarded. Null = lazy init. */ + private ?bool $_discardInvalid = null; + + /** @var int Number of {@see \Prado\TEventHandler} instances currently tracked. */ + private int $_eventHandlerCount = 0; + + /** + * Returns true if {@see scrubWeakReferences} is currently executing. + * The re-entrancy guard prevents cyclic GC from invoking a second scrub + * while the outer loop is still iterating. + * @return bool + * @since 4.3.3 + */ + protected function isScrubbing(): bool + { + return $this->_scrubbing; + } + + /** + * Sets or clears the {@see scrubWeakReferences} re-entrancy guard. + * @param bool $value True when entering the scrub loop; false on exit. + * @since 4.3.3 + */ + protected function setScrubbing(bool $value): void + { + $this->_scrubbing = $value; + } + /** * Initializes a new WeakMap */ @@ -163,11 +194,29 @@ protected function weakStop(): void } /** + * Appends weak-collection properties that must be excluded from serialization. + * + * Always excluded (pure runtime state): + * - `_weakMap` / `_weakCount` — rebuilt from data on wakeup + * - `_scrubbing` — transient re-entrancy flag, always false at rest + * - `_eventHandlerCount` — derived from data, always 0 after wakeup + * + * Conditionally excluded: + * - `_discardInvalid` when null — null means "lazy: derive from ReadOnly", + * so persisting null is harmless but storing the derived value is preferable; + * excluding null causes wakeup to re-derive correctly. + * * @param array &$exprops Properties to remove from serialize. + * @since 4.3.0 */ protected function _weakZappableSleepProps(array &$exprops): void { $exprops[] = "\0" . __CLASS__ . "\0_weakMap"; $exprops[] = "\0" . __CLASS__ . "\0_weakCount"; + $exprops[] = "\0" . __CLASS__ . "\0_scrubbing"; + $exprops[] = "\0" . __CLASS__ . "\0_eventHandlerCount"; + if ($this->_discardInvalid === null) { + $exprops[] = "\0" . __CLASS__ . "\0_discardInvalid"; + } } } diff --git a/framework/Collections/TWeakList.php b/framework/Collections/TWeakList.php index 9617f6c1c..3d544e089 100644 --- a/framework/Collections/TWeakList.php +++ b/framework/Collections/TWeakList.php @@ -67,14 +67,6 @@ class TWeakList extends TList implements IWeakCollection, ICollectionFilter { use TWeakCollectionTrait; - /** @var ?bool Should invalid WeakReference automatically be deleted from the list. - * Default True. - */ - private ?bool $_discardInvalid = null; - - /** @var int The number of TEventHandlers in the list */ - private int $_eventHandlerCount = 0; - /** * Constructor. * Initializes the weak list with an array or an iterable object. @@ -195,32 +187,45 @@ public static function filterItemForInput(&$item): void /** * When a change in the WeakMap is detected, scrub the list of invalid WeakReference. + * + * Re-entrancy guard: PHP's cyclic garbage collector can fire between any two opcodes + * and invoke object destructors mid-loop. If a destructor removes an entry from this + * list (e.g. via TComponent::__destruct → unlisten → remove → scrubWeakReferences), + * the inner call would shorten $_d and $_c while the outer loop is still iterating, + * making the outer index stale and causing an "Undefined array key" error. The + * {@see isScrubbing} guard prevents the inner call from executing; any entries the + * inner call would have removed are picked up during the next outer pass. */ protected function scrubWeakReferences(): void { - if (!$this->getDiscardInvalid() || !$this->weakChanged()) { + if ($this->isScrubbing() || !$this->getDiscardInvalid() || !$this->weakChanged()) { return; } - for ($i = $this->_c - 1; $i >= 0; $i--) { - if (is_object($this->_d[$i])) { - $object = $this->_d[$i]; - if ($isEventHandler = ($object instanceof TEventHandler)) { - $object = $object->getHandlerObject(true); - } - if (($object instanceof WeakReference) && $object->get() === null) { - $this->_c--; - if ($i === $this->_c) { - array_pop($this->_d); - } else { - array_splice($this->_d, $i, 1); + $this->setScrubbing(true); + try { + for ($i = $this->_c - 1; $i >= 0; $i--) { + if (is_object($this->_d[$i])) { + $object = $this->_d[$i]; + if ($isEventHandler = ($object instanceof TEventHandler)) { + $object = $object->getHandlerObject(true); } - if ($isEventHandler) { - $this->_eventHandlerCount--; + if (($object instanceof WeakReference) && $object->get() === null) { + $this->_c--; + if ($i === $this->_c) { + array_pop($this->_d); + } else { + array_splice($this->_d, $i, 1); + } + if ($isEventHandler) { + $this->_eventHandlerCount--; + } } } } + $this->weakResetCount(); + } finally { + $this->setScrubbing(false); } - $this->weakResetCount(); } /** @@ -627,8 +632,5 @@ protected function _getZappableSleepProps(&$exprops) $this->_c = $c; $this->_weakZappableSleepProps($exprops); - if ($this->_discardInvalid === null) { - $exprops[] = "\0" . __CLASS__ . "\0_discardInvalid"; - } } } diff --git a/framework/Collections/TWeakMap.php b/framework/Collections/TWeakMap.php new file mode 100644 index 000000000..bc1853a2d --- /dev/null +++ b/framework/Collections/TWeakMap.php @@ -0,0 +1,634 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Collections; + +use Prado\Exceptions\TInvalidDataTypeException; +use Prado\Exceptions\TInvalidOperationException; +use Prado\Prado; +use Prado\TEventHandler; +use Prado\TPropertyValue; +use Closure; +use Traversable; +use WeakReference; + +/** + * TWeakMap class + * + * TWeakMap implements a key-value collection where object *values* are held as + * {@see WeakReference}, so the map does not prevent its values from being garbage- + * collected. Keys are always strongly retained (as PHP arrays require string or + * integer keys). + * + * Non-object values (null, string, int, bool, array) are stored directly and are + * never weakened. {@see \Closure} objects are also stored directly to prevent + * anonymous functions from being collected if the map is their only reference. + * Objects implementing {@see IWeakRetainable} (including {@see \Prado\TEventHandler}) + * are stored directly; for TEventHandler the inner callable object is tracked in the + * WeakMap rather than the TEventHandler wrapper itself. All other object values are + * wrapped in {@see WeakReference} for storage. + * + * TWeakMap supports two modes controlled by {@see setDiscardInvalid DiscardInvalid}: + * + * - **true** (default for mutable maps): when a value object is garbage-collected, + * its entry is silently removed from the map. The count and key set shrink. + * - **false** (default for read-only maps): entries are retained; a GC'd value + * resolves to `null` on read. The count and key set are stable. + * + * A PHP 8 {@see WeakMap} is used internally to detect garbage-collection events + * efficiently; see {@see TWeakCollectionTrait} for the implementation. + * + * Re-entrancy: PHP's cyclic garbage collector can fire destructors between any two + * opcodes, potentially calling back into {@see scrubWeakReferences} while it is + * already executing. The {@see TWeakCollectionTrait::isScrubbing} guard prevents the inner call from + * modifying the internal array while the outer loop is iterating; any entries + * skipped by the inner call are cleaned on the next outer pass. + * + * @author Brad Anderson + * @since 4.3.3 + */ +class TWeakMap extends TMap implements IWeakCollection, ICollectionFilter +{ + use TWeakCollectionTrait; + + /** + * Constructor. + * @param null|array|\Traversable $data Initial data. Default null. + * @param ?bool $readOnly Whether the map is read-only. Default null. + * @param ?bool $discardInvalid Whether GC'd entries are removed (true) or + * retained as null (false). Default null: opposite of $readOnly. + */ + public function __construct($data = null, $readOnly = null, $discardInvalid = null) + { + parent::__construct($data, $readOnly); + $this->setDiscardInvalid($discardInvalid); + } + + /** + * Cloning a TWeakMap requires cloning the WeakMap cache. + */ + public function __clone() + { + $this->weakClone(); + parent::__clone(); + } + + /** + * Waking up a TWeakMap re-initialises the WeakMap cache if discardInvalid is + * active (data is not serialised, so the cache starts empty). + */ + public function __wakeup() + { + if ($this->_discardInvalid) { + $this->weakStart(); + } + parent::__wakeup(); + } + + // ------------------------------------------------------------------------- + // WeakMap bookkeeping helpers + // ------------------------------------------------------------------------- + + /** + * Adds an object value to the WeakMap cache. For TEventHandler values the + * inner callable object is tracked rather than the handler wrapper. + * Closures are skipped — they are strongly retained by the map on purpose. + * @param object $object The stored value (original, before WeakReference wrapping). + * @return int The updated instance count for the tracked object. + */ + protected function weakCustomAdd(object $object): int + { + if ($object instanceof TEventHandler) { + $this->_eventHandlerCount++; + $inner = $object->getHandlerObject(); + + if ($inner !== null) { + return $this->weakAdd($inner); + } + + return 0; + } + + if ($object instanceof Closure) { + return 0; + } + + return $this->weakAdd($object); + } + + /** + * Removes an object value from the WeakMap cache. Mirrors {@see weakCustomAdd}. + * + * The stored value may be the original object, a {@see WeakReference} wrapping it + * (from {@see filterItemForInput}), a {@see \Prado\TEventHandler}, or a {@see \Closure}. + * WeakReferences are resolved to the actual object before removal; a null resolution + * (GC'd) is a no-op since the WeakMap entry was already auto-removed. + * + * @param object $object The stored value (may be a WeakReference or original object). + * @return int The remaining instance count for the tracked object. + */ + protected function weakCustomRemove(object $object): int + { + if ($object instanceof TEventHandler) { + $this->_eventHandlerCount--; + $inner = $object->getHandlerObject(); + + if ($inner !== null) { + return $this->weakRemove($inner); + } + + return 0; + } + + if ($object instanceof Closure) { + return 0; + } + + // filterItemForInput wraps regular objects in WeakReference for storage. + // Resolve back to the actual object before interacting with the WeakMap. + if ($object instanceof WeakReference) { + $actual = $object->get(); + + if ($actual !== null) { + return $this->weakRemove($actual); + } + + // Already GC'd — WeakMap dropped the entry automatically; nothing to do. + return 0; + } + + return $this->weakRemove($object); + } + + // ------------------------------------------------------------------------- + // ICollectionFilter — WeakReference encoding/decoding + // ------------------------------------------------------------------------- + + /** + * Converts an object value into a {@see WeakReference} for internal storage. + * {@see \Closure}, {@see IWeakRetainable} objects (including TEventHandler), and + * non-objects are stored as-is. Already-wrapped WeakReferences are unchanged. + * @param mixed &$item The value to convert. + */ + public static function filterItemForInput(&$item): void + { + if ( + is_object($item) + && !($item instanceof WeakReference) + && !($item instanceof Closure) + && !($item instanceof IWeakRetainable) + ) { + $item = WeakReference::create($item); + } + } + + /** + * Converts a stored value back to its original form. + * A dead {@see WeakReference} (GC'd object) resolves to null. + * A {@see \Prado\TEventHandler} with no live handler resolves to null. + * @param mixed &$item The stored value to restore. + */ + public static function filterItemForOutput(&$item): void + { + if ($item instanceof WeakReference) { + $item = $item->get(); + } elseif (($item instanceof TEventHandler) && !$item->hasHandler()) { + $item = null; + } + } + + // ------------------------------------------------------------------------- + // Scrubbing + // ------------------------------------------------------------------------- + + /** + * Removes entries whose object value has been garbage-collected. + * + * Re-entrancy guard: PHP's cyclic GC can fire destructors between any two opcodes. + * If a destructor calls back into the map (e.g. removing a handler) while this + * method is iterating, {@see isScrubbing} prevents the inner call from modifying + * $_d underneath the outer loop. Entries skipped by the inner call are cleaned on + * the next outer pass. + */ + protected function scrubWeakReferences(): void + { + if ($this->isScrubbing() || !$this->getDiscardInvalid() || !$this->weakChanged()) { + return; + } + $this->setScrubbing(true); + try { + foreach (array_keys($this->_d) as $key) { + if (!array_key_exists($key, $this->_d)) { + continue; // already removed by a re-entrant call (shouldn't happen with guard, but defensive) + } + $stored = $this->_d[$key]; + + if (!is_object($stored)) { + continue; + } + + $isEventHandler = false; + $isDead = false; + $object = $stored; + + if ($isEventHandler = ($stored instanceof TEventHandler)) { + $object = $stored->getHandlerObject(true); + } + + if (($object instanceof WeakReference) && $object->get() === null) { + $isDead = true; + } + + if ($isDead) { + unset($this->_d[$key]); + + if ($isEventHandler) { + $this->_eventHandlerCount--; + } + } + } + $this->weakResetCount(); + } finally { + $this->setScrubbing(false); + } + } + + // ------------------------------------------------------------------------- + // DiscardInvalid + // ------------------------------------------------------------------------- + + /** + * @return bool Whether GC'd entries are automatically removed from the map. + */ + public function getDiscardInvalid(): bool + { + $this->collapseDiscardInvalid(); + return $this->_discardInvalid; + } + + /** + * Ensures DiscardInvalid is initialised to its default (opposite of ReadOnly). + */ + protected function collapseDiscardInvalid(): void + { + if ($this->_discardInvalid === null) { + $this->setDiscardInvalid(!$this->getReadOnly()); + } + } + + /** + * Sets whether GC'd entries are automatically discarded. + * + * Once set externally this property is locked — only the object itself (e.g. + * the constructor or a subclass) may change it again. External callers that + * attempt a second set will receive a {@see TInvalidOperationException}. + * + * When transitioning to true an existing WeakMap cache is started and the + * current entries are scanned: live objects are registered, dead entries + * are removed immediately. When transitioning to false the WeakMap cache + * is stopped. + * + * @param ?bool $value true to discard, false to retain, null is a no-op. + * @throws TInvalidOperationException if already set and called from outside. + */ + public function setDiscardInvalid($value): void + { + if ($value === $this->_discardInvalid) { + return; + } + + if ($this->_discardInvalid !== null && !Prado::isCallingSelf()) { + throw new TInvalidOperationException('weak_no_set_discard_invalid', $this::class); + } + + $value = TPropertyValue::ensureBoolean($value); + + if ($value && !$this->_discardInvalid) { + $this->weakStart(); + + foreach (array_keys($this->_d) as $key) { + if (!array_key_exists($key, $this->_d)) { + continue; + } + $stored = $this->_d[$key]; + + if (!is_object($stored)) { + continue; + } + + $isEventHandler = false; + $object = $stored; + + if ($isEventHandler = ($stored instanceof TEventHandler)) { + $object = $stored->getHandlerObject(true); + } + + if ($object instanceof WeakReference) { + $object = $object->get(); + } + + if ($object === null) { + unset($this->_d[$key]); + + if ($isEventHandler) { + $this->_eventHandlerCount--; + } + } elseif (!($stored instanceof Closure)) { + $this->weakAdd($object); + } + } + + $this->weakResetCount(); + } elseif (!$value && $this->_discardInvalid) { + $this->weakStop(); + } + + $this->_discardInvalid = $value; + } + + // ------------------------------------------------------------------------- + // TMap overrides + // ------------------------------------------------------------------------- + + /** + * Returns an iterator over the live (dereferenced) entries in the map. + * @return \Iterator + */ + public function getIterator(): \Iterator + { + return new \ArrayIterator($this->toArray()); + } + + /** + * Returns the number of entries in the map. GC'd entries are scrubbed first + * when DiscardInvalid is true. + * @return int + */ + public function getCount(): int + { + $this->scrubWeakReferences(); + return parent::getCount(); + } + + /** + * Returns the value at the specified key, with WeakReference resolved. + * A GC'd value returns null. A missing key calls the dyNoItem behaviour. + * @param mixed $key + * @return mixed + */ + public function itemAt($key) + { + $this->scrubWeakReferences(); + + if (!isset($this->_d[$key]) && !array_key_exists($key, $this->_d)) { + return $this->dyNoItem(null, $key); + } + + $value = $this->_d[$key]; + static::filterItemForOutput($value); + return $value; + } + + /** + * Adds or replaces an entry in the map. + * + * When a key already exists the old value's WeakMap registration is removed + * before the new value is registered. Object values are wrapped in + * WeakReference for storage (except Closure and IWeakRetainable). + * + * @param mixed $key String or integer key, or null to append. + * @param mixed $value The value to store. + * @throws TInvalidOperationException if the map is read-only. + * @return mixed The key actually used (useful when $key is null). + */ + public function add($key, $value): mixed + { + $this->collapseDiscardInvalid(); + $this->collapseReadOnly(); + + if ($this->getReadOnly()) { + throw new TInvalidOperationException('map_readonly', $this::class); + } + + $this->scrubWeakReferences(); + + // Remove old WeakMap entry when overwriting an existing key. + if ($key !== null && (isset($this->_d[$key]) || array_key_exists($key, $this->_d))) { + $oldStored = $this->_d[$key]; + + if (is_object($oldStored)) { + $this->weakCustomRemove($oldStored); + } + } + + if (is_object($value)) { + $this->weakCustomAdd($value); + } + + $stored = $value; + static::filterItemForInput($stored); + + if ($key === null) { + $this->_d[] = $stored; + $key = array_key_last($this->_d); + } else { + $this->_d[$key] = $stored; + } + + $this->dyAddItem($key, $value); + return $key; + } + + /** + * Removes the entry with the specified key and returns its (dereferenced) value. + * Returns null if the key does not exist. + * @param mixed $key + * @throws TInvalidOperationException if the map is read-only. + * @return mixed The removed value, or null if the key was absent. + */ + public function remove($key) + { + if ($this->getReadOnly()) { + throw new TInvalidOperationException('map_readonly', $this::class); + } + + $this->scrubWeakReferences(); + + if (!isset($this->_d[$key]) && !array_key_exists($key, $this->_d)) { + return null; + } + + $stored = $this->_d[$key]; + unset($this->_d[$key]); + + $value = $stored; + static::filterItemForOutput($value); + + if (is_object($stored)) { + $this->weakCustomRemove($stored); + } + + $this->dyRemoveItem($key, $value); + return $value; + } + + /** + * Removes all entries whose (dereferenced) value equals $item. + * Returns an array of [key => removed value] pairs. + * @param mixed $item + * @throws TInvalidOperationException if the map is read-only. + * @return array + */ + public function removeItem(mixed $item): array + { + if ($this->getReadOnly()) { + throw new TInvalidOperationException('map_readonly', $this::class); + } + + $removed = []; + + foreach ($this->toArray() as $key => $value) { + if ($item === $value) { + $removed[$key] = $this->remove($key); + } + } + + return $removed; + } + + /** + * Removes all entries and resets the WeakMap cache. + */ + public function clear(): void + { + $c = count($this->_d); + + foreach (array_keys($this->_d) as $key) { + $stored = $this->_d[$key]; + $value = $stored; + static::filterItemForOutput($value); + unset($this->_d[$key]); + $this->dyRemoveItem($key, $value); + } + + if ($c) { + $this->weakRestart(); + } + } + + /** + * Returns whether the map contains the specified key. + * GC'd entries are scrubbed first when DiscardInvalid is true. + * @param mixed $key + * @return bool + */ + public function contains($key): bool + { + $this->scrubWeakReferences(); + return parent::contains($key); + } + + /** + * Returns the key(s) whose (dereferenced) value equals $item. + * @param mixed $item + * @param bool $multiple When true (default) returns all matching keys as an array; + * when false returns the first matching key or false. + * @return mixed + */ + public function keyOf($item, bool $multiple = true): mixed + { + $arr = $this->toArray(); + + if ($multiple) { + $result = []; + + foreach ($arr as $key => $value) { + if ($item === $value) { + $result[$key] = $value; + } + } + + return $result; + } + + return array_search($item, $arr, true); + } + + /** + * Returns all entries as a plain array with WeakReferences resolved. + * GC'd entries are scrubbed first when DiscardInvalid is true. + * @return array + */ + public function toArray(): array + { + $this->scrubWeakReferences(); + $items = $this->_d; + + foreach ($items as &$item) { + static::filterItemForOutput($item); + } + unset($item); + + return $items; + } + + /** + * Replaces all entries with the contents of $data. + * @param mixed $data Array or Traversable. + * @throws TInvalidDataTypeException if $data is neither an array nor Traversable. + */ + public function copyFrom($data): void + { + if (is_array($data) || $data instanceof Traversable) { + if (count($this->_d) > 0) { + $this->clear(); + } + + foreach ($data as $key => $value) { + $this->add($key, $value); + } + } elseif ($data !== null) { + throw new TInvalidDataTypeException('map_data_not_iterable'); + } + } + + /** + * Merges the contents of $data into the map, overwriting duplicate keys. + * @param mixed $data Array or Traversable. + * @throws TInvalidDataTypeException if $data is neither an array nor Traversable. + */ + public function mergeWith($data): void + { + if (is_array($data) || $data instanceof Traversable) { + foreach ($data as $key => $value) { + $this->add($key, $value); + } + } elseif ($data !== null) { + throw new TInvalidDataTypeException('map_data_not_iterable'); + } + } + + /** + * Returns the array of serialisation-excluded property names. + * The data array is excluded because values are weakly held and cannot be + * meaningfully persisted. + * @param array $exprops by reference + */ + protected function _getZappableSleepProps(&$exprops) + { + // Temporarily clear _d so TMap's implementation marks it for exclusion. + $d = $this->_d; + $this->_d = []; + parent::_getZappableSleepProps($exprops); + $this->_d = $d; + + $this->_weakZappableSleepProps($exprops); + } +} diff --git a/framework/classes.php b/framework/classes.php index 9b351fd61..a5251a485 100644 --- a/framework/classes.php +++ b/framework/classes.php @@ -56,6 +56,7 @@ 'TWeakCallableCollection' => 'Prado\Collections\TWeakCallableCollection', 'TWeakCollectionTrait' => 'Prado\Collections\TWeakCollectionTrait', 'TWeakList' => 'Prado\Collections\TWeakList', +'TWeakMap' => 'Prado\Collections\TWeakMap', 'TActiveRecordConfigurationException' => 'Prado\Data\ActiveRecord\Exceptions\TActiveRecordConfigurationException', 'TActiveRecordException' => 'Prado\Data\ActiveRecord\Exceptions\TActiveRecordException', 'TActiveRecordBelongsTo' => 'Prado\Data\ActiveRecord\Relations\TActiveRecordBelongsTo', diff --git a/tests/unit/Collections/TWeakMapTest.php b/tests/unit/Collections/TWeakMapTest.php new file mode 100644 index 000000000..ef1e65875 --- /dev/null +++ b/tests/unit/Collections/TWeakMapTest.php @@ -0,0 +1,656 @@ +weakCount(); + } + + public function getWeakObjectCount(object $obj): ?int + { + return $this->weakObjectCount($obj); + } + + /** Bypass the once-only guard so tests can toggle DiscardInvalid. */ + public function resetDiscardInvalid(bool $value): void + { + $this->setDiscardInvalid($value); + } +} + +/** + * A plain object used as a map value. + */ +class WeakMapItem +{ + public mixed $data; + + public function __construct(mixed $data = null) + { + $this->data = $data; + } + + /** Callable method so instances can be used as TEventHandler targets. */ + public function handle(): void + { + } +} + +/** + * An object that opts out of WeakReference wrapping. + */ +class WeakMapRetainableItem extends WeakMapItem implements IWeakRetainable +{ +} + +/** + * Unit tests for TWeakMap. + * + * Structure mirrors TWeakListTest: each public method exercises one area of the + * class, with separate _TEventHandler variants for TEventHandler-specific paths. + * + * The GC re-entrancy scenario covered by $_scrubbing is intentionally omitted — + * reliably triggering PHP's cyclic GC in a test is not feasible. + */ +class TWeakMapTest extends TMapTest +{ + // ---- fixture ---------------------------------------------------------- + + protected function newList(): string + { + return TWeakMapUnit::class; + } + + protected function newListItem(): string + { + return WeakMapItem::class; + } + + protected function setUp(): void + { + $this->_baseClass = $this->newList(); + $this->_baseItemClass = $this->newListItem(); + + $this->map = new $this->_baseClass(); + $this->item1 = new $this->_baseItemClass(1); + $this->item2 = new $this->_baseItemClass(2); + $this->item3 = new $this->_baseItemClass(3); + $this->map->add('key1', $this->item1); + $this->map->add('key2', $this->item2); + } + + // ---- override TMapTest cases that are incompatible with weak semantics ---- + + /** + * TMap stores null values directly; TWeakMap does too (null is not an object + * so it is never wrapped). This simply confirms the behaviour is unchanged. + */ + public function testArrayRead() + { + // Non-existent key → null (via dyNoItem) + $this->assertNull($this->map['NoItemHere']); + + $this->assertSame($this->item1, $this->map['key1']); + $this->assertSame($this->item2, $this->map['key2']); + + // Null value stored directly — contains() must still return true + $this->map['key3'] = null; + $this->assertNull($this->map['key3']); + $this->assertNull($this->map->itemAt('key3')); + $this->assertTrue($this->map->contains('key3')); + } + + // ======================================================================== + // Construction + // ======================================================================== + + public function testConstructTWeakMap() + { + // Default (mutable): discardInvalid=true + $m = new $this->_baseClass(); + $this->assertTrue($m->getDiscardInvalid()); + + // readOnly=false → discardInvalid defaults to true + $m = new $this->_baseClass(null, false); + $this->assertTrue($m->getDiscardInvalid()); + + // readOnly=true → discardInvalid defaults to false + $m = new $this->_baseClass(null, true); + $this->assertFalse($m->getDiscardInvalid()); + + // Explicit discardInvalid overrides the readOnly-based default + $m = new $this->_baseClass(null, true, true); + $this->assertTrue($m->getDiscardInvalid()); + + $m = new $this->_baseClass(null, false, false); + $this->assertFalse($m->getDiscardInvalid()); + + // Data loaded at construction: objects that survive are still accessible + $obj1 = new $this->_baseItemClass(1); + $obj2 = new $this->_baseItemClass(2); + $m = new $this->_baseClass(['a' => $obj1, 'b' => $obj2]); + $this->assertSame($obj1, $m['a']); + $this->assertSame($obj2, $m['b']); + $this->assertSame(2, $m->getCount()); + + // Data with dead values in discardInvalid=true mode: + // after construction the object is held by $obj3, so not yet GC'd. + $obj3 = new $this->_baseItemClass(3); + $m = new $this->_baseClass(['x' => $obj3], false, true); + $this->assertSame($obj3, $m['x']); + $obj3 = null; + // Now GC'd — scrub on next access + $this->assertSame(0, $m->getCount()); + + // discardInvalid=false: GC'd value stays as null entry + $obj4 = new $this->_baseItemClass(4); + $m = new $this->_baseClass(['y' => $obj4], false, false); + $obj4 = null; + $this->assertSame(1, $m->getCount()); + $this->assertNull($m['y']); + } + + // ======================================================================== + // getIterator + // ======================================================================== + + public function testGetIteratorTWeakMap() + { + unset($this->item2); + + $iter = $this->map->getIterator(); + $arr = iterator_to_array($iter); + + $this->assertCount(1, $arr); + $this->assertSame($this->item1, $arr['key1']); + $this->assertArrayNotHasKey('key2', $arr); + } + + // ======================================================================== + // getCount / scrubbing + // ======================================================================== + + public function testGetCountTWeakMap() + { + // Both alive → 2 + $this->assertSame(2, $this->map->getCount()); + + // Release item2 → scrub reduces to 1 + unset($this->item2); + $this->assertSame(1, $this->map->getCount()); + $this->assertSame(1, $this->map->getWeakCount()); + + // discardInvalid=false: GC'd entry stays, count unchanged + $obj1 = new $this->_baseItemClass(10); + $obj2 = new $this->_baseItemClass(20); + $m = new $this->_baseClass(['a' => $obj1, 'b' => $obj2], false, false); + unset($obj2); + $this->assertSame(2, $m->getCount()); + $this->assertNull($m['b']); + } + + // ======================================================================== + // itemAt + // ======================================================================== + + public function testItemAtTWeakMap() + { + // Live value → returned normally + $this->assertSame($this->item1, $this->map->itemAt('key1')); + $this->assertSame($this->item2, $this->map->itemAt('key2')); + + // After GC → key disappears (discardInvalid=true) + unset($this->item2); + $this->assertNull($this->map->itemAt('key2')); // scrubbed; calls dyNoItem + + // discardInvalid=false → null returned in-place + $obj = new $this->_baseItemClass(99); + $m = new $this->_baseClass(['k' => $obj], false, false); + unset($obj); + $this->assertNull($m->itemAt('k')); + $this->assertTrue($m->contains('k')); // key still present + } + + // ======================================================================== + // add — WeakMap accounting + // ======================================================================== + + public function testAddTWeakMap() + { + // After adding item3 we have 3 tracked objects + $this->map->add('key3', $this->item3); + $this->assertSame(3, $this->map->getWeakCount()); + + // Release item2 → WeakMap shrinks, scrub clears key2 + unset($this->item2); + $this->map->add('key4', $this->item3); // triggers scrub + $this->assertSame(2, $this->map->getWeakCount()); // item1 + item3 + $this->assertSame(2, $this->map->getWeakObjectCount($this->item3)); // key3 + key4 + $this->assertSame(1, $this->map->getWeakObjectCount($this->item1)); + } + + public function testAddOverwriteTWeakMap() + { + // Overwriting an existing key must remove the old WeakMap entry first + $old = $this->item1; + $this->map->add('key1', $this->item3); + + $this->assertSame(2, $this->map->getWeakCount()); // old item1 out, item3 in + $this->assertNull($this->map->getWeakObjectCount($old)); + $this->assertSame(1, $this->map->getWeakObjectCount($this->item3)); + $this->assertSame($this->item3, $this->map->itemAt('key1')); + } + + public function testAddTWeakMap_TEventHandler() + { + $this->map->clear(); + + $obj = new WeakMapItem('handler'); + $handler = [$obj, 'handle']; + $eh = new TEventHandler($handler, 5); + + $this->map->add('eh', $eh); + + // TEventHandler itself is IWeakRetainable — inner obj is tracked + $this->assertSame(1, $this->map->getWeakCount()); + $this->assertSame(1, $this->map->getWeakObjectCount($obj)); + $this->assertSame($eh, $this->map->itemAt('eh')); + } + + // ======================================================================== + // remove + // ======================================================================== + + public function testRemoveTWeakMap() + { + $removed = $this->map->remove('key1'); + $this->assertSame($this->item1, $removed); + $this->assertSame(1, $this->map->getCount()); + $this->assertFalse($this->map->contains('key1')); + $this->assertNull($this->map->getWeakObjectCount($this->item1)); + + // Removing non-existent key returns null + $this->assertNull($this->map->remove('no-such-key')); + + // After GC in discardInvalid=true mode, key is scrubbed before remove attempt + unset($this->item2); + $this->assertNull($this->map->remove('key2')); // already gone after scrub + } + + public function testRemoveTWeakMap_TEventHandler() + { + $obj = new WeakMapItem('h'); + $eh = new TEventHandler([$obj, 'handle'], 3); + $this->map->add('eh', $eh); + + $this->assertSame(1, $this->map->getWeakObjectCount($obj)); + + $removed = $this->map->remove('eh'); + $this->assertSame($eh, $removed); + $this->assertSame(0, $this->map->getWeakCount() - 2); // only item1+item2 left + $this->assertNull($this->map->getWeakObjectCount($obj)); + } + + // ======================================================================== + // removeItem + // ======================================================================== + + public function testRemoveItemTWeakMap() + { + // Add item1 under a second key + $this->map->add('key1-also', $this->item1); + + $result = $this->map->removeItem($this->item1); + $this->assertEquals(['key1' => $this->item1, 'key1-also' => $this->item1], $result); + $this->assertSame(1, $this->map->getCount()); + $this->assertFalse($this->map->contains('key1')); + $this->assertNull($this->map->getWeakObjectCount($this->item1)); + + // Item not in map → empty array + $this->assertEquals([], $this->map->removeItem($this->item3)); + } + + // ======================================================================== + // clear + // ======================================================================== + + public function testClearTWeakMap() + { + $this->map->add('key3', $this->item3); + $this->assertSame(3, $this->map->getWeakCount()); + + $this->map->clear(); + + $this->assertSame(0, $this->map->getCount()); + $this->assertSame(0, $this->map->getWeakCount()); + $this->assertNull($this->map->getWeakObjectCount($this->item1)); + $this->assertNull($this->map->getWeakObjectCount($this->item2)); + $this->assertNull($this->map->getWeakObjectCount($this->item3)); + } + + // ======================================================================== + // contains + // ======================================================================== + + public function testContainsTWeakMap() + { + $this->assertTrue($this->map->contains('key1')); + $this->assertTrue($this->map->contains('key2')); + $this->assertFalse($this->map->contains('key3')); + + // GC'd value: key removed by scrub + unset($this->item2); + $this->assertFalse($this->map->contains('key2')); + } + + // ======================================================================== + // keyOf + // ======================================================================== + + public function testKeyOfTWeakMap() + { + $this->map->add('key1-also', $this->item1); + + // Multiple matches + $this->assertEquals(['key1' => $this->item1, 'key1-also' => $this->item1], $this->map->keyOf($this->item1)); + + // Single match + $this->assertSame('key2', $this->map->keyOf($this->item2, false)); + + // Not found + $this->assertFalse($this->map->keyOf($this->item3, false)); + $this->assertEquals([], $this->map->keyOf($this->item3)); + + // GC'd value: key2 is scrubbed; key1 and key1-also survive (both hold item1) + unset($this->item2); + $this->assertSame(2, $this->map->getCount()); + $this->assertArrayNotHasKey('key2', $this->map->keyOf($this->item1)); + } + + // ======================================================================== + // toArray + // ======================================================================== + + public function testToArrayTWeakMap() + { + // Both alive + $this->assertEquals(['key1' => $this->item1, 'key2' => $this->item2], $this->map->toArray()); + + // After GC: dead entry removed (discardInvalid=true) + unset($this->item2); + $this->assertEquals(['key1' => $this->item1], $this->map->toArray()); + + // discardInvalid=false: null in place of GC'd value + $obj1 = new $this->_baseItemClass(10); + $obj2 = new $this->_baseItemClass(20); + $m = new $this->_baseClass(['a' => $obj1, 'b' => $obj2], false, false); + unset($obj2); + $this->assertEquals(['a' => $obj1, 'b' => null], $m->toArray()); + } + + // ======================================================================== + // copyFrom / mergeWith + // ======================================================================== + + public function testCopyFromTWeakMap() + { + $this->map->copyFrom(['x' => $this->item3]); + + $this->assertSame(1, $this->map->getCount()); + $this->assertSame($this->item3, $this->map['x']); + $this->assertNull($this->map->getWeakObjectCount($this->item1)); + $this->assertSame(1, $this->map->getWeakObjectCount($this->item3)); + } + + public function testMergeWithTWeakMap() + { + $this->map->mergeWith(['key2' => $this->item3, 'key3' => $this->item3]); + + $this->assertSame(3, $this->map->getCount()); + $this->assertSame($this->item1, $this->map['key1']); + $this->assertSame($this->item3, $this->map['key2']); // overwritten + $this->assertSame($this->item3, $this->map['key3']); + $this->assertNull($this->map->getWeakObjectCount($this->item2)); // evicted from WeakMap + $this->assertSame(2, $this->map->getWeakObjectCount($this->item3)); + } + + // ======================================================================== + // Closure values — not wrapped in WeakReference + // ======================================================================== + + public function testClosureValueNotWrapped() + { + $fn = static function () { + return 42; + }; + $this->map->add('fn', $fn); + + // Closure must survive without any other strong reference + $retrieved = $this->map->itemAt('fn'); + $this->assertInstanceOf(Closure::class, $retrieved); + $this->assertSame(42, $retrieved()); + + // Closure is not tracked in the WeakMap + $this->assertSame(2, $this->map->getWeakCount()); // only item1 + item2 + } + + // ======================================================================== + // IWeakRetainable values — stored directly, tracked in WeakMap + // ======================================================================== + + public function testIWeakRetainableValueNotWrapped() + { + $ret = new WeakMapRetainableItem('retained'); + $this->map->add('r', $ret); + + // Returned as-is, not dereferenced from WeakReference + $this->assertSame($ret, $this->map->itemAt('r')); + + // IS tracked in the WeakMap (it's an object, just not wrapped) + $this->assertSame(3, $this->map->getWeakCount()); + $this->assertSame(1, $this->map->getWeakObjectCount($ret)); + } + + // ======================================================================== + // Non-object values — stored directly, not tracked + // ======================================================================== + + public function testNonObjectValues() + { + $this->map->add('str', 'hello'); + $this->map->add('int', 42); + $this->map->add('null', null); + $this->map->add('arr', [1, 2, 3]); + + $this->assertSame('hello', $this->map->itemAt('str')); + $this->assertSame(42, $this->map->itemAt('int')); + $this->assertNull($this->map->itemAt('null')); + $this->assertSame([1, 2, 3], $this->map->itemAt('arr')); + + // Non-objects are not tracked in the WeakMap + $this->assertSame(2, $this->map->getWeakCount()); + } + + // ======================================================================== + // discardInvalid mode transitions + // ======================================================================== + + public function testDiscardInvalidTWeakMap() + { + // Confirm default + $this->assertTrue($this->map->getDiscardInvalid()); + + // External caller may not change it once set + $this->expectException(TInvalidOperationException::class); + $this->map->setDiscardInvalid(false); + } + + public function testDiscardInvalidTransitionTrueToFalse() + { + // Transition from true → false stops the WeakMap + $this->assertSame(2, $this->map->getWeakCount()); + $this->map->resetDiscardInvalid(false); + $this->assertNull($this->map->getWeakCount()); // WeakMap stopped + $this->assertFalse($this->map->getDiscardInvalid()); + + // Values still accessible (strong array storage) + $this->assertSame($this->item1, $this->map['key1']); + } + + public function testDiscardInvalidTransitionFalseToTrue() + { + // Start with discardInvalid=false + $obj1 = new $this->_baseItemClass(10); + $obj2 = new $this->_baseItemClass(20); + $obj3 = new $this->_baseItemClass(30); + $m = new $this->_baseClass(['a' => $obj1, 'b' => $obj2, 'c' => $obj3], false, false); + + // Kill obj2 before transitioning + unset($obj2); + + // Transition to true: dead entry is removed, live objects tracked + $m->resetDiscardInvalid(true); + $this->assertTrue($m->getDiscardInvalid()); + $this->assertSame(2, $m->getCount()); // obj2 removed + $this->assertSame(2, $m->getWeakCount()); + $this->assertSame(1, $m->getWeakObjectCount($obj1)); + $this->assertSame(1, $m->getWeakObjectCount($obj3)); + } + + public function testDiscardInvalidTransitionWithTEventHandler() + { + $m = new $this->_baseClass(null, false, false); + $obj = new WeakMapItem('inner'); + $eh = new TEventHandler([$obj, 'handle'], 1); + $m->add('eh', $eh); + + // Kill the inner handler object + unset($obj); + + // Transition to discardInvalid=true should remove the dead TEventHandler entry + $m->resetDiscardInvalid(true); + $this->assertSame(0, $m->getCount()); + } + + // ======================================================================== + // Serialisation — data must not be persisted (values are weak) + // ======================================================================== + + public function testSleepDoesNotPersistData() + { + // Serialize a populated map and unserialize it. + // Weak values are not persisted, so the restored map must be empty. + $m = new $this->_baseClass(['a' => $this->item1, 'b' => $this->item2]); + $this->assertSame(2, $m->getCount()); + + $restored = unserialize(serialize($m)); + + $this->assertSame(0, $restored->getCount()); + $this->assertFalse($restored->contains('a')); + $this->assertFalse($restored->contains('b')); + } + + // ======================================================================== + // ArrayAccess via TMap (offsetGet/offsetSet/offsetUnset/offsetExists) + // ======================================================================== + + public function testOffsetSetTWeakMap() + { + unset($this->item2); + + // Replace key2 with item3 + $this->map['key2'] = $this->item3; + + // Old item2 should be gone from WeakMap, item3 registered + $this->assertSame(2, $this->map->getWeakCount()); + $this->assertSame(1, $this->map->getWeakObjectCount($this->item1)); + $this->assertSame(1, $this->map->getWeakObjectCount($this->item3)); + $this->assertSame($this->item3, $this->map['key2']); + } + + public function testOffsetUnsetTWeakMap() + { + unset($this->map['key1']); + + $this->assertSame(1, $this->map->getCount()); + $this->assertFalse($this->map->contains('key1')); + $this->assertNull($this->map->getWeakObjectCount($this->item1)); + $this->assertSame(1, $this->map->getWeakCount()); + } + + // ======================================================================== + // TEventHandler as value — inner handler tracked + // ======================================================================== + + public function testTEventHandlerValueTracking() + { + $this->map->clear(); + + $obj1 = new WeakMapItem('h1'); + $obj2 = new WeakMapItem('h2'); + $eh1 = new TEventHandler([$obj1, 'handle'], 1); + $eh2 = new TEventHandler([$obj2, 'handle'], 2); + + $this->map->add('a', $eh1); + $this->map->add('b', $eh2); + + $this->assertSame(2, $this->map->getWeakCount()); // obj1 + obj2 tracked + $this->assertSame(1, $this->map->getWeakObjectCount($obj1)); + $this->assertSame(1, $this->map->getWeakObjectCount($obj2)); + + // Kill obj2 — its TEventHandler entry should be scrubbed + unset($obj2); + $this->assertSame(1, $this->map->getCount()); + $this->assertSame(1, $this->map->getWeakCount()); + $this->assertSame($eh1, $this->map->itemAt('a')); + } + + public function testTEventHandlerValueRemovedWithKey() + { + $this->map->clear(); + + $obj = new WeakMapItem('h'); + $eh = new TEventHandler([$obj, 'handle'], 1); + $this->map->add('k', $eh); + + $this->assertSame(1, $this->map->getWeakObjectCount($obj)); + + $this->map->remove('k'); + $this->assertNull($this->map->getWeakObjectCount($obj)); + } + + // ======================================================================== + // Nested TEventHandler + // ======================================================================== + + public function testNestedTEventHandler() + { + $this->map->clear(); + + $obj = new WeakMapItem('deep'); + $inner = new TEventHandler([$obj, 'handle'], 1); + $outer = new TEventHandler($inner, 2); + + $this->map->add('k', $outer); + + // The innermost handler object is tracked + $this->assertSame(1, $this->map->getWeakCount()); + $this->assertSame(1, $this->map->getWeakObjectCount($obj)); + + unset($obj); + $this->assertSame(0, $this->map->getCount()); + } +}