Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cloning #96

Open
wants to merge 18 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"ipl/stdlib": ">=0.12.0",
"ipl/validator": "dev-master",
"psr/http-message": "~1.0",
"react/promise": "^2",
"guzzlehttp/psr7": "^1"
},
"autoload": {
Expand Down
79 changes: 65 additions & 14 deletions src/Attributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@

use ArrayAccess;
use ArrayIterator;
use Closure;
use InvalidArgumentException;
use IteratorAggregate;
use ReflectionFunction;
use Traversable;

use function ipl\Stdlib\get_php_type;
Expand Down Expand Up @@ -365,29 +367,19 @@ public function setPrefix($prefix)
/**
* Register callback for an attribute
*
* @param string $name Name of the attribute to register the callback for
* @param callable $callback Callback to call when retrieving the attribute
* @param callable $setterCallback Callback to call when setting the attribute
* @param string $name Name of the attribute to register the callback for
* @param ?callable $callback Callback to call when retrieving the attribute
* @param ?callable $setterCallback Callback to call when setting the attribute
*
* @return $this
*
* @throws InvalidArgumentException If $callback is not callable or if $setterCallback is set and not callable
*/
public function registerAttributeCallback($name, $callback, $setterCallback = null)
public function registerAttributeCallback(string $name, ?callable $callback, ?callable $setterCallback = null): self
{
if ($callback !== null) {
if (! is_callable($callback)) {
throw new InvalidArgumentException(__METHOD__ . ' expects a callable callback');
}

$this->callbacks[$name] = $callback;
}

if ($setterCallback !== null) {
if (! is_callable($setterCallback)) {
throw new InvalidArgumentException(__METHOD__ . ' expects a callable setterCallback');
}

$this->setterCallbacks[$name] = $setterCallback;
}

Expand Down Expand Up @@ -518,4 +510,63 @@ public function getIterator(): Traversable
{
return new ArrayIterator($this->attributes);
}

/**
* Rebind all attribute callbacks whose `$this` object IDs match the given ID to the object specified in `$newThis`
*
* If both this attributes and objects that registered attribute callbacks are cloned either
* explicitly or implicitly, the callbacks must be rebound if the clones are to continue to work together.
* This method is called automatically for classes extending {@link BaseHtmlElement}.
*
* @param int $thisObjectId {@link spl_object_id() Object ID} for matching the callbacks currently bound objects
* @param object $newThis The object to which the matching callbacks should be bound
*/
public function rebindAttributeCallbacks(int $thisObjectId, object $newThis): void
{
$this->rebindCallbacksInPlace($this->callbacks, $thisObjectId, $newThis);
$this->rebindCallbacksInPlace($this->setterCallbacks, $thisObjectId, $newThis);
}

public function __clone()
{
foreach ($this->attributes as &$attribute) {
$attribute = clone $attribute;
}
}

/**
* Loops over all `$callbacks` and binds them to `$newThis` if
* `$oldThisId` matches the {@link spl_object_id() object ID} of the currently bound object.
* The callbacks are modified directly at the `$callbacks` reference.
*
* @param callable[] $callbacks
* @param int $thisObjectId {@link spl_object_id() Object ID} for matching the callbacks currently bound objects
* @param object $newThis The object to which the matching callbacks should be bound
*/
private function rebindCallbacksInPlace(array &$callbacks, int $thisObjectId, object $newThis): void
{
foreach ($callbacks as &$callback) {
if (! $callback instanceof Closure) {
if (is_array($callback) && ! is_string($callback[0])) {
if (spl_object_id($callback[0]) === $thisObjectId) {
$callback[0] = $newThis;
}
}

continue;
}

$closureThis = (new ReflectionFunction($callback))
->getClosureThis();

// Closure is most likely static
if ($closureThis === null) {
continue;
}

if (spl_object_id($closureThis) === $thisObjectId) {
$callback = $callback->bindTo($newThis);
}
}
}
}
68 changes: 68 additions & 0 deletions src/BaseHtmlElement.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

namespace ipl\Html;

use LogicException;
use RuntimeException;
use SplObjectStorage;

/**
* Base class for HTML elements
Expand Down Expand Up @@ -75,6 +77,9 @@ abstract class BaseHtmlElement extends HtmlDocument
/** @var string Tag of element. Set this property in order to provide the element's tag when extending this class */
protected $tag;

/** @var int This {@link spl_object_id() object ID} which is used after cloning to find and rebind own callbacks */
private $objectId;

/**
* Get the attributes of the element
*
Expand Down Expand Up @@ -240,6 +245,8 @@ public function ensureAttributeCallbacksRegistered()
if (! $this->attributeCallbacksRegistered) {
$this->attributeCallbacksRegistered = true;
$this->registerAttributeCallbacks($this->attributes);

$this->ensureObjectId();
}

return $this;
Expand Down Expand Up @@ -280,6 +287,39 @@ public function wrap(HtmlDocument $document)
return $this;
}

/**
* Ensure that $this {@link spl_object_id() object ID} is set
*
* @param bool $override Whether the currently set object ID should be overridden
*/
protected function ensureObjectId(bool $override = false): void
{
if ($this->objectId === null || $override) {
$this->objectId = spl_object_id($this);
}
}

protected function initAssemble(): void
{
$this->ensureObjectId();
}

/**
* Get the currently set {@link spl_object_id() object ID}
*
* @return int
*
* @throws LogicException If the object ID has not been set yet
*/
protected function objectId(): int
{
if ($this->objectId === null) {
throw new LogicException('Cannot access object ID because it has not been set yet');
}

return $this->objectId;
}

/**
* Internal method for accessing the tag
*
Expand Down Expand Up @@ -352,4 +392,32 @@ public function renderUnwrapped()
$tag
);
}

public function __clone()
{
$this->copy()->then(function (SplObjectStorage $copies): void {
foreach ($copies as $ignored) {
// SplObjectMap always iterates over the attached objects that
// are the elements before they have been cloned.
// But we want to work with the cloned element.
$copy = $copies->getInfo();
if ($copy instanceof self) {
if ($copy->attributes !== null) {
$copy->attributes->rebindAttributeCallbacks($this->objectId(), $this);
}
}
}

if ($this->attributes !== null) {
$this->attributes = clone $this->attributes;

// $this->objectId() returns the ID of the object before cloning, $this is the newly cloned object.
$this->attributes->rebindAttributeCallbacks($this->objectId(), $this);
}

$this->ensureObjectId(true);
});

parent::__clone();
}
}
29 changes: 29 additions & 0 deletions src/Form.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use ipl\Html\FormElement\FormElements;
use ipl\Stdlib\Messages;
use Psr\Http\Message\ServerRequestInterface;
use SplObjectStorage;

class Form extends BaseHtmlElement
{
Expand Down Expand Up @@ -284,6 +285,34 @@ public function remove(ValidHtml $elementOrHtml)
$this->removeElement($elementOrHtml);
}

public function __clone()
{
$this->copy()->then(function (SplObjectStorage $copies): void {
$cloned = [];

foreach ($copies as $original) {
if (! $original instanceof FormElement) {
continue;
}

if (! $this->hasElement($original)) {
continue;
}

$cloned[$original->getName()] = $copies->getInfo();
}

// Also clone elements that have only been registered.
foreach (array_diff_key($this->elements, $cloned) as $name => $element) {
$cloned[$name] = clone $element;
}

$this->elements = $cloned;
});

parent::__clone();
}

protected function onError()
{
$errors = Html::tag('ul', ['class' => 'errors']);
Expand Down
67 changes: 64 additions & 3 deletions src/FormElement/SelectElement.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

namespace ipl\Html\FormElement;

use InvalidArgumentException;
use ipl\Html\Attributes;
use ipl\Html\Common\MultipleAttribute;
use ipl\Html\Html;
use ipl\Html\HtmlElement;
use ipl\Validator\DeferredInArrayValidator;
use ipl\Validator\ValidatorChain;
use SplObjectStorage;
use UnexpectedValueException;

class SelectElement extends BaseFormElement
Expand Down Expand Up @@ -116,6 +116,64 @@ public function getNameAttribute()
return $this->isMultiple() ? ($name . '[]') : $name;
}

public function __clone()
{
if ($this->hasBeenAssembled) {
$contentObjectIds = [];
foreach ($this->optionContent as $value => $content) {
$contentObjectIds[$value] = spl_object_id($content);
}
foreach (array_diff_key($this->options, $contentObjectIds) as $value => $option) {
$contentObjectIds[$value] = spl_object_id($option);
}
$this->copy()->then(function (SplObjectStorage $copies) use ($contentObjectIds) {
$lookup = array_flip($contentObjectIds);
foreach ($copies as $option) {
$objectId = spl_object_id($option);
if (! isset($lookup[$objectId])) {
continue;
}
$value = $lookup[$objectId];
$copy = $copies->getInfo();
if (isset($this->optionContent[$value])) {
$this->optionContent[$value] = $copy;
}
if (isset($this->options[$value])) {
$this->options[$value] = $copy;
}
}
});

parent::__clone();

return;
}

foreach ($this->optionContent as $value => &$content) {
if (! $content instanceof SelectOption) {
$content->copy()->then(function (SplObjectStorage $copies) use ($value) {
foreach ($copies as $option) {
if (! $option instanceof SelectOption) {
continue;
}
/** @var SelectOption $copy */
$copy = $copies->getInfo();
$copy->getAttributes()->rebindAttributeCallbacks($this->objectId(), $this);
$this->options[$value] = $copy;
}
});

$content = clone $content;
} else {
$content = clone $content;
$content->getAttributes()->rebindAttributeCallbacks($this->objectId(), $this);
$this->options[$value] = $content;
}
}

parent::__clone();
}

/**
* Make the selectOption for the specified value and the label
*
Expand All @@ -138,8 +196,11 @@ protected function makeOption($value, $label)
$option = (new SelectOption($value, $label))
->setAttribute('disabled', in_array($value, $this->disabledOptions, ! is_int($value)));

$option->getAttributes()->registerAttributeCallback('selected', function () use ($option) {
return $this->isSelectedOption($option->getValue());
// The value of select options is immutable,
// so we use that for the callback instead of the option itself to
// make it possible to rebind callbacks after cloning the element.
$option->getAttributes()->registerAttributeCallback('selected', function () use ($value) {
return $this->isSelectedOption($value);
});

$this->options[$value] = $option;
Expand Down
Loading