diff options
Diffstat (limited to '')
220 files changed, 23438 insertions, 0 deletions
diff --git a/vendor/ipl/html/LICENSE b/vendor/ipl/html/LICENSE new file mode 100644 index 0000000..58005ec --- /dev/null +++ b/vendor/ipl/html/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2018 Icinga GmbH https://www.icinga.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/vendor/ipl/html/composer.json b/vendor/ipl/html/composer.json new file mode 100644 index 0000000..853c7b2 --- /dev/null +++ b/vendor/ipl/html/composer.json @@ -0,0 +1,27 @@ +{ + "name": "ipl/html", + "type": "library", + "description": "Icinga PHP Library - HTML abstraction layer", + "license": "MIT", + "keywords": ["html"], + "homepage": "https://github.com/Icinga/ipl-html", + "config": { + "sort-packages": true + }, + "require": { + "php": ">=7.2", + "ipl/stdlib": ">=0.12.0", + "ipl/validator": ">=0.4.0", + "psr/http-message": "~1.0" + }, + "autoload": { + "psr-4": { + "ipl\\Html\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "ipl\\Tests\\Html\\": "tests" + } + } +} diff --git a/vendor/ipl/html/src/Attribute.php b/vendor/ipl/html/src/Attribute.php new file mode 100644 index 0000000..0cf13b8 --- /dev/null +++ b/vendor/ipl/html/src/Attribute.php @@ -0,0 +1,301 @@ +<?php + +namespace ipl\Html; + +use InvalidArgumentException; + +/** + * HTML Attribute + * + * Every single HTML attribute is (or should be) an instance of this class. + * This guarantees that every attribute is safe and escaped correctly. + * + * Usually attributes are not instantiated directly, but created through an HTML + * element's exposed methods. + */ +class Attribute +{ + /** @var string */ + protected $name; + + /** @var string|array|bool|null */ + protected $value; + + /** + * Create a new HTML attribute from the given name and value + * + * @param string $name The name of the attribute + * @param string|bool|array|null $value The value of the attribute + * + * @throws InvalidArgumentException If the name of the attribute contains special characters + */ + public function __construct($name, $value = null) + { + $this->setName($name)->setValue($value); + } + + /** + * Create a new HTML attribute from the given name and value + * + * @param string $name The name of the attribute + * @param string|bool|array|null $value The value of the attribute + * + * @return static + * + * @throws InvalidArgumentException If the name of the attribute contains special characters + */ + public static function create($name, $value) + { + return new static($name, $value); + } + + /** + * Create a new empty HTML attribute from the given name + * + * The value of the attribute will be null after construction. + * + * @param string $name The name of the attribute + * + * @return static + * + * @throws InvalidArgumentException If the name of the attribute contains special characters + */ + public static function createEmpty($name) + { + return new static($name, null); + } + + /** + * Escape the name of an attribute + * + * Makes sure that the name of an attribute really is a string. + * + * @param string $name + * + * @return string + */ + public static function escapeName($name) + { + return (string) $name; + } + + /** + * Escape the value of an attribute + * + * If the value is an array, returns the string representation + * of all array elements joined with the specified glue string. + * + * Values are escaped according to the HTML5 double-quoted attribute value syntax: + * {@link https://html.spec.whatwg.org/multipage/syntax.html#attributes-2 }. + * + * @param string|array $value + * @param string $glue Glue string to join elements if value is an array + * + * @return string + */ + public static function escapeValue($value, $glue = ' ') + { + if (is_array($value)) { + $value = implode($glue, $value); + } + + // We force double-quoted attribute value syntax so let's start by escaping double quotes + $value = str_replace('"', '"', $value); + + // In addition, values must not contain ambiguous ampersands + $value = preg_replace_callback( + '/&[0-9A-Z]+;/i', + function ($match) { + $subject = $match[0]; + + if (htmlspecialchars_decode($subject, ENT_COMPAT | ENT_HTML5) === $subject) { + // Ambiguous ampersand + return str_replace('&', '&', $subject); + } + + return $subject; + }, + $value + ); + + return $value; + } + + /** + * Get the name of the attribute + * + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Set the name of the attribute + * + * @param string $name + * + * @return $this + * + * @throws InvalidArgumentException If the name contains special characters + */ + protected function setName($name) + { + if (! preg_match('/^[a-z][a-z0-9:-]*$/i', $name)) { + throw new InvalidArgumentException(sprintf( + 'Attribute names with special characters are not yet allowed: %s', + $name + )); + } + + $this->name = $name; + + return $this; + } + + /** + * Get the value of the attribute + * + * @return string|bool|array|null + */ + public function getValue() + { + return $this->value; + } + + /** + * Set the value of the attribute + * + * @param string|bool|array|null $value + * + * @return $this + */ + public function setValue($value) + { + $this->value = $value; + + return $this; + } + + /** + * Add the given value(s) to the attribute + * + * @param string|array $value The value(s) to add + * + * @return $this + */ + public function addValue($value) + { + $this->value = array_merge((array) $this->value, (array) $value); + + return $this; + } + + /** + * Remove the given value(s) from the attribute + * + * The current value is set to null if it matches the value to remove + * or is in the array of values to remove. + * + * If the current value is an array, all elements are removed which + * match the value(s) to remove. + * + * Does nothing if there is no such value to remove. + * + * @param string|array $value The value(s) to remove + * + * @return $this + */ + public function removeValue($value) + { + $value = (array) $value; + + $current = $this->getValue(); + + if (is_array($current)) { + $this->setValue(array_diff($current, $value)); + } elseif (in_array($current, $value, true)) { + $this->setValue(null); + } + + return $this; + } + + /** + * Test and return true if the attribute is boolean, false otherwise + * + * @return bool + */ + public function isBoolean() + { + return is_bool($this->value); + } + + /** + * Test and return true if the attribute is empty, false otherwise + * + * Null and the empty array will be considered empty. + * + * @return bool + */ + public function isEmpty() + { + return $this->value === null || $this->value === []; + } + + /** + * Render the attribute to HTML + * + * If the value of the attribute is of type boolean, it will be rendered as + * {@link http://www.w3.org/TR/html5/infrastructure.html#boolean-attributes boolean attribute}. + * Note that in this case if the value of the attribute is false, the empty string will be returned. + * + * If the value of the attribute is null or an empty array, + * the empty string will be returned as well. + * + * Escaping of the attribute's value takes place automatically using {@link Attribute::escapeValue()}. + * + * @return string + */ + public function render() + { + if ($this->isEmpty()) { + return ''; + } + + if ($this->isBoolean()) { + if ($this->value) { + return $this->renderName(); + } + + return ''; + } else { + return sprintf( + '%s="%s"', + $this->renderName(), + $this->renderValue() + ); + } + } + + /** + * Render the name of the attribute to HTML + * + * @return string + */ + public function renderName() + { + return static::escapeName($this->name); + } + + /** + * Render the value of the attribute to HTML + * + * @return string + */ + public function renderValue() + { + return static::escapeValue($this->value); + } +} diff --git a/vendor/ipl/html/src/Attributes.php b/vendor/ipl/html/src/Attributes.php new file mode 100644 index 0000000..ae15ef8 --- /dev/null +++ b/vendor/ipl/html/src/Attributes.php @@ -0,0 +1,521 @@ +<?php + +namespace ipl\Html; + +use ArrayAccess; +use ArrayIterator; +use InvalidArgumentException; +use IteratorAggregate; +use Traversable; + +use function ipl\Stdlib\get_php_type; + +/** + * HTML attributes + * + * HTML attributes provide additional information about HTML elements, that configure the elements or adjust their + * behavior in various ways. + * + * Attributes usually come in name-value pairs and are rendered as name="value". + */ +class Attributes implements ArrayAccess, IteratorAggregate +{ + /** @var Attribute[] */ + protected $attributes = []; + + /** @var callable[] */ + protected $callbacks = []; + + /** @var string */ + protected $prefix = ''; + + /** @var callable[] */ + protected $setterCallbacks = []; + + /** + * Create new HTML attributes + * + * @param array $attributes + */ + public function __construct(array $attributes = null) + { + if (empty($attributes)) { + return; + } + + foreach ($attributes as $key => $value) { + if ($value instanceof Attribute) { + $this->addAttribute($value); + } elseif (is_string($key)) { + $this->add($key, $value); + } elseif (is_array($value) && count($value) === 2) { + $this->add(array_shift($value), array_shift($value)); + } + } + } + + /** + * Create new HTML attributes + * + * @param array $attributes + * + * @return static + */ + public static function create(array $attributes = null) + { + return new static($attributes); + } + + /** + * Ensure that the given attributes of mixed type are converted to an instance of attributes + * + * The conversion procedure is as follows: + * + * If the given attributes is already an instance of Attributes, returns the very same element. + * If the attributes are given as an array of attribute name-value pairs, they are used to + * construct and return a new Attributes instance. + * If the attributes are null, an empty new instance of Attributes is returned. + * + * @param array|static|null $attributes + * + * @return static + * + * @throws InvalidArgumentException In case the given attributes are of an unsupported type + */ + public static function wantAttributes($attributes) + { + if ($attributes instanceof self) { + return $attributes; + } + + if (is_array($attributes)) { + return new static($attributes); + } + + if ($attributes === null) { + return new static(); + } + + throw new InvalidArgumentException(sprintf( + 'Attributes instance, array or null expected. Got %s instead.', + get_php_type($attributes) + )); + } + + /** + * Get the collection of attributes as array + * + * @return Attribute[] + */ + public function getAttributes() + { + return $this->attributes; + } + + /** + * Merge the given attributes + * + * @param Attributes $attributes + * + * @return $this + */ + public function merge(Attributes $attributes) + { + foreach ($attributes as $attribute) { + $this->addAttribute($attribute); + } + + foreach ($attributes->callbacks as $name => $getter) { + $setter = null; + if (isset($attributes->setterCallbacks[$name])) { + $setter = $attributes->setterCallbacks[$name]; + } + + $this->registerAttributeCallback($name, $getter, $setter); + } + + return $this; + } + + /** + * Return true if the attribute with the given name exists, false otherwise + * + * @param string $name + * + * @return bool + */ + public function has($name) + { + return array_key_exists($name, $this->attributes); + } + + /** + * Get the attribute with the given name + * + * If the attribute does not yet exist, it is automatically created and registered to this Attributes instance. + * + * @param string $name + * + * @return Attribute + * + * @throws InvalidArgumentException If the attribute does not yet exist and its name contains special characters + */ + public function get($name) + { + if (! $this->has($name)) { + $this->attributes[$name] = Attribute::createEmpty($name); + } + + return $this->attributes[$name]; + } + + /** + * Set the given attribute(s) + * + * If the attribute with the given name already exists, it gets overridden. + * + * @param string|array|Attribute|self $attribute The attribute(s) to add + * @param string|bool|array $value The value of the attribute + * + * @return $this + * + * @throws InvalidArgumentException If the attribute name contains special characters + */ + public function set($attribute, $value = null) + { + if ($attribute instanceof self) { + foreach ($attribute as $a) { + $this->setAttribute($a); + } + + return $this; + } + + if ($attribute instanceof Attribute) { + $this->setAttribute($attribute); + + return $this; + } + + if (is_array($attribute)) { + foreach ($attribute as $name => $value) { + $this->set($name, $value); + } + + return $this; + } + + if (array_key_exists($attribute, $this->setterCallbacks)) { + $callback = $this->setterCallbacks[$attribute]; + + $callback($value); + + return $this; + } + + $this->attributes[$attribute] = Attribute::create($attribute, $value); + + return $this; + } + + /** + * Add the given attribute(s) + * + * If an attribute with the same name already exists, the attribute's value will be added to the current value of + * the attribute. + * + * @param string|array|Attribute|self $attribute The attribute(s) to add + * @param string|bool|array $value The value of the attribute + * + * @return $this + * + * @throws InvalidArgumentException If the attribute does not yet exist and its name contains special characters + */ + public function add($attribute, $value = null) + { + if ($attribute === null) { + return $this; + } + + if ($attribute instanceof self) { + foreach ($attribute as $attr) { + $this->add($attr); + } + + return $this; + } + + if (is_array($attribute)) { + foreach ($attribute as $name => $value) { + $this->add($name, $value); + } + + return $this; + } + + if ($attribute instanceof Attribute) { + $this->addAttribute($attribute); + + return $this; + } + + if (array_key_exists($attribute, $this->setterCallbacks)) { + $callback = $this->setterCallbacks[$attribute]; + + $callback($value); + + return $this; + } + + if (! array_key_exists($attribute, $this->attributes)) { + $this->attributes[$attribute] = Attribute::create($attribute, $value); + } else { + $this->attributes[$attribute]->addValue($value); + } + + return $this; + } + + /** + * Remove the attribute with the given name or remove the given value from the attribute + * + * @param string $name The name of the attribute + * @param null|string|array $value The value to remove if specified + * + * @return Attribute|false + */ + public function remove($name, $value = null) + { + if (! $this->has($name)) { + return false; + } + + $attribute = $this->attributes[$name]; + + if ($value === null) { + unset($this->attributes[$name]); + } else { + $attribute->removeValue($value); + } + + return $attribute; + } + + /** + * Set the specified attribute + * + * @param Attribute $attribute + * + * @return $this + */ + public function setAttribute(Attribute $attribute) + { + $this->attributes[$attribute->getName()] = $attribute; + + return $this; + } + + /** + * Add the specified attribute + * + * If an attribute with the same name already exists, the given attribute's value + * will be added to the current value of the attribute. + * + * @param Attribute $attribute + * + * @return $this + */ + public function addAttribute(Attribute $attribute) + { + $name = $attribute->getName(); + + if ($this->has($name)) { + $this->attributes[$name]->addValue($attribute->getValue()); + } else { + $this->attributes[$name] = $attribute; + } + + return $this; + } + + /** + * Get the attributes name prefix + * + * @return string|null + */ + public function getPrefix() + { + return $this->prefix; + } + + /** + * Set the attributes name prefix + * + * @param string $prefix + * + * @return $this + */ + public function setPrefix($prefix) + { + $this->prefix = $prefix; + + return $this; + } + + /** + * 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 + * + * @return $this + * + * @throws InvalidArgumentException If $callback is not callable or if $setterCallback is set and not callable + */ + public function registerAttributeCallback($name, $callback, $setterCallback = null) + { + 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; + } + + return $this; + } + + /** + * Render attributes to HTML + * + * If the value of an attribute is of type boolean, it will be rendered as + * {@link http://www.w3.org/TR/html5/infrastructure.html#boolean-attributes boolean attribute}. + * + * If the value of an attribute is null, it will be skipped. + * + * HTML-escaping of the attributes' values takes place automatically using {@link Attribute::escapeValue()}. + * + * @return string + * + * @throws InvalidArgumentException If the result of a callback is invalid + */ + public function render() + { + $attributes = $this->attributes; + foreach ($this->callbacks as $name => $callback) { + $attribute = call_user_func($callback); + if ($attribute instanceof Attribute) { + if ($attribute->isEmpty()) { + continue; + } + } elseif ($attribute === null) { + continue; + } elseif (is_scalar($attribute)) { + $attribute = Attribute::create($name, $attribute); + } else { + throw new InvalidArgumentException(sprintf( + 'A registered attribute callback must return a scalar, null' + . ' or an Attribute, got a %s', + get_php_type($attribute) + )); + } + + $name = $attribute->getName(); + if (isset($attributes[$name])) { + $attributes[$name] = clone $attributes[$name]; + $attributes[$name]->addValue($attribute->getValue()); + } else { + $attributes[$name] = $attribute; + } + } + + $parts = []; + foreach ($attributes as $attribute) { + if ($attribute->isEmpty()) { + continue; + } + + $parts[] = $attribute->render(); + } + + if (empty($parts)) { + return ''; + } + + $separator = ' ' . $this->getPrefix(); + + return $separator . implode($separator, $parts); + } + + /** + * Get whether the attribute with the given name exists + * + * @param string $name Name of the attribute + * + * @return bool + */ + public function offsetExists($name): bool + { + return $this->has($name); + } + + /** + * Get the attribute with the given name + * + * If the attribute does not yet exist, it is automatically created and registered to this Attributes instance. + * + * @param string $name Name of the attribute + * + * @return Attribute + * + * @throws InvalidArgumentException If the attribute does not yet exist and its name contains special characters + */ + public function offsetGet($name): Attribute + { + return $this->get($name); + } + + /** + * Set the given attribute + * + * If the attribute with the given name already exists, it gets overridden. + * + * @param string $name Name of the attribute + * @param mixed $value Value of the attribute + * + * @throws InvalidArgumentException If the attribute name contains special characters + */ + public function offsetSet($name, $value): void + { + $this->set($name, $value); + } + + /** + * Remove the attribute with the given name + * + * @param string $name Name of the attribute + */ + public function offsetUnset($name): void + { + $this->remove($name); + } + + /** + * Get an iterator for traversing the attributes + * + * @return Attribute[]|ArrayIterator + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->attributes); + } +} diff --git a/vendor/ipl/html/src/BaseHtmlElement.php b/vendor/ipl/html/src/BaseHtmlElement.php new file mode 100644 index 0000000..da8348d --- /dev/null +++ b/vendor/ipl/html/src/BaseHtmlElement.php @@ -0,0 +1,355 @@ +<?php + +namespace ipl\Html; + +use RuntimeException; + +/** + * Base class for HTML elements + * + * Extend this class in order to provide concrete HTML elements or series of HTML elements, e.g. widgets. + * When extending this class you should provide the element's tag with {@link $tag}. Setting default attributes is + * possible via {@link $defaultAttributes}. And the content of the element is provided in {@link assemble()}. + * + * # Example Usage + * ``` + * namespace Acme\Widgets; + * + * use ipl\Html\BaseHtmlElement; + * + * class Dashboard extends BaseHtmlElement + * { + * protected $defaultAttributes = ['class' => 'acme-dashboard']; + * + * protected $tag = 'div'; + * + * protected function assemble() + * { + * // ... + * $this->add($content); + * } + * } + * ``` + */ +abstract class BaseHtmlElement extends HtmlDocument +{ + /** + * List of void elements which must not contain end tags or content + * + * If {@link $isVoid} is null, this property should be used to decide whether the content and end tag has to be + * rendered. + * + * @var array + * + * @see https://www.w3.org/TR/html5/syntax.html#void-elements + */ + protected static $voidElements = [ + 'area' => 1, + 'base' => 1, + 'br' => 1, + 'col' => 1, + 'embed' => 1, + 'hr' => 1, + 'img' => 1, + 'input' => 1, + 'link' => 1, + 'meta' => 1, + 'param' => 1, + 'source' => 1, + 'track' => 1, + 'wbr' => 1 + ]; + + /** @var Attributes */ + protected $attributes; + + /** @var bool Whether possible attribute callbacks have been registered */ + protected $attributeCallbacksRegistered = false; + + /** @var bool|null Whether the element is void. If null, void check should use {@link $voidElements} */ + protected $isVoid; + + /** @var array You may want to set default attributes when extending this class */ + protected $defaultAttributes; + + /** @var string Tag of element. Set this property in order to provide the element's tag when extending this class */ + protected $tag; + + /** + * Get the attributes of the element + * + * @return Attributes + */ + public function getAttributes() + { + if ($this->attributes === null) { + $default = $this->getDefaultAttributes(); + if (empty($default)) { + $this->attributes = new Attributes(); + } else { + $this->attributes = Attributes::wantAttributes($default); + } + + $this->ensureAttributeCallbacksRegistered(); + } + + return $this->attributes; + } + + /** + * Set the attributes of the element + * + * @param Attributes|array|null $attributes + * + * @return $this + */ + public function setAttributes($attributes) + { + $this->attributes = Attributes::wantAttributes($attributes); + + $this->attributeCallbacksRegistered = false; + $this->ensureAttributeCallbacksRegistered(); + + return $this; + } + + /** + * Set the attribute with the given name and value + * + * If the attribute with the given name already exists, it gets overridden. + * + * @param string $name The name of the attribute + * @param string|bool|array $value The value of the attribute + * + * @return $this + */ + public function setAttribute($name, $value) + { + $this->getAttributes()->set($name, $value); + + return $this; + } + + /** + * Add the given attributes + * + * @param Attributes|array $attributes + * + * @return $this + */ + public function addAttributes($attributes) + { + $this->getAttributes()->add($attributes); + + return $this; + } + + /** + * Get the default attributes of the element + * + * @return array + */ + public function getDefaultAttributes() + { + return $this->defaultAttributes; + } + + /** + * Get the tag of the element + * + * Since HTML Elements must have a tag, this method throws an exception if the element does not have a tag. + * + * @return string + * + * @throws RuntimeException If the element does not have a tag + */ + final public function getTag() + { + $tag = $this->tag(); + + if (! $tag) { + throw new RuntimeException('Element must have a tag'); + } + + return $tag; + } + + /** + * Set the tag of the element + * + * @param string $tag + * + * @return $this + */ + public function setTag($tag) + { + $this->tag = $tag; + + return $this; + } + + /** + * Get whether the element is void + * + * The default void detection which checks whether the element's tag is in the list of void elements according to + * https://www.w3.org/TR/html5/syntax.html#void-elements. + * + * If you want to override this behavior, use {@link setVoid()}. + * + * @return bool + */ + public function isVoid() + { + if ($this->isVoid !== null) { + return $this->isVoid; + } + + $tag = $this->getTag(); + + return isset(self::$voidElements[$tag]); + } + + /** + * Set whether the element is void + * + * You may use this method to override the default void detection which checks whether the element's tag is in the + * list of void elements according to https://www.w3.org/TR/html5/syntax.html#void-elements. + * + * If you specify null, void detection is reset to its default behavior. + * + * @param bool|null $void + * + * @return $this + */ + public function setVoid($void = true) + { + $this->isVoid = $void === null ?: (bool) $void; + + return $this; + } + + /** + * Ensure that possible attribute callbacks have been registered + * + * Note that this method is automatically called in {@link getAttributes()} and {@link setAttributes()}. + * + * @return $this + */ + public function ensureAttributeCallbacksRegistered() + { + if (! $this->attributeCallbacksRegistered) { + $this->attributeCallbacksRegistered = true; + $this->registerAttributeCallbacks($this->attributes); + } + + return $this; + } + + /** + * Render the content of the element to HTML + * + * @return string + */ + public function renderContent() + { + return parent::renderUnwrapped(); + } + + /** + * Get whether the closing tag should be rendered + * + * @return bool True for void elements, false otherwise + */ + public function wantsClosingTag() + { + // TODO: There is more. SVG and MathML namespaces + return ! $this->isVoid(); + } + + /** + * Use this element to wrap the given document + * + * @param HtmlDocument $document + * + * @return $this + */ + public function wrap(HtmlDocument $document) + { + $document->addWrapper($this); + + return $this; + } + + /** + * Internal method for accessing the tag + * + * You may override this method in order to provide the tag dynamically + * + * @return string + */ + protected function tag() + { + return $this->tag; + } + + /** + * Register attribute callbacks + * + * Override this method in order to register attribute callbacks in concrete classes. + */ + protected function registerAttributeCallbacks(Attributes $attributes) + { + } + + public function addHtml(ValidHtml ...$content) + { + $this->ensureAssembled(); + + parent::addHtml(...$content); + + return $this; + } + + /** + * @inheritdoc + * + * @throws RuntimeException If the element does not have a tag or is void but has content + */ + public function renderUnwrapped() + { + $this->ensureAssembled(); + + $tag = $this->getTag(); + $content = $this->renderContent(); + $attributes = $this->getAttributes()->render(); + + if (strlen($this->contentSeparator)) { + $length = strlen($content); + if ($length > 0) { + if ($content[0] === '<') { + $content = $this->contentSeparator . $content; + $length++; + } + if ($content[$length - 1] === '>') { + $content .= $this->contentSeparator; + } + } + } + + if (! $this->wantsClosingTag()) { + if (strlen($content)) { + throw new RuntimeException('Void elements must not have content'); + } + + return sprintf('<%s%s />', $tag, $attributes); + } + + return sprintf( + '<%s%s>%s</%s>', + $tag, + $attributes, + $content, + $tag + ); + } +} diff --git a/vendor/ipl/html/src/Contract/FormElement.php b/vendor/ipl/html/src/Contract/FormElement.php new file mode 100644 index 0000000..6aa1456 --- /dev/null +++ b/vendor/ipl/html/src/Contract/FormElement.php @@ -0,0 +1,125 @@ +<?php + +namespace ipl\Html\Contract; + +use ipl\Html\Attributes; +use ipl\Html\Form; + +/** + * Representation of form elements + */ +interface FormElement extends Wrappable +{ + /** + * Get the attributes or options of the element + * + * @return Attributes + */ + public function getAttributes(); + + /** + * Add attributes or options to the form element + * + * @param iterable $attributes + * + * @return $this + */ + public function addAttributes($attributes); + + /** + * Get the description for the element, if any + * + * @return string|null + */ + public function getDescription(); + + /** + * Get the label for the element, if any + * + * @return string|null + */ + public function getLabel(); + + /** + * Get the validation error messages + * + * @return array + */ + public function getMessages(); + + /** + * Add a validation error message + * + * @param string $message + * + * @return $this + */ + public function addMessage($message); + + /** + * Get the name of the element + * + * @return string + */ + public function getName(); + + /** + * Get whether the element has a value + * + * @return bool False if the element's value is null, the empty string or the empty array; true otherwise + */ + public function hasValue(); + + /** + * Get the value of the element + * + * @return mixed + */ + public function getValue(); + + /** + * Set the value of the element + * + * @param mixed $value + * + * @return $this + */ + public function setValue($value); + + /** + * Get whether the element has been validated + * + * @return bool + */ + public function hasBeenValidated(); + + /** + * Get whether the element is ignored + * + * @return bool + */ + public function isIgnored(); + + /** + * Get whether the element is required + * + * @return bool + */ + public function isRequired(); + + /** + * Get whether the element is valid + * + * @return bool + */ + public function isValid(); + + /** + * Handler which is called after this element has been registered + * + * @param Form $form + * + * @return void + */ + public function onRegistered(Form $form); +} diff --git a/vendor/ipl/html/src/Contract/FormElementDecorator.php b/vendor/ipl/html/src/Contract/FormElementDecorator.php new file mode 100644 index 0000000..1e862bb --- /dev/null +++ b/vendor/ipl/html/src/Contract/FormElementDecorator.php @@ -0,0 +1,18 @@ +<?php + +namespace ipl\Html\Contract; + +use ipl\Html\ValidHtml; + +/** + * Representation of form element decorators + */ +interface FormElementDecorator extends ValidHtml +{ + /** + * Decorate the given form element + * + * @param FormElement $formElement + */ + public function decorate(FormElement $formElement); +} diff --git a/vendor/ipl/html/src/Contract/FormSubmitElement.php b/vendor/ipl/html/src/Contract/FormSubmitElement.php new file mode 100644 index 0000000..4909df8 --- /dev/null +++ b/vendor/ipl/html/src/Contract/FormSubmitElement.php @@ -0,0 +1,13 @@ +<?php + +namespace ipl\Html\Contract; + +interface FormSubmitElement extends FormElement +{ + /** + * Get whether the element has been pressed + * + * @return bool + */ + public function hasBeenPressed(); +} diff --git a/vendor/ipl/html/src/Contract/ValueCandidates.php b/vendor/ipl/html/src/Contract/ValueCandidates.php new file mode 100644 index 0000000..0271500 --- /dev/null +++ b/vendor/ipl/html/src/Contract/ValueCandidates.php @@ -0,0 +1,22 @@ +<?php + +namespace ipl\Html\Contract; + +interface ValueCandidates +{ + /** + * Get value candidates of this element + * + * @return array + */ + public function getValueCandidates(); + + /** + * Set value candidates of this element + * + * @param array $values + * + * @return $this + */ + public function setValueCandidates(array $values); +} diff --git a/vendor/ipl/html/src/Contract/Wrappable.php b/vendor/ipl/html/src/Contract/Wrappable.php new file mode 100644 index 0000000..c8cf924 --- /dev/null +++ b/vendor/ipl/html/src/Contract/Wrappable.php @@ -0,0 +1,45 @@ +<?php + +namespace ipl\Html\Contract; + +use ipl\Html\ValidHtml; + +/** + * Representation of wrappable elements + */ +interface Wrappable extends ValidHtml +{ + /** + * Get the wrapper, if any + * + * @return Wrappable|null + */ + public function getWrapper(); + + /** + * Set the wrapper + * + * @param Wrappable $wrapper + * + * @return $this + */ + public function setWrapper(Wrappable $wrapper); + + /** + * Add a wrapper + * + * @param Wrappable $wrapper + * + * @return $this + */ + public function addWrapper(Wrappable $wrapper); + + /** + * Prepend a wrapper + * + * @param Wrappable $wrapper + * + * @return $this + */ + public function prependWrapper(Wrappable $wrapper); +} diff --git a/vendor/ipl/html/src/DeferredText.php b/vendor/ipl/html/src/DeferredText.php new file mode 100644 index 0000000..2d308f1 --- /dev/null +++ b/vendor/ipl/html/src/DeferredText.php @@ -0,0 +1,114 @@ +<?php + +namespace ipl\Html; + +use Exception; + +/** + * Text node where content creation is deferred until rendering + * + * The content is created by a passed in callback which is only called when the node is going to be rendered and + * automatically escaped to HTML. + * If the created content is already escaped, see {@link setEscaped()} to indicate this. + * + * # Example Usage + * ``` + * $benchmark = new Benchmark(); + * + * $performance = new DeferredText(function () use ($benchmark) { + * return $benchmark->summary(); + * }); + * + * execute_query(); + * + * $benchmark->tick('Fetched results'); + * + * generate_report(); + * + * $benchmark->tick('Report generated'); + * + * echo $performance; + * ``` + */ +class DeferredText implements ValidHtml +{ + /** @var callable will return the text that should be rendered */ + protected $callback; + + /** @var bool */ + protected $escaped = false; + + /** + * Create a new text node where content creation is deferred until rendering + * + * @param callable $callback Must return the content that should be rendered + */ + public function __construct(callable $callback) + { + $this->callback = $callback; + } + + /** + * Create a new text node where content creation is deferred until rendering + * + * @param callable $callback Must return the content that should be rendered + * + * @return static + */ + public static function create(callable $callback) + { + return new static($callback); + } + + /** + * Get whether the callback promises that its content is already escaped + * + * @return bool + */ + public function isEscaped() + { + return $this->escaped; + } + + /** + * Set whether the callback's content is already escaped + * + * @param bool $escaped + * + * @return $this + */ + public function setEscaped($escaped = true) + { + $this->escaped = $escaped; + + return $this; + } + + /** + * Render text to HTML when treated like a string + * + * Calls {@link render()} internally in order to render the text to HTML. + * Exceptions will be automatically caught and returned as HTML string as well using {@link Error::render()}. + * + * @return string + */ + public function __toString() + { + try { + return $this->render(); + } catch (Exception $e) { + return Error::render($e); + } + } + + public function render() + { + $callback = $this->callback; + + if ($this->escaped) { + return $callback(); + } else { + return Html::escape($callback()); + } + } +} diff --git a/vendor/ipl/html/src/Error.php b/vendor/ipl/html/src/Error.php new file mode 100644 index 0000000..a4ab253 --- /dev/null +++ b/vendor/ipl/html/src/Error.php @@ -0,0 +1,117 @@ +<?php + +namespace ipl\Html; + +use Exception; +use Throwable; + +use function ipl\Stdlib\get_php_type; + +/** + * Class Error + * + * TODO: Eventually allow to (statically) inject a custom error renderer. + * + * @package ipl\Html + */ +abstract class Error +{ + /** @var bool */ + protected static $showTraces = true; + + /** + * + * @param Exception|Throwable|string $error + * @return HtmlDocument + */ + public static function show($error) + { + if ($error instanceof Throwable) { + // PHP 7+ + $msg = static::createMessageForException($error); + } elseif ($error instanceof Exception) { + // PHP 5.x + $msg = static::createMessageForException($error); + } elseif (is_string($error)) { + $msg = $error; + } else { + // TODO: translate? + $msg = 'Got an invalid error'; + } + + $result = static::renderErrorMessage($msg); + if (static::showTraces()) { + $result->addHtml(Html::tag('pre', $error->getTraceAsString())); + } + + return $result; + } + + /** + * + * @param Exception|Throwable|string $error + * @return string + */ + public static function render($error) + { + return static::show($error)->render(); + } + + /** + * @param bool|null $show + * @return bool + */ + public static function showTraces($show = null) + { + if ($show !== null) { + self::$showTraces = (bool) $show; + } + + return self::$showTraces; + } + + /** + * @deprecated Use {@link get_php_type()} instead + */ + public static function getPhpTypeName($any) + { + return get_php_type($any); + } + + /** + * @param Exception|Throwable $exception + * @return string + */ + protected static function createMessageForException($exception) + { + $file = preg_split('/[\/\\\]/', $exception->getFile(), -1, PREG_SPLIT_NO_EMPTY); + $file = array_pop($file); + return sprintf( + '%s (%s:%d)', + $exception->getMessage(), + $file, + $exception->getLine() + ); + } + + /** + * @param string + * @return HtmlDocument + */ + protected static function renderErrorMessage($message) + { + $output = new HtmlDocument(); + $output->addHtml( + Html::tag('div', ['class' => 'exception'], [ + Html::tag('h1', [ + Html::tag('i', ['class' => 'icon-bug']), + // TODO: Translate? More hints? + 'Oops, an error occurred!' + ]), + Html::tag('pre', $message) + ]) + ); + + return $output; + } +} diff --git a/vendor/ipl/html/src/Form.php b/vendor/ipl/html/src/Form.php new file mode 100644 index 0000000..e301af1 --- /dev/null +++ b/vendor/ipl/html/src/Form.php @@ -0,0 +1,384 @@ +<?php + +namespace ipl\Html; + +use Exception; +use ipl\Html\Contract\FormElement; +use ipl\Html\Contract\FormSubmitElement; +use ipl\Html\FormElement\FormElements; +use ipl\Stdlib\Messages; +use Psr\Http\Message\ServerRequestInterface; + +class Form extends BaseHtmlElement +{ + use FormElements { + FormElements::remove as private removeElement; + } + use Messages; + + const ON_ELEMENT_REGISTERED = 'elementRegistered'; + const ON_ERROR = 'error'; + const ON_REQUEST = 'request'; + const ON_SUCCESS = 'success'; + const ON_SENT = 'sent'; + const ON_VALIDATE = 'validate'; + + /** @var string Form submission URL */ + protected $action; + + /** @var string HTTP method to submit the form with */ + protected $method = 'POST'; + + /** @var FormSubmitElement Primary submit button */ + protected $submitButton; + + /** @var FormSubmitElement[] Other elements that may submit the form */ + protected $submitElements = []; + + /** @var bool Whether the form is valid */ + protected $isValid; + + /** @var ServerRequestInterface The server request being processed */ + protected $request; + + /** @var string */ + protected $redirectUrl; + + protected $tag = 'form'; + + /** + * Get the Form submission URL + * + * @return string|null + */ + public function getAction() + { + return $this->action; + } + + /** + * Set the Form submission URL + * + * @param string $action + * + * @return $this + */ + public function setAction($action) + { + $this->action = $action; + + return $this; + } + + /** + * Get the HTTP method to submit the form with + * + * @return string + */ + public function getMethod() + { + return $this->method; + } + + /** + * Set the HTTP method to submit the form with + * + * @param string $method + * + * @return $this + */ + public function setMethod($method) + { + $this->method = strtoupper($method); + + return $this; + } + + /** + * Get whether the form has a primary submit button + * + * @return bool + */ + public function hasSubmitButton() + { + return $this->submitButton !== null; + } + + /** + * Get the primary submit button + * + * @return FormSubmitElement|null + */ + public function getSubmitButton() + { + return $this->submitButton; + } + + /** + * Set the primary submit button + * + * @param FormSubmitElement $element + * + * @return $this + */ + public function setSubmitButton(FormSubmitElement $element) + { + $this->submitButton = $element; + + return $this; + } + + /** + * Get the submit element used to send the form + * + * @return FormSubmitElement|null + */ + public function getPressedSubmitElement() + { + foreach ($this->submitElements as $submitElement) { + if ($submitElement->hasBeenPressed()) { + return $submitElement; + } + } + + return null; + } + + /** + * @return ServerRequestInterface|null + */ + public function getRequest() + { + return $this->request; + } + + public function setRequest($request) + { + $this->request = $request; + $this->emit(Form::ON_REQUEST, [$request]); + + return $this; + } + + /** + * Get the url to redirect to on success + * + * @return string + */ + public function getRedirectUrl() + { + return $this->redirectUrl; + } + + /** + * Set the url to redirect to on success + * + * @param string $url + * + * @return $this + */ + public function setRedirectUrl($url) + { + $this->redirectUrl = $url; + + return $this; + } + + /** + * @param ServerRequestInterface $request + * @return $this + */ + public function handleRequest(ServerRequestInterface $request) + { + $this->setRequest($request); + + if (! $this->hasBeenSent()) { + // Always assemble + $this->ensureAssembled(); + + return $this; + } + + switch ($request->getMethod()) { + case 'POST': + $params = $request->getParsedBody(); + + break; + case 'GET': + parse_str($request->getUri()->getQuery(), $params); + + break; + default: + $params = []; + } + + $this->populate($params); + + // Assemble after populate in order to conditionally provide form elements + $this->ensureAssembled(); + + if ($this->hasBeenSubmitted()) { + if ($this->isValid()) { + try { + $this->emit(Form::ON_SENT, [$this]); + $this->onSuccess(); + $this->emitOnce(Form::ON_SUCCESS, [$this]); + } catch (Exception $e) { + $this->addMessage($e); + $this->onError(); + $this->emit(Form::ON_ERROR, [$e, $this]); + } + } else { + $this->onError(); + } + } else { + $this->validatePartial(); + $this->emit(Form::ON_SENT, [$this]); + } + + return $this; + } + + /** + * Get whether the form has been sent + * + * A form is considered sent if the request's method equals the form's method. + * + * @return bool + */ + public function hasBeenSent() + { + if ($this->request === null) { + return false; + } + + return $this->request->getMethod() === $this->getMethod(); + } + + /** + * Get whether the form has been submitted + * + * A form is submitted when it has been sent and when the primary submit button, if set, has been pressed. + * This method calls {@link hasBeenSent()} in order to detect whether the form has been sent. + * + * @return bool + */ + public function hasBeenSubmitted() + { + if (! $this->hasBeenSent()) { + return false; + } + + if ($this->hasSubmitButton()) { + return $this->getSubmitButton()->hasBeenPressed(); + } + + return true; + } + + /** + * Get whether the form is valid + * + * {@link validate()} is called automatically if the form has not been validated before. + * + * @return bool + */ + public function isValid() + { + if ($this->isValid === null) { + $this->validate(); + + $this->emit(self::ON_VALIDATE, [$this]); + } + + return $this->isValid; + } + + /** + * Validate all elements + * + * @return $this + */ + public function validate() + { + $valid = true; + foreach ($this->elements as $element) { + if ($element->isRequired() && ! $element->hasValue()) { + $element->addMessage('This field is required'); + $valid = false; + continue; + } + if (! $element->isValid()) { + $valid = false; + } + } + + $this->isValid = $valid; + + return $this; + } + + /** + * Validate all elements that have a value + * + * @return $this + */ + public function validatePartial() + { + foreach ($this->getElements() as $element) { + $element->validate(); + } + + return $this; + } + + public function remove(ValidHtml $elementOrHtml) + { + if ($this->submitButton === $elementOrHtml) { + $this->submitButton = null; + } + + $this->removeElement($elementOrHtml); + } + + protected function onError() + { + $errors = Html::tag('ul', ['class' => 'errors']); + foreach ($this->getMessages() as $message) { + if ($message instanceof Exception) { + $message = $message->getMessage(); + } + + $errors->addHtml(Html::tag('li', $message)); + } + + if (! $errors->isEmpty()) { + $this->prependHtml($errors); + } + } + + protected function onSuccess() + { + // $this->redirectOnSuccess(); + } + + protected function onElementRegistered(FormElement $element) + { + if ($element instanceof FormSubmitElement) { + $this->submitElements[$element->getName()] = $element; + + if (! $this->hasSubmitButton()) { + $this->setSubmitButton($element); + } + } + + $element->onRegistered($this); + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + $attributes + ->registerAttributeCallback('action', [$this, 'getAction'], [$this, 'setAction']) + ->registerAttributeCallback('method', [$this, 'getMethod'], [$this, 'setMethod']); + } +} diff --git a/vendor/ipl/html/src/FormDecorator/CallbackDecorator.php b/vendor/ipl/html/src/FormDecorator/CallbackDecorator.php new file mode 100644 index 0000000..1048bfb --- /dev/null +++ b/vendor/ipl/html/src/FormDecorator/CallbackDecorator.php @@ -0,0 +1,41 @@ +<?php + +namespace ipl\Html\FormDecorator; + +use Closure; +use ipl\Html\Contract\FormElement; +use ipl\Html\Contract\FormElementDecorator; +use ipl\Html\HtmlDocument; + +class CallbackDecorator extends HtmlDocument implements FormElementDecorator +{ + /** @var Closure The decorating callback */ + protected $callback; + + /** @var FormElement The decorated form element */ + protected $formElement; + + /** + * Create a new CallbackDecorator + * + * @param Closure $callback + */ + public function __construct(Closure $callback) + { + $this->callback = $callback; + } + + public function decorate(FormElement $formElement) + { + $decorator = clone $this; + + $decorator->formElement = $formElement; + + $formElement->prependWrapper($decorator); + } + + protected function assemble() + { + call_user_func($this->callback, $this->formElement, $this); + } +} diff --git a/vendor/ipl/html/src/FormDecorator/DdDtDecorator.php b/vendor/ipl/html/src/FormDecorator/DdDtDecorator.php new file mode 100644 index 0000000..ae0f8e6 --- /dev/null +++ b/vendor/ipl/html/src/FormDecorator/DdDtDecorator.php @@ -0,0 +1,141 @@ +<?php + +namespace ipl\Html\FormDecorator; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\FormElement\BaseFormElement; +use ipl\Html\Html; +use ipl\Html\ValidHtml; + +class DdDtDecorator extends BaseHtmlElement implements DecoratorInterface +{ + protected $tag = 'dl'; + + protected $dt; + + protected $dd; + + /** @var BaseFormElement */ + protected $wrappedElement; + + protected $ready = false; + + /** + * @param BaseFormElement $element + * @return static + */ + public function decorate(BaseFormElement $element) + { + // TODO: ignore hidden? + $newWrapper = clone($this); + $newWrapper->wrappedElement = $element; + $element->prependWrapper($newWrapper); + + return $newWrapper; + } + + protected function renderLabel() + { + if ($this->wrappedElement instanceof BaseFormElement) { + $label = $this->wrappedElement->getLabel(); + if ($label) { + return Html::tag('label', null, $label); + } + } + + return null; + } + + public function getAttributes() + { + $attributes = parent::getAttributes(); + + // TODO: only when sent?! + if ($this->wrappedElement->hasBeenValidatedAndIsNotValid()) { + $classes = $attributes->get('class'); + if ( + empty($classes) + || (is_array($classes) && ! in_array('errors', $classes)) + || (is_string($classes) && $classes !== 'errors') + ) { + $attributes->add('class', 'errors'); + } + } + + return $attributes; + } + + protected function renderDescription() + { + if ($this->wrappedElement instanceof BaseFormElement) { + $description = $this->wrappedElement->getDescription(); + if ($description) { + return Html::tag('p', ['class' => 'description'], $description); + } + } + + return null; + } + + protected function renderErrors() + { + if ($this->wrappedElement instanceof BaseFormElement) { + $errors = []; + foreach ($this->wrappedElement->getMessages() as $message) { + $errors[] = Html::tag('p', ['class' => 'error'], $message); + } + + if (! empty($errors)) { + return $errors; + } + } + + return null; + } + + public function addHtml(ValidHtml ...$content) + { + // TODO: is this required? + if (! in_array($this->wrappedElement, $content, true)) { + parent::addHtml(...$content); + } + + return $this; + } + + protected function assemble() + { + $this->addHtml($this->dt(), $this->dd()); + $this->ready = true; + } + + protected function dt() + { + if ($this->dt === null) { + $this->dt = Html::tag('dt', null, $this->renderLabel()); + } + + return $this->dt; + } + + /** + * @return \ipl\Html\HtmlElement + */ + protected function dd() + { + if ($this->dd === null) { + $this->dd = Html::tag('dd', null, [ + $this->wrappedElement, + $this->renderErrors(), + $this->renderDescription() + ]); + } + + return $this->dd; + } + + public function __destruct() + { + $this->wrapper = null; + } +} diff --git a/vendor/ipl/html/src/FormDecorator/DecoratorInterface.php b/vendor/ipl/html/src/FormDecorator/DecoratorInterface.php new file mode 100644 index 0000000..1ed1a38 --- /dev/null +++ b/vendor/ipl/html/src/FormDecorator/DecoratorInterface.php @@ -0,0 +1,19 @@ +<?php + +namespace ipl\Html\FormDecorator; + +use ipl\Html\Contract\FormElementDecorator; +use ipl\Html\FormElement\BaseFormElement; + +/** @deprecated Use {@link FormElementDecorator} instead */ +interface DecoratorInterface +{ + /** + * Set the form element to decorate + * + * @param BaseFormElement $formElement + * + * @return static + */ + public function decorate(BaseFormElement $formElement); +} diff --git a/vendor/ipl/html/src/FormDecorator/DivDecorator.php b/vendor/ipl/html/src/FormDecorator/DivDecorator.php new file mode 100644 index 0000000..574bc26 --- /dev/null +++ b/vendor/ipl/html/src/FormDecorator/DivDecorator.php @@ -0,0 +1,132 @@ +<?php + +namespace ipl\Html\FormDecorator; + +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Contract\FormElement; +use ipl\Html\Contract\FormElementDecorator; +use ipl\Html\Contract\FormSubmitElement; +use ipl\Html\FormElement\HiddenElement; +use ipl\Html\Html; +use ipl\Html\HtmlElement; +use ipl\Html\Text; + +/** + * Form element decorator based on div elements + */ +class DivDecorator extends BaseHtmlElement implements FormElementDecorator +{ + /** @var string CSS class to use for submit elements */ + const SUBMIT_ELEMENT_CLASS = 'form-control'; + + /** @var string CSS class to use for all input elements */ + const INPUT_ELEMENT_CLASS = 'form-element'; + + /** @var string CSS class to use for form descriptions */ + const DESCRIPTION_CLASS = 'form-element-description'; + + /** @var string CSS class to use for form errors */ + const ERROR_CLASS = 'form-element-errors'; + + /** @var string CSS class to set on the decorator if the element has errors */ + const ERROR_HINT_CLASS = 'has-error'; + + /** @var FormElement The decorated form element */ + protected $formElement; + + protected $tag = 'div'; + + public function decorate(FormElement $formElement) + { + if ($formElement instanceof HiddenElement) { + return; + } + + $decorator = clone $this; + + $decorator->formElement = $formElement; + + $classes = [static::INPUT_ELEMENT_CLASS]; + if ($formElement instanceof FormSubmitElement) { + $classes[] = static::SUBMIT_ELEMENT_CLASS; + } + + $decorator->getAttributes()->add('class', $classes); + + $formElement->prependWrapper($decorator); + } + + protected function assembleDescription() + { + $description = $this->formElement->getDescription(); + + if ($description !== null) { + $descriptionId = null; + if ($this->formElement->getAttributes()->has('id')) { + $descriptionId = 'desc_' . $this->formElement->getAttributes()->get('id')->getValue(); + $this->formElement->getAttributes()->set('aria-describedby', $descriptionId); + } + + return Html::tag('p', ['id' => $descriptionId, 'class' => static::DESCRIPTION_CLASS], $description); + } + + return null; + } + + protected function assembleElement() + { + if ($this->formElement->isRequired()) { + $this->formElement->getAttributes()->set('aria-required', 'true'); + } + + return $this->formElement; + } + + protected function assembleErrors() + { + $errors = new HtmlElement('ul', Attributes::create(['class' => static::ERROR_CLASS])); + + foreach ($this->formElement->getMessages() as $message) { + $errors->addHtml( + new HtmlElement('li', Attributes::create(['class' => static::ERROR_CLASS]), Text::create($message)) + ); + } + + if (! $errors->isEmpty()) { + return $errors; + } + + return null; + } + + protected function assembleLabel() + { + $label = $this->formElement->getLabel(); + + if ($label !== null) { + $attributes = null; + if ($this->formElement->getAttributes()->has('id')) { + $attributes = new Attributes(['for' => $this->formElement->getAttributes()->get('id')->getValue()]); + } + + return Html::tag('label', $attributes, $label); + } + + return null; + } + + protected function assemble() + { + if ($this->formElement->hasBeenValidated() && ! $this->formElement->isValid()) { + $this->getAttributes()->add('class', static::ERROR_HINT_CLASS); + } + + $this->addHtml(...Html::wantHtmlList([ + $this->assembleLabel(), + $this->assembleElement(), + $this->assembleDescription(), + $this->assembleErrors() + ])); + } +} diff --git a/vendor/ipl/html/src/FormElement/BaseFormElement.php b/vendor/ipl/html/src/FormElement/BaseFormElement.php new file mode 100644 index 0000000..c75a636 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/BaseFormElement.php @@ -0,0 +1,348 @@ +<?php + +namespace ipl\Html\FormElement; + +use ipl\Html\Attribute; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Contract\FormElement; +use ipl\Html\Contract\ValueCandidates; +use ipl\Html\Form; +use ipl\Stdlib\Messages; +use ipl\Validator\ValidatorChain; + +abstract class BaseFormElement extends BaseHtmlElement implements FormElement, ValueCandidates +{ + use Messages; + + /** @var string Description of the element */ + protected $description; + + /** @var string Label of the element */ + protected $label; + + /** @var string Name of the element */ + protected $name; + + /** @var bool Whether the element is ignored */ + protected $ignored = false; + + /** @var bool Whether the element is required */ + protected $required = false; + + /** @var null|bool Whether the element is valid; null if the element has not been validated yet, bool otherwise */ + protected $valid; + + /** @var ValidatorChain Registered validators */ + protected $validators; + + /** @var mixed Value of the element */ + protected $value; + + /** @var array Value candidates of the element */ + protected $valueCandidates = []; + + /** + * Create a new form element + * + * @param string $name Name of the form element + * @param mixed $attributes Attributes of the form element + */ + public function __construct($name, $attributes = null) + { + if ($attributes !== null) { + $this->addAttributes($attributes); + } + $this->setName($name); + } + + public function getDescription() + { + return $this->description; + } + + /** + * Set the description of the element + * + * @param string $description + * + * @return $this + */ + public function setDescription($description) + { + $this->description = $description; + + return $this; + } + + public function getLabel() + { + return $this->label; + } + + /** + * Set the label of the element + * + * @param string $label + * + * @return $this + */ + public function setLabel($label) + { + $this->label = $label; + + return $this; + } + + public function getName() + { + return $this->name; + } + + /** + * Set the name for the element + * + * @param string $name + * + * @return $this + */ + public function setName($name) + { + $this->name = $name; + + return $this; + } + + public function isIgnored() + { + return $this->ignored; + } + + /** + * Set whether the element is ignored + * + * @param bool $ignored + * + * @return $this + */ + public function setIgnored($ignored = true) + { + $this->ignored = (bool) $ignored; + + return $this; + } + + public function isRequired() + { + return $this->required; + } + + /** + * Set whether the element is required + * + * @param bool $required + * + * @return $this + */ + public function setRequired($required = true) + { + $this->required = (bool) $required; + + return $this; + } + + public function isValid() + { + if ($this->valid === null) { + $this->validate(); + } + + return $this->valid; + } + + /** + * Get whether the element has been validated and is not valid + * + * @return bool + * + * @deprecated Use {@link hasBeenValidated()} in combination with {@link isValid()} instead + */ + public function hasBeenValidatedAndIsNotValid() + { + return $this->valid !== null && ! $this->valid; + } + + /** + * Get the validators + * + * @return ValidatorChain + */ + public function getValidators() + { + if ($this->validators === null) { + $this->validators = new ValidatorChain(); + $this->addDefaultValidators(); + } + + return $this->validators; + } + + /** + * Add default validators + */ + public function addDefaultValidators() + { + } + + /** + * Set the validators + * + * @param iterable $validators + * + * @return $this + */ + public function setValidators($validators) + { + $this + ->getValidators() + ->clearValidators() + ->addValidators($validators); + + return $this; + } + + /** + * Add validators + * + * @param iterable $validators + * + * @return $this + */ + public function addValidators($validators) + { + $this->getValidators()->addValidators($validators); + + return $this; + } + + public function hasValue() + { + $value = $this->getValue(); + + return $value !== null && $value !== '' && $value !== []; + } + + public function getValue() + { + return $this->value; + } + + public function setValue($value) + { + if ($value === '') { + $this->value = null; + } else { + $this->value = $value; + } + $this->valid = null; + + return $this; + } + + public function getValueCandidates() + { + return $this->valueCandidates; + } + + public function setValueCandidates(array $values) + { + $this->valueCandidates = $values; + + return $this; + } + + public function onRegistered(Form $form) + { + } + + /** + * Validate the element using all registered validators + * + * @return $this + */ + public function validate() + { + $this->valid = $this->getValidators()->isValid($this->getValue()); + $this->addMessages($this->getValidators()->getMessages()); + + return $this; + } + + public function hasBeenValidated() + { + return $this->valid !== null; + } + + /** + * Callback for the name attribute + * + * @return Attribute|string + */ + public function getNameAttribute() + { + return $this->getName(); + } + + /** + * Callback for the required attribute + * + * @return Attribute|null + */ + public function getRequiredAttribute() + { + if ($this->isRequired()) { + return new Attribute('required', true); + } + + return null; + } + + /** + * Callback for the value attribute + * + * @return mixed + */ + public function getValueAttribute() + { + return $this->getValue(); + } + + protected function registerValueCallback(Attributes $attributes) + { + $attributes->registerAttributeCallback( + 'value', + [$this, 'getValueAttribute'], + [$this, 'setValue'] + ); + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + $this->registerValueCallback($attributes); + + $attributes + ->registerAttributeCallback('label', null, [$this, 'setLabel']) + ->registerAttributeCallback('name', [$this, 'getNameAttribute'], [$this, 'setName']) + ->registerAttributeCallback('description', null, [$this, 'setDescription']) + ->registerAttributeCallback('validators', null, [$this, 'setValidators']) + ->registerAttributeCallback('ignore', null, [$this, 'setIgnored']) + ->registerAttributeCallback('required', [$this, 'getRequiredAttribute'], [$this, 'setRequired']); + + $this->registerCallbacks(); + } + + /** @deprecated Use {@link registerAttributeCallbacks()} instead */ + protected function registerCallbacks() + { + } +} diff --git a/vendor/ipl/html/src/FormElement/ButtonElement.php b/vendor/ipl/html/src/FormElement/ButtonElement.php new file mode 100644 index 0000000..63ae540 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/ButtonElement.php @@ -0,0 +1,8 @@ +<?php + +namespace ipl\Html\FormElement; + +class ButtonElement extends BaseFormElement +{ + protected $tag = 'button'; +} diff --git a/vendor/ipl/html/src/FormElement/CheckboxElement.php b/vendor/ipl/html/src/FormElement/CheckboxElement.php new file mode 100644 index 0000000..5e16fc2 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/CheckboxElement.php @@ -0,0 +1,125 @@ +<?php + +namespace ipl\Html\FormElement; + +use ipl\Html\Attributes; + +class CheckboxElement extends InputElement +{ + /** @var bool Whether the checkbox is checked */ + protected $checked = false; + + /** @var string Value of the checkbox when it is checked */ + protected $checkedValue = 'y'; + + /** @var string Value of the checkbox when it is not checked */ + protected $uncheckedValue = 'n'; + + protected $type = 'checkbox'; + + /** + * Get whether the checkbox is checked + * + * @return bool + */ + public function isChecked() + { + return $this->checked; + } + + /** + * Set whether the checkbox is checked + * + * @param bool $checked + * + * @return $this + */ + public function setChecked($checked) + { + $this->checked = (bool) $checked; + + return $this; + } + + /** + * Get the value of the checkbox when it is checked + * + * @return string + */ + public function getCheckedValue() + { + return $this->checkedValue; + } + + /** + * Set the value of the checkbox when it is checked + * + * @param string $checkedValue + * + * @return $this + */ + public function setCheckedValue($checkedValue) + { + $this->checkedValue = $checkedValue; + + return $this; + } + + /** + * Get the value of the checkbox when it is not checked + * + * @return string + */ + public function getUncheckedValue() + { + return $this->uncheckedValue; + } + + /** + * Set the value of the checkbox when it is not checked + * + * @param string $uncheckedValue + * + * @return $this + */ + public function setUncheckedValue($uncheckedValue) + { + $this->uncheckedValue = $uncheckedValue; + + return $this; + } + + public function setValue($value) + { + if (is_bool($value)) { + $value = $value ? $this->getCheckedValue() : $this->getUncheckedValue(); + } + + $this->setChecked($value === $this->getCheckedValue()); + + return parent::setValue($value); + } + + public function getValueAttribute() + { + return $this->getCheckedValue(); + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $attributes->registerAttributeCallback( + 'checked', + [$this, 'isChecked'], + [$this, 'setChecked'] + ); + } + + public function renderUnwrapped() + { + $html = parent::renderUnwrapped(); + + return (new HiddenElement($this->getName(), ['value' => $this->getUncheckedValue()])) . $html; + } +} diff --git a/vendor/ipl/html/src/FormElement/DateElement.php b/vendor/ipl/html/src/FormElement/DateElement.php new file mode 100644 index 0000000..2f73b3c --- /dev/null +++ b/vendor/ipl/html/src/FormElement/DateElement.php @@ -0,0 +1,8 @@ +<?php + +namespace ipl\Html\FormElement; + +class DateElement extends InputElement +{ + protected $type = 'date'; +} diff --git a/vendor/ipl/html/src/FormElement/FormElements.php b/vendor/ipl/html/src/FormElement/FormElements.php new file mode 100644 index 0000000..d8bb784 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/FormElements.php @@ -0,0 +1,502 @@ +<?php + +namespace ipl\Html\FormElement; + +use InvalidArgumentException; +use ipl\Html\Contract\FormElement; +use ipl\Html\Contract\FormElementDecorator; +use ipl\Html\Contract\ValueCandidates; +use ipl\Html\Form; +use ipl\Html\FormDecorator\DecoratorInterface; +use ipl\Html\ValidHtml; +use ipl\Stdlib\Events; +use ipl\Stdlib\Plugins; +use UnexpectedValueException; + +use function ipl\Stdlib\get_php_type; + +trait FormElements +{ + use Events; + use Plugins; + + /** @var FormElementDecorator|null */ + private $defaultElementDecorator; + + /** @var bool Whether the default element decorator loader has been registered */ + private $defaultElementDecoratorLoaderRegistered = false; + + /** @var bool Whether the default element loader has been registered */ + private $defaultElementLoaderRegistered = false; + + /** @var FormElement[] */ + private $elements = []; + + /** @var array */ + private $populatedValues = []; + + /** + * Get all elements + * + * @return FormElement[] + */ + public function getElements() + { + return $this->elements; + } + + /** + * Get whether the given element exists + * + * @param string|FormElement $element + * + * @return bool + */ + public function hasElement($element) + { + if (is_string($element)) { + return array_key_exists($element, $this->elements); + } + + if ($element instanceof FormElement) { + return in_array($element, $this->elements, true); + } + + return false; + } + + /** + * Get the element by the given name + * + * @param string $name + * + * @return FormElement + * + * @throws InvalidArgumentException If no element with the given name exists + */ + public function getElement($name) + { + if (! array_key_exists($name, $this->elements)) { + throw new InvalidArgumentException(sprintf( + "Can't get element '%s'. Element does not exist", + $name + )); + } + + return $this->elements[$name]; + } + + /** + * Add an element + * + * @param string|FormElement $typeOrElement Type of the element as string or an instance of FormElement + * @param string $name Name of the element + * @param mixed $options Element options as key-value pairs + * + * @return $this + * + * @throws InvalidArgumentException If $typeOrElement is neither a string nor an instance of FormElement + * or if $typeOrElement is a string and $name is not set + * or if $typeOrElement is a string but type is unknown + * or if $typeOrElement is an instance of FormElement but does not have a name + */ + public function addElement($typeOrElement, $name = null, $options = null) + { + if (is_string($typeOrElement)) { + if ($name === null) { + throw new InvalidArgumentException(sprintf( + '%s expects parameter 2 to be set if parameter 1 is a string', + __METHOD__ + )); + } + + $element = $this->createElement($typeOrElement, $name, $options); + } elseif ($typeOrElement instanceof FormElement) { + $element = $typeOrElement; + } else { + throw new InvalidArgumentException(sprintf( + '%s() expects parameter 1 to be a string or an instance of %s, %s given', + __METHOD__, + FormElement::class, + get_php_type($typeOrElement) + )); + } + + $this + ->registerElement($element) // registerElement() must be called first because of the name check + ->decorate($element) + ->addHtml($element); + + return $this; + } + + /** + * Create an element + * + * @param string $type Type of the element + * @param string $name Name of the element + * @param mixed $options Element options as key-value pairs + * + * @return FormElement + * + * @throws InvalidArgumentException If the type of the element is unknown + */ + public function createElement($type, $name, $options = null) + { + $this->ensureDefaultElementLoaderRegistered(); + + $class = $this->loadPlugin('element', $type); + + if (! $class) { + throw new InvalidArgumentException(sprintf( + "Can't create element of unknown type '%s", + $type + )); + } + + /** @var FormElement $element */ + $element = new $class($name); + + if ($options !== null) { + $element->addAttributes($options); + } + + return $element; + } + + /** + * Register an element + * + * Registers the element for value and validation handling but does not add it to the render stack. + * + * @param FormElement $element + * + * @return $this + * + * @throws InvalidArgumentException If $element does not provide a name + */ + public function registerElement(FormElement $element) + { + $name = $element->getName(); + + if ($name === null) { + throw new InvalidArgumentException(sprintf( + '%s expects the element to provide a name', + __METHOD__ + )); + } + + $this->elements[$name] = $element; + + if (array_key_exists($name, $this->populatedValues)) { + $element->setValue($this->populatedValues[$name][count($this->populatedValues[$name]) - 1]); + + if ($element instanceof ValueCandidates) { + $element->setValueCandidates($this->populatedValues[$name]); + } + } + + $this->onElementRegistered($element); + $this->emit(Form::ON_ELEMENT_REGISTERED, [$element]); + + return $this; + } + + /** + * Get whether a default element decorator exists + * + * @return bool + */ + public function hasDefaultElementDecorator() + { + return $this->defaultElementDecorator !== null; + } + + /** + * Get the default element decorator, if any + * + * @return FormElementDecorator|null + */ + public function getDefaultElementDecorator() + { + return $this->defaultElementDecorator; + } + + /** + * Set the default element decorator + * + * If $decorator is a string, the decorator will be automatically created from a registered decorator loader. + * A loader for the namespace ipl\\Html\\FormDecorator is automatically registered by default. + * See {@link addDecoratorLoader()} for registering a custom loader. + * + * @param FormElementDecorator|string $decorator + * + * @return $this + * + * @throws InvalidArgumentException If $decorator is a string and can't be loaded from registered decorator loaders + * or if a decorator loader does not return an instance of + * {@link FormElementDecorator} + */ + public function setDefaultElementDecorator($decorator) + { + if ($decorator instanceof FormElementDecorator || $decorator instanceof DecoratorInterface) { + $this->defaultElementDecorator = $decorator; + } else { + $this->ensureDefaultElementDecoratorLoaderRegistered(); + + $d = $this->loadPlugin('decorator', $decorator); + + if (! $d instanceof FormElementDecorator && ! $d instanceof DecoratorInterface) { + throw new InvalidArgumentException(sprintf( + "Expected instance of %s for decorator '%s'," + . " got %s from a decorator loader instead", + FormElementDecorator::class, + $decorator, + get_php_type($d) + )); + } + + $this->defaultElementDecorator = $d; + } + + return $this; + } + + /** + * Get the value of the element specified by name + * + * Returns $default if the element does not exist or has no value. + * + * @param string $name + * @param mixed $default + * + * @return mixed + */ + public function getValue($name, $default = null) + { + if ($this->hasElement($name)) { + $value = $this->getElement($name)->getValue(); + if ($value !== null) { + return $value; + } + } + + return $default; + } + + /** + * Get the values for all but ignored elements + * + * @return array Values as name-value pairs + */ + public function getValues() + { + $values = []; + foreach ($this->getElements() as $element) { + if (! $element->isIgnored()) { + $values[$element->getName()] = $element->getValue(); + } + } + + return $values; + } + + /** + * Populate values of registered elements + * + * @param iterable $values Values as name-value pairs + * + * @return $this + */ + public function populate($values) + { + foreach ($values as $name => $value) { + $this->populatedValues[$name][] = $value; + if ($this->hasElement($name)) { + $this->getElement($name)->setValue($value); + } + } + + return $this; + } + + /** + * Get the populated value of the element specified by name + * + * Returns $default if there is no populated value for this element. + * + * @param string $name + * @param mixed $default + * + * @return mixed + */ + public function getPopulatedValue($name, $default = null) + { + return isset($this->populatedValues[$name]) + ? $this->populatedValues[$name][count($this->populatedValues[$name]) - 1] + : $default; + } + + /** + * Clear populated value of the given element + * + * @param string $name + * + * @return $this + */ + public function clearPopulatedValue($name) + { + if (! $this->hasBeenSubmitted() && isset($this->populatedValues[$name])) { + unset($this->populatedValues[$name]); + } + + return $this; + } + + /** + * Add all elements from the given element collection + * + * @param Form|SubFormElement $form + * + * @return $this + */ + public function addElementsFrom($form) + { + foreach ($form->getElements() as $element) { + $this->addElement($element); + } + + return $this; + } + + /** + * Add a decorator loader + * + * @param string $namespace Namespace of the decorators + * @param string $postfix Decorator name postfix, if any + * + * @return $this + */ + public function addDecoratorLoader($namespace, $postfix = null) + { + $this->addPluginLoader('decorator', $namespace, $postfix); + + return $this; + } + + /** + * Add an element loader + * + * @param string $namespace Namespace of the elements + * @param string $postfix Element name postfix, if any + * + * @return $this + */ + public function addElementLoader($namespace, $postfix = null) + { + $this->addPluginLoader('element', $namespace, $postfix); + + return $this; + } + + /** + * Ensure that our default element decorator loader is registered + * + * @return $this + */ + protected function ensureDefaultElementDecoratorLoaderRegistered() + { + if (! $this->defaultElementDecoratorLoaderRegistered) { + $this->addDefaultPluginLoader( + 'decorator', + 'ipl\\Html\\FormDecorator', + 'Decorator' + ); + + $this->defaultElementDecoratorLoaderRegistered = true; + } + + return $this; + } + + /** + * Ensure that our default element loader is registered + * + * @return $this + */ + protected function ensureDefaultElementLoaderRegistered() + { + if (! $this->defaultElementLoaderRegistered) { + $this->addDefaultPluginLoader('element', __NAMESPACE__, 'Element'); + + $this->defaultElementLoaderRegistered = true; + } + + return $this; + } + + /** + * Decorate the given element + * + * @param FormElement $element + * + * @return $this + * + * @throws UnexpectedValueException If the default decorator is set but not an instance of + * {@link FormElementDecorator} + */ + protected function decorate(FormElement $element) + { + if ($this->hasDefaultElementDecorator()) { + $decorator = $this->getDefaultElementDecorator(); + + if (! $decorator instanceof FormElementDecorator && ! $decorator instanceof DecoratorInterface) { + throw new UnexpectedValueException(sprintf( + '%s expects the default decorator to be an instance of %s, got %s instead', + __METHOD__, + FormElementDecorator::class, + get_php_type($decorator) + )); + } + + $decorator->decorate($element); + } + + return $this; + } + + public function isValidEvent($event) + { + return in_array($event, [ + Form::ON_SUCCESS, + Form::ON_SENT, + Form::ON_ERROR, + Form::ON_REQUEST, + Form::ON_VALIDATE, + Form::ON_ELEMENT_REGISTERED, + ]); + } + + public function remove(ValidHtml $elementOrHtml) + { + if ($elementOrHtml instanceof FormElement) { + if ($this->hasElement($elementOrHtml)) { + $name = array_search($elementOrHtml, $this->elements, true); + if ($name !== false) { + unset($this->elements[$name]); + } + } + } + + parent::remove($elementOrHtml); + } + + /** + * Handler which is called after an element has been registered + * + * @param FormElement $element + */ + protected function onElementRegistered(FormElement $element) + { + } +} diff --git a/vendor/ipl/html/src/FormElement/HiddenElement.php b/vendor/ipl/html/src/FormElement/HiddenElement.php new file mode 100644 index 0000000..bffc7eb --- /dev/null +++ b/vendor/ipl/html/src/FormElement/HiddenElement.php @@ -0,0 +1,8 @@ +<?php + +namespace ipl\Html\FormElement; + +class HiddenElement extends InputElement +{ + protected $type = 'hidden'; +} diff --git a/vendor/ipl/html/src/FormElement/InputElement.php b/vendor/ipl/html/src/FormElement/InputElement.php new file mode 100644 index 0000000..d5f945d --- /dev/null +++ b/vendor/ipl/html/src/FormElement/InputElement.php @@ -0,0 +1,49 @@ +<?php + +namespace ipl\Html\FormElement; + +use ipl\Html\Attribute; +use ipl\Html\Attributes; + +class InputElement extends BaseFormElement +{ + /** @var string Type of the input */ + protected $type; + + protected $tag = 'input'; + + /** + * Get the type of the input + * + * @return string + */ + public function getType() + { + return $this->type; + } + + /** + * Set the type of the input + * + * @param string $type + * + * @return $this + */ + public function setType($type) + { + $this->type = (string) $type; + + return $this; + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $attributes->registerAttributeCallback( + 'type', + [$this, 'getType'], + [$this, 'setType'] + ); + } +} diff --git a/vendor/ipl/html/src/FormElement/LocalDateTimeElement.php b/vendor/ipl/html/src/FormElement/LocalDateTimeElement.php new file mode 100644 index 0000000..e165c7d --- /dev/null +++ b/vendor/ipl/html/src/FormElement/LocalDateTimeElement.php @@ -0,0 +1,52 @@ +<?php + +namespace ipl\Html\FormElement; + +use DateTime; +use ipl\Validator\DateTimeValidator; + +class LocalDateTimeElement extends InputElement +{ + const FORMAT = 'Y-m-d\TH:i:s'; + + protected $type = 'datetime-local'; + + protected $defaultAttributes = ['step' => '1']; + + /** @var DateTime */ + protected $value; + + public function setValue($value) + { + if (is_string($value)) { + $originalVal = $value; + $value = DateTime::createFromFormat(static::FORMAT, $value); + // In Chrome, if the seconds are set to 00, DateTime::createFromFormat() returns false. + // Create DateTime without seconds in format + if ($value === false) { + $format = substr(static::FORMAT, 0, strrpos(static::FORMAT, ':')); + $value = DateTime::createFromFormat($format, $originalVal); + } + + if ($value === false) { + $value = $originalVal; + } + } + + return parent::setValue($value); + } + + public function getValueAttribute() + { + if (! $this->value instanceof DateTime) { + return $this->value; + } + + return $this->value->format(static::FORMAT); + } + + public function addDefaultValidators() + { + $this->getValidators()->add(new DateTimeValidator()); + } +} diff --git a/vendor/ipl/html/src/FormElement/NumberElement.php b/vendor/ipl/html/src/FormElement/NumberElement.php new file mode 100644 index 0000000..b593135 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/NumberElement.php @@ -0,0 +1,8 @@ +<?php + +namespace ipl\Html\FormElement; + +class NumberElement extends InputElement +{ + protected $type = 'number'; +} diff --git a/vendor/ipl/html/src/FormElement/PasswordElement.php b/vendor/ipl/html/src/FormElement/PasswordElement.php new file mode 100644 index 0000000..6e0e25f --- /dev/null +++ b/vendor/ipl/html/src/FormElement/PasswordElement.php @@ -0,0 +1,55 @@ +<?php + +namespace ipl\Html\FormElement; + +use ipl\Html\Attributes; +use ipl\Html\Form; + +class PasswordElement extends InputElement +{ + /** @var string Dummy passwd of this element to be rendered */ + const DUMMYPASSWORD = '_ipl_form_5847ed1b5b8ca'; + + protected $type = 'password'; + + /** @var bool Status of the form */ + protected $isFormValid = true; + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $attributes->registerAttributeCallback( + 'value', + function () { + if ($this->hasValue() && count($this->getValueCandidates()) === 1 && $this->isFormValid) { + return self::DUMMYPASSWORD; + } + + if (parent::getValue() === self::DUMMYPASSWORD) { + return self::DUMMYPASSWORD; + } + + return null; + } + ); + } + + public function onRegistered(Form $form) + { + $form->on(Form::ON_VALIDATE, function ($form) { + $this->isFormValid = $form->isValid(); + }); + } + + public function getValue() + { + $value = parent::getValue(); + $candidates = $this->getValueCandidates(); + while ($value === self::DUMMYPASSWORD) { + $value = array_pop($candidates); + } + + return $value; + } +} diff --git a/vendor/ipl/html/src/FormElement/SelectElement.php b/vendor/ipl/html/src/FormElement/SelectElement.php new file mode 100644 index 0000000..3933222 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/SelectElement.php @@ -0,0 +1,144 @@ +<?php + +namespace ipl\Html\FormElement; + +use ipl\Html\Html; + +class SelectElement extends BaseFormElement +{ + protected $tag = 'select'; + + /** @var SelectOption[] */ + protected $options = []; + + protected $optionContent = []; + + public function __construct($name, $attributes = null) + { + $this->getAttributes()->registerAttributeCallback( + 'options', + null, + [$this, 'setOptions'] + ); + // ZF1 compatibility: + $this->getAttributes()->registerAttributeCallback( + 'multiOptions', + null, + [$this, 'setOptions'] + ); + + parent::__construct($name, $attributes); + } + + public function hasOption($value) + { + return isset($this->options[$value]); + } + + public function validate() + { + $value = $this->getValue(); + if ( + $value !== null && ( + ! ($option = $this->getOption($value)) + || $option->getAttributes()->has('disabled') + ) + ) { + $this->valid = false; + $this->addMessage("'$value' is not allowed here"); + } elseif ($this->isRequired() && $value === null) { + $this->valid = false; + } else { + parent::validate(); + } + + return $this; + } + + public function deselect() + { + $this->setValue(null); + + return $this; + } + + public function disableOption($value) + { + if ($option = $this->getOption($value)) { + $option->getAttributes()->add('disabled', true); + } + if ($this->getValue() == $value) { + $this->valid = false; + $this->addMessage("'$value' is not allowed here"); + } + + return $this; + } + + public function disableOptions($values) + { + foreach ($values as $value) { + $this->disableOption($value); + } + + return $this; + } + + /** + * @param $value + * @return SelectOption|null + */ + public function getOption($value) + { + if ($this->hasOption($value)) { + return $this->options[$value]; + } else { + return null; + } + } + + /** + * @param array $options + * @return $this + */ + public function setOptions(array $options) + { + $this->options = []; + foreach ($options as $value => $label) { + $this->optionContent[$value] = $this->makeOption($value, $label); + } + + return $this; + } + + protected function makeOption($value, $label) + { + if (is_array($label)) { + $grp = Html::tag('optgroup', ['label' => $value]); + foreach ($label as $option => $val) { + $grp->addHtml($this->makeOption($option, $val)); + } + + return $grp; + } else { + $option = new SelectOption($value, $label); + $option->getAttributes()->registerAttributeCallback('selected', function () use ($option) { + $optionValue = $option->getValue(); + + return is_int($optionValue) + // The loose comparison is required because PHP casts + // numeric strings to integers if used as array keys + ? $this->getValue() == $optionValue + : $this->getValue() === $optionValue; + }); + $this->options[$value] = $option; + + return $this->options[$value]; + } + } + + protected function assemble() + { + $this->addHtml(...array_values($this->optionContent)); + } +} diff --git a/vendor/ipl/html/src/FormElement/SelectOption.php b/vendor/ipl/html/src/FormElement/SelectOption.php new file mode 100644 index 0000000..d0dbf7c --- /dev/null +++ b/vendor/ipl/html/src/FormElement/SelectOption.php @@ -0,0 +1,45 @@ +<?php + +namespace ipl\Html\FormElement; + +use ipl\Html\BaseHtmlElement; + +class SelectOption extends BaseHtmlElement +{ + protected $tag = 'option'; + + /** @var mixed */ + protected $value; + + /** + * SelectOption constructor. + * @param string|null $value + * @param string|null $label + */ + public function __construct($value = null, $label = null) + { + $this->value = $value; + $this->add($label); + + $this->getAttributes()->registerAttributeCallback('value', [$this, 'getValue']); + } + + /** + * @param $label + * @return $this + */ + public function setLabel($label) + { + $this->setContent($label); + + return $this; + } + + /** + * @return string + */ + public function getValue() + { + return $this->value; + } +} diff --git a/vendor/ipl/html/src/FormElement/SubFormElement.php b/vendor/ipl/html/src/FormElement/SubFormElement.php new file mode 100644 index 0000000..5e0f77e --- /dev/null +++ b/vendor/ipl/html/src/FormElement/SubFormElement.php @@ -0,0 +1,57 @@ +<?php + +namespace ipl\Html\FormElement; + +use ipl\Html\Attributes; + +class SubFormElement extends BaseFormElement +{ + use FormElements; + + protected $tag = 'div'; + + protected $defaultAttributes = [ + 'class' => 'ipl-subform' + ]; + + public function getValue($name = null) + { + if ($name === null) { + return $this->getValues(); + } else { + return $this->getElement($name)->getValue(); + } + } + + public function setValue($value) + { + $this->populate($value); + + return $this; + } + + public function isValid() + { + foreach ($this->getElements() as $element) { + if (! $element->isValid()) { + return false; + } + } + + return true; + } + + public function hasSubmitButton() + { + return true; + } + + protected function registerValueCallback(Attributes $attributes) + { + $attributes->registerAttributeCallback( + 'value', + null, + [$this, 'setValue'] + ); + } +} diff --git a/vendor/ipl/html/src/FormElement/SubmitButtonElement.php b/vendor/ipl/html/src/FormElement/SubmitButtonElement.php new file mode 100644 index 0000000..e94b681 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/SubmitButtonElement.php @@ -0,0 +1,27 @@ +<?php + +namespace ipl\Html\FormElement; + +use ipl\Html\Contract\FormSubmitElement; + +class SubmitButtonElement extends ButtonElement implements FormSubmitElement +{ + protected $defaultAttributes = ['type' => 'submit']; + + protected $value = 'y'; + + public function setLabel($label) + { + return $this->setContent($label); + } + + public function hasBeenPressed() + { + return (bool) $this->getValue(); + } + + public function isIgnored() + { + return true; + } +} diff --git a/vendor/ipl/html/src/FormElement/SubmitElement.php b/vendor/ipl/html/src/FormElement/SubmitElement.php new file mode 100644 index 0000000..51d4aa5 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/SubmitElement.php @@ -0,0 +1,50 @@ +<?php + +namespace ipl\Html\FormElement; + +use ipl\Html\Attribute; +use ipl\Html\Contract\FormSubmitElement; + +class SubmitElement extends InputElement implements FormSubmitElement +{ + protected $type = 'submit'; + + protected $buttonLabel; + + public function setLabel($label) + { + $this->buttonLabel = $label; + + return $this; + } + + /** + * @return string + */ + public function getButtonLabel() + { + if ($this->buttonLabel === null) { + return $this->getName(); + } else { + return $this->buttonLabel; + } + } + + /** + * @return mixed|static + */ + public function getValueAttribute() + { + return new Attribute('value', $this->getButtonLabel()); + } + + public function hasBeenPressed() + { + return $this->getButtonLabel() === $this->getValue(); + } + + public function isIgnored() + { + return true; + } +} diff --git a/vendor/ipl/html/src/FormElement/TextElement.php b/vendor/ipl/html/src/FormElement/TextElement.php new file mode 100644 index 0000000..0e3423d --- /dev/null +++ b/vendor/ipl/html/src/FormElement/TextElement.php @@ -0,0 +1,8 @@ +<?php + +namespace ipl\Html\FormElement; + +class TextElement extends InputElement +{ + protected $type = 'text'; +} diff --git a/vendor/ipl/html/src/FormElement/TextareaElement.php b/vendor/ipl/html/src/FormElement/TextareaElement.php new file mode 100644 index 0000000..dc5c42b --- /dev/null +++ b/vendor/ipl/html/src/FormElement/TextareaElement.php @@ -0,0 +1,24 @@ +<?php + +namespace ipl\Html\FormElement; + +class TextareaElement extends BaseFormElement +{ + protected $tag = 'textarea'; + + public function setValue($value) + { + parent::setValue($value); + + // A textarea's content actually is the value + $this->setContent($value); + + return $this; + } + + public function getValueAttribute() + { + // textarea elements don't have a value attribute + return null; + } +} diff --git a/vendor/ipl/html/src/FormElement/TimeElement.php b/vendor/ipl/html/src/FormElement/TimeElement.php new file mode 100644 index 0000000..1ee0323 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/TimeElement.php @@ -0,0 +1,8 @@ +<?php + +namespace ipl\Html\FormElement; + +class TimeElement extends InputElement +{ + protected $type = 'time'; +} diff --git a/vendor/ipl/html/src/FormattedString.php b/vendor/ipl/html/src/FormattedString.php new file mode 100644 index 0000000..1ef9b5b --- /dev/null +++ b/vendor/ipl/html/src/FormattedString.php @@ -0,0 +1,101 @@ +<?php + +namespace ipl\Html; + +use Exception; +use InvalidArgumentException; + +use function ipl\Stdlib\get_php_type; + +/** + * {@link sprintf()}-like formatted HTML string supporting lazy rendering of {@link ValidHtml} element arguments + * + * # Example Usage + * ``` + * $info = new FormattedString( + * 'Follow the %s for more information on %s', + * [ + * new Link('doc/html', 'HTML documentation'), + * Html::tag('strong', 'HTML elements') + * ] + * ); + * ``` + */ +class FormattedString implements ValidHtml +{ + /** @var ValidHtml[] */ + protected $args = []; + + /** @var ValidHtml */ + protected $format; + + /** + * Create a new {@link sprintf()}-like formatted HTML string + * + * @param string $format + * @param iterable $args + * + * @throws InvalidArgumentException If arguments given but not iterable + */ + public function __construct($format, $args = null) + { + $this->format = Html::wantHtml($format); + + if ($args !== null) { + if (! is_iterable($args)) { + throw new InvalidArgumentException(sprintf( + '%s expects parameter two to be iterable, got %s instead', + __METHOD__, + get_php_type($args) + )); + } + + foreach ($args as $key => $val) { + if (! is_scalar($val) || (is_string($val) && ! is_numeric($val))) { + $val = Html::wantHtml($val); + } + + $this->args[$key] = $val; + } + } + } + + + /** + * Create a new {@link sprintf()}-like formatted HTML string + * + * @param string $format + * @param mixed ...$args + * + * @return static + */ + public static function create($format, ...$args) + { + return new static($format, $args); + } + + /** + * Render text to HTML when treated like a string + * + * Calls {@link render()} internally in order to render the text to HTML. + * Exceptions will be automatically caught and returned as HTML string as well using {@link Error::render()}. + * + * @return string + */ + public function __toString() + { + try { + return $this->render(); + } catch (Exception $e) { + return Error::render($e); + } + } + + public function render() + { + return vsprintf( + $this->format->render(), + $this->args + ); + } +} diff --git a/vendor/ipl/html/src/Html.php b/vendor/ipl/html/src/Html.php new file mode 100644 index 0000000..afcba39 --- /dev/null +++ b/vendor/ipl/html/src/Html.php @@ -0,0 +1,241 @@ +<?php + +namespace ipl\Html; + +use InvalidArgumentException; + +use function ipl\Stdlib\get_php_type; +use function ipl\Stdlib\iterable_key_first; + +/** + * Main utility class when working with ipl\Html + */ +abstract class Html +{ + /** + * Create a HTML element from the given tag, attributes and content + * + * This method does not render the HTML element but creates a {@link HtmlElement} + * instance from the given tag, attributes and content + * + * @param string $name The desired HTML tag name + * @param mixed $attributes HTML attributes or content for the element + * @param mixed $content The content of the element if no attributes have been given + * + * @return HtmlElement The created element + */ + public static function tag($name, $attributes = null, $content = null) + { + if ($content !== null) { + // If not null, it's html content, no question + $content = static::wantHtmlList($content); + } elseif ($attributes instanceof ValidHtml || is_scalar($attributes)) { + // Otherwise $attributes may be $content, but only if definitely **NOT** attributes + $content = static::wantHtmlList($attributes); + $attributes = null; + } + + if ($attributes !== null) { + if (! is_iterable($attributes) || ! is_int(iterable_key_first($attributes))) { + // Not an array (e.g. instance of Attributes) or an associative array + $attributes = Attributes::wantAttributes($attributes); + } elseif (is_iterable($attributes)) { + // $attributes may still be $content, in case of a sequenced array + if ($content !== null) { + // But not if there's already $content + throw new InvalidArgumentException('Value of argument $attributes are no attributes'); + } + + $content = static::wantHtmlList($attributes); + $attributes = null; + } + } + + return new HtmlElement($name, $attributes, ...($content ?: [])); + } + + /** + * Convert special characters to HTML5 entities using the UTF-8 character + * set for encoding + * + * This method internally uses {@link htmlspecialchars} with the following + * flags: + * + * * Single quotes are not escaped (ENT_COMPAT) + * * Uses HTML5 entities, disallowing 
 (ENT_HTML5) + * * Invalid characters are replaced with � (ENT_SUBSTITUTE) + * + * Already existing HTML entities will be encoded as well. + * + * @param string $content The content to encode + * + * @return string The encoded content + */ + public static function escape($content) + { + return htmlspecialchars($content, ENT_COMPAT | ENT_HTML5 | ENT_SUBSTITUTE, 'UTF-8'); + } + + /** + * Factory for {@link sprintf()}-like formatted HTML strings + * + * This allows to use {@link sprintf()}-like format strings with {@link ValidHtml} element arguments, but with the + * advantage that they'll not be rendered immediately. + * + * # Example Usage + * ``` + * echo Html::sprintf('Hello %s!', Html::tag('strong', $name)); + * ``` + * + * @param string $format + * @param mixed ...$args + * + * @return FormattedString + */ + public static function sprintf($format, ...$args) + { + return new FormattedString($format, $args); + } + + /** + * Wrap each item of then given list + * + * $wrapper is a simple HTML tag per entry if a string is given, + * otherwise the given callable is called with key and value of each list item as parameters. + * + * @param iterable $list + * @param string|callable $wrapper + * + * @return HtmlDocument + */ + public static function wrapEach($list, $wrapper) + { + if (! is_iterable($list)) { + throw new InvalidArgumentException(sprintf( + 'Html::wrapEach() requires a traversable list, got "%s"', + get_php_type($list) + )); + } + $result = new HtmlDocument(); + foreach ($list as $name => $value) { + if (is_string($wrapper)) { + $result->addHtml(Html::tag($wrapper, $value)); + } elseif (is_callable($wrapper)) { + $result->add($wrapper($name, $value)); + } else { + throw new InvalidArgumentException(sprintf( + 'Wrapper must be callable or a string in Html::wrapEach(), got "%s"', + get_php_type($wrapper) + )); + } + } + + return $result; + } + + /** + * Ensure that the given content of mixed type is converted to an instance of {@link ValidHtml} + * + * Returns the very same element in case it's already an instance of {@link ValidHtml}. + * + * @param mixed $any + * + * @return ValidHtml + * + * @throws InvalidArgumentException In case the given content is of an unsupported type + */ + public static function wantHtml($any) + { + if ($any instanceof ValidHtml) { + return $any; + } elseif (static::canBeRenderedAsString($any)) { + return new Text($any); + } elseif (is_iterable($any)) { + $html = new HtmlDocument(); + foreach ($any as $el) { + if ($el !== null) { + $html->addHtml(static::wantHtml($el)); + } + } + + return $html; + } else { + throw new InvalidArgumentException(sprintf( + 'String, Html Element or Array of such expected, got "%s"', + get_php_type($any) + )); + } + } + + /** + * Accept any input and return it as list of ValidHtml + * + * @param mixed $content + * + * @return ValidHtml[] + */ + public static function wantHtmlList($content) + { + $list = []; + + if ($content === null) { + return $list; + } elseif (! is_iterable($content)) { + $list[] = static::wantHtml($content); + } elseif ($content instanceof ValidHtml) { + $list[] = $content; + } else { + foreach ($content as $part) { + $list = array_merge($list, static::wantHtmlList($part)); + } + } + + return $list; + } + + /** + * Get whether the given variable be rendered as a string + * + * @param mixed $any + * + * @return bool + */ + public static function canBeRenderedAsString($any) + { + return is_scalar($any) || is_null($any) || ( + is_object($any) && method_exists($any, '__toString') + ); + } + + /** + * Forward inaccessible static method calls to {@link Html::tag()} with the method's name as tag + * + * @param string $name + * @param array $arguments + * + * @return HtmlElement + */ + public static function __callStatic($name, $arguments) + { + $attributes = array_shift($arguments); + $content = array_shift($arguments); + + return static::tag($name, $attributes, $content); + } + + /** + * @deprecated Use {@link Html::encode()} instead + */ + public static function escapeForHtml($content) + { + return static::escape($content); + } + + /** + * @deprecated Use {@link Error::render()} instead + */ + public static function renderError($error) + { + return Error::render($error); + } +} diff --git a/vendor/ipl/html/src/HtmlDocument.php b/vendor/ipl/html/src/HtmlDocument.php new file mode 100644 index 0000000..dbd53a1 --- /dev/null +++ b/vendor/ipl/html/src/HtmlDocument.php @@ -0,0 +1,538 @@ +<?php + +namespace ipl\Html; + +use Countable; +use Exception; +use InvalidArgumentException; +use ipl\Html\Contract\Wrappable; +use RuntimeException; + +/** + * HTML document + * + * An HTML document is composed of a tree of HTML nodes, i.e. text nodes and HTML elements. + */ +class HtmlDocument implements Countable, Wrappable +{ + /** @var string Content separator */ + protected $contentSeparator = ''; + + /** @var bool Whether the document has been assembled */ + protected $hasBeenAssembled = false; + + /** @var Wrappable Wrapper */ + protected $wrapper; + + /** @var Wrappable Wrapped element */ + private $wrapped; + + /** @var HtmlDocument The currently responsible wrapper */ + private $renderedBy; + + /** @var ValidHtml[] Content */ + private $content = []; + + /** @var array */ + private $contentIndex = []; + + /** + * Set the element to wrap + * + * @param Wrappable $element + * + * @return $this + */ + private function setWrapped(Wrappable $element) + { + $this->wrapped = $element; + + return $this; + } + + /** + * Consume the wrapped element + * + * @return Wrappable + */ + private function consumeWrapped() + { + $wrapped = $this->wrapped; + $this->wrapped = null; + + return $wrapped; + } + + /** + * Get the content + * + * return ValidHtml[] + */ + public function getContent() + { + return $this->content; + } + + /** + * Set the content + * + * @param mixed $content + * + * @return $this + */ + public function setContent($content) + { + $this->content = []; + $this->setHtmlContent(...Html::wantHtmlList($content)); + + return $this; + } + + /** + * Set content + * + * @param ValidHtml ...$content + * + * @return $this + */ + public function setHtmlContent(ValidHtml ...$content) + { + $this->content = []; + foreach ($content as $element) { + $this->addIndexedContent($element); + } + + return $this; + } + + /** + * Get the content separator + * + * @return string + */ + public function getSeparator() + { + return $this->contentSeparator; + } + + /** + * Set the content separator + * + * @param string $separator + * + * @return $this + */ + public function setSeparator($separator) + { + $this->contentSeparator = $separator; + + return $this; + } + + /** + * Get the first {@link BaseHtmlElement} with the given tag + * + * @param string $tag + * + * @return BaseHtmlElement + * + * @throws InvalidArgumentException If no {@link BaseHtmlElement} with the given tag exists + */ + public function getFirst($tag) + { + foreach ($this->content as $c) { + if ($c instanceof BaseHtmlElement && $c->getTag() === $tag) { + return $c; + } + } + + throw new InvalidArgumentException(sprintf( + 'Trying to get first %s, but there is no such', + $tag + )); + } + + /** + * Add content + * + * @param mixed $content + * + * @return $this + */ + public function add($content) + { + $this->addHtml(...Html::wantHtmlList($content)); + + return $this; + } + + /** + * Add content + * + * @param ValidHtml ...$content + * + * @return $this + */ + public function addHtml(ValidHtml ...$content) + { + foreach ($content as $element) { + $this->addIndexedContent($element); + } + + return $this; + } + + /** + * Add content from the given document + * + * @param HtmlDocument $from + * @param callable $callback Optional callback in order to transform the content to add + * + * @return $this + */ + public function addFrom(HtmlDocument $from, $callback = null) + { + $from->ensureAssembled(); + + $isCallable = is_callable($callback); + foreach ($from->getContent() as $item) { + $this->add($isCallable ? $callback($item) : $item); + } + + return $this; + } + + /** + * Check whether the given element is a direct or indirect child of this document + * + * A direct child is one that is part of this document's content. An indirect child + * is one that is either part of a direct child's content (recursively) or a wrapper + * of such (recursively). + * + * @param ValidHtml $element + * + * @return bool + */ + public function contains(ValidHtml $element) + { + $key = spl_object_hash($element); + if (array_key_exists($key, $this->contentIndex)) { + return true; + } + + foreach ($this->content as $child) { + if ($child instanceof self && ($child->contains($element) || $child->wrappedBy($element))) { + return true; + } + } + + return false; + } + + /** + * Prepend content + * + * @param mixed $content + * + * @return $this + */ + public function prepend($content) + { + $this->prependHtml(...Html::wantHtmlList($content)); + + return $this; + } + + /** + * Prepend content + * + * @param ValidHtml ...$content + * + * @return $this + */ + public function prependHtml(ValidHtml ...$content) + { + foreach (array_reverse($content) as $html) { + array_unshift($this->content, $html); + $this->incrementIndexKeys(); + $this->addObjectPosition($html, 0); + } + + return $this; + } + + /** + * Remove content + * + * @param ValidHtml $html + * + * @return $this + */ + public function remove(ValidHtml $html) + { + $key = spl_object_hash($html); + if (array_key_exists($key, $this->contentIndex)) { + foreach ($this->contentIndex[$key] as $pos) { + unset($this->content[$pos]); + } + } + $this->content = array_values($this->content); + + $this->reIndexContent(); + + return $this; + } + + /** + * Ensure that the document has been assembled + * + * @return $this + */ + public function ensureAssembled() + { + if (! $this->hasBeenAssembled) { + $this->hasBeenAssembled = true; + $this->assemble(); + } + + return $this; + } + + /** + * Get whether the document is empty + * + * @return bool + */ + public function isEmpty() + { + $this->ensureAssembled(); + + return empty($this->content); + } + + /** + * Render the content to HTML but ignore any wrapper + * + * @return string + */ + public function renderUnwrapped() + { + $this->ensureAssembled(); + $html = []; + + // This **must** be consumed after the document's assembly but before rendering the content. + // If the document consumes it during assembly, nothing happens. If the document is used as + // wrapper for another element, consuming it asap prevents a left-over reference and avoids + // the element from getting rendered multiple times. + $wrapped = $this->consumeWrapped(); + + $content = $this->getContent(); + if ($wrapped !== null && ! $this->contains($wrapped)) { + $content[] = $wrapped; + } + + foreach ($content as $element) { + if ($element instanceof self) { + $element->renderedBy = $this; + } + + $html[] = $element->render(); + + if ($element instanceof self) { + unset($element->renderedBy); + } + } + + return implode($this->contentSeparator, $html); + } + + public function __clone() + { + foreach ($this->content as $key => $element) { + $this->content[$key] = clone $element; + } + + $this->reIndexContent(); + } + + /** + * Render content to HTML when treated like a string + * + * Calls {@link render()} internally in order to render the text to HTML. + * Exceptions will be automatically caught and returned as HTML string as well using {@link Error::render()}. + * + * @return string + */ + public function __toString() + { + try { + return $this->render(); + } catch (Exception $e) { + return Error::render($e); + } + } + + /** + * Assemble the document + * + * Override this method in order to provide content in concrete classes. + */ + protected function assemble() + { + } + + /** + * Render the document to HTML respecting the set wrapper + * + * @return string + */ + protected function renderWrapped() + { + $wrapper = $this->wrapper; + + if (isset($this->renderedBy)) { + if ($wrapper === $this->renderedBy || $wrapper->contains($this->renderedBy)) { + // $this might be an intermediate wrapper that's already about to be rendered. + // In case of an element (referencing $this as a wrapper) that is a child of an + // outer wrapper, it is required to ignore $wrapper as otherwise it's a loop. + // ($wrapper then is in the render path of the outer wrapper and sideways "stolen") + return $this->renderUnwrapped(); + } + + $wrapper->renderedBy = $this->renderedBy; + } elseif (isset($wrapper->renderedBy)) { + throw new RuntimeException('Wrapper loop detected'); + } else { + $this->renderedBy = $wrapper; + } + + $html = $wrapper->renderWrappedDocument($this); + + if (isset($this->renderedBy)) { + if ($this->renderedBy === $wrapper) { + unset($this->renderedBy); + } elseif ($wrapper->renderedBy === $this->renderedBy) { + unset($wrapper->renderedBy); + } + } + + return $html; + } + + /** + * Render the given document to HTML by treating this document as the wrapper + * + * @param HtmlDocument $document + * + * @return string + */ + protected function renderWrappedDocument(HtmlDocument $document) + { + return $this->setWrapped($document)->render(); + } + + #[\ReturnTypeWillChange] + public function count() + { + return count($this->content); + } + + public function getWrapper() + { + return $this->wrapper; + } + + public function setWrapper(Wrappable $wrapper) + { + $this->wrapper = $wrapper; + + return $this; + } + + public function addWrapper(Wrappable $wrapper) + { + if ($this->wrapper === null) { + $this->setWrapper($wrapper); + } else { + $this->wrapper->addWrapper($wrapper); + } + + return $this; + } + + public function prependWrapper(Wrappable $wrapper) + { + if ($this->wrapper === null) { + $this->setWrapper($wrapper); + } else { + $wrapper->addWrapper($this->wrapper); + $this->setWrapper($wrapper); + } + + return $this; + } + + /** + * Check whether the given element wraps this document (recursively) + * + * @param ValidHtml $element + * + * @return bool + */ + protected function wrappedBy(ValidHtml $element) + { + if ($this->wrapper === null) { + return false; + } + + if ($this->wrapper === $element || $this->wrapper->wrappedBy($element)) { + return true; + } + + return false; + } + + public function render() + { + $this->ensureAssembled(); + if ($this->wrapper === null) { + return $this->renderUnwrapped(); + } else { + return $this->renderWrapped(); + } + } + + private function addIndexedContent(ValidHtml $html) + { + $pos = count($this->content); + $this->content[$pos] = $html; + $this->addObjectPosition($html, $pos); + } + + private function addObjectPosition(ValidHtml $html, $pos) + { + $key = spl_object_hash($html); + if (array_key_exists($key, $this->contentIndex)) { + $this->contentIndex[$key][] = $pos; + } else { + $this->contentIndex[$key] = [$pos]; + } + } + + private function incrementIndexKeys() + { + foreach ($this->contentIndex as & $index) { + foreach ($index as & $pos) { + $pos++; + } + } + } + + private function reIndexContent() + { + $this->contentIndex = []; + foreach ($this->content as $pos => $html) { + $this->addObjectPosition($html, $pos); + } + } +} diff --git a/vendor/ipl/html/src/HtmlElement.php b/vendor/ipl/html/src/HtmlElement.php new file mode 100644 index 0000000..4f5d162 --- /dev/null +++ b/vendor/ipl/html/src/HtmlElement.php @@ -0,0 +1,43 @@ +<?php + +namespace ipl\Html; + +/** + * The HtmlElement represents any HTML element + * + * A typical HTML element includes a tag, attributes and content. + */ +class HtmlElement extends BaseHtmlElement +{ + /** + * Create a new HTML element from the given tag, attributes and content + * + * @param string $tag The tag for the element + * @param Attributes $attributes The HTML attributes for the element + * @param ValidHtml ...$content The content of the element + */ + public function __construct($tag, Attributes $attributes = null, ValidHtml ...$content) + { + $this->tag = $tag; + + if ($attributes !== null) { + $this->getAttributes()->merge($attributes); + } + + $this->setHtmlContent(...$content); + } + + /** + * Create a new HTML element from the given tag, attributes and content + * + * @param string $tag The tag for the element + * @param mixed $attributes The HTML attributes for the element + * @param mixed $content The content of the element + * + * @return static + */ + public static function create($tag, $attributes = null, $content = null) + { + return new static($tag, Attributes::wantAttributes($attributes), ...Html::wantHtmlList($content)); + } +} diff --git a/vendor/ipl/html/src/HtmlString.php b/vendor/ipl/html/src/HtmlString.php new file mode 100644 index 0000000..e62086b --- /dev/null +++ b/vendor/ipl/html/src/HtmlString.php @@ -0,0 +1,13 @@ +<?php + +namespace ipl\Html; + +/** + * HTML string + * + * HTML strings promise to be already escaped and can be anything from simple text to full HTML markup. + */ +class HtmlString extends Text +{ + protected $escaped = true; +} diff --git a/vendor/ipl/html/src/Table.php b/vendor/ipl/html/src/Table.php new file mode 100644 index 0000000..28a6738 --- /dev/null +++ b/vendor/ipl/html/src/Table.php @@ -0,0 +1,226 @@ +<?php + +namespace ipl\Html; + +use RuntimeException; +use stdClass; + +class Table extends BaseHtmlElement +{ + protected $contentSeparator = "\n"; + + /** @var string */ + protected $tag = 'table'; + + /** @var HtmlElement */ + private $caption; + + /** @var HtmlElement */ + private $header; + + /** @var HtmlElement */ + private $body; + + /** @var HtmlElement */ + private $footer; + + public function addHtml(ValidHtml ...$content) + { + foreach ($content as $html) { + if ($html instanceof BaseHtmlElement) { + switch ($html->getTag()) { + case 'tr': + $this->getBody()->addHtml($html); + + break; + case 'thead': + parent::addHtml($html); + $this->header = $html; + + break; + case 'tbody': + parent::addHtml($html); + $this->body = $html; + + break; + case 'tfoot': + parent::addHtml($html); + $this->footer = $html; + + break; + case 'caption': + if ($this->caption === null) { + $this->prependHtml($html); + $this->caption = $html; + } else { + throw new RuntimeException( + 'Tables allow only one <caption> tag' + ); + } + + break; + default: + $this->getBody()->addHtml(static::row([$html])); + } + } else { + $this->getBody()->addHtml(static::row([$html])); + } + } + + return $this; + } + + /** + * @param mixed $content + * @return $this + */ + public function add($content) + { + if ($content instanceof stdClass) { + $this->getBody()->addHtml(static::row((array) $content)); + } elseif (is_iterable($content)) { + $this->getBody()->addHtml(static::row($content)); + } elseif ($content instanceof ValidHtml) { + $this->addHtml($content); + } else { + $this->getBody()->addHtml(static::row([$content])); + } + + return $this; + } + + /** + * Set the table title + * + * Will be rendered as a "caption" HTML element + * + * @param mixed $caption + * @return $this + */ + public function setCaption($caption) + { + if ($caption instanceof BaseHtmlElement && $caption->getTag() === 'caption') { + $this->caption = $caption; + $this->prependHtml($caption); + } elseif ($this->caption === null) { + $this->caption = new HtmlElement('caption', null, ...Html::wantHtmlList($caption)); + $this->prependHtml($this->caption); + } else { + $this->caption->setContent($caption); + } + + return $this; + } + + /** + * Static helper creating a tr element + * + * @param Attributes|array $attributes + * @param Html|array|string $content + * @return HtmlElement + */ + public static function tr($content = null, $attributes = null) + { + return Html::tag('tr', $attributes, $content); + } + + /** + * Static helper creating a th element + * + * @param Attributes|array $attributes + * @param Html|array|string $content + * @return HtmlElement + */ + public static function th($content = null, $attributes = null) + { + return Html::tag('th', $attributes, $content); + } + + /** + * Static helper creating a td element + * + * @param Attributes|array $attributes + * @param Html|array|string $content + * @return HtmlElement + */ + public static function td($content = null, $attributes = null) + { + return Html::tag('td', $attributes, $content); + } + + /** + * @param $row + * @param null $attributes + * @param string $tag + * @return HtmlElement + */ + public static function row($row, $attributes = null, $tag = 'td') + { + $tr = static::tr(); + foreach ((array) $row as $value) { + $tr->addHtml(Html::tag($tag, null, $value)); + } + + if ($attributes !== null) { + $tr->setAttributes($attributes); + } + + return $tr; + } + + /** + * @return HtmlElement + */ + public function getBody() + { + if ($this->body === null) { + $this->addHtml(Html::tag('tbody')->setSeparator("\n")); + } + + return $this->body; + } + + /** + * @return HtmlElement + */ + public function getHeader() + { + if ($this->header === null) { + $this->addHtml(Html::tag('thead')->setSeparator("\n")); + } + + return $this->header; + } + + /** + * @return HtmlElement + */ + public function getFooter() + { + if ($this->footer === null) { + $this->addHtml(Html::tag('tfoot')->setSeparator("\n")); + } + + return $this->footer; + } + + /** + * @return HtmlElement + */ + public function nextBody() + { + $this->body = null; + + return $this->getBody(); + } + + /** + * @return HtmlElement + */ + public function nextHeader() + { + $this->header = null; + + return $this->getHeader(); + } +} diff --git a/vendor/ipl/html/src/TemplateString.php b/vendor/ipl/html/src/TemplateString.php new file mode 100644 index 0000000..611ea12 --- /dev/null +++ b/vendor/ipl/html/src/TemplateString.php @@ -0,0 +1,175 @@ +<?php + +namespace ipl\Html; + +use Exception; + +/** + * Render {{#mustache}}Mustache{{/mustache}}-like string from {@link ValidHtml} element arguments + * + * # Example Usage + * ``` + * $info = TemplateString::create( + * 'Follow the {{#doc}}HTML documentation{{/doc}} for more information on {{#strong}}HTML elements{{/strong}}', + * [ + * 'doc' => new Link(null, 'doc/html'), + * 'strong' => Html::tag('strong') + * ] + * ); + * ``` + */ +class TemplateString extends FormattedString +{ + /** @var array */ + protected $templateArgs = []; + + /** @var int */ + protected $pos = 0; + + /** @var string */ + protected $string; + + /** @var int */ + protected $length; + + public function __construct($format, $args = null) + { + $parentArgs = []; + foreach ($args ?: [] as $val) { + if (is_array($val) && is_string(key($val))) { + $this->templateArgs += $val; + } else { + $parentArgs[] = $val; + } + } + + parent::__construct($format, $parentArgs); + } + + /** + * Parse template strings + * + * @param null $for template name + * @return HtmlDocument + * @throws Exception in case of missing template argument or unbounded open or close templates + */ + protected function parseTemplates($for = null) + { + $buffer = ''; + + while (($char = $this->readChar()) !== false) { + if ($char !== '{') { + $buffer .= $char; + continue; + } + + $nextChar = $this->readChar(); + if ($nextChar !== '{') { + $buffer .= $char . $nextChar; + continue; + } + + $templateHandle = $this->readChar(); + $start = $templateHandle === '#'; + $end = $templateHandle === '/'; + + $templateKey = $this->readUntil('}'); + // if the string following '{{#' is read up to the last character or (length - 1)th character + // then it is not a template + if ($this->pos >= $this->length - 1) { + $buffer .= $char . $nextChar . $templateHandle . $templateKey; + continue; + } + + $this->pos++; + $closeChar = $this->readChar(); + + if ($closeChar !== '}') { + $buffer .= $char . $nextChar . $templateHandle . $templateKey . '}' . $closeChar; + continue; + } + + if ($start) { + if (isset($this->templateArgs[$templateKey])) { + $wrapper = $this->templateArgs[$templateKey]; + + $buffer .= $this->parseTemplates($templateKey)->prependWrapper($wrapper); + } else { + throw new Exception(sprintf( + 'Missing template argument: %s ', + $templateKey + )); + } + } elseif ($for === $templateKey && $end) { + // close the template + $for = null; + break; + } else { + // throw exception for unbounded closing of templates + throw new Exception(sprintf( + 'Unbound closing of template: %s', + $templateKey + )); + } + } + + if ($this->pos === $this->length && $for !== null) { + throw new Exception(sprintf( + 'Unbound opening of template: %s', + $for + )); + } + + return (new HtmlDocument())->addHtml(HtmlString::create($buffer)); + } + + /** + * Read until any of the given chars appears + * + * @param string ...$chars + * + * @return string + */ + protected function readUntil(...$chars) + { + $buffer = ''; + while (($c = $this->readChar()) !== false) { + if (in_array($c, $chars, true)) { + $this->pos--; + break; + } + + $buffer .= $c; + } + + return $buffer; + } + + /** + * Read a single character + * + * @return false|string false if there is no character left + */ + protected function readChar() + { + if ($this->length > $this->pos) { + return $this->string[$this->pos++]; + } + + return false; + } + + public function render() + { + $formattedstring = parent::render(); + if (empty($this->templateArgs)) { + return $formattedstring; + } + + $this->string = $formattedstring; + + $this->length = strlen($formattedstring); + + return $this->parseTemplates()->render(); + } +} diff --git a/vendor/ipl/html/src/Text.php b/vendor/ipl/html/src/Text.php new file mode 100644 index 0000000..710e3d9 --- /dev/null +++ b/vendor/ipl/html/src/Text.php @@ -0,0 +1,116 @@ +<?php + +namespace ipl\Html; + +use Exception; + +/** + * A text node + * + * Primitive element that renders text to HTML while automatically escaping its content. + * If the passed content is already escaped, see {@link setEscaped()} to indicate this. + */ +class Text implements ValidHtml +{ + /** @var string */ + protected $content; + + /** @var bool Whether the content is already escaped */ + protected $escaped = false; + + /** + * Create a new text node + * + * @param string $content + */ + public function __construct($content) + { + $this->setContent($content); + } + + /** + * Create a new text node + * + * @param string $content + * + * @return static + */ + public static function create($content) + { + return new static($content); + } + + /** + * Get the content + * + * @return string + */ + public function getContent() + { + return $this->content; + } + + /** + * Set the content + * + * @param string $content + * + * @return $this + */ + public function setContent($content) + { + $this->content = (string) $content; + + return $this; + } + + /** + * Get whether the content promises to be already escaped + * + * @return bool + */ + public function isEscaped() + { + return $this->escaped; + } + + /** + * Set whether the content is already escaped + * + * @param bool $escaped + * + * @return $this + */ + public function setEscaped($escaped = true) + { + $this->escaped = $escaped; + + return $this; + } + + /** + * Render text to HTML when treated like a string + * + * Calls {@link render()} internally in order to render the text to HTML. + * Exceptions will be automatically caught and returned as HTML string as well using {@link Error::render()}. + * + * @return string + */ + public function __toString() + { + try { + return $this->render(); + } catch (Exception $e) { + return Error::render($e); + } + } + + public function render() + { + if ($this->escaped) { + return $this->content; + } else { + return Html::escape($this->content); + } + } +} diff --git a/vendor/ipl/html/src/ValidHtml.php b/vendor/ipl/html/src/ValidHtml.php new file mode 100644 index 0000000..2b88af4 --- /dev/null +++ b/vendor/ipl/html/src/ValidHtml.php @@ -0,0 +1,17 @@ +<?php + +namespace ipl\Html; + +/** + * Interface for HTML elements or primitives that promise to render valid UTF-8 encoded HTML5 with special characters + * converted to HTML entities + */ +interface ValidHtml +{ + /** + * Render to HTML + * + * @return string UTF-8 encoded HTML5 with special characters converted to HTML entities + */ + public function render(); +} diff --git a/vendor/ipl/i18n/LICENSE b/vendor/ipl/i18n/LICENSE new file mode 100644 index 0000000..e179593 --- /dev/null +++ b/vendor/ipl/i18n/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2017 Icinga GmbH https://www.icinga.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/vendor/ipl/i18n/composer.json b/vendor/ipl/i18n/composer.json new file mode 100644 index 0000000..79b9b51 --- /dev/null +++ b/vendor/ipl/i18n/composer.json @@ -0,0 +1,25 @@ +{ + "name": "ipl/i18n", + "type": "library", + "description": "Icinga PHP Library - Internationalization", + "keywords": ["gettext", "i18n", "internationalization", "localization", "translation"], + "homepage": "https://github.com/Icinga/ipl-i18n", + "license": "MIT", + "require": { + "php": ">=7.2", + "ext-intl": "*", + "ext-gettext": "*", + "ipl/stdlib": ">=0.12.0" + }, + "autoload": { + "files": ["src/functions_include.php"], + "psr-4": { + "ipl\\I18n\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "ipl\\Tests\\I18n\\": "tests" + } + } +} diff --git a/vendor/ipl/i18n/src/GettextTranslator.php b/vendor/ipl/i18n/src/GettextTranslator.php new file mode 100644 index 0000000..a7a8144 --- /dev/null +++ b/vendor/ipl/i18n/src/GettextTranslator.php @@ -0,0 +1,351 @@ +<?php + +namespace ipl\I18n; + +use FilesystemIterator; +use ipl\Stdlib\Contract\Translator; + +/** + * Translator using PHP's native [gettext](https://www.php.net/gettext) extension + * + * # Example Usage + * + * ```php + * $translator = (new GettextTranslator()) + * ->addTranslationDirectory('/path/to/locales') + * ->addTranslationDirectory('/path/to/locales-of-domain', 'special') // Could also be the same directory as above + * ->setLocale('de_DE'); + * + * $translator->translate('user'); + * + * printf( + * $translator->translatePlural('%d user', '%d user', 42), + * 42 + * ); + * + * $translator->translateInDomain('special-domain', 'request'); + * + * printf( + * $translator->translatePluralInDomain('special-domain', '%d request', '%d requests', 42), + * 42 + * ); + * + * // All translation functions also accept a context as last parameter + * $translator->translate('group', 'a-context'); + * ``` + * + */ +class GettextTranslator implements Translator +{ + /** @var string Default gettext domain */ + protected $defaultDomain = 'default'; + + /** @var string Default locale code */ + protected $defaultLocale = 'en_US'; + + /** @var array Known translation directories as array[$domain] => $directory */ + protected $translationDirectories = []; + + /** @var array Loaded translations as array[$domain] => $directory */ + protected $loadedTranslations = []; + + /** @var string Primary locale code used for translations */ + protected $locale; + + /** + * Get the default domain + * + * @return string + */ + public function getDefaultDomain() + { + return $this->defaultDomain; + } + + /** + * Set the default domain + * + * @param string $defaultDomain + * + * @return $this + */ + public function setDefaultDomain($defaultDomain) + { + $this->defaultDomain = $defaultDomain; + + return $this; + } + + /** + * Get the default locale + * + * @return string + */ + public function getDefaultLocale() + { + return $this->defaultLocale; + } + + /** + * Set the default locale + * + * @param string $defaultLocale + * + * @return $this + */ + public function setDefaultLocale($defaultLocale) + { + $this->defaultLocale = $defaultLocale; + + return $this; + } + + /** + * Get available translations + * + * @return array Available translations as array[$domain] => $directory + */ + public function getTranslationDirectories() + { + return $this->translationDirectories; + } + + /** + * Add a translation directory + * + * @param string $directory Path to translation files + * @param string $domain Optional domain of the translation + * + * @return $this + */ + public function addTranslationDirectory($directory, $domain = null) + { + $this->translationDirectories[$domain ?: $this->defaultDomain] = $directory; + + return $this; + } + + /** + * Get loaded translations + * + * @return array Loaded translations as array[$domain] => $directory + */ + public function getLoadedTranslations() + { + return $this->loadedTranslations; + } + + /** + * Load a translation so that gettext is able to locate its message catalogs + * + * {@link bindtextdomain()} is called internally for every domain and path + * that has been added with {@link addTranslationDirectory()}. + * + * @return $this + * @throws \Exception If {@link bindtextdomain()} fails for a domain + */ + public function loadTranslations() + { + foreach ($this->translationDirectories as $domain => $directory) { + if ( + isset($this->loadedTranslations[$domain]) + && $this->loadedTranslations[$domain] === $directory + ) { + continue; + } + + if (bindtextdomain($domain, $directory) !== $directory) { + throw new \Exception(sprintf( + "Can't register domain '%s' with path '%s'", + $domain, + $directory + )); + } + + bind_textdomain_codeset($domain, 'UTF-8'); + + $this->loadedTranslations[$domain] = $directory; + } + + return $this; + } + + /** + * Get the primary locale code used for translations + * + * @return string + */ + public function getLocale() + { + return $this->locale; + } + + /** + * Setup the primary locale code to use for translations + * + * Calls {@link loadTranslations()} internally. + * + * @param string $locale Locale code + * + * @return $this + * @throws \Exception If {@link bindtextdomain()} fails for a domain + */ + public function setLocale($locale) + { + putenv("LANGUAGE=$locale.UTF-8"); + setlocale(LC_ALL, $locale . '.UTF-8'); + + $this->loadTranslations(); + + textdomain($this->getDefaultDomain()); + + $this->locale = $locale; + + return $this; + } + + /** + * Encode a message with context to the representation used in .mo files + * + * @param string $message + * @param string $context + * + * @return string The encoded message as context + "\x04" + message + */ + public function encodeMessageWithContext($message, $context) + { + // The encoding of a context and a message in a .mo file is + // context + "\x04" + message (gettext version >= 0.15) + return "{$context}\x04{$message}"; + } + + public function translate($message, $context = null) + { + if ($context !== null) { + $messageForGettext = $this->encodeMessageWithContext($message, $context); + } else { + $messageForGettext = $message; + } + + $translation = gettext($messageForGettext); + + if ($translation === $messageForGettext) { + return $message; + } + + return $translation; + } + + public function translateInDomain($domain, $message, $context = null) + { + if ($context !== null) { + $messageForGettext = $this->encodeMessageWithContext($message, $context); + } else { + $messageForGettext = $message; + } + + $translation = dgettext( + $domain, + $messageForGettext + ); + + if ($translation === $messageForGettext) { + $translation = dgettext( + $this->getDefaultDomain(), + $messageForGettext + ); + } + + if ($translation === $messageForGettext) { + return $message; + } + + return $translation; + } + + public function translatePlural($singular, $plural, $number, $context = null) + { + if ($context !== null) { + $singularForGettext = $this->encodeMessageWithContext($singular, $context); + } else { + $singularForGettext = $singular; + } + + + $translation = ngettext( + $singularForGettext, + $plural, + $number + ); + + if ($translation === $singularForGettext) { + return $number === 1 ? $singular : $plural; + } + + return $translation; + } + + public function translatePluralInDomain($domain, $singular, $plural, $number, $context = null) + { + if ($context !== null) { + $singularForGettext = $this->encodeMessageWithContext($singular, $context); + } else { + $singularForGettext = $singular; + } + + $translation = dngettext( + $domain, + $singularForGettext, + $plural, + $number + ); + + $isSingular = $number === 1; + + if ($translation === ($isSingular ? $singularForGettext : $plural)) { + $translation = dngettext( + $this->getDefaultDomain(), + $singularForGettext, + $plural, + $number + ); + } + + if ($translation === $singularForGettext) { + return $isSingular ? $singular : $plural; + } + + return $translation; + } + + /** + * List available locales by traversing the translation directories from {@link addTranslationDirectory()} + * + * @return string[] Array of available locale codes + */ + public function listLocales() + { + $locales = []; + + foreach (array_unique($this->getTranslationDirectories()) as $directory) { + $fs = new FilesystemIterator( + $directory, + FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::SKIP_DOTS + ); + + foreach ($fs as $file) { + if (! $file->isDir()) { + continue; + } + + $locales[] = $file->getBasename(); + } + } + + $locales = array_filter(array_unique($locales)); + + sort($locales); + + return $locales; + } +} diff --git a/vendor/ipl/i18n/src/Locale.php b/vendor/ipl/i18n/src/Locale.php new file mode 100644 index 0000000..d9e6b05 --- /dev/null +++ b/vendor/ipl/i18n/src/Locale.php @@ -0,0 +1,124 @@ +<?php + +namespace ipl\I18n; + +class Locale +{ + /** @var string Default locale code */ + protected $defaultLocale = 'en_US'; + + /** + * Get the default locale + * + * @return string + */ + public function getDefaultLocale() + { + return $this->defaultLocale; + } + + /** + * Set the default locale + * + * @param string $defaultLocale + * + * @return $this + */ + public function setDefaultLocale($defaultLocale) + { + $this->defaultLocale = $defaultLocale; + + return $this; + } + + /** + * Return the preferred locale based on the given HTTP header and the available translations + * + * @param string $header The HTTP "Accept-Language" header + * @param array $available Available translations + * + * @return string The browser's preferred locale code + */ + public function getPreferred($header, array $available) + { + $headerValues = explode(',', $header); + for ($i = 0; $i < count($headerValues); $i++) { + // In order to accomplish a stable sort we need to take the original + // index into account as well during element comparison + $headerValues[$i] = [$headerValues[$i], $i]; + } + usort( // Sort DESC but keep equal elements ASC + $headerValues, + function ($a, $b) { + $tagA = explode(';', $a[0], 2); + $tagB = explode(';', $b[0], 2); + $qValA = (float) (strpos($a[0], ';') > 0 ? substr(array_pop($tagA), 2) : 1); + $qValB = (float) (strpos($b[0], ';') > 0 ? substr(array_pop($tagB), 2) : 1); + + return $qValA < $qValB ? 1 : ($qValA > $qValB ? -1 : ($a[1] > $b[1] ? 1 : ($a[1] < $b[1] ? -1 : 0))); + } + ); + for ($i = 0; $i < count($headerValues); $i++) { + // We need to reset the array to its original structure once it's sorted + $headerValues[$i] = $headerValues[$i][0]; + } + $requestedLocales = []; + foreach ($headerValues as $headerValue) { + if (strpos($headerValue, ';') > 0) { + $parts = explode(';', $headerValue, 2); + $headerValue = $parts[0]; + } + $requestedLocales[] = str_replace('-', '_', $headerValue); + } + $requestedLocales = array_combine( + array_map('strtolower', array_values($requestedLocales)), + array_values($requestedLocales) + ); + + $available[] = $this->defaultLocale; + $availableLocales = array_combine( + array_map('strtolower', array_values($available)), + array_values($available) + ); + + $similarMatch = null; + + foreach ($requestedLocales as $requestedLocaleLowered => $requestedLocale) { + $localeObj = $this->parseLocale($requestedLocaleLowered); + + if ( + isset($availableLocales[$requestedLocaleLowered]) + && (! $similarMatch || $this->parseLocale($similarMatch)->language === $localeObj->language) + ) { + // Prefer perfect match only if no similar match has been found yet or the perfect match is more precise + // than the similar match + return $availableLocales[$requestedLocaleLowered]; + } + + if (! $similarMatch) { + foreach ($availableLocales as $availableLocaleLowered => $availableLocale) { + if ($this->parseLocale($availableLocaleLowered)->language === $localeObj->language) { + $similarMatch = $availableLocaleLowered; + break; + } + } + } + } + + return $similarMatch ? $availableLocales[$similarMatch] : $this->defaultLocale; + } + + /** + * Parse a locale into its subtags + * + * Converts to output of {@link \Locale::parseLocale()} to an object and returns it. + * + * @param string $locale + * + * @return object Output of {@link \Locale::parseLocale()} converted to an object + */ + public function parseLocale($locale) + { + return (object) \Locale::parseLocale($locale); + } +} diff --git a/vendor/ipl/i18n/src/NoopTranslator.php b/vendor/ipl/i18n/src/NoopTranslator.php new file mode 100644 index 0000000..1f9aab2 --- /dev/null +++ b/vendor/ipl/i18n/src/NoopTranslator.php @@ -0,0 +1,31 @@ +<?php + +namespace ipl\I18n; + +use ipl\Stdlib\Contract\Translator; + +/** + * Translator that just returns the original messages + */ +class NoopTranslator implements Translator +{ + public function translate($message, $context = null) + { + return $message; + } + + public function translateInDomain($domain, $message, $context = null) + { + return $message; + } + + public function translatePlural($singular, $plural, $number, $context = null) + { + return $number === 1 ? $singular : $plural; + } + + public function translatePluralInDomain($domain, $singular, $plural, $number, $context = null) + { + return $number === 1 ? $singular : $plural; + } +} diff --git a/vendor/ipl/i18n/src/StaticTranslator.php b/vendor/ipl/i18n/src/StaticTranslator.php new file mode 100644 index 0000000..d2869bf --- /dev/null +++ b/vendor/ipl/i18n/src/StaticTranslator.php @@ -0,0 +1,14 @@ +<?php + +namespace ipl\I18n; + +use ipl\Stdlib\Contract\Translator; + +/** + * Static entrypoint for a translator instance + */ +class StaticTranslator +{ + /** @var Translator */ + public static $instance; +} diff --git a/vendor/ipl/i18n/src/Translation.php b/vendor/ipl/i18n/src/Translation.php new file mode 100644 index 0000000..52639da --- /dev/null +++ b/vendor/ipl/i18n/src/Translation.php @@ -0,0 +1,101 @@ +<?php + +namespace ipl\I18n; + +trait Translation +{ + /** + * The domain to use in methods {@see Translation::translate()} and {@see Translation::translatePlural()} + * + * Set this to your desired domain and use both mentioned methods as usual, if you never require the + * default translation domain. (It's still being used as a fallback if your domain doesn't provide a + * particular message.) + * + * @var string + */ + protected $translationDomain; + + /** + * Translate a message + * + * @param string $message + * @param string $context Message context + * + * @return string Translated message or original message if no translation is found + */ + public function translate($message, $context = null) + { + return $this->translationDomain === null + ? StaticTranslator::$instance->translate($message, $context) + : StaticTranslator::$instance->translateInDomain($this->translationDomain, $message, $context); + } + + /** + * Translate a message in the given domain + * + * If no translation is found in the specified domain, the translation is also searched for in the default domain. + * + * @param string $domain + * @param string $message + * @param string $context Message context + * + * @return string Translated message or original message if no translation is found + */ + public function translateInDomain($domain, $message, $context = null) + { + return StaticTranslator::$instance->translateInDomain($domain, $message, $context); + } + + /** + * Translate a plural message + * + * The returned message is based on the given number to decide between the singular and plural forms. + * That is also the case if no translation is found. + * + * @param string $singular Singular message + * @param string $plural Plural message + * @param int $number Number to decide between the returned singular and plural forms + * @param string $context Message context + * + * @return string Translated message or original message if no translation is found + */ + public function translatePlural($singular, $plural, $number, $context = null) + { + return $this->translationDomain === null + ? StaticTranslator::$instance->translatePlural($singular, $plural, $number, $context) + : StaticTranslator::$instance->translatePluralInDomain( + $this->translationDomain, + $singular, + $plural, + $number ?? 0, + $context + ); + } + + /** + * Translate a plural message in the given domain + * + * If no translation is found in the specified domain, the translation is also searched for in the default domain. + * + * The returned message is based on the given number to decide between the singular and plural forms. + * That is also the case if no translation is found. + * + * @param string $domain + * @param string $singular Singular message + * @param string $plural Plural message + * @param int $number Number to decide between the returned singular and plural forms + * @param string $context Message context + * + * @return string Translated message or original message if no translation is found + */ + public function translatePluralInDomain($domain, $singular, $plural, $number, $context = null) + { + return StaticTranslator::$instance->translatePluralInDomain( + $domain, + $singular, + $plural, + $number ?? 0, + $context + ); + } +} diff --git a/vendor/ipl/i18n/src/functions.php b/vendor/ipl/i18n/src/functions.php new file mode 100644 index 0000000..74d58df --- /dev/null +++ b/vendor/ipl/i18n/src/functions.php @@ -0,0 +1,34 @@ +<?php + +namespace ipl\I18n; + +/** + * Translate a message + * + * @param string $message + * @param string $context Message context + * + * @return string Translated message or original message if no translation is found + */ +function t($message, $context = null) +{ + return StaticTranslator::$instance->translate($message, $context); +} + +/** + * Translate a plural message + * + * The returned message is based on the given number to decide between the singular and plural forms. + * That is also the case if no translation is found. + * + * @param string $singular Singular message + * @param string $plural Plural message + * @param int $number Number to decide between the returned singular and plural forms + * @param string $context Message context + * + * @return string Translated message or original message if no translation is found + */ +function tp($singular, $plural, $number, $context = null) +{ + return StaticTranslator::$instance->translatePlural($singular, $plural, $number, $context); +} diff --git a/vendor/ipl/i18n/src/functions_include.php b/vendor/ipl/i18n/src/functions_include.php new file mode 100644 index 0000000..68f3806 --- /dev/null +++ b/vendor/ipl/i18n/src/functions_include.php @@ -0,0 +1,6 @@ +<?php + +// Don't redefine the functions if included multiple times +if (! function_exists('ipl\I18n\t')) { + require __DIR__ . '/functions.php'; +} diff --git a/vendor/ipl/orm/LICENSE b/vendor/ipl/orm/LICENSE new file mode 100644 index 0000000..9233b0f --- /dev/null +++ b/vendor/ipl/orm/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2019 Icinga GmbH https://www.icinga.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/vendor/ipl/orm/composer.json b/vendor/ipl/orm/composer.json new file mode 100644 index 0000000..c09127e --- /dev/null +++ b/vendor/ipl/orm/composer.json @@ -0,0 +1,31 @@ +{ + "name": "ipl/orm", + "type": "library", + "description": "Icinga PHP Library - ORM", + "license": "MIT", + "keywords": [ + "sql", + "database", + "orm" + ], + "homepage": "https://github.com/Icinga/ipl-orm", + "require": { + "php": ">=7.2", + "ext-pdo": "*", + "ipl/sql": ">=0.5.0", + "ipl/stdlib": ">=0.12.0" + }, + "autoload": { + "psr-4": { + "ipl\\Orm\\": "src" + } + }, + "require-dev": { + "ext-pdo_sqlite": "*" + }, + "autoload-dev": { + "psr-4": { + "ipl\\Tests\\Orm\\": "tests" + } + } +} diff --git a/vendor/ipl/orm/src/AliasedExpression.php b/vendor/ipl/orm/src/AliasedExpression.php new file mode 100644 index 0000000..ed733a2 --- /dev/null +++ b/vendor/ipl/orm/src/AliasedExpression.php @@ -0,0 +1,36 @@ +<?php + +namespace ipl\Orm; + +use ipl\Sql\Expression; + +class AliasedExpression extends Expression +{ + /** @var string */ + protected $alias; + + /** + * Create a new database expression + * + * @param string $alias The alias to use for the expression, this is expected to be quoted and qualified + * @param string $statement The statement of the expression + * @param ?array $columns The columns used by the expression + * @param mixed ...$values The values for the expression + */ + public function __construct(string $alias, string $statement, array $columns = null, ...$values) + { + parent::__construct($statement, $columns, ...$values); + + $this->alias = $alias; + } + + /** + * Get this expression's alias + * + * @return string + */ + public function getAlias(): string + { + return $this->alias; + } +} diff --git a/vendor/ipl/orm/src/Behavior.php b/vendor/ipl/orm/src/Behavior.php new file mode 100644 index 0000000..45b5e87 --- /dev/null +++ b/vendor/ipl/orm/src/Behavior.php @@ -0,0 +1,12 @@ +<?php + +namespace ipl\Orm; + +/** + * Interface Behavior + * + * @internal Used for type hinting only. Concrete behaviors are supposed to implement contracts from ipl\Orm\Contract + */ +interface Behavior +{ +} diff --git a/vendor/ipl/orm/src/Behavior/Binary.php b/vendor/ipl/orm/src/Behavior/Binary.php new file mode 100644 index 0000000..98a4bf4 --- /dev/null +++ b/vendor/ipl/orm/src/Behavior/Binary.php @@ -0,0 +1,116 @@ +<?php + +namespace ipl\Orm\Behavior; + +use ipl\Orm\Contract\PropertyBehavior; +use ipl\Orm\Contract\QueryAwareBehavior; +use ipl\Orm\Contract\RewriteFilterBehavior; +use ipl\Orm\Query; +use ipl\Sql\Adapter\Pgsql; +use ipl\Stdlib\Filter\Condition; +use UnexpectedValueException; + +use function ipl\Stdlib\get_php_type; + +/** + * Support hex filters for binary columns and PHP resource (in) / bytea hex format (out) transformation for PostgreSQL + */ +class Binary extends PropertyBehavior implements QueryAwareBehavior, RewriteFilterBehavior +{ + /** + * Properties for {@see rewriteCondition()} to support hex filters for each adapter + * + * Set in {@see setQuery()} from the {@see $properties} array because the latter is reset for adapters other than + * {@see Pgsql}, so {@see fromDb()} and {@see toDb()} don't run for them. + * + * @var array + */ + protected $rewriteSubjects; + + public function fromDb($value, $key, $_) + { + if ($value !== null) { + if (is_resource($value)) { + return stream_get_contents($value); + } + + return $value; + } + + return null; + } + + /** + * @throws UnexpectedValueException If value is a resource + */ + public function toDb($value, $key, $_) + { + if (is_resource($value)) { + throw new UnexpectedValueException(sprintf('Unexpected resource for %s', $key)); + } + + if ($value === '*') { + /** + * Support IS (NOT) NULL filter transformation. + * {@see \ipl\Sql\Compat\FilterProcessor::assemblePredicate()} + */ + return $value; + } + + /** + * TODO(lippserd): If the filter is moved to a subquery, the value has already been processed. + * This is because our filter processor is unfortunately doing the transformation twice at the moment: + * + * {@link https://github.com/Icinga/ipl-orm/issues/48} + * + * {@see \ipl\Orm\Compat\FilterProcessor::requireAndResolveFilterColumns()} + */ + if (substr($value, 0, 2) === '\\x') { + return $value; + } + + return sprintf('\\x%s', bin2hex($value)); + } + + public function setQuery(Query $query) + { + $this->rewriteSubjects = $this->properties; + + if (! $query->getDb()->getAdapter() instanceof Pgsql) { + // Only process properties if the adapter is PostgreSQL. + $this->properties = []; + } + } + + public function rewriteCondition(Condition $condition, $relation = null) + { + /** + * TODO(lippserd): Duplicate code because {@see RewriteFilterBehavior}s come after {@see PropertyBehavior}s. + * {@see \ipl\Orm\Compat\FilterProcessor::requireAndResolveFilterColumns()} + */ + $column = $condition->metaData()->get('columnName'); + if (isset($this->rewriteSubjects[$column])) { + $value = $condition->getValue(); + + if (empty($this->properties) && is_resource($value)) { + // Only for PostgreSQL. + throw new UnexpectedValueException(sprintf('Unexpected resource for %s', $column)); + } + + // ctype_xdigit expects strings. + $value = (string) $value; + /** + * Although this code path is also affected by the duplicate behavior evaluation stated in {@see toDb()}, + * no further adjustments are needed as ctype_xdigit returns false for binary and bytea hex strings. + */ + if (ctype_xdigit($value)) { + if (empty($this->properties) && substr($value, 0, 2) !== '\\x') { + // Only for PostgreSQL. + $condition->setValue(sprintf('\\x%s', $value)); + } else { + $condition->setValue(hex2bin($value)); + } + } + } + } +} diff --git a/vendor/ipl/orm/src/Behaviors.php b/vendor/ipl/orm/src/Behaviors.php new file mode 100644 index 0000000..5c54350 --- /dev/null +++ b/vendor/ipl/orm/src/Behaviors.php @@ -0,0 +1,238 @@ +<?php + +namespace ipl\Orm; + +use ArrayIterator; +use ipl\Orm\Contract\PersistBehavior; +use ipl\Orm\Contract\PropertyBehavior; +use ipl\Orm\Contract\RetrieveBehavior; +use ipl\Orm\Contract\RewriteColumnBehavior; +use ipl\Orm\Contract\RewriteFilterBehavior; +use ipl\Orm\Contract\RewritePathBehavior; +use ipl\Stdlib\Filter; +use IteratorAggregate; +use Traversable; + +class Behaviors implements IteratorAggregate +{ + /** @var array Registered behaviors */ + protected $behaviors = []; + + /** @var RetrieveBehavior[] Registered retrieve behaviors */ + protected $retrieveBehaviors = []; + + /** @var PersistBehavior[] Registered persist behaviors */ + protected $persistBehaviors = []; + + /** @var PropertyBehavior[] Registered property behaviors */ + protected $propertyBehaviors = []; + + /** @var RewriteFilterBehavior[] Registered rewrite filter behaviors */ + protected $rewriteFilterBehaviors = []; + + /** @var RewriteColumnBehavior[] Registered rewrite column behaviors */ + protected $rewriteColumnBehaviors = []; + + /** @var RewritePathBehavior[] Registered rewrite path behaviors */ + protected $rewritePathBehaviors = []; + + /** + * Add a behavior + * + * @param PersistBehavior|PropertyBehavior|RetrieveBehavior|RewriteFilterBehavior $behavior + */ + public function add(Behavior $behavior) + { + $this->behaviors[] = $behavior; + + if ($behavior instanceof PropertyBehavior) { + $this->retrieveBehaviors[] = $behavior; + $this->persistBehaviors[] = $behavior; + $this->propertyBehaviors[] = $behavior; + } else { + if ($behavior instanceof RetrieveBehavior) { + $this->retrieveBehaviors[] = $behavior; + } + + if ($behavior instanceof PersistBehavior) { + $this->persistBehaviors[] = $behavior; + } + } + + if ($behavior instanceof RewriteFilterBehavior) { + $this->rewriteFilterBehaviors[] = $behavior; + } + + if ($behavior instanceof RewriteColumnBehavior) { + $this->rewriteColumnBehaviors[] = $behavior; + } + + if ($behavior instanceof RewritePathBehavior) { + $this->rewritePathBehaviors[] = $behavior; + } + } + + /** + * Iterate registered behaviors + * + * @return ArrayIterator + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->behaviors); + } + + /** + * Apply all retrieve behaviors on the given model + * + * @param Model $model + */ + public function retrieve(Model $model) + { + foreach ($this->retrieveBehaviors as $behavior) { + $behavior->retrieve($model); + } + } + + /** + * Apply all persist behaviors on the given model + * + * @param Model $model + */ + public function persist(Model $model) + { + foreach ($this->persistBehaviors as $behavior) { + $behavior->persist($model); + } + } + + /** + * Transform the retrieved key's value by use of all property behaviors + * + * @param mixed $value + * @param string $key + * + * @return mixed + */ + public function retrieveProperty($value, $key) + { + foreach ($this->propertyBehaviors as $behavior) { + $value = $behavior->retrieveProperty($value, $key); + } + + return $value; + } + + /** + * Transform the to be persisted key's value by use of all property behaviors + * + * @param mixed $value + * @param string $key + * + * @return mixed + */ + public function persistProperty($value, $key) + { + foreach ($this->propertyBehaviors as $behavior) { + $value = $behavior->persistProperty($value, $key); + } + + return $value; + } + + /** + * Rewrite the given filter condition + * + * @param Filter\Condition $condition + * @param string $relation Absolute path (with a trailing dot) of the model + * + * @return Filter\Rule|null + */ + public function rewriteCondition(Filter\Condition $condition, $relation = null) + { + $filter = null; + foreach ($this->rewriteFilterBehaviors as $behavior) { + $replacement = $behavior->rewriteCondition($filter ?: $condition, $relation); + if ($replacement !== null) { + $filter = $replacement; + } + } + + return $filter; + } + + /** + * Rewrite the given relation path + * + * @param string $path + * @param string $relation Absolute path of the model + * + * @return string|null + */ + public function rewritePath($path, $relation = null) + { + $newPath = null; + foreach ($this->rewritePathBehaviors as $behavior) { + $replacement = $behavior->rewritePath($newPath ?: $path, $relation); + if ($replacement !== null) { + $newPath = $replacement; + } + } + + return $newPath; + } + + /** + * Rewrite the given column + * + * @param string $column + * @param string $relation Absolute path of the model + * + * @return mixed + */ + public function rewriteColumn($column, $relation = null) + { + $newColumn = null; + foreach ($this->rewriteColumnBehaviors as $behavior) { + $replacement = $behavior->rewriteColumn($newColumn ?: $column, $relation); + if ($replacement !== null) { + $newColumn = $replacement; + } + } + + return $newColumn; + } + + /** + * Rewrite the given column definition + * + * @param ColumnDefinition $def + * @param string $relation Absolute path of the model + * + * @return void + */ + public function rewriteColumnDefinition(ColumnDefinition $def, string $relation): void + { + foreach ($this->rewriteColumnBehaviors as $behavior) { + $behavior->rewriteColumnDefinition($def, $relation); + } + } + + /** + * Get whether the given column is selectable + * + * @param string $column + * + * @return bool + */ + public function isSelectableColumn(string $column): bool + { + foreach ($this->rewriteColumnBehaviors as $behavior) { + if ($behavior->isSelectableColumn($column)) { + return true; + } + } + + return false; + } +} diff --git a/vendor/ipl/orm/src/ColumnDefinition.php b/vendor/ipl/orm/src/ColumnDefinition.php new file mode 100644 index 0000000..ddb8062 --- /dev/null +++ b/vendor/ipl/orm/src/ColumnDefinition.php @@ -0,0 +1,80 @@ +<?php + +namespace ipl\Orm; + +use InvalidArgumentException; +use LogicException; + +class ColumnDefinition +{ + /** @var string The name of the column */ + protected $name; + + /** @var ?string The label of the column */ + protected $label; + + /** + * Create a new column definition + * + * @param string $name + */ + public function __construct(string $name) + { + $this->name = $name; + } + + /** + * Get the column name + * + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * Get the column label + * + * @return ?string + */ + public function getLabel(): ?string + { + return $this->label; + } + + /** + * Set the column label + * + * @param ?string $label + * + * @return $this + */ + public function setLabel(?string $label): self + { + $this->label = $label; + + return $this; + } + + /** + * Create a new column definition based on the given options + * + * @param array $options + * + * @return self + */ + public static function fromArray(array $options): self + { + if (! isset($options['name'])) { + throw new InvalidArgumentException('$options must provide a name'); + } + + $self = new static($options['name']); + if (isset($options['label'])) { + $self->setLabel($options['label']); + } + + return $self; + } +} diff --git a/vendor/ipl/orm/src/Common/PropertiesWithDefaults.php b/vendor/ipl/orm/src/Common/PropertiesWithDefaults.php new file mode 100644 index 0000000..e8d3a84 --- /dev/null +++ b/vendor/ipl/orm/src/Common/PropertiesWithDefaults.php @@ -0,0 +1,31 @@ +<?php + +namespace ipl\Orm\Common; + +use Closure; +use Traversable; + +trait PropertiesWithDefaults +{ + use \ipl\Stdlib\Properties { + \ipl\Stdlib\Properties::getProperty as private parentGetProperty; + } + + protected function getProperty($key) + { + if (isset($this->properties[$key]) && $this->properties[$key] instanceof Closure) { + $this->setProperty($key, $this->properties[$key]($this, $key)); + } + + return $this->parentGetProperty($key); + } + + public function getIterator(): Traversable + { + foreach ($this->properties as $key => $value) { + if (! $value instanceof Closure) { + yield $key => $value; + } + } + } +} diff --git a/vendor/ipl/orm/src/Common/SortUtil.php b/vendor/ipl/orm/src/Common/SortUtil.php new file mode 100644 index 0000000..0e014e9 --- /dev/null +++ b/vendor/ipl/orm/src/Common/SortUtil.php @@ -0,0 +1,65 @@ +<?php + +namespace ipl\Orm\Common; + +use ipl\Stdlib\Str; + +class SortUtil +{ + /** + * Create the sort column(s) and direction(s) from the given sort spec + * + * @param array|string $sort + * + * @return array|null Sort column(s) and direction(s) suitable for {@link OrderByInterface::orderBy()} + */ + public static function createOrderBy($sort) + { + $columnsAndDirections = static::explodeSortSpec($sort); + $orderBy = []; + + foreach ($columnsAndDirections as $columnAndDirection) { + list($column, $direction) = static::splitColumnAndDirection($columnAndDirection); + + $orderBy[] = [$column, $direction]; + } + + return $orderBy; + } + + /** + * Explode the given sort spec into its sort parts + * + * @param array|string $sort + * + * @return array + */ + public static function explodeSortSpec($sort) + { + return Str::trimSplit(implode(',', (array) $sort)); + } + + /** + * Normalize the given sort spec to a sort string + * + * @param array|string $sort + * + * @return string + */ + public static function normalizeSortSpec($sort) + { + return implode(',', static::explodeSortSpec($sort)); + } + + /** + * Explode the given sort part into its sort column and direction + * + * @param string $sort + * + * @return array + */ + public static function splitColumnAndDirection($sort) + { + return Str::symmetricSplit($sort, ' ', 2); + } +} diff --git a/vendor/ipl/orm/src/Compat/FilterProcessor.php b/vendor/ipl/orm/src/Compat/FilterProcessor.php new file mode 100644 index 0000000..0b091f1 --- /dev/null +++ b/vendor/ipl/orm/src/Compat/FilterProcessor.php @@ -0,0 +1,329 @@ +<?php + +namespace ipl\Orm\Compat; + +use AppendIterator; +use ArrayIterator; +use ipl\Orm\Exception\InvalidColumnException; +use ipl\Orm\Query; +use ipl\Orm\Relation; +use ipl\Orm\UnionQuery; +use ipl\Sql\Expression; +use ipl\Sql\Filter\Exists; +use ipl\Sql\Filter\NotExists; +use ipl\Stdlib\Contract\Filterable; +use ipl\Stdlib\Filter\MetaDataProvider; +use ipl\Stdlib\Filter; + +class FilterProcessor extends \ipl\Sql\Compat\FilterProcessor +{ + protected $baseJoins = []; + + protected $madeJoins = []; + + /** + * Require and resolve the filter rule and apply it on the query + * + * Note that this applies the filter to {@see Query::$selectBase} + * directly and bypasses {@see Query::$filter}. If this is not + * desired, utilize the {@see Filterable} functions of the query. + * + * @param Filter\Rule $filter + * @param Query $query + */ + public static function apply(Filter\Rule $filter, Query $query) + { + if ($query instanceof UnionQuery) { + foreach ($query->getUnions() as $union) { + static::apply($filter, $union); + } + + return; + } + + if ($filter instanceof Filter\Condition || ! $filter->isEmpty()) { + $filter = clone $filter; + if (! $filter instanceof Filter\Chain) { + $filter = Filter::all($filter); + } + + static::resolveFilter($filter, $query); + + $where = static::assembleFilter($filter); + + if ($where) { + $operator = array_shift($where); + $conditions = array_shift($where); + + $query->getSelectBase()->where($conditions, $operator); + } + } + } + + /** + * Resolve the filter in order to apply it on the query + * + * @param Filter\Chain $filter + * @param Query $query + * + * @return void + */ + public static function resolveFilter(Filter\Chain $filter, Query $query) + { + $processor = new static(); + foreach ($query->getUtilize() as $path => $_) { + $processor->baseJoins[$path] = true; + } + + $processor->requireAndResolveFilterColumns($filter, $query); + } + + protected function requireAndResolveFilterColumns(Filter\Rule $filter, Query $query, $forceOptimization = null) + { + if ($filter instanceof Filter\Condition) { + if ($filter instanceof Exists || $filter instanceof NotExists) { + return; + } + + $resolver = $query->getResolver(); + $baseTable = $query->getModel()->getTableName(); + $column = $resolver->qualifyPath( + $filter->metaData()->get('columnName', $filter->getColumn()), + $baseTable + ); + + $filter->metaData()->set('columnPath', $column); + + list($relationPath, $columnName) = preg_split('/\.(?=[^.]+$)/', $column); + + $relations = new AppendIterator(); + $relations->append(new ArrayIterator([$baseTable => null])); + $relations->append($resolver->resolveRelations($relationPath)); + foreach ($relations as $path => $relation) { + $columnName = substr($column, strlen($path) + 1); + + if ($path === $baseTable) { + $subject = $query->getModel(); + } else { + /** @var Relation $relation */ + $subject = $relation->getTarget(); + } + + $subjectBehaviors = $resolver->getBehaviors($subject); + + // Prepare filter as if it were final to allow full control for rewrite filter behaviors + $filter->setValue($subjectBehaviors->persistProperty($filter->getValue(), $columnName)); + $filter->setColumn($resolver->getAlias($subject) . '.' . $columnName); + $filter->metaData()->set('columnName', $columnName); + $filter->metaData()->set('relationPath', $path); + + $rewrittenFilter = $subjectBehaviors->rewriteCondition($filter, $path . '.'); + if ($rewrittenFilter !== null) { + return $this->requireAndResolveFilterColumns($rewrittenFilter, $query, $forceOptimization) + ?: $rewrittenFilter; + } + } + + if (! $resolver->hasSelectableColumn($subject, $columnName)) { + throw new InvalidColumnException($columnName, $subject); + } + + if ($relationPath !== $baseTable) { + $query->utilize($relationPath); + $this->madeJoins[$relationPath][] = $filter; + } + } else { + /** @var Filter\Chain $filter */ + + if ($filter->metaData()->has('forceOptimization')) { + // Rules can override the default behavior how it's determined that they need to be + // optimized. If it's done by a chain, it applies to all of its children. + $forceOptimization = $filter->metaData()->get('forceOptimization'); + } + + $subQueryGroups = []; + $outsourcedRules = []; + foreach ($filter as $child) { + /** @var Filter\Rule $child */ + $rewrittenFilter = $this->requireAndResolveFilterColumns($child, $query, $forceOptimization); + if ($rewrittenFilter !== null) { + $filter->replace($child, $rewrittenFilter); + $child = $rewrittenFilter; + } + + $optimizeChild = $forceOptimization; + if ($child instanceof MetaDataProvider && $child->metaData()->has('forceOptimization')) { + $optimizeChild = $child->metaData()->get('forceOptimization'); + } + + // We only optimize rules in a single level, nested chains are ignored + if ($child instanceof Filter\Condition && $child->metaData()->has('relationPath')) { + $relationPath = $child->metaData()->get('relationPath'); + if ( + $relationPath !== $query->getModel()->getTableName() // Not the base table + && ( + $optimizeChild !== null && $optimizeChild + || ( + $optimizeChild === null + && ! isset($query->getWith()[$relationPath]) // Not a selected join + && ! $query->getResolver()->isDistinctRelation($relationPath) // Not a to-one relation + ) + ) + ) { + $subQueryGroups[$relationPath][$child->getColumn()][get_class($child)][] = $child; + + // Register all rules that are going to be put into sub queries, for later cleanup + $outsourcedRules[] = $child; + } + } + } + + foreach ($subQueryGroups as $relationPath => $columns) { + $generalRules = []; + foreach ($columns as $column => & $comparisons) { + if (isset($comparisons[Filter\Unequal::class]) || isset($comparisons[Filter\Unlike::class])) { + // If there's a unequal (!=) comparison for any column, all other comparisons (for the same + // column) also need to be outsourced to their own sub query. Regardless of their amount of + // occurrence. This is because `$generalRules` apply to all comparisons of such a column and + // need to be applied to all sub queries. + continue; + } + + // Single occurring columns don't need their own sub query + foreach ($comparisons as $conditionClass => $rules) { + if (count($rules) === 1) { + $generalRules[] = $rules[0]; + unset($comparisons[$conditionClass]); + } + } + + if (empty($comparisons)) { + unset($columns[$column]); + } + } + + $count = null; + $baseFilters = null; + $subQueryFilters = []; + foreach ($columns as $column => $comparisons) { + foreach ($comparisons as $conditionClass => $rules) { + if ($conditionClass === Filter\Unequal::class || $conditionClass === Filter\Unlike::class) { + // Unequal comparisons are always put into their own sub query + $subQueryFilters[] = [$rules, count($rules), true]; + } elseif (count($rules) > $count) { + // If there are multiple columns used multiple times in the same relation, we have to decide + // which to use as the primary comparison. That is the column that is used most often. + if (! empty($baseFilters)) { + array_push($generalRules, ...$baseFilters); + } + + $count = count($rules); + $baseFilters = $rules; + } else { + array_push($generalRules, ...$rules); + } + } + } + + if (! empty($baseFilters) || ! empty($generalRules)) { + $subQueryFilters[] = [$baseFilters ?: $generalRules, $count, false]; + } + + foreach ($subQueryFilters as list($filters, $count, $negate)) { + $subQueryFilter = null; + if ($count !== null) { + $aggregateFilter = Filter::any(); + foreach ($filters as $condition) { + if ($negate) { + if ($condition instanceof Filter\Unequal) { + $negation = Filter::equal($condition->getColumn(), $condition->getValue()); + } else { // if ($condition instanceof Filter\Unlike) + $negation = Filter::like($condition->getColumn(), $condition->getValue()); + } + + $negation->metaData()->merge($condition->metaData()); + $condition = $negation; + $count = 1; + } + + switch (true) { + case $filter instanceof Filter\All: + $aggregateFilter->add(Filter::all($condition, ...$generalRules)); + break; + case $filter instanceof Filter\Any: + $aggregateFilter->add(Filter::any($condition, ...$generalRules)); + break; + case $filter instanceof Filter\None: + $aggregateFilter->add(Filter::none($condition, ...$generalRules)); + break; + } + } + + $subQueryFilter = $aggregateFilter; + } else { + switch (true) { + case $filter instanceof Filter\All: + $subQueryFilter = Filter::all(...$filters); + break; + case $filter instanceof Filter\Any: + $subQueryFilter = Filter::any(...$filters); + break; + case $filter instanceof Filter\None: + $subQueryFilter = Filter::none(...$filters); + break; + } + } + + $relation = $query->getResolver()->resolveRelation($relationPath); + $subQuery = $query->createSubQuery($relation->getTarget(), $relationPath); + $subQuery->columns([new Expression('1')]); + + if ($count !== null && ($negate || $filter instanceof Filter\All)) { + $targetKeys = join( + ',', + array_values( + $subQuery->getResolver()->qualifyColumns( + (array) $subQuery->getModel()->getKeyName(), + $subQuery->getModel() + ) + ) + ); + + $subQuery->getSelectBase()->having(["COUNT(DISTINCT $targetKeys) >= ?" => $count]); + } + + $subQuery->filter($subQueryFilter); + + if ($negate) { + $filter->add(new NotExists($subQuery->assembleSelect()->resetOrderBy())); + } else { + $filter->add(new Exists($subQuery->assembleSelect()->resetOrderBy())); + } + } + } + + foreach ($outsourcedRules as $rule) { + // Remove joins solely used for filter conditions + foreach ($this->madeJoins as $joinPath => & $madeBy) { + $madeBy = array_filter( + $madeBy, + function ($relationFilter) use ($rule) { + return $rule !== $relationFilter + && ($rule instanceof Filter\Condition || ! $rule->has($relationFilter)); + } + ); + + if (empty($madeBy)) { + if (! isset($this->baseJoins[$joinPath])) { + $query->omit($joinPath); + } + + unset($this->madeJoins[$joinPath]); + } + } + + $filter->remove($rule); + } + } + } +} diff --git a/vendor/ipl/orm/src/Contract/PersistBehavior.php b/vendor/ipl/orm/src/Contract/PersistBehavior.php new file mode 100644 index 0000000..a6db05d --- /dev/null +++ b/vendor/ipl/orm/src/Contract/PersistBehavior.php @@ -0,0 +1,18 @@ +<?php + +namespace ipl\Orm\Contract; + +use ipl\Orm\Behavior; +use ipl\Orm\Model; + +interface PersistBehavior extends Behavior +{ + /** + * Apply this behavior on the given model + * + * Called when the model is persisted in the database. + * + * @param Model $model + */ + public function persist(Model $model); +} diff --git a/vendor/ipl/orm/src/Contract/PropertyBehavior.php b/vendor/ipl/orm/src/Contract/PropertyBehavior.php new file mode 100644 index 0000000..04e5637 --- /dev/null +++ b/vendor/ipl/orm/src/Contract/PropertyBehavior.php @@ -0,0 +1,104 @@ +<?php + +namespace ipl\Orm\Contract; + +use ipl\Orm\Model; +use OutOfBoundsException; + +abstract class PropertyBehavior implements RetrieveBehavior, PersistBehavior +{ + /** @var array Property names of which the value should be processed */ + protected $properties; + + /** + * PropertyBehavior constructor + * + * @param array $properties Property names to process, as values + */ + public function __construct(array $properties) + { + if (is_int(key($properties))) { + $this->properties = array_flip($properties); + } else { + $this->properties = $properties; + } + } + + public function retrieve(Model $model) + { + foreach ($this->properties as $key => $ctx) { + try { + $model[$key] = $this->fromDb($model[$key], $key, $ctx); + } catch (OutOfBoundsException $_) { + // pass + } + } + } + + public function persist(Model $model) + { + foreach ($this->properties as $key => $ctx) { + try { + $model[$key] = $this->toDb($model[$key], $key, $ctx); + } catch (OutOfBoundsException $_) { + // pass + } + } + } + + /** + * Transform the given value, just fetched from the database + * + * @param mixed $value + * @param string $key + * + * @return mixed + */ + public function retrieveProperty($value, $key) + { + if (! isset($this->properties[$key])) { + return $value; + } + + return $this->fromDb($value, $key, $this->properties[$key]); + } + + /** + * Transform the given value, about to be persisted to the database + * + * @param mixed $value + * @param string $key + * + * @return mixed + */ + public function persistProperty($value, $key) + { + if (! isset($this->properties[$key])) { + return $value; + } + + return $this->toDb($value, $key, $this->properties[$key]); + } + + /** + * Transform the given value which has just been fetched from the database + * + * @param mixed $value + * @param string $key + * @param mixed $context + * + * @return mixed + */ + abstract public function fromDb($value, $key, $context); + + /** + * Transform the given value which is about to be persisted to the database + * + * @param mixed $value + * @param string $key + * @param mixed $context + * + * @return mixed + */ + abstract public function toDb($value, $key, $context); +} diff --git a/vendor/ipl/orm/src/Contract/QueryAwareBehavior.php b/vendor/ipl/orm/src/Contract/QueryAwareBehavior.php new file mode 100644 index 0000000..b67bf51 --- /dev/null +++ b/vendor/ipl/orm/src/Contract/QueryAwareBehavior.php @@ -0,0 +1,18 @@ +<?php + +namespace ipl\Orm\Contract; + +use ipl\Orm\Behavior; +use ipl\Orm\Query; + +interface QueryAwareBehavior extends Behavior +{ + /** + * Set the query + * + * @param Query $query + * + * @return $this + */ + public function setQuery(Query $query); +} diff --git a/vendor/ipl/orm/src/Contract/RetrieveBehavior.php b/vendor/ipl/orm/src/Contract/RetrieveBehavior.php new file mode 100644 index 0000000..884d074 --- /dev/null +++ b/vendor/ipl/orm/src/Contract/RetrieveBehavior.php @@ -0,0 +1,18 @@ +<?php + +namespace ipl\Orm\Contract; + +use ipl\Orm\Behavior; +use ipl\Orm\Model; + +interface RetrieveBehavior extends Behavior +{ + /** + * Apply this behavior on the given model + * + * Called when the model is fetched from the database. + * + * @param Model $model + */ + public function retrieve(Model $model); +} diff --git a/vendor/ipl/orm/src/Contract/RewriteColumnBehavior.php b/vendor/ipl/orm/src/Contract/RewriteColumnBehavior.php new file mode 100644 index 0000000..b6f545b --- /dev/null +++ b/vendor/ipl/orm/src/Contract/RewriteColumnBehavior.php @@ -0,0 +1,39 @@ +<?php + +namespace ipl\Orm\Contract; + +use ipl\Orm\ColumnDefinition; + +interface RewriteColumnBehavior extends RewriteFilterBehavior +{ + /** + * Rewrite the given column + * + * The result must be returned otherwise (NULL is returned) the original column is kept as is. + * + * @param mixed $column + * @param ?string $relation The absolute path of the model. For reference only, don't include it in the result + * + * @return mixed + */ + public function rewriteColumn($column, ?string $relation = null); + + /** + * Get whether {@see rewriteColumn} might return an otherwise unknown column or expression + * + * @param string $name + * + * @return bool + */ + public function isSelectableColumn(string $name): bool; + + /** + * Rewrite the given column definition + * + * @param ColumnDefinition $def + * @param string $relation The absolute path of the model. For reference only, don't include it in the result + * + * @return void + */ + public function rewriteColumnDefinition(ColumnDefinition $def, string $relation): void; +} diff --git a/vendor/ipl/orm/src/Contract/RewriteFilterBehavior.php b/vendor/ipl/orm/src/Contract/RewriteFilterBehavior.php new file mode 100644 index 0000000..af6de5b --- /dev/null +++ b/vendor/ipl/orm/src/Contract/RewriteFilterBehavior.php @@ -0,0 +1,25 @@ +<?php + +namespace ipl\Orm\Contract; + +use ipl\Orm\Behavior; +use ipl\Stdlib\Filter; + +interface RewriteFilterBehavior extends Behavior +{ + /** + * Rewrite the given filter condition + * + * The condition can either be adjusted directly or replaced by an entirely new rule. The result must be + * returned otherwise (NULL is returned) processing continues normally. (Isn't restarted) + * + * If a result is returned, it is required to append the given absolute path of the model to the column. + * Processing of the condition will be restarted, hence the column has to be an absolute path again. + * + * @param Filter\Condition $condition + * @param string $relation The absolute path (with a trailing dot) of the model + * + * @return Filter\Rule|null + */ + public function rewriteCondition(Filter\Condition $condition, $relation = null); +} diff --git a/vendor/ipl/orm/src/Contract/RewritePathBehavior.php b/vendor/ipl/orm/src/Contract/RewritePathBehavior.php new file mode 100644 index 0000000..b5b0385 --- /dev/null +++ b/vendor/ipl/orm/src/Contract/RewritePathBehavior.php @@ -0,0 +1,20 @@ +<?php + +namespace ipl\Orm\Contract; + +use ipl\Orm\Behavior; + +interface RewritePathBehavior extends Behavior +{ + /** + * Rewrite the given relation path + * + * The result must be returned otherwise (NULL is returned) the original path is kept as is. + * + * @param string $path + * @param ?string $relation The absolute path of the model. For reference only, don't include it in the result + * + * @return ?string + */ + public function rewritePath(string $path, ?string $relation = null): ?string; +} diff --git a/vendor/ipl/orm/src/Defaults.php b/vendor/ipl/orm/src/Defaults.php new file mode 100644 index 0000000..aa2d517 --- /dev/null +++ b/vendor/ipl/orm/src/Defaults.php @@ -0,0 +1,52 @@ +<?php + +namespace ipl\Orm; + +use IteratorAggregate; +use Traversable; + +class Defaults implements IteratorAggregate +{ + /** @var array<string, mixed> Registered defaults */ + protected $defaults = []; + + /** + * Iterate over the defaults + * + * @return Traversable + */ + public function getIterator(): Traversable + { + foreach ($this->defaults as $column => $default) { + yield $column => $default; + } + } + + /** + * Add a default for the given property + * + * @param string $property + * @param mixed $default If it's a closure, its interface is assumed to be + * ({@see Model} $subject, string $propertyName) + * + * @return $this + */ + public function add(string $property, $default): self + { + $this->defaults[$property] = $default; + + return $this; + } + + /** + * Get whether a default for the given property exists + * + * @param string $property + * + * @return bool + */ + public function has(string $property): bool + { + return array_key_exists($property, $this->defaults); + } +} diff --git a/vendor/ipl/orm/src/Exception/InvalidColumnException.php b/vendor/ipl/orm/src/Exception/InvalidColumnException.php new file mode 100644 index 0000000..cd320c6 --- /dev/null +++ b/vendor/ipl/orm/src/Exception/InvalidColumnException.php @@ -0,0 +1,53 @@ +<?php + +namespace ipl\Orm\Exception; + +use Exception; +use ipl\Orm\Model; + +class InvalidColumnException extends Exception +{ + /** @var string The column name */ + protected $column; + + /** @var Model The target model */ + protected $model; + + /** + * Create a new InvalidColumnException + * + * @param string $column The column name + * @param Model $model The target model + */ + public function __construct($column, Model $model) + { + $this->column = (string) $column; + $this->model = $model; + + parent::__construct(sprintf( + "Can't require column '%s' in model '%s'. Column not found.", + $column, + get_class($model) + )); + } + + /** + * Get the column name + * + * @return string + */ + public function getColumn() + { + return $this->column; + } + + /** + * Get the target model + * + * @return Model + */ + public function getModel() + { + return $this->model; + } +} diff --git a/vendor/ipl/orm/src/Exception/InvalidRelationException.php b/vendor/ipl/orm/src/Exception/InvalidRelationException.php new file mode 100644 index 0000000..51e81bb --- /dev/null +++ b/vendor/ipl/orm/src/Exception/InvalidRelationException.php @@ -0,0 +1,53 @@ +<?php + +namespace ipl\Orm\Exception; + +use Exception; +use ipl\Orm\Model; + +class InvalidRelationException extends Exception +{ + /** @var string The relation name */ + protected $relation; + + /** @var Model The target model */ + protected $model; + + /** + * Create a new InvalidRelationException + * + * @param string $relation The relation name + * @param Model $model The target model + */ + public function __construct($relation, Model $model = null) + { + $this->relation = (string) $relation; + $this->model = $model; + + parent::__construct(sprintf( + 'Cannot join relation "%s"%s. Relation not found.', + $relation, + $model ? ' in model ' . get_class($model) : '' + )); + } + + /** + * Get the relation name + * + * @return string + */ + public function getRelation() + { + return $this->relation; + } + + /** + * Get the target model + * + * @return Model + */ + public function getModel() + { + return $this->model; + } +} diff --git a/vendor/ipl/orm/src/Hydrator.php b/vendor/ipl/orm/src/Hydrator.php new file mode 100644 index 0000000..272e4b6 --- /dev/null +++ b/vendor/ipl/orm/src/Hydrator.php @@ -0,0 +1,197 @@ +<?php + +namespace ipl\Orm; + +use InvalidArgumentException; +use ipl\Orm\Exception\InvalidRelationException; + +/** + * Hydrates raw database rows into concrete model instances. + */ +class Hydrator +{ + /** @var array Additional hydration rules for the model's relations */ + protected $hydrators = []; + + /** @var Query The query the hydration rules are for */ + protected $query; + + /** + * Create a new Hydrator + * + * @param Query $query + */ + public function __construct(Query $query) + { + $this->query = $query; + } + + /** + * Add a hydration rule + * + * @param string $path Model path + * + * @return $this + * + * @throws \InvalidArgumentException If a hydrator for the given path already exists + */ + public function add($path) + { + if (isset($this->hydrators[$path])) { + throw new \InvalidArgumentException("Hydrator for path '$path' already exists"); + } + + $resolver = $this->query->getResolver(); + $target = $this->query->getModel(); + $relation = null; + + if ($path === $target->getTableName()) { + $selectableColumns = $resolver->getSelectableColumns($target); + $columnToPropertyMap = array_combine($selectableColumns, $selectableColumns); + } else { + $relation = $resolver->resolveRelation($path); + $target = $relation->getTarget(); + $selectableColumns = $resolver->getSelectableColumns($target); + $columnToPropertyMap = array_combine( + array_keys($resolver->qualifyColumnsAndAliases($selectableColumns, $target)), + $selectableColumns + ); + } + + $relationLoader = function (Model $subject, string $relationName) { + return $this->query->derive($relationName, $subject); + }; + + $defaults = $this->query->getResolver()->getDefaults($target); + foreach ($resolver->getRelations($target) as $targetRelation) { + $targetRelationName = $targetRelation->getName(); + if (! $defaults->has($targetRelationName)) { + $defaults->add($targetRelationName, $relationLoader); + } + } + + $this->hydrators[$path] = [$target, $relation, $columnToPropertyMap, $defaults]; + + return $this; + } + + /** + * Hydrate the given raw database rows into the specified model + * + * @param array $data + * @param Model $model + * + * @return Model + */ + public function hydrate(array $data, Model $model) + { + $defaultsToApply = []; + foreach ($this->hydrators as $path => $vars) { + list($target, $relation, $columnToPropertyMap, $defaults) = $vars; + + $subject = $model; + if ($relation !== null) { + /** @var Relation $relation */ + + $steps = explode('.', $path); + $baseTable = array_shift($steps); + $relationName = array_pop($steps); + + $parent = $model; + foreach ($steps as $i => $step) { + if (! isset($parent->$step)) { + $intermediateRelation = $this->query->getResolver()->resolveRelation( + $baseTable . '.' . implode('.', array_slice($steps, 0, $i + 1)), + $model + ); + $parentClass = $intermediateRelation->getTargetClass(); + $parent = $parent->$step = new $parentClass(); + } else { + $parent = $parent->$step; + } + } + + if (isset($parent->$relationName)) { + $subject = $parent->$relationName; + } else { + $subjectClass = $relation->getTargetClass(); + $subject = new $subjectClass(); + $parent->$relationName = $subject; + } + } + + $subject->setProperties($this->extractAndMap($data, $columnToPropertyMap)); + $this->query->getResolver()->getBehaviors($target)->retrieve($subject); + $defaultsToApply[] = [$subject, $defaults]; + } + + // If there are any columns left, propagate them to the targeted relation if possible, to the base otherwise + foreach ($data as $column => $value) { + $columnName = $column; + $steps = explode('_', $column); + $baseTable = array_shift($steps); + + $subject = $model; + $target = $this->query->getModel(); + $stepsTaken = []; + foreach ($steps as $step) { + $stepsTaken[] = $step; + $relationPath = "$baseTable." . implode('.', $stepsTaken); + + try { + $relation = $this->query->getResolver()->resolveRelation($relationPath); + } catch (InvalidArgumentException $_) { + // The base table is missing, which means the alias hasn't been qualified and is custom defined + break; + } catch (InvalidRelationException $_) { + array_pop($stepsTaken); + $columnName = implode('_', array_slice($steps, count($stepsTaken))); + break; + } + + if (! $subject->hasProperty($step)) { + $stepClass = $relation->getTargetClass(); + $subject->$step = new $stepClass(); + } + + $subject = $subject->$step; + $target = $relation->getTarget(); + } + + $subject->$columnName = $this->query + ->getResolver() + ->getBehaviors($target) + ->retrieveProperty($value, $columnName); + } + + // Apply defaults last, otherwise we may evaluate them during hydration + foreach ($defaultsToApply as list($subject, $defaults)) { + foreach ($defaults as $name => $default) { + if (! $subject->hasProperty($name)) { + $subject->$name = $default; + } + } + } + + return $model; + } + + /** + * Extract and map the given data based on the specified column to property resolution map + * + * @param array $data + * @param array $columnToPropertyMap + * + * @return array + */ + protected function extractAndMap(array &$data, array $columnToPropertyMap) + { + $extracted = []; + foreach (array_intersect_key($columnToPropertyMap, $data) as $column => $property) { + $extracted[$property] = $data[$column]; + unset($data[$column]); + } + + return $extracted; + } +} diff --git a/vendor/ipl/orm/src/Model.php b/vendor/ipl/orm/src/Model.php new file mode 100644 index 0000000..0a05819 --- /dev/null +++ b/vendor/ipl/orm/src/Model.php @@ -0,0 +1,132 @@ +<?php + +namespace ipl\Orm; + +use ipl\Orm\Common\PropertiesWithDefaults; +use ipl\Sql\Connection; + +/** + * Models represent single database tables or parts of it. + * They are also used to interact with the tables, i.e. in order to query for data. + */ +abstract class Model implements \ArrayAccess, \IteratorAggregate +{ + use PropertiesWithDefaults; + + final public function __construct(array $properties = null) + { + if ($this->hasProperties()) { + $this->setProperties($properties); + } + + $this->init(); + } + + /** + * Get the related database table's name + * + * @return string + */ + abstract public function getTableName(); + + /** + * Get the column name(s) of the primary key + * + * @return string|array Array if the primary key is compound, string otherwise + */ + abstract public function getKeyName(); + + /** + * Get the model's queryable columns + * + * @return array + */ + abstract public function getColumns(); + + /** + * Get the model's column definitions + * + * The array is indexed by column names, values are either strings (labels) or arrays of this format: + * + * [ + * 'label' => 'A Column', + * 'type' => 'enum(y,n)' + * ] + * + * @return array + */ + public function getColumnDefinitions() + { + return []; + } + + /** + * Get a query which is tied to this model and the given database connection + * + * @param Connection $db + * + * @return Query + */ + public static function on(Connection $db) + { + return (new Query()) + ->setDb($db) + ->setModel(new static()); + } + + /** + * Get the model's default sort + * + * @return array|string + */ + public function getDefaultSort() + { + return []; + } + + /** + * Get the model's search columns + * + * @return array + */ + public function getSearchColumns() + { + return []; + } + + /** + * Create the model's behaviors + * + * @param Behaviors $behaviors + */ + public function createBehaviors(Behaviors $behaviors) + { + } + + /** + * Create the model's defaults + * + * @param Defaults $defaults + */ + public function createDefaults(Defaults $defaults) + { + } + + /** + * Create the model's relations + * + * If your model should be associated to other models, override this method and create the model's relations. + */ + public function createRelations(Relations $relations) + { + } + + /** + * Initialize the model + * + * If you want to adjust the model after construction, override this method. + */ + protected function init() + { + } +} diff --git a/vendor/ipl/orm/src/Query.php b/vendor/ipl/orm/src/Query.php new file mode 100644 index 0000000..b55c331 --- /dev/null +++ b/vendor/ipl/orm/src/Query.php @@ -0,0 +1,787 @@ +<?php + +namespace ipl\Orm; + +use ArrayObject; +use Generator; +use InvalidArgumentException; +use ipl\Orm\Common\SortUtil; +use ipl\Orm\Compat\FilterProcessor; +use ipl\Sql\Connection; +use ipl\Sql\ExpressionInterface; +use ipl\Sql\LimitOffset; +use ipl\Sql\LimitOffsetInterface; +use ipl\Sql\OrderBy; +use ipl\Sql\OrderByInterface; +use ipl\Sql\Select; +use ipl\Stdlib\Contract\Filterable; +use ipl\Stdlib\Contract\Paginatable; +use ipl\Stdlib\Events; +use ipl\Stdlib\Filter; +use ipl\Stdlib\Filters; +use IteratorAggregate; +use ReflectionClass; +use SplObjectStorage; +use Traversable; + +/** + * Represents a database query which is associated to a model and a database connection. + */ +class Query implements Filterable, LimitOffsetInterface, OrderByInterface, Paginatable, IteratorAggregate +{ + use Events; + use Filters; + use LimitOffset; + use OrderBy; + + /** + * Event raised after assembling a {@link Select} object for the query + * + * **Example usage:** + * + * ``` + * $query->on(Query::ON_SELECT_ASSEMBLED, function (Select $select) { + * // ... + * }); + * ``` + */ + const ON_SELECT_ASSEMBLED = 'selectAssembled'; + + /** @var int Count cache */ + protected $count; + + /** @var Connection Database connection */ + protected $db; + + /** @var string Class to return results as */ + protected $resultSetClass = ResultSet::class; + + /** @var Model Model to query */ + protected $model; + + /** @var array Columns to select from the model (or its relations). If empty, all columns are selected */ + protected $columns = []; + + /** @var array Additional columns to select from the model (or its relations) */ + protected $withColumns = []; + + /** @var bool Whether to peek ahead for more results */ + protected $peekAhead = false; + + /** @var Resolver Column and relation resolver */ + protected $resolver; + + /** @var Select Base SELECT query */ + protected $selectBase; + + /** @var Relation[] Relations to eager load */ + protected $with = []; + + /** @var Relation[] Relations to utilize (join) */ + protected $utilize = []; + + /** @var bool Whether to disable the default sorts of the model */ + protected $disableDefaultSort = false; + + /** + * Get the database connection + * + * @return Connection + */ + public function getDb() + { + return $this->db; + } + + /** + * Set the database connection + * + * @param Connection $db + * + * @return $this + */ + public function setDb(Connection $db) + { + $this->db = $db; + + return $this; + } + + /** + * Get the class to return results as + * + * @return string + */ + public function getResultSetClass() + { + return $this->resultSetClass; + } + + /** + * Set the class to return results as + * + * @param string $class + * + * @return $this + * + * @throws InvalidArgumentException If class is not an instance of {@link ResultSet} + */ + public function setResultSetClass($class) + { + if (! is_string($class)) { + throw new InvalidArgumentException('Argument $class must be a string'); + } + + if (! (new ReflectionClass($class))->newInstanceWithoutConstructor() instanceof ResultSet) { + throw new InvalidArgumentException( + $class . ' must be an instance of ' . ResultSet::class + ); + } + + $this->resultSetClass = $class; + + return $this; + } + + /** + * Get the model to query + * + * @return Model + */ + public function getModel() + { + return $this->model; + } + + /** + * Set the model to query + * + * @param $model + * + * @return $this + */ + public function setModel(Model $model) + { + $this->model = $model; + $this->getResolver()->setAlias($model, $model->getTableName()); + + return $this; + } + + /** + * Get the columns to select from the model + * + * @return array + */ + public function getColumns() + { + return $this->columns; + } + + /** + * Set the filter of the query + * + * @param Filter\Chain $filter + * + * @return $this + */ + public function setFilter(Filter\Chain $filter) + { + $this->filter = $filter; + + return $this; + } + + /** + * Disable default sorts + * + * Prevents the default sort rules of the source model from being used + * + * @param bool $disable + * + * @return $this + */ + public function disableDefaultSort($disable = true) + { + $this->disableDefaultSort = (bool) $disable; + + return $this; + } + + /** + * Get whether to not use the default sort rules of the source model + * + * @return bool + */ + public function defaultSortDisabled() + { + return $this->disableDefaultSort; + } + + + /** + * Set columns to select from the model (or its relations) + * + * By default, i.e. if you do not specify any columns, all columns of the model and + * any relation added via {@see with()} will be selected. + * Multiple calls to this method will overwrite the previously specified columns. + * If you specify columns from the model's relations, the relations are automatically joined upon querying. + * Note that a call to this method also overwrites any previously column specified via {@see withColumns()}. + * + * @param string|array $columns The column(s) to select + * + * @return $this + */ + public function columns($columns) + { + $this->columns = (array) $columns; + $this->withColumns = []; + + return $this; + } + + /** + * Set additional columns to select from the model (or its relations) + * + * Multiple calls to this method will not overwrite the previous set columns but append the columns to the query. + * + * @param string|array $columns The column(s) to select + * + * @return $this + */ + public function withColumns($columns) + { + $this->withColumns = array_merge($this->withColumns, (array) $columns); + + return $this; + } + + /** + * Get the query's resolver + * + * @return Resolver + */ + public function getResolver() + { + if ($this->resolver === null) { + $this->resolver = new Resolver($this); + } + + return $this->resolver; + } + + /** + * Get the SELECT base query + * + * @return Select + */ + public function getSelectBase() + { + if ($this->selectBase === null) { + $this->selectBase = new Select(); + + $this->selectBase->from([ + $this->getResolver()->getAlias($this->getModel()) => $this->getModel()->getTableName() + ]); + } + + return $this->selectBase; + } + + /** + * Get the relations to eager load + * + * @return Relation[] + */ + public function getWith() + { + return $this->with; + } + + /** + * Add a relation to eager load + * + * @param string|array $relations + * + * @return $this + */ + public function with($relations) + { + $tableName = $this->getModel()->getTableName(); + foreach ((array) $relations as $relation) { + $relation = $this->getResolver()->qualifyPath($relation, $tableName); + $this->with[$relation] = $this->getResolver()->resolveRelation($relation); + } + + return $this; + } + + /** + * Remove an eager loaded relation + * + * @param string|array $relations + * + * @return $this + */ + public function without($relations) + { + $tableName = $this->getModel()->getTableName(); + foreach ((array) $relations as $relation) { + $relation = $this->getResolver()->qualifyPath($relation, $tableName); + unset($this->with[$relation]); + } + + return $this; + } + + /** + * Get utilized (joined) relations + * + * @return Relation[] + */ + public function getUtilize() + { + return $this->utilize; + } + + /** + * Add a relation to utilize (join) + * + * @param string $path + * + * @return $this + */ + public function utilize($path) + { + $path = $this->getResolver()->qualifyPath($path, $this->getModel()->getTableName()); + $this->utilize[$path] = $this->getResolver()->resolveRelation($path); + + return $this; + } + + /** + * Remove a utilized (joined) relation + * + * @param string $path + * + * @return $this + */ + public function omit($path) + { + $path = $this->getResolver()->qualifyPath($path, $this->getModel()->getTableName()); + unset($this->utilize[$path]); + + return $this; + } + + /** + * Assemble and return the SELECT query + * + * @return Select + */ + public function assembleSelect() + { + $columns = $this->getColumns(); + $model = $this->getModel(); + $resolver = $this->getResolver(); + $select = clone $this->getSelectBase(); + + if (empty($columns)) { + $columns = $resolver->getSelectColumns($model); + + foreach ($this->getWith() as $path => $relation) { + foreach ($resolver->getSelectColumns($relation->getTarget()) as $alias => $column) { + if (is_int($alias)) { + $columns[] = "$path.$column"; + } else { + $columns[] = "$path.$alias"; + } + } + } + + $columns = array_merge($columns, $this->withColumns); + $customAliases = array_flip(array_filter(array_keys($this->withColumns), 'is_string')); + } else { + $columns = array_merge($columns, $this->withColumns); + $customAliases = array_flip(array_filter(array_keys($columns), 'is_string')); + } + + $resolved = $this->groupColumnsByTarget($resolver->requireAndResolveColumns($columns)); + foreach ($resolved as $target) { + $targetColumns = $resolved[$target]->getArrayCopy(); + if (! empty($customAliases)) { + $customColumns = array_intersect_key($targetColumns, $customAliases); + $targetColumns = array_diff_key($targetColumns, $customAliases); + + $select->columns($resolver->qualifyColumns($customColumns, $target)); + } + + $select->columns( + $resolver->qualifyColumnsAndAliases( + $targetColumns, + $target, + $target !== $model + ) + ); + } + + $filter = clone $this->getFilter(); + FilterProcessor::resolveFilter($filter, $this); + $where = FilterProcessor::assembleFilter($filter); + if ($where) { + $select->where(...array_reverse($where)); + } + + $joinedRelations = []; + foreach ($this->getWith() + $this->getUtilize() as $path => $_) { + foreach ($resolver->resolveRelations($path) as $relationPath => $relation) { + if (isset($joinedRelations[$relationPath])) { + continue; + } + + foreach ($relation->resolve() as list($source, $target, $relatedKeys)) { + /** @var Model $source */ + /** @var Model $target */ + + $sourceAlias = $resolver->getAlias($source); + $targetAlias = $resolver->getAlias($target); + + $conditions = []; + foreach ($relatedKeys as $fk => $ck) { + $conditions[] = sprintf( + '%s = %s', + $resolver->qualifyColumn($fk, $targetAlias), + $resolver->qualifyColumn($ck, $sourceAlias) + ); + } + + $table = [$targetAlias => $target->getTableName()]; + + switch ($relation->getJoinType()) { + case 'LEFT': + $select->joinLeft($table, $conditions); + + break; + case 'RIGHT': + $select->joinRight($table, $conditions); + + break; + case 'INNER': + default: + $select->join($table, $conditions); + } + } + + $joinedRelations[$relationPath] = true; + } + } + + if ($this->hasLimit()) { + $limit = $this->getLimit(); + + if ($this->peekAhead) { + ++$limit; + } + + $select->limit($limit); + } + if ($this->hasOffset()) { + $select->offset($this->getOffset()); + } + + $this->order($select); + + $this->emit(static::ON_SELECT_ASSEMBLED, [$select]); + + return $select; + } + + /** + * Create and return the hydrator + * + * @return Hydrator + */ + public function createHydrator() + { + $hydrator = new Hydrator($this); + + $hydrator->add($this->getModel()->getTableName()); + foreach ($this->getWith() as $path => $_) { + $hydrator->add($path); + } + + return $hydrator; + } + + /** + * Derive a new query to load the specified relation from a concrete model + * + * @param string $relation + * @param Model $source + * + * @return static + * + * @throws InvalidArgumentException If the relation with the given name does not exist + */ + public function derive($relation, Model $source) + { + // TODO: Think of a way to merge derive() and createSubQuery() + return $this->createSubQuery( + $this->getResolver()->getRelations($source)->get($relation)->getTarget(), + $this->getResolver()->qualifyPath($relation, $source->getTableName()), + $source + ); + } + + /** + * Create a sub-query linked to rows of this query + * + * @param Model $target The model to query + * @param string $targetPath The target's absolute relation path + * @param Model $from The source model + * + * @return static + */ + public function createSubQuery(Model $target, $targetPath, Model $from = null) + { + $subQuery = (new static()) + ->setDb($this->getDb()) + ->setModel($target); + + $resolver = $this->getResolver(); + $sourceParts = array_reverse(explode('.', $targetPath)); + $sourceParts[0] = $target->getTableName(); + + $subQueryResolver = $subQuery->getResolver(); + $sourcePath = join('.', $sourceParts); + $subQuery->utilize($sourcePath); // TODO: Don't join if there's a matching foreign key + + // TODO: Should be done by the caller. Though, that's not possible until we've got a filter abstraction + // which allows to post-pone filter column qualification. + $subQueryResolver->setAliasPrefix('sub_'); + + $baseAlias = $resolver->getAlias($this->getModel()); + $sourceAlias = $subQueryResolver->getAlias($subQueryResolver->resolveRelation($sourcePath)->getTarget()); + + $subQueryConditions = []; + foreach ((array) $this->getModel()->getKeyName() as $column) { + $fk = $subQueryResolver->qualifyColumn($column, $sourceAlias); + + if (isset($from->$column)) { + $subQueryConditions["$fk = ?"] = $resolver + ->getBehaviors($from) + ->persistProperty($from->$column, $column); + } else { + $subQueryConditions[] = "$fk = " . $resolver->qualifyColumn($column, $baseAlias); + } + } + + $subQuery->getSelectBase()->where($subQueryConditions); + + return $subQuery; + } + + /** + * Dump the query + * + * @return array + */ + public function dump() + { + return $this->getDb()->getQueryBuilder()->assembleSelect($this->assembleSelect()); + } + + /** + * Execute the query + * + * @return ResultSet + */ + public function execute() + { + $class = $this->getResultSetClass(); + /** @var ResultSet $class Just for type hinting. $class is of course a string */ + + return $class::fromQuery($this); + } + + /** + * Fetch and return the first result + * + * @return Model|null Null in case there's no result + */ + public function first() + { + return $this->execute()->current(); + } + + /** + * Set whether to peek ahead for more results + * + * Enabling this causes the current query limit to be increased by one. The potential extra row being yielded will + * be removed from the result set. Note that this only applies when fetching multiple results of limited queries. + * + * @param bool $peekAhead + * + * @return $this + */ + public function peekAhead($peekAhead = true) + { + $this->peekAhead = (bool) $peekAhead; + + return $this; + } + + /** + * Yield the query's results + * + * @return \Generator + */ + public function yieldResults() + { + $select = $this->assembleSelect(); + $stmt = $this->getDb()->select($select); + $stmt->setFetchMode(\PDO::FETCH_ASSOC); + + $hydrator = $this->createHydrator(); + $modelClass = get_class($this->getModel()); + + foreach ($stmt as $row) { + yield $hydrator->hydrate($row, new $modelClass()); + } + } + + public function count(): int + { + if ($this->count === null) { + $this->count = $this->getDb()->select($this->assembleSelect()->getCountQuery())->fetchColumn(0); + } + + return $this->count; + } + + public function getIterator(): Traversable + { + return $this->execute(); + } + + /** + * Group columns from {@link Resolver::requireAndResolveColumns()} by target models + * + * @param Generator $columns + * + * @return SplObjectStorage + */ + protected function groupColumnsByTarget(Generator $columns) + { + $columnStorage = new SplObjectStorage(); + + foreach ($columns as list($target, $alias, $column)) { + if (! $columnStorage->contains($target)) { + $resolved = new ArrayObject(); + $columnStorage->attach($target, $resolved); + } else { + $resolved = $columnStorage[$target]; + } + + if (is_int($alias)) { + $resolved[] = $column; + } else { + $resolved[$alias] = $column; + } + } + + return $columnStorage; + } + + /** + * Resolve, require and apply ORDER BY columns + * + * @param Select $select + * + * @return $this + */ + protected function order(Select $select) + { + $orderBy = $this->getOrderBy(); + $defaultSort = []; + if (! $this->defaultSortDisabled()) { + $defaultSort = (array) $this->getModel()->getDefaultSort(); + } + + if (empty($orderBy)) { + if (empty($defaultSort)) { + return $this; + } + + $orderBy = SortUtil::createOrderBy($defaultSort); + } + + $columnsAndDirections = []; + $orderByResolved = []; + $resolver = $this->getResolver(); + $selectedColumns = $select->getColumns(); + + foreach ($orderBy as $part) { + list($column, $direction) = $part; + + if (isset($selectedColumns[$column])) { + // If it's a custom alias, we have no other way of knowing it, + // unless the caller explicitly uses it in the sort rule. + $orderByResolved[] = $part; + } else { + // Prepare flat ORDER BY column(s) and direction(s) for requireAndResolveColumns() + $columnsAndDirections[$column] = $direction; + } + } + + foreach ( + $resolver->requireAndResolveColumns(array_keys($columnsAndDirections)) as list($model, $alias, $column) + ) { + $direction = reset($columnsAndDirections); + $selectColumns = $resolver->getSelectColumns($model); + $tableName = $resolver->getAlias($model); + + if ($column instanceof ExpressionInterface) { + if (is_int($alias) && $column instanceof AliasedExpression) { + $alias = $column->getAlias(); + } elseif (is_string($alias) && $model !== $this->getModel()) { + $alias = $resolver->qualifyColumnAlias($alias, $tableName); + } + + if (is_string($alias) && isset($selectedColumns[$alias])) { + // An expression's alias can only be used if the expression is also selected + $column = $alias; + } + } else { + if (isset($selectColumns[$column])) { + $column = $selectColumns[$column]; + } + + if (is_string($column)) { + $column = $resolver->qualifyColumn($column, $tableName); + } + } + + $orderByResolved[] = [$column, $direction]; + + array_shift($columnsAndDirections); + } + + $select->orderBy($orderByResolved); + + return $this; + } + + public function __clone() + { + $this->resolver = clone $this->resolver; + + if ($this->filter !== null) { + $this->filter = clone $this->filter; + } + + if ($this->selectBase !== null) { + $this->selectBase = clone $this->selectBase; + } + } +} diff --git a/vendor/ipl/orm/src/Relation.php b/vendor/ipl/orm/src/Relation.php new file mode 100644 index 0000000..1959363 --- /dev/null +++ b/vendor/ipl/orm/src/Relation.php @@ -0,0 +1,336 @@ +<?php + +namespace ipl\Orm; + +use function ipl\Stdlib\get_php_type; + +/** + * Relations represent the connection between models, i.e. the association between rows in one or more tables + * on the basis of matching key columns. The relationships are defined using candidate key-foreign key constructs. + */ +class Relation +{ + /** @var string Name of the relation */ + protected $name; + + /** @var Model Source model */ + protected $source; + + /** @var string|array Column name(s) of the foreign key found in the target table */ + protected $foreignKey; + + /** @var string|array Column name(s) of the candidate key in the source table which references the foreign key */ + protected $candidateKey; + + /** @var string Target model class */ + protected $targetClass; + + /** @var Model Target model */ + protected $target; + + /** @var string Type of the JOIN used in the query */ + protected $joinType = 'INNER'; + + /** @var bool Whether this is the inverse of a relationship */ + protected $inverse; + + /** @var bool Whether this is a to-one relationship */ + protected $isOne = true; + + /** + * Get the default column name(s) in the source table used to match the foreign key + * + * The default candidate key is the primary key column name(s) of the given model. + * + * @param Model $source + * + * @return array + */ + public static function getDefaultCandidateKey(Model $source) + { + return (array) $source->getKeyName(); + } + + /** + * Get the default column name(s) of the foreign key found in the target table + * + * The default foreign key is the given model's primary key column name(s) prefixed with its table name. + * + * @param Model $source + * + * @return array + */ + public static function getDefaultForeignKey(Model $source) + { + $tableName = $source->getTableName(); + + return array_map( + function ($key) use ($tableName) { + return "{$tableName}_{$key}"; + }, + (array) $source->getKeyName() + ); + } + + /** + * Get whether this is a to-one relationship + * + * @return bool + */ + public function isOne() + { + return $this->isOne; + } + + /** + * Get the name of the relation + * + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Set the name of the relation + * + * @param string $name + * + * @return $this + */ + public function setName($name) + { + $this->name = $name; + + return $this; + } + + /** + * Get the source model of the relation + * + * @return Model + */ + public function getSource() + { + return $this->source; + } + + /** + * Set the source model of the relation + * + * @param Model $source + * + * @return $this + */ + public function setSource(Model $source) + { + $this->source = $source; + + return $this; + } + + /** + * Get the column name(s) of the foreign key found in the target table + * + * @return string|array Array if the foreign key is compound, string otherwise + */ + public function getForeignKey() + { + return $this->foreignKey; + } + + /** + * Set the column name(s) of the foreign key found in the target table + * + * @param string|array $foreignKey Array if the foreign key is compound, string otherwise + * + * @return $this + */ + public function setForeignKey($foreignKey) + { + $this->foreignKey = $foreignKey; + + return $this; + } + + /** + * Get the column name(s) of the candidate key in the source table which references the foreign key + * + * @return string|array Array if the candidate key is compound, string otherwise + */ + public function getCandidateKey() + { + return $this->candidateKey; + } + + /** + * Set the column name(s) of the candidate key in the source table which references the foreign key + * + * @param string|array $candidateKey Array if the candidate key is compound, string otherwise + * + * @return $this + */ + public function setCandidateKey($candidateKey) + { + $this->candidateKey = $candidateKey; + + return $this; + } + + /** + * Get the target model class + * + * @return string + */ + public function getTargetClass() + { + return $this->targetClass; + } + + /** + * Set the target model class + * + * @param string $targetClass + * + * @return $this + * + * @throws \InvalidArgumentException If the target model class is not of type string + */ + public function setTargetClass($targetClass) + { + if (! is_string($targetClass)) { + // Require a class name here instead of a concrete model in oder to prevent circular references when + // constructing relations + throw new \InvalidArgumentException(sprintf( + '%s() expects parameter 1 to be string, %s given', + __METHOD__, + get_php_type($targetClass) + )); + } + + $this->targetClass = $targetClass; + + return $this; + } + + /** + * Get the target model + * + * Returns the model from {@link setTarget()} or an instance of {@link getTargetClass()}. + * Note that multiple calls to this method always returns the very same model instance. + * + * @return Model + */ + public function getTarget() + { + if ($this->target === null) { + $targetClass = $this->getTargetClass(); + $this->target = new $targetClass(); + } + + return $this->target; + } + + /** + * Set the the target model + * + * @param Model $target + * + * @return $this + */ + public function setTarget(Model $target) + { + $this->target = $target; + + return $this; + } + + /** + * Get the type of the JOIN used in the query + * + * @return string + */ + public function getJoinType() + { + return $this->joinType; + } + + /** + * Set the type of the JOIN used in the query + * + * @param string $joinType + * + * @return Relation + */ + public function setJoinType($joinType) + { + $this->joinType = $joinType; + + return $this; + } + + /** + * Determine the candidate key-foreign key construct of the relation + * + * @param Model $source + * + * @return array Candidate key-foreign key column name pairs + * + * @throws \UnexpectedValueException If there's no candidate key to be found + * or the foreign key count does not match the candidate key count + */ + public function determineKeys(Model $source) + { + $candidateKey = (array) $this->getCandidateKey(); + + if (empty($candidateKey)) { + $candidateKey = $this->inverse + ? static::getDefaultForeignKey($this->getTarget()) + : static::getDefaultCandidateKey($source); + } + + if (empty($candidateKey)) { + throw new \UnexpectedValueException(sprintf( + "Can't join relation '%s' in model '%s'. No candidate key found.", + $this->getName(), + get_class($source) + )); + } + + $foreignKey = (array) $this->getForeignKey(); + + if (empty($foreignKey)) { + $foreignKey = $this->inverse + ? static::getDefaultCandidateKey($this->getTarget()) + : static::getDefaultForeignKey($source); + } + + if (count($foreignKey) !== count($candidateKey)) { + throw new \UnexpectedValueException(sprintf( + "Can't join relation '%s' in model '%s'." + . " Foreign key count (%s) does not match candidate key count (%s).", + $this->getName(), + get_class($source), + implode(', ', $foreignKey), + implode(', ', $candidateKey) + )); + } + + return array_combine($foreignKey, $candidateKey); + } + + /** + * Resolve the relation + * + * Yields a three-element array consisting of the source model, target model and the join keys. + * + * @return \Generator + */ + public function resolve() + { + $source = $this->getSource(); + + yield [$source, $this->getTarget(), $this->determineKeys($source)]; + } +} diff --git a/vendor/ipl/orm/src/Relation/BelongsTo.php b/vendor/ipl/orm/src/Relation/BelongsTo.php new file mode 100644 index 0000000..1edcff3 --- /dev/null +++ b/vendor/ipl/orm/src/Relation/BelongsTo.php @@ -0,0 +1,13 @@ +<?php + +namespace ipl\Orm\Relation; + +use ipl\Orm\Relation; + +/** + * Inverse of a one-to-one or one-to-many relationship + */ +class BelongsTo extends Relation +{ + protected $inverse = true; +} diff --git a/vendor/ipl/orm/src/Relation/BelongsToMany.php b/vendor/ipl/orm/src/Relation/BelongsToMany.php new file mode 100644 index 0000000..132fe55 --- /dev/null +++ b/vendor/ipl/orm/src/Relation/BelongsToMany.php @@ -0,0 +1,199 @@ +<?php + +namespace ipl\Orm\Relation; + +use ipl\Orm\Model; +use ipl\Orm\Relation; +use ipl\Orm\Relations; + +use function ipl\Stdlib\get_php_type; + +/** + * Many-to-many relationship + */ +class BelongsToMany extends Relation +{ + protected $isOne = false; + + /** @var string Name of the join table or junction model class */ + protected $throughClass; + + /** @var Model The junction model */ + protected $through; + + /** @var string|array Column name(s) of the target model's foreign key found in the join table */ + protected $targetForeignKey; + + /** @var string|array Candidate key column name(s) in the target table which references the target foreign key */ + protected $targetCandidateKey; + + /** + * Get the name of the join table or junction model class + * + * @return string + */ + public function getThroughClass() + { + return $this->throughClass; + } + + /** + * Set the join table name or junction model class + * + * @param string $through + * + * @return $this + */ + public function through($through) + { + $this->throughClass = $through; + + return $this; + } + + /** + * Get the junction model + * + * @return Model|Junction + */ + public function getThrough() + { + if ($this->through === null) { + $throughClass = $this->getThroughClass(); + + if (class_exists($throughClass)) { + $this->through = new $throughClass(); + } else { + $this->through = (new Junction()) + ->setTableName($throughClass); + } + } + + return $this->through; + } + + /** + * Set the junction model + * + * @param Model $through + * + * @return $this + */ + public function setThrough($through) + { + $this->through = $through; + + return $this; + } + + /** + * Get the column name(s) of the target model's foreign key found in the join table + * + * @return string|array Array if the foreign key is compound, string otherwise + */ + public function getTargetForeignKey() + { + return $this->targetForeignKey; + } + + /** + * Set the column name(s) of the target model's foreign key found in the join table + * + * @param string|array $targetForeignKey Array if the foreign key is compound, string otherwise + * + * @return $this + */ + public function setTargetForeignKey($targetForeignKey) + { + $this->targetForeignKey = $targetForeignKey; + + return $this; + } + + /** + * Get the candidate key column name(s) in the target table which references the target foreign key + * + * @return string|array Array if the foreign key is compound, string otherwise + */ + public function getTargetCandidateKey() + { + return $this->targetCandidateKey; + } + + /** + * Set the candidate key column name(s) in the target table which references the target foreign key + * + * @param string|array $targetCandidateKey Array if the foreign key is compound, string otherwise + * + * @return $this + */ + public function setTargetCandidateKey($targetCandidateKey) + { + $this->targetCandidateKey = $targetCandidateKey; + + return $this; + } + + public function resolve() + { + $source = $this->getSource(); + + $possibleCandidateKey = [$this->getCandidateKey()]; + $possibleForeignKey = [$this->getForeignKey()]; + + $target = $this->getTarget(); + + $possibleTargetCandidateKey = [$this->getTargetForeignKey() ?: static::getDefaultForeignKey($target)]; + $possibleTargetForeignKey = [$this->getTargetCandidateKey() ?: static::getDefaultCandidateKey($target)]; + + $junction = $this->getThrough(); + + if (! $junction instanceof Junction) { + $relations = new Relations(); + $junction->createRelations($relations); + + if ($relations->has($source->getTableName())) { + $sourceRelation = $relations->get($source->getTableName()); + + $possibleCandidateKey[] = $sourceRelation->getForeignKey(); + $possibleForeignKey[] = $sourceRelation->getCandidateKey(); + } + + if ($relations->has($target->getTableName())) { + $targetRelation = $relations->get($target->getTableName()); + + $possibleTargetCandidateKey[] = $targetRelation->getCandidateKey(); + $possibleTargetForeignKey[] = $targetRelation->getForeignKey(); + } + } + + $toJunction = (new HasMany()) + ->setName($junction->getTableName()) + ->setSource($source) + ->setTarget($junction) + ->setCandidateKey($this->extractKey($possibleCandidateKey)) + ->setForeignKey($this->extractKey($possibleForeignKey)); + + $toTarget = (new HasMany()) + ->setName($this->getName()) + ->setSource($junction) + ->setTarget($target) + ->setCandidateKey($this->extractKey($possibleTargetCandidateKey)) + ->setForeignKey($this->extractKey($possibleTargetForeignKey)); + + foreach ($toJunction->resolve() as $k => $v) { + yield $k => $v; + } + + foreach ($toTarget->resolve() as $k => $v) { + yield $k => $v; + } + } + + protected function extractKey(array $possibleKey) + { + $filtered = array_filter($possibleKey); + + return array_pop($filtered); + } +} diff --git a/vendor/ipl/orm/src/Relation/HasMany.php b/vendor/ipl/orm/src/Relation/HasMany.php new file mode 100644 index 0000000..3e71e25 --- /dev/null +++ b/vendor/ipl/orm/src/Relation/HasMany.php @@ -0,0 +1,13 @@ +<?php + +namespace ipl\Orm\Relation; + +use ipl\Orm\Relation; + +/** + * One-to-many relationship + */ +class HasMany extends Relation +{ + protected $isOne = false; +} diff --git a/vendor/ipl/orm/src/Relation/HasOne.php b/vendor/ipl/orm/src/Relation/HasOne.php new file mode 100644 index 0000000..8f7a802 --- /dev/null +++ b/vendor/ipl/orm/src/Relation/HasOne.php @@ -0,0 +1,12 @@ +<?php + +namespace ipl\Orm\Relation; + +use ipl\Orm\Relation; + +/** + * One-to-one relationship + */ +class HasOne extends Relation +{ +} diff --git a/vendor/ipl/orm/src/Relation/Junction.php b/vendor/ipl/orm/src/Relation/Junction.php new file mode 100644 index 0000000..9e23bb2 --- /dev/null +++ b/vendor/ipl/orm/src/Relation/Junction.php @@ -0,0 +1,43 @@ +<?php + +namespace ipl\Orm\Relation; + +use ipl\Orm\Model; + +/** + * Junction model for many-to-many relations + */ +class Junction extends Model +{ + /** @var string */ + protected $tableName; + + public function getTableName() + { + return $this->tableName; + } + + /** + * Set the table name + * + * @param string $tableName + * + * @return $this + */ + public function setTableName($tableName) + { + $this->tableName = $tableName; + + return $this; + } + + public function getKeyName() + { + return null; + } + + public function getColumns() + { + return null; + } +} diff --git a/vendor/ipl/orm/src/Relations.php b/vendor/ipl/orm/src/Relations.php new file mode 100644 index 0000000..340435f --- /dev/null +++ b/vendor/ipl/orm/src/Relations.php @@ -0,0 +1,212 @@ +<?php + +namespace ipl\Orm; + +use ArrayIterator; +use ipl\Orm\Exception\InvalidRelationException; +use ipl\Orm\Relation\BelongsTo; +use ipl\Orm\Relation\BelongsToMany; +use ipl\Orm\Relation\HasMany; +use ipl\Orm\Relation\HasOne; +use IteratorAggregate; +use Traversable; + +use function ipl\Stdlib\get_php_type; + +/** + * Collection of a model's relations. + */ +class Relations implements IteratorAggregate +{ + /** @var Relation[] */ + protected $relations = []; + + /** + * Get whether a relation with the given name exists + * + * @param string $name + * + * @return bool + */ + public function has($name) + { + return isset($this->relations[$name]); + } + + /** + * Get the relation with the given name + * + * @param string $name + * + * @return Relation + * + * @throws \InvalidArgumentException If the relation with the given name does not exist + */ + public function get($name) + { + $this->assertRelationExists($name); + + return $this->relations[$name]; + } + + /** + * Add the given relation to the collection + * + * @param Relation $relation + * + * @return $this + * + * @throws \InvalidArgumentException If a relation with the given name already exists + */ + public function add(Relation $relation) + { + $name = $relation->getName(); + + $this->assertRelationDoesNotYetExist($name); + + $this->relations[$name] = $relation; + + return $this; + } + + /** + * Create a new relation from the given class, name and target model class + * + * @param string $class Class of the relation to create + * @param string $name Name of the relation + * @param string $targetClass Target model class + * + * @return BelongsTo|BelongsToMany|HasMany|HasOne|Relation + * + * @throws \InvalidArgumentException If the target model class is not of type string + */ + public function create($class, $name, $targetClass) + { + $relation = new $class(); + + if (! $relation instanceof Relation) { + throw new \InvalidArgumentException(sprintf( + '%s() expects parameter 1 to be a subclass of %s, %s given', + __METHOD__, + Relation::class, + get_php_type($relation) + )); + } + + // Test target model + $target = new $targetClass(); + if (! $target instanceof Model) { + throw new \InvalidArgumentException(sprintf( + '%s() expects parameter 3 to be a subclass of %s, %s given', + __METHOD__, + Model::class, + get_php_type($target) + )); + } + + /** @var Relation $relation */ + $relation + ->setName($name) + ->setTarget($target) + ->setTargetClass($targetClass); + + return $relation; + } + + /** + * Define a one-to-one relationship + * + * @param string $name Name of the relation + * @param string $targetClass Target model class + * + * @return HasOne + */ + public function hasOne($name, $targetClass) + { + $relation = $this->create(HasOne::class, $name, $targetClass); + + $this->add($relation); + + return $relation; + } + + /** + * Define a one-to-many relationship + * + * @param string $name Name of the relation + * @param string $targetClass Target model class + * + * @return HasMany + */ + public function hasMany($name, $targetClass) + { + $relation = $this->create(HasMany::class, $name, $targetClass); + + $this->add($relation); + + return $relation; + } + + /** + * Define the inverse of a one-to-one or one-to-many relationship + * + * @param string $name Name of the relation + * @param string $targetClass Target model class + * + * @return BelongsTo + */ + public function belongsTo($name, $targetClass) + { + $relation = $this->create(BelongsTo::class, $name, $targetClass); + + $this->add($relation); + + return $relation; + } + + /** + * Define a many-to-many relationship + * + * @param string $name Name of the relation + * @param string $targetClass Target model class + * + * @return BelongsToMany + */ + public function belongsToMany($name, $targetClass) + { + $relation = $this->create(BelongsToMany::class, $name, $targetClass); + + $this->add($relation); + + return $relation; + } + + public function getIterator(): Traversable + { + return new ArrayIterator($this->relations); + } + + /** + * Throw exception if a relation with the given name already exists + * + * @param string $name + */ + protected function assertRelationDoesNotYetExist($name) + { + if ($this->has($name)) { + throw new \InvalidArgumentException("Relation '$name' already exists"); + } + } + + /** + * Throw exception if a relation with the given name does not exist + * + * @param string $name + */ + protected function assertRelationExists($name) + { + if (! $this->has($name)) { + throw new InvalidRelationException($name); + } + } +} diff --git a/vendor/ipl/orm/src/ResolvedExpression.php b/vendor/ipl/orm/src/ResolvedExpression.php new file mode 100644 index 0000000..86883da --- /dev/null +++ b/vendor/ipl/orm/src/ResolvedExpression.php @@ -0,0 +1,49 @@ +<?php + +namespace ipl\Orm; + +use Generator; +use ipl\Sql\Expression; +use ipl\Sql\ExpressionInterface; +use RuntimeException; + +class ResolvedExpression extends Expression +{ + /** @var Generator */ + protected $resolvedColumns; + + /** + * Create a resolved database expression + * + * @param ExpressionInterface $expr The original expression + * @param Generator $resolvedColumns The generator as returned by {@see Resolver::requireAndResolveColumns()} + */ + public function __construct(ExpressionInterface $expr, Generator $resolvedColumns) + { + parent::__construct($expr->getStatement(), $expr->getColumns(), ...$expr->getValues()); + + $this->resolvedColumns = $resolvedColumns; + } + + /** + * @throws RuntimeException In case the columns are not qualified yet + */ + public function getColumns() + { + if ($this->resolvedColumns->valid()) { + throw new RuntimeException('Columns are not yet qualified'); + } + + return parent::getColumns(); + } + + /** + * Get the resolved column generator + * + * @return Generator + */ + public function getResolvedColumns() + { + return $this->resolvedColumns; + } +} diff --git a/vendor/ipl/orm/src/Resolver.php b/vendor/ipl/orm/src/Resolver.php new file mode 100644 index 0000000..a910716 --- /dev/null +++ b/vendor/ipl/orm/src/Resolver.php @@ -0,0 +1,783 @@ +<?php + +namespace ipl\Orm; + +use AppendIterator; +use ArrayIterator; +use Generator; +use InvalidArgumentException; +use ipl\Orm\Contract\QueryAwareBehavior; +use ipl\Orm\Exception\InvalidColumnException; +use ipl\Orm\Exception\InvalidRelationException; +use ipl\Orm\Relation\BelongsToMany; +use ipl\Sql\ExpressionInterface; +use LogicException; +use OutOfBoundsException; +use SplObjectStorage; + +use function ipl\Stdlib\get_php_type; + +/** + * Column and relation resolver. Acts as glue between queries and models + */ +class Resolver +{ + /** @var Query The query to resolve */ + protected $query; + + /** @var SplObjectStorage Model relations */ + protected $relations; + + /** @var SplObjectStorage Model behaviors */ + protected $behaviors; + + /** @var SplObjectStorage Model defaults */ + protected $defaults; + + /** @var SplObjectStorage Model aliases */ + protected $aliases; + + /** @var string The alias prefix to use */ + protected $aliasPrefix; + + /** @var SplObjectStorage Selectable columns from resolved models */ + protected $selectableColumns; + + /** @var SplObjectStorage Select columns from resolved models */ + protected $selectColumns; + + /** @var SplObjectStorage Meta data from models and their direct relations */ + protected $metaData; + + /** @var SplObjectStorage Resolved relations */ + protected $resolvedRelations; + + /** + * Create a new resolver + * + * @param Query $query The query to resolve + */ + public function __construct(Query $query) + { + $this->query = $query; + + $this->relations = new SplObjectStorage(); + $this->behaviors = new SplObjectStorage(); + $this->defaults = new SplObjectStorage(); + $this->aliases = new SplObjectStorage(); + $this->selectableColumns = new SplObjectStorage(); + $this->selectColumns = new SplObjectStorage(); + $this->metaData = new SplObjectStorage(); + $this->resolvedRelations = new SplObjectStorage(); + } + + /** + * Get a model's relations + * + * @param Model $model + * + * @return Relations + */ + public function getRelations(Model $model) + { + if (! $this->relations->contains($model)) { + $relations = new Relations(); + $model->createRelations($relations); + $this->relations->attach($model, $relations); + } + + return $this->relations[$model]; + } + + /** + * Get a model's behaviors + * + * @param Model $model + * + * @return Behaviors + */ + public function getBehaviors(Model $model) + { + if (! $this->behaviors->contains($model)) { + $behaviors = new Behaviors(); + $model->createBehaviors($behaviors); + $this->behaviors->attach($model, $behaviors); + + foreach ($behaviors as $behavior) { + if ($behavior instanceof QueryAwareBehavior) { + $behavior->setQuery($this->query); + } + } + } + + return $this->behaviors[$model]; + } + + /** + * Get a model's defaults + * + * @param Model $model + * + * @return Defaults + */ + public function getDefaults(Model $model): Defaults + { + if (! $this->defaults->contains($model)) { + $defaults = new Defaults($this->query); + $model->createDefaults($defaults); + $this->defaults->attach($model, $defaults); + } + + return $this->defaults[$model]; + } + + /** + * Get a model alias + * + * @param Model $model + * + * @return string + * + * @throws OutOfBoundsException If no alias exists for the given model + */ + public function getAlias(Model $model) + { + if (! $this->aliases->contains($model)) { + throw new OutOfBoundsException(sprintf( + "Can't get alias for model '%s'. Alias does not exist", + get_class($model) + )); + } + + return $this->aliasPrefix . $this->aliases[$model]; + } + + /** + * Set a model alias + * + * @param Model $model + * @param string $alias + * + * @return $this + */ + public function setAlias(Model $model, $alias) + { + $this->aliases[$model] = $alias; + + return $this; + } + + /** + * Get the alias prefix + * + * @return string + */ + public function getAliasPrefix() + { + return $this->aliasPrefix; + } + + /** + * Set the alias prefix + * + * @param string $alias + * + * @return $this + */ + public function setAliasPrefix($alias) + { + $this->aliasPrefix = $alias; + + return $this; + } + + /** + * Get whether the specified model provides the given selectable column + * + * @param Model $subject + * @param string $column + * + * @return bool + */ + public function hasSelectableColumn(Model $subject, $column) + { + if (! $this->selectableColumns->contains($subject)) { + $this->collectColumns($subject); + } + + $columns = $this->selectableColumns[$subject]; + if (! isset($columns[$column])) { + $columns[$column] = $this->getBehaviors($subject)->isSelectableColumn($column); + } + + return $columns[$column]; + } + + /** + * Get all selectable columns from the given model + * + * @param Model $subject + * + * @return array + */ + public function getSelectableColumns(Model $subject) + { + if (! $this->selectableColumns->contains($subject)) { + $this->collectColumns($subject); + } + + return array_keys($this->selectableColumns[$subject]); + } + + /** + * Get all select columns from the given model + * + * @param Model $subject + * + * @return array Select columns suitable for {@link \ipl\Sql\Select::columns()} + */ + public function getSelectColumns(Model $subject) + { + if (! $this->selectColumns->contains($subject)) { + $this->collectColumns($subject); + } + + return $this->selectColumns[$subject]; + } + + /** + * Get all meta data from the given model and its direct relations + * + * @param Model $subject + * + * @return array Column paths as keys (relative to $subject) and their meta data as values + */ + public function getColumnDefinitions(Model $subject) + { + if (! $this->metaData->contains($subject)) { + $this->metaData->attach($subject, $this->collectMetaData($subject)); + } + + return $this->metaData[$subject]; + } + + /** + * Get definition of the given column + * + * @param string $columnPath + * + * @return ColumnDefinition + */ + public function getColumnDefinition(string $columnPath): ColumnDefinition + { + $parts = explode('.', $columnPath); + $model = $this->query->getModel(); + + if ($parts[0] !== $model->getTableName()) { + array_unshift($parts, $model->getTableName()); + } + + do { + $relationPath[] = array_shift($parts); + $column = implode('.', $parts); + + if (count($relationPath) === 1) { + $subject = $model; + } else { + $subject = $this->resolveRelation(implode('.', $relationPath))->getTarget(); + } + + if ($this->hasSelectableColumn($subject, $column)) { + break; + } + } while ($parts); + + $definition = $this->getColumnDefinitions($subject)[$column] ?? new ColumnDefinition($column); + $this->getBehaviors($subject)->rewriteColumnDefinition($definition, implode('.', $relationPath)); + + return $definition; + } + + /** + * Qualify the given alias by the specified table name + * + * @param string $alias + * @param string $tableName + * + * @return string + */ + public function qualifyColumnAlias($alias, $tableName) + { + return $tableName . '_' . $alias; + } + + /** + * Qualify the given column by the specified table name + * + * @param string $column + * @param string $tableName + * + * @return string + */ + public function qualifyColumn($column, $tableName) + { + return $tableName . '.' . $column; + } + + /** + * Qualify the given columns by the specified model + * + * @param iterable $columns + * @param Model $model Leave null in case $columns is {@see Resolver::requireAndResolveColumns()} + * + * @return array + * + * @throws InvalidArgumentException If $columns is not iterable + * @throws InvalidArgumentException If $model is not passed and $columns is not a generator + */ + public function qualifyColumns($columns, Model $model = null) + { + $target = $model ?: $this->query->getModel(); + $targetAlias = $this->getAlias($target); + + if (! is_iterable($columns)) { + throw new InvalidArgumentException( + sprintf('$columns is not iterable, got %s instead', get_php_type($columns)) + ); + } + + $qualified = []; + foreach ($columns as $alias => $column) { + if (is_int($alias) && is_array($column)) { + // $columns is $this->requireAndResolveColumns() + list($target, $alias, $columnName) = $column; + $targetAlias = $this->getAlias($target); + + // Thanks to PHP 5.6 where `list` is evaluated from right to left. It will extract + // the values for `$target` and `$alias` then from the third argument (`$column`). + $column = $columnName; + } elseif ($target === null) { + throw new InvalidArgumentException( + 'Passing no model is only possible if $columns is a generator' + ); + } + + if ($column instanceof ResolvedExpression) { + $column->setColumns($this->qualifyColumns($column->getResolvedColumns())); + } elseif ($column instanceof ExpressionInterface) { + $column = clone $column; // The expression may be part of a model and those shouldn't change implicitly + $column->setColumns($this->qualifyColumns($column->getColumns(), $target)); + } else { + $column = $this->qualifyColumn($column, $targetAlias); + } + + $qualified[$alias] = $column; + } + + return $qualified; + } + + /** + * Qualify the given columns and aliases by the specified model + * + * @param iterable $columns + * @param Model $model Leave null in case $columns is {@see Resolver::requireAndResolveColumns()} + * @param bool $autoAlias Set an alias for columns which have none + * + * @return array + * + * @throws InvalidArgumentException If $columns is not iterable + * @throws InvalidArgumentException If $model is not passed and $columns is not a generator + */ + public function qualifyColumnsAndAliases($columns, Model $model = null, $autoAlias = true) + { + $target = $model ?: $this->query->getModel(); + $targetAlias = $this->getAlias($target); + + if (! is_iterable($columns)) { + throw new InvalidArgumentException( + sprintf('$columns is not iterable, got %s instead', get_php_type($columns)) + ); + } + + $qualified = []; + foreach ($columns as $alias => $column) { + if (is_int($alias) && is_array($column)) { + // $columns is $this->requireAndResolveColumns() + list($target, $alias, $columnName) = $column; + $targetAlias = $this->getAlias($target); + + // Thanks to PHP 5.6 where `list` is evaluated from right to left. It will extract + // the values for `$target` and `$alias` then from the third argument (`$column`). + $column = $columnName; + } elseif ($target === null) { + throw new InvalidArgumentException( + 'Passing no model is only possible if $columns is a generator' + ); + } + + if (is_int($alias)) { + if ($column instanceof AliasedExpression) { + $alias = $column->getAlias(); + } elseif ($autoAlias && ! $column instanceof ExpressionInterface) { + $alias = $this->qualifyColumnAlias($column, $targetAlias); + } + } elseif ($target !== $this->query->getModel()) { + if (strpos($alias, '.') !== false) { + // This is safe, because custom aliases won't be qualified + $alias = str_replace('.', '_', $alias); + } else { + $alias = $this->qualifyColumnAlias($alias, $targetAlias); + } + } + + if ($column instanceof ResolvedExpression) { + $column->setColumns($this->qualifyColumns($column->getResolvedColumns())); + } elseif ($column instanceof ExpressionInterface) { + $column = clone $column; // The expression may be part of a model and those shouldn't change implicitly + $column->setColumns($this->qualifyColumns($column->getColumns(), $target)); + } else { + $column = $this->qualifyColumn($column, $targetAlias); + } + + $qualified[$alias] = $column; + } + + return $qualified; + } + + /** + * Qualify the given path by the specified table name + * + * @param string $path + * @param string $tableName + * + * @return string + */ + public function qualifyPath($path, $tableName) + { + $segments = explode('.', $path, 2); + + if ($segments[0] !== $tableName) { + array_unshift($segments, $tableName); + } + + $path = implode('.', $segments); + + return $path; + } + + /** + * Get whether the given relation path points to a distinct entity + * + * @param string $path + * + * @return bool + */ + public function isDistinctRelation($path) + { + foreach ($this->resolveRelations($path) as $relation) { + if (! $relation->isOne()) { + return false; + } + } + + return true; + } + + /** + * Resolve the rightmost relation of the given path + * + * Also resolves all other relations. + * + * @param string $path + * @param Model $subject + * + * @return Relation + */ + public function resolveRelation($path, Model $subject = null) + { + $subject = $subject ?: $this->query->getModel(); + if (! $this->resolvedRelations->contains($subject) || ! isset($this->resolvedRelations[$subject][$path])) { + foreach ($this->resolveRelations($path, $subject) as $_) { + // run and exhaust generator + } + } + + return $this->resolvedRelations[$subject][$path]; + } + + /** + * Resolve all relations of the given path + * + * Traverses the entire path and yields the path travelled so far as key and the relation as value. + * + * @param string $path + * @param Model $subject + * + * @return Generator + * @throws InvalidArgumentException In case $path is not fully qualified + * @throws InvalidRelationException In case a relation is unknown + */ + public function resolveRelations($path, Model $subject = null) + { + $relations = explode('.', $path); + $subject = $subject ?: $this->query->getModel(); + + if ($relations[0] !== $subject->getTableName()) { + throw new InvalidArgumentException(sprintf( + 'Cannot resolve relation path "%s". Base table name is missing.', + $path + )); + } + + $resolvedRelations = []; + if ($this->resolvedRelations->contains($subject)) { + $resolvedRelations = $this->resolvedRelations[$subject]; + } + + $target = $subject; + $pathBeingResolved = null; + $segments = [array_shift($relations)]; + while (! empty($relations)) { + $newPath = $this->getBehaviors($target) + ->rewritePath(join('.', $relations), join('.', $segments)); + if ($newPath !== null) { + $relations = explode('.', $newPath); + $pathBeingResolved = $path; + } + + $relationName = array_shift($relations); + $segments[] = $relationName; + $relationPath = join('.', $segments); + + if (isset($resolvedRelations[$relationPath])) { + $relation = $resolvedRelations[$relationPath]; + } else { + $targetRelations = $this->getRelations($target); + if (! $targetRelations->has($relationName)) { + throw new InvalidRelationException($relationName, $target); + } + + $relation = $targetRelations->get($relationName); + $relation->setSource($target); + + $resolvedRelations[$relationPath] = $relation; + + if ($relation instanceof BelongsToMany) { + $through = $relation->getThrough(); + $this->setAlias($through, join('_', array_merge( + array_slice($segments, 0, -1), + [$through->getTableName()] + ))); + } + + $this->setAlias($relation->getTarget(), join('_', $segments)); + } + + yield $relationPath => $relation; + + $target = $relation->getTarget(); + } + + if ($pathBeingResolved !== null) { + $resolvedRelations[$pathBeingResolved] = $relation; + } + + $this->resolvedRelations->attach($subject, $resolvedRelations); + } + + /** + * Require and resolve columns + * + * Related models will be automatically added for eager-loading. + * + * @param array $columns + * @param Model $model + * + * @return Generator + * + * @throws InvalidColumnException If a column does not exist + */ + public function requireAndResolveColumns(array $columns, Model $model = null) + { + $model = $model ?: $this->query->getModel(); + $tableName = $model->getTableName(); + + foreach ($columns as $alias => $column) { + $columnPath = &$column; + if ($column instanceof ExpressionInterface) { + $column = new ResolvedExpression( + $column, + $this->requireAndResolveColumns($column->getColumns(), $model) + ); + + if (is_int($alias)) { + // Scalar queries and such + yield [$model, $alias, $column]; + + continue; + } + + $columnPath = &$alias; + } elseif ($column === '*') { + yield [$model, $alias, $column]; + + continue; + } + + $dot = strrpos($columnPath, '.'); + + switch (true) { + /** @noinspection PhpMissingBreakStatementInspection */ + case $dot !== false: + $hydrationPath = substr($columnPath, 0, $dot); + $columnPath = substr($columnPath, $dot + 1); // Updates also $column or $alias + + if ($hydrationPath !== $tableName) { + $hydrationPath = $this->qualifyPath($hydrationPath, $tableName); + + $relations = new AppendIterator(); + $relations->append(new ArrayIterator([$tableName => null])); + $relations->append($this->resolveRelations($hydrationPath)); + foreach ($relations as $relationPath => $relation) { + if ($column instanceof ExpressionInterface) { + continue; + } + + if ($relationPath === $tableName) { + $subject = $model; + } else { + /** @var Relation $relation */ + $subject = $relation->getTarget(); + } + + $columnName = $columnPath; + if ($relationPath !== $hydrationPath) { + // It's still an intermediate relation, not the target + $columnName = substr($hydrationPath, strlen($relationPath) + 1) . ".$columnName"; + } + + $newColumn = $this->getBehaviors($subject)->rewriteColumn($columnName, $relationPath); + if ($newColumn !== null) { + if ($newColumn instanceof ExpressionInterface) { + $column = $newColumn; + $target = $subject; + break 2; // Expressions don't need to be *withed* and get no automatic alias either + } + + $column = $newColumn; + break; + } + } + + if (is_int($alias) && $relationPath !== $hydrationPath) { + // If the actual relation is resolved differently, + // ensure the hydration path is not an unexpected one + $alias = "$hydrationPath.$column"; + } + + $this->query->with($hydrationPath); + $target = $relation->getTarget(); + + break; + } + // Move to default + default: + $relationPath = null; + $target = $model; + + if (! $column instanceof ExpressionInterface) { + $column = $this->getBehaviors($target)->rewriteColumn($column) ?: $column; + } + } + + if (! $column instanceof ExpressionInterface) { + $targetColumns = $target->getColumns(); + if (isset($targetColumns[$column])) { + // $column is actually an alias + $alias = is_string($alias) ? $alias : ($relationPath ? "$hydrationPath.$column" : $column); + $column = $targetColumns[$column]; + + if ($column instanceof ExpressionInterface) { + $column = new ResolvedExpression( + $column, + $this->requireAndResolveColumns($column->getColumns(), $target) + ); + } + } + } + + if (! $column instanceof ExpressionInterface && ! $this->hasSelectableColumn($target, $columnPath)) { + throw new InvalidColumnException($columnPath, $target); + } + + yield [$target, $alias, $column]; + } + } + + /** + * Collect all selectable columns from the given model + * + * @param Model $subject + */ + protected function collectColumns(Model $subject) + { + // Don't fail if Model::getColumns() also contains the primary key columns + $columns = array_merge((array) $subject->getKeyName(), (array) $subject->getColumns()); + + $this->selectColumns->attach($subject, $columns); + + $selectable = []; + + foreach ($columns as $alias => $column) { + if (is_string($alias)) { + $selectable[$alias] = true; + } + + if (is_string($column)) { + $selectable[$column] = true; + } + } + + $this->selectableColumns->attach($subject, $selectable); + } + + /** + * Collect all meta data from the given model and its direct relations + * + * @param Model $subject + * + * @return array + */ + protected function collectMetaData(Model $subject) + { + $definitions = []; + foreach ($subject->getColumnDefinitions() as $name => $data) { + if ($data instanceof ColumnDefinition) { + $definition = $data; + } else { + if (is_string($data)) { + $data = ['name' => $name, 'label' => $data]; + } elseif (! isset($data[$name])) { + $data['name'] = $name; + } + + $definition = ColumnDefinition::fromArray($data); + } + + if (is_string($name) && $definition->getName() !== $name) { + throw new LogicException(sprintf( + 'Model %s provides a column definition with a different name (%s) than the index (%s)', + get_class($subject), + $definition->getName(), + $name + )); + } + + $definitions[$name] = $definition; + } + + return $definitions; + } +} diff --git a/vendor/ipl/orm/src/ResultSet.php b/vendor/ipl/orm/src/ResultSet.php new file mode 100644 index 0000000..05117a5 --- /dev/null +++ b/vendor/ipl/orm/src/ResultSet.php @@ -0,0 +1,146 @@ +<?php + +namespace ipl\Orm; + +use ArrayIterator; +use Iterator; +use Traversable; + +class ResultSet implements Iterator +{ + protected $cache; + + /** @var bool Whether cache is disabled */ + protected $isCacheDisabled = false; + + protected $generator; + + protected $limit; + + protected $position; + + public function __construct(Traversable $traversable, $limit = null) + { + $this->cache = new ArrayIterator(); + $this->generator = $this->yieldTraversable($traversable); + $this->limit = $limit; + } + + /** + * Create a new result set from the given query + * + * @param Query $query + * + * @return static + */ + public static function fromQuery(Query $query) + { + return new static($query->yieldResults(), $query->getLimit()); + } + + /** + * Do not cache query result + * + * ResultSet instance can only be iterated once + * + * @return $this + */ + public function disableCache() + { + $this->isCacheDisabled = true; + + return $this; + } + + public function hasMore() + { + return $this->generator->valid(); + } + + public function hasResult() + { + return $this->generator->valid(); + } + + #[\ReturnTypeWillChange] + public function current() + { + if ($this->position === null) { + $this->advance(); + } + + return $this->isCacheDisabled ? $this->generator->current() : $this->cache->current(); + } + + public function next(): void + { + if (! $this->isCacheDisabled) { + $this->cache->next(); + } + + if ($this->isCacheDisabled || ! $this->cache->valid()) { + $this->generator->next(); + $this->advance(); + } else { + $this->position += 1; + } + } + + public function key(): int + { + if ($this->position === null) { + $this->advance(); + } + + return $this->isCacheDisabled ? $this->generator->key() : $this->cache->key(); + } + + public function valid(): bool + { + if ($this->limit !== null && $this->position === $this->limit) { + return false; + } + + return $this->cache->valid() || $this->generator->valid(); + } + + public function rewind(): void + { + if (! $this->isCacheDisabled) { + $this->cache->rewind(); + } + + if ($this->position === null) { + $this->advance(); + } else { + $this->position = 0; + } + } + + protected function advance() + { + if (! $this->generator->valid()) { + return; + } + + if (! $this->isCacheDisabled) { + $this->cache[$this->generator->key()] = $this->generator->current(); + + // Only required on PHP 5.6, 7+ does it automatically + $this->cache->seek($this->generator->key()); + } + + if ($this->position === null) { + $this->position = 0; + } else { + $this->position += 1; + } + } + + protected function yieldTraversable(Traversable $traversable) + { + foreach ($traversable as $key => $value) { + yield $key => $value; + } + } +} diff --git a/vendor/ipl/orm/src/UnionModel.php b/vendor/ipl/orm/src/UnionModel.php new file mode 100644 index 0000000..5373bb9 --- /dev/null +++ b/vendor/ipl/orm/src/UnionModel.php @@ -0,0 +1,29 @@ +<?php + +namespace ipl\Orm; + +use ipl\Sql\Connection; + +abstract class UnionModel extends Model +{ + /** + * Get a UNION query which is tied to this model and the given database connection + * + * @param Connection $db + * + * @return UnionQuery + */ + public static function on(Connection $db) + { + return (new UnionQuery()) + ->setDb($db) + ->setModel(new static()); + } + + /** + * Get the UNION models and columns + * + * @return array + */ + abstract public function getUnions(); +} diff --git a/vendor/ipl/orm/src/UnionQuery.php b/vendor/ipl/orm/src/UnionQuery.php new file mode 100644 index 0000000..1229ff2 --- /dev/null +++ b/vendor/ipl/orm/src/UnionQuery.php @@ -0,0 +1,59 @@ +<?php + +namespace ipl\Orm; + +use ipl\Sql\Select; + +class UnionQuery extends Query +{ + /** @var Query[] Underlying queries */ + private $unions; + + /** + * Get the underlying queries + * + * @return Query[] + */ + public function getUnions() + { + if ($this->unions === null) { + $this->unions = []; + + foreach ($this->getModel()->getUnions() as list($target, $relations, $columns)) { + $query = (new Query()) + ->setDb($this->getDb()) + ->setModel(new $target()) + ->columns($columns) + ->disableDefaultSort() + ->with($relations); + + $this->unions[] = $query; + } + } + + return $this->unions; + } + + public function getSelectBase() + { + if ($this->selectBase === null) { + $this->selectBase = new Select(); + } + + $union = new Select(); + + foreach ($this->getUnions() as $query) { + $select = $query->assembleSelect(); + $columns = $select->getColumns(); + $select->resetColumns(); + ksort($columns); + $select->columns($columns); + + $union->unionAll($select); + } + + $this->selectBase->from([$this->getModel()->getTableName() => $union]); + + return $this->selectBase; + } +} diff --git a/vendor/ipl/sql/LICENSE b/vendor/ipl/sql/LICENSE new file mode 100644 index 0000000..e179593 --- /dev/null +++ b/vendor/ipl/sql/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2017 Icinga GmbH https://www.icinga.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/vendor/ipl/sql/composer.json b/vendor/ipl/sql/composer.json new file mode 100644 index 0000000..5c4aad1 --- /dev/null +++ b/vendor/ipl/sql/composer.json @@ -0,0 +1,26 @@ +{ + "name": "ipl/sql", + "type": "library", + "description": "Icinga PHP Library - SQL abstraction layer", + "keywords": ["sql", "database"], + "homepage": "https://github.com/Icinga/ipl-sql", + "license": "MIT", + "require": { + "php": ">=7.2", + "ext-pdo": "*", + "ipl/stdlib": ">=0.12.0" + }, + "autoload": { + "psr-4": { + "ipl\\Sql\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "ipl\\Tests\\Sql\\": "tests" + } + }, + "scripts": { + "test": "vendor/bin/phpunit" + } +} diff --git a/vendor/ipl/sql/src/Adapter/BaseAdapter.php b/vendor/ipl/sql/src/Adapter/BaseAdapter.php new file mode 100644 index 0000000..97bfca4 --- /dev/null +++ b/vendor/ipl/sql/src/Adapter/BaseAdapter.php @@ -0,0 +1,117 @@ +<?php + +namespace ipl\Sql\Adapter; + +use DateTime; +use DateTimeZone; +use ipl\Sql\Config; +use ipl\Sql\Connection; +use ipl\Sql\Contract\Adapter; +use ipl\Sql\QueryBuilder; +use ipl\Sql\Select; +use PDO; +use UnexpectedValueException; + +abstract class BaseAdapter implements Adapter +{ + /** + * Quote character to use for quoting identifiers + * + * The default quote character is the double quote (") which is used by databases that behave close to ANSI SQL. + * + * @var array + */ + protected $quoteCharacter = ['"', '"']; + + /** @var string Character to use for escaping quote characters */ + protected $escapeCharacter = '\\"'; + + /** @var array Default PDO connect options */ + protected $options = [ + PDO::ATTR_CASE => PDO::CASE_NATURAL, + PDO::ATTR_EMULATE_PREPARES => false, + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_ORACLE_NULLS => PDO::NULL_NATURAL, + PDO::ATTR_STRINGIFY_FETCHES => false + ]; + + public function getDsn(Config $config) + { + $dsn = "{$config->db}:"; + + $parts = []; + + foreach (['host', 'dbname', 'port'] as $part) { + if (! empty($config->$part)) { + $parts[] = "{$part}={$config->$part}"; + } + } + + return $dsn . implode(';', $parts); + } + + public function getOptions(Config $config) + { + if (is_array($config->options)) { + return $config->options + $this->options; + } + + return $this->options; + } + + public function setClientTimezone(Connection $db) + { + } + + public function quoteIdentifier($identifiers) + { + if (is_string($identifiers)) { + $identifiers = explode('.', $identifiers); + } + + foreach ($identifiers as $i => $identifier) { + if ($identifier === '*') { + continue; + } + + $identifiers[$i] = $this->quoteCharacter[0] + . str_replace($this->quoteCharacter[0], $this->escapeCharacter, $identifier) + . $this->quoteCharacter[1]; + } + + return implode('.', $identifiers); + } + + public function registerQueryBuilderCallbacks(QueryBuilder $queryBuilder) + { + $queryBuilder->on(QueryBuilder::ON_ASSEMBLE_SELECT, function (Select $select): void { + if ($select->hasOrderBy()) { + foreach ($select->getOrderBy() as list($_, $direction)) { + switch (strtolower($direction ?? '')) { + case '': + case 'asc': + case 'desc': + break; + default: + throw new UnexpectedValueException( + sprintf('Invalid direction "%s" in ORDER BY', $direction) + ); + } + } + } + }); + } + + protected function getTimezoneOffset() + { + $tz = new DateTimeZone(date_default_timezone_get()); + $offset = $tz->getOffset(new DateTime()); + $prefix = $offset >= 0 ? '+' : '-'; + $offset = abs($offset); + + $hours = (int) floor($offset / 3600); + $minutes = (int) floor(($offset % 3600) / 60); + + return sprintf('%s%d:%02d', $prefix, $hours, $minutes); + } +} diff --git a/vendor/ipl/sql/src/Adapter/Mssql.php b/vendor/ipl/sql/src/Adapter/Mssql.php new file mode 100644 index 0000000..c1c4e1c --- /dev/null +++ b/vendor/ipl/sql/src/Adapter/Mssql.php @@ -0,0 +1,78 @@ +<?php + +namespace ipl\Sql\Adapter; + +use ipl\Sql\Config; +use ipl\Sql\QueryBuilder; +use ipl\Sql\Select; +use PDO; +use RuntimeException; + +class Mssql extends BaseAdapter +{ + protected $quoteCharacter = ['[', ']']; + + protected $escapeCharacter = '[[]'; + + public function getDsn(Config $config) + { + $drivers = array_intersect(['sqlsrv', 'dblib', 'mssql', 'sybase'], PDO::getAvailableDrivers()); + + if (empty($drivers)) { + throw new RuntimeException('No PDO driver available for connecting to a Microsoft SQL Server'); + } + + $driver = reset($drivers); // array_intersect preserves keys, so the first may not be indexed at 0 + + $isSqlSrv = $driver === 'sqlsrv'; + if ($isSqlSrv) { + $hostOption = 'Server'; + $dbOption = 'Database'; + } else { + $hostOption = 'host'; + $dbOption = 'dbname'; + } + + $dsn = "{$driver}:{$hostOption}={$config->host}"; + + if (! empty($config->port)) { + if ($isSqlSrv || strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { + $seperator = ','; + } else { + $seperator = ':'; + } + + $dsn .= "{$seperator}{$config->port}"; + } + + $dsn .= ";{$dbOption}={$config->dbname}"; + + if (! empty($config->charset) && ! $isSqlSrv) { + $dsn .= ";charset={$config->charset}"; + } + + if (isset($config->use_ssl) && $isSqlSrv) { + $dsn .= ';Encrypt=' . ($config->use_ssl ? 'true' : 'false'); + } + + if (isset($config->ssl_do_not_verify_server_cert) && $isSqlSrv) { + $dsn .= ';TrustServerCertificate=' . ($config->ssl_do_not_verify_server_cert ? 'true' : 'false'); + } + + return $dsn; + } + + public function registerQueryBuilderCallbacks(QueryBuilder $queryBuilder) + { + parent::registerQueryBuilderCallbacks($queryBuilder); + + $queryBuilder->on(QueryBuilder::ON_ASSEMBLE_SELECT, function (Select $select) { + if ( + ($select->hasLimit() || $select->hasOffset()) + && ! $select->hasOrderBy() + ) { + $select->orderBy(1); + } + }); + } +} diff --git a/vendor/ipl/sql/src/Adapter/Mysql.php b/vendor/ipl/sql/src/Adapter/Mysql.php new file mode 100644 index 0000000..b9a18c5 --- /dev/null +++ b/vendor/ipl/sql/src/Adapter/Mysql.php @@ -0,0 +1,57 @@ +<?php + +namespace ipl\Sql\Adapter; + +use ipl\Sql\Config; +use ipl\Sql\Connection; +use PDO; + +class Mysql extends BaseAdapter +{ + protected $quoteCharacter = ['`', '`']; + + protected $escapeCharacter = '``'; + + public function setClientTimezone(Connection $db) + { + $db->exec('SET time_zone = ' . $db->quote($this->getTimezoneOffset())); + + return $this; + } + + public function getOptions(Config $config) + { + $options = parent::getOptions($config); + + if (! empty($config->use_ssl)) { + if (! empty($config->ssl_key)) { + $options[PDO::MYSQL_ATTR_SSL_KEY] = $config->ssl_key; + } + + if (! empty($config->ssl_cert)) { + $options[PDO::MYSQL_ATTR_SSL_CERT] = $config->ssl_cert; + } + + if (! empty($config->ssl_ca)) { + $options[PDO::MYSQL_ATTR_SSL_CA] = $config->ssl_ca; + } + + if (! empty($config->ssl_capath)) { + $options[PDO::MYSQL_ATTR_SSL_CAPATH] = $config->ssl_capath; + } + + if (! empty($config->ssl_cipher)) { + $options[PDO::MYSQL_ATTR_SSL_CIPHER] = $config->ssl_cipher; + } + + if ( + defined('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT') + && ! empty($config->ssl_do_not_verify_server_cert) + ) { + $options[PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] = false; + } + } + + return $options; + } +} diff --git a/vendor/ipl/sql/src/Adapter/Oracle.php b/vendor/ipl/sql/src/Adapter/Oracle.php new file mode 100644 index 0000000..de0aee5 --- /dev/null +++ b/vendor/ipl/sql/src/Adapter/Oracle.php @@ -0,0 +1,39 @@ +<?php + +namespace ipl\Sql\Adapter; + +use ipl\Sql\Config; +use ipl\Sql\Connection; + +class Oracle extends BaseAdapter +{ + public function getDsn(Config $config) + { + $dsn = 'oci:dbname='; + + if (! empty($config->host)) { + $dsn .= "//{$config->host}"; + + if (! empty($config->port)) { + $dsn .= ":{$config->port}/"; + } + + $dsn .= '/'; + } + + $dsn .= $config->dbname; + + if (! empty($config->charset)) { + $dsn .= ";charset={$config->charset}"; + } + + return $dsn; + } + + public function setClientTimezone(Connection $db) + { + $db->prepexec('ALTER SESSION SET TIME_ZONE = ?', [$this->getTimezoneOffset()]); + + return $this; + } +} diff --git a/vendor/ipl/sql/src/Adapter/Pgsql.php b/vendor/ipl/sql/src/Adapter/Pgsql.php new file mode 100644 index 0000000..18bf15d --- /dev/null +++ b/vendor/ipl/sql/src/Adapter/Pgsql.php @@ -0,0 +1,15 @@ +<?php + +namespace ipl\Sql\Adapter; + +use ipl\Sql\Connection; + +class Pgsql extends BaseAdapter +{ + public function setClientTimezone(Connection $db) + { + $db->exec(sprintf('SET TIME ZONE INTERVAL %s HOUR TO MINUTE', $db->quote($this->getTimezoneOffset()))); + + return $this; + } +} diff --git a/vendor/ipl/sql/src/Adapter/Sqlite.php b/vendor/ipl/sql/src/Adapter/Sqlite.php new file mode 100644 index 0000000..9f4e209 --- /dev/null +++ b/vendor/ipl/sql/src/Adapter/Sqlite.php @@ -0,0 +1,13 @@ +<?php + +namespace ipl\Sql\Adapter; + +use ipl\Sql\Config; + +class Sqlite extends BaseAdapter +{ + public function getDsn(Config $config) + { + return "sqlite:{$config->dbname}"; + } +} diff --git a/vendor/ipl/sql/src/CommonTableExpression.php b/vendor/ipl/sql/src/CommonTableExpression.php new file mode 100644 index 0000000..596ec39 --- /dev/null +++ b/vendor/ipl/sql/src/CommonTableExpression.php @@ -0,0 +1,53 @@ +<?php + +namespace ipl\Sql; + +/** + * Implementation for the {@link CommonTableExpressionInterface} to allow CTEs via {@link with()} + */ +trait CommonTableExpression +{ + /** + * All CTEs + * + * [ + * [$query, $alias, $recursive], + * ... + * ] + * + * @var array[] + */ + protected $with = []; + + public function getWith() + { + return $this->with; + } + + public function with(Select $query, $alias, $recursive = false) + { + $this->with[] = [$query, $alias, $recursive]; + + return $this; + } + + public function resetWith() + { + $this->with = []; + + return $this; + } + + /** + * Clone the properties provided by this trait + * + * Shall be called by using classes in their __clone() + */ + protected function cloneCte() + { + foreach ($this->with as &$cte) { + $cte[0] = clone $cte[0]; + } + unset($cte); + } +} diff --git a/vendor/ipl/sql/src/CommonTableExpressionInterface.php b/vendor/ipl/sql/src/CommonTableExpressionInterface.php new file mode 100644 index 0000000..7e93bc8 --- /dev/null +++ b/vendor/ipl/sql/src/CommonTableExpressionInterface.php @@ -0,0 +1,39 @@ +<?php + +namespace ipl\Sql; + +/** + * Interface for CTEs via {@link with()} + */ +interface CommonTableExpressionInterface +{ + /** + * Get all CTEs + * + * [ + * [$query, $alias, $columns, $recursive], + * ... + * ] + * + * @return array[] + */ + public function getWith(); + + /** + * Add a CTE + * + * @param Select $query + * @param string $alias + * @param bool $recursive + * + * @return $this + */ + public function with(Select $query, $alias, $recursive = false); + + /** + * Reset all CTEs + * + * @return $this + */ + public function resetWith(); +} diff --git a/vendor/ipl/sql/src/Compat/FilterProcessor.php b/vendor/ipl/sql/src/Compat/FilterProcessor.php new file mode 100644 index 0000000..7d779d7 --- /dev/null +++ b/vendor/ipl/sql/src/Compat/FilterProcessor.php @@ -0,0 +1,110 @@ +<?php + +namespace ipl\Sql\Compat; + +use InvalidArgumentException; +use ipl\Sql\Filter\Exists; +use ipl\Sql\Filter\NotExists; +use ipl\Sql\Sql; +use ipl\Stdlib\Filter; + +class FilterProcessor +{ + public static function assembleFilter(Filter\Rule $filter, $level = 0) + { + $condition = null; + + if ($filter instanceof Filter\Chain) { + if ($filter instanceof Filter\All) { + $operator = Sql::ALL; + } elseif ($filter instanceof Filter\Any) { + $operator = Sql::ANY; + } elseif ($filter instanceof Filter\None) { + $operator = Sql::NOT_ALL; + } + + if (! isset($operator)) { + throw new InvalidArgumentException(sprintf('Cannot render filter: %s', get_class($filter))); + } + + if (! $filter->isEmpty()) { + foreach ($filter as $filterPart) { + $part = static::assembleFilter($filterPart, $level + 1); + if ($part) { + if ($condition === null) { + $condition = [$operator, [$part]]; + } else { + if ($condition[0] === $operator) { + $condition[1][] = $part; + } elseif ($operator === Sql::NOT_ALL) { + $condition = [Sql::ALL, [$condition, [$operator, [$part]]]]; + } elseif ($operator === Sql::NOT_ANY) { + $condition = [Sql::ANY, [$condition, [$operator, [$part]]]]; + } else { + $condition = [$operator, [$condition, $part]]; + } + } + } + } + } else { + // TODO(el): Explicitly return the empty string due to the FilterNot case? + } + } else { + /** @var Filter\Condition $filter */ + $condition = [Sql::ALL, static::assemblePredicate($filter)]; + } + + return $condition; + } + + public static function assemblePredicate(Filter\Condition $filter) + { + $column = $filter->getColumn(); + $expression = $filter->getValue(); + + if (is_array($expression)) { + if ($filter instanceof Filter\UnEqual || $filter instanceof Filter\Unlike) { + return ["($column NOT IN (?) OR $column IS NULL)" => $expression]; + } elseif ($filter instanceof Filter\Equal || $filter instanceof Filter\Like) { + return ["$column IN (?)" => $expression]; + } + + throw new InvalidArgumentException( + 'Unable to render array expressions with operators other than equal or not equal' + ); + } elseif ( + ($filter instanceof Filter\Like || $filter instanceof Filter\Unlike) + && strpos($expression, '*') !== false + ) { + if ($expression === '*') { + return ["$column IS " . ($filter instanceof Filter\Like ? 'NOT ' : '') . 'NULL']; + } elseif ($filter instanceof Filter\Unlike) { + return ["($column NOT LIKE ? OR $column IS NULL)" => str_replace('*', '%', $expression)]; + } else { + return ["$column LIKE ?" => str_replace('*', '%', $expression)]; + } + } elseif ($filter instanceof Filter\Unequal || $filter instanceof Filter\Unlike) { + return ["($column != ? OR $column IS NULL)" => $expression]; + } else { + if ($filter instanceof Filter\Like || $filter instanceof Filter\Equal) { + $operator = '='; + } elseif ($filter instanceof Filter\GreaterThan) { + $operator = '>'; + } elseif ($filter instanceof Filter\GreaterThanOrEqual) { + $operator = '>='; + } elseif ($filter instanceof Filter\LessThan) { + $operator = '<'; + } elseif ($filter instanceof Filter\LessThanOrEqual) { + $operator = '<='; + } elseif ($filter instanceof Exists) { + $operator = 'EXISTS'; + } elseif ($filter instanceof NotExists) { + $operator = 'NOT EXISTS'; + } else { + throw new InvalidArgumentException(sprintf('Cannot render filter: %s', get_class($filter))); + } + + return ["$column $operator ?" => $expression]; + } + } +} diff --git a/vendor/ipl/sql/src/Config.php b/vendor/ipl/sql/src/Config.php new file mode 100644 index 0000000..df32fde --- /dev/null +++ b/vendor/ipl/sql/src/Config.php @@ -0,0 +1,72 @@ +<?php + +namespace ipl\Sql; + +use InvalidArgumentException; + +use function ipl\Stdlib\get_php_type; + +/** + * SQL connection configuration + */ +class Config +{ + /** + * Create a new SQL connection configuration from the given configuration key-value pairs + * + * @param iterable $config Configuration key-value pairs + * + * @throws InvalidArgumentException If $config is not iterable + */ + public function __construct($config) + { + if (! is_iterable($config)) { + throw new InvalidArgumentException(sprintf( + '%s expects parameter one to be iterable, got %s instead', + __METHOD__, + get_php_type($config) + )); + } + + foreach ($config as $key => $value) { + $this->$key = $value; + } + } + + /** @var string Type of the DBMS */ + public $db; + + /** @var string Database host */ + public $host; + + /** @var int Database port */ + public $port; + + /** @var string Database name */ + public $dbname; + + /** @var string Username to use for authentication */ + public $username; + + /** @var string Password to use for authentication */ + public $password; + + /** + * Character set for the connection + * + * If you want to use the default charset as configured by the database, don't set this property. + * + * @var string + */ + public $charset; + + /** + * PDO connect options + * + * Array of key-value pairs that should be set when calling {@link Connection::connect()} in order to establish a DB + * connection. + * + * @var array + */ + public $options; +} diff --git a/vendor/ipl/sql/src/Connection.php b/vendor/ipl/sql/src/Connection.php new file mode 100644 index 0000000..bfc3f70 --- /dev/null +++ b/vendor/ipl/sql/src/Connection.php @@ -0,0 +1,554 @@ +<?php + +namespace ipl\Sql; + +use BadMethodCallException; +use Exception; +use InvalidArgumentException; +use ipl\Sql\Contract\Adapter; +use ipl\Sql\Contract\Quoter; +use ipl\Stdlib\Plugins; +use PDO; +use PDOStatement; + +/** + * Connection to a SQL database using the native PDO for database access + */ +class Connection implements Quoter +{ + use Plugins; + + /** @var Config */ + protected $config; + + /** @var PDO */ + protected $pdo; + + /** @var QueryBuilder */ + protected $queryBuilder; + + /** @var Adapter */ + protected $adapter; + + /** + * Create a new database connection using the given config for initialising the options for the connection + * + * {@link init()} is called after construction. + * + * @param Config|iterable $config + * + * @throws InvalidArgumentException If there's no adapter for the given database available + */ + public function __construct($config) + { + $config = $config instanceof Config ? $config : new Config($config); + + $this->addPluginLoader('adapter', __NAMESPACE__ . '\\Adapter'); + + $adapter = $this->loadPlugin('adapter', $config->db); + + if (! $adapter) { + throw new InvalidArgumentException("Can't load database adapter for '{$config->db}'."); + } + + $this->adapter = new $adapter(); + $this->config = $config; + + $this->init(); + } + + /** + * Proxy PDO method calls + * + * @param string $name The name of the PDO method to call + * @param array $arguments Arguments for the method to call + * + * @return mixed + * + * @throws BadMethodCallException If the called method does not exist + * + */ + public function __call($name, array $arguments) + { + $this->connect(); + + if (! method_exists($this->pdo, $name)) { + $class = get_class($this); + $message = "Call to undefined method $class::$name"; + + throw new BadMethodCallException($message); + } + + return call_user_func_array([$this->pdo, $name], $arguments); + } + + /** + * Initialise the database connection + * + * If you have to adjust the connection after construction, override this method. + */ + public function init() + { + } + + /** + * Get the database adapter + * + * @return Adapter + */ + public function getAdapter() + { + return $this->adapter; + } + + /** + * Get the connection configuration + * + * @return Config + */ + public function getConfig() + { + return $this->config; + } + + /** + * Get the query builder for the database connection + * + * @return QueryBuilder + */ + public function getQueryBuilder() + { + if ($this->queryBuilder === null) { + $this->queryBuilder = new QueryBuilder($this->adapter); + } + + return $this->queryBuilder; + } + + /** + * Create and return the PDO instance + * + * This method is called via {@link connect()} to establish a database connection. + * If the default PDO needs to be adjusted for a certain DBMS, override this method. + * + * @return PDO + */ + protected function createPdoAdapter() + { + $adapter = $this->getAdapter(); + + $config = $this->getConfig(); + + return new PDO( + $adapter->getDsn($config), + $config->username, + $config->password, + $adapter->getOptions($config) + ); + } + + /** + * Connect to the database, if not already connected + * + * @return $this + */ + public function connect() + { + if ($this->pdo !== null) { + return $this; + } + + $this->pdo = $this->createPdoAdapter(); + + if (! empty($this->config->charset)) { + $this->exec(sprintf('SET NAMES %s', $this->pdo->quote($this->config->charset))); + } + + $this->adapter->setClientTimezone($this); + + return $this; + } + + /** + * Disconnect from the database + * + * @return $this + */ + public function disconnect() + { + $this->pdo = null; + + return $this; + } + + /** + * Check whether the connection to the database is still available + * + * @param bool $reconnect Whether to automatically reconnect + * + * @return bool + */ + public function ping($reconnect = true) + { + try { + $this->query('SELECT 1')->closeCursor(); + } catch (Exception $e) { + if (! $reconnect) { + return false; + } + + $this->disconnect(); + + return $this->ping(false); + } + + return true; + } + + /** + * Fetch and return all result rows as sequential array + * + * @param Select|string $stmt The SQL statement to prepare and execute. + * @param array $values Values to bind to the statement + * + * @return array + */ + public function fetchAll($stmt, array $values = null) + { + return $this->prepexec($stmt, $values) + ->fetchAll(); + } + + /** + * Fetch and return the first column of all result rows as sequential array + * + * @param Select|string $stmt The SQL statement to prepare and execute. + * @param array $values Values to bind to the statement + * + * @return array + */ + public function fetchCol($stmt, array $values = null) + { + return $this->prepexec($stmt, $values) + ->fetchAll(PDO::FETCH_COLUMN, 0); + } + + /** + * Fetch and return the first row of the result rows + * + * @param Select|string $stmt The SQL statement to prepare and execute. + * @param array $values Values to bind to the statement + * + * @return array + */ + public function fetchOne($stmt, array $values = null) + { + return $this->prepexec($stmt, $values) + ->fetch(); + } + + /** + * Alias of {@link fetchOne()} + */ + public function fetchRow($stmt, array $values = null) + { + return $this->prepexec($stmt, $values) + ->fetch(); + } + + /** + * Fetch and return all result rows as an array of key-value pairs + * + * First column is the key and the second column is the value. + * + * @param Select|string $stmt The SQL statement to prepare and execute. + * @param array $values Values to bind to the statement + * + * @return array + */ + public function fetchPairs($stmt, array $values = null) + { + return $this->prepexec($stmt, $values) + ->fetchAll(PDO::FETCH_KEY_PAIR); + } + + /** + * Fetch and return the first column of the first result row + * + * @param Select|string $stmt The SQL statement to prepare and execute. + * @param array $values Values to bind to the statement + * + * @return string + */ + public function fetchScalar($stmt, array $values = null) + { + return $this->prepexec($stmt, $values) + ->fetchColumn(0); + } + + /** + * Yield each result row + * + * `Connection::yieldAll(Select|string $stmt [[, array $values], int $fetchMode [, mixed ...$fetchModeOptions]])` + * + * @param Select|string $stmt The SQL statement to prepare and execute. + * @param mixed ...$args Values to bind to the statement, fetch mode for the statement, fetch mode options + * + * @return \Generator + */ + public function yieldAll($stmt, ...$args) + { + $values = null; + + if (! empty($args)) { + if (is_array($args[0])) { + $values = array_shift($args); + } + } + + $fetchMode = null; + + if (! empty($args)) { + $fetchMode = array_shift($args); + + switch ($fetchMode) { + case PDO::FETCH_KEY_PAIR: + foreach ($this->yieldPairs($stmt, $values) as $key => $value) { + yield $key => $value; + } + + return; + case PDO::FETCH_COLUMN: + if (empty($args)) { + $args[] = 0; + } + + break; + } + } + + $sth = $this->prepexec($stmt, $values); + + if ($fetchMode !== null) { + $sth->setFetchMode($fetchMode, ...$args); + } + + foreach ($sth as $key => $row) { + yield $key => $row; + } + } + + /** + * Yield the first column of each result row + * + * @param Select|string $stmt The SQL statement to prepare and execute + * @param array $values Values to bind to the statement + * + * @return \Generator + */ + public function yieldCol($stmt, array $values = null) + { + $sth = $this->prepexec($stmt, $values); + + $sth->setFetchMode(PDO::FETCH_COLUMN, 0); + + foreach ($sth as $key => $row) { + yield $key => $row; + } + } + + /** + * Yield key-value pairs with the first column as key and the second column as value for each result row + * + * @param Select|string $stmt The SQL statement to prepare and execute + * @param array $values Values to bind to the statement + * + * @return \Generator + */ + public function yieldPairs($stmt, array $values = null) + { + $sth = $this->prepexec($stmt, $values); + + $sth->setFetchMode(PDO::FETCH_NUM); + + foreach ($sth as $row) { + list($key, $value) = $row; + + yield $key => $value; + } + } + + /** + * Prepare and execute the given statement + * + * @param Delete|Insert|Select|Update|string $stmt The SQL statement to prepare and execute + * @param string|array $values Values to bind to the statement, if any + * + * @return PDOStatement + */ + public function prepexec($stmt, $values = null) + { + if ($values !== null && ! is_array($values)) { + $values = [$values]; + } + + if (is_object($stmt)) { + list($stmt, $values) = $this->getQueryBuilder()->assemble($stmt); + } + + $this->connect(); + + $sth = $this->pdo->prepare($stmt); + $sth->execute($values); + + return $sth; + } + + /** + * Prepare and execute the given Select query + * + * @param Select $select + * + * @return PDOStatement + */ + public function select(Select $select) + { + list($stmt, $values) = $this->getQueryBuilder()->assembleSelect($select); + + return $this->prepexec($stmt, $values); + } + + /** + * Insert a table row with the specified data + * + * @param string $table The table to insert data into. The table specification must be in + * one of the following formats: 'table' or 'schema.table' + * @param iterable $data Row data in terms of column-value pairs + * + * @return PDOStatement + * + * @throws InvalidArgumentException If data type is invalid + */ + public function insert($table, $data) + { + $insert = (new Insert()) + ->into($table) + ->values($data); + + return $this->prepexec($insert); + } + + /** + * Update table rows with the specified data, optionally based on a given condition + * + * @param string|array $table The table to update. The table specification must be in one of + * the following formats: + * 'table', 'table alias', ['alias' => 'table'] + * @param iterable $data The columns to update in terms of column-value pairs + * @param mixed $condition The WHERE condition + * @param string $operator The operator to combine multiple conditions with, + * if the condition is in the array format + * + * @return PDOStatement + * + * @throws InvalidArgumentException If data type is invalid + */ + public function update($table, $data, $condition = null, $operator = Sql::ALL) + { + $update = (new Update()) + ->table($table) + ->set($data); + + if ($condition !== null) { + $update->where($condition, $operator); + } + + return $this->prepexec($update); + } + + /** + * Delete table rows, optionally based on a given condition + * + * @param string|array $table The table to delete data from. The table specification must be in one of the + * following formats: 'table', 'table alias', ['alias' => 'table'] + * @param mixed $condition The WHERE condition + * @param string $operator The operator to combine multiple conditions with, + * if the condition is in the array format + * + * @return PDOStatement + */ + public function delete($table, $condition = null, $operator = Sql::ALL) + { + $delete = (new Delete()) + ->from($table); + + if ($condition !== null) { + $delete->where($condition, $operator); + } + + return $this->prepexec($delete); + } + + /** + * Begin a transaction + * + * @return bool Whether the transaction was started successfully + */ + public function beginTransaction() + { + $this->connect(); + + return $this->pdo->beginTransaction(); + } + + /** + * Commit a transaction + * + * @return bool Whether the transaction was committed successfully + */ + public function commitTransaction() + { + return $this->pdo->commit(); + } + + /** + * Roll back a transaction + * + * @return bool Whether the transaction was rolled back successfully + */ + public function rollBackTransaction() + { + return $this->pdo->rollBack(); + } + + /** + * Run the given callback in a transaction + * + * @param callable $callback The callback to run in a transaction. + * This connection instance is passed as parameter to the callback + * + * @return mixed The return value of the callback + * + * @throws Exception If an error occurs when running the callback + */ + public function transaction(callable $callback) + { + $this->beginTransaction(); + + try { + $result = call_user_func($callback, $this); + $this->commitTransaction(); + } catch (Exception $e) { + $this->rollBackTransaction(); + + throw $e; + } + + return $result; + } + + public function quoteIdentifier($identifier) + { + return $this->getAdapter()->quoteIdentifier($identifier); + } +} diff --git a/vendor/ipl/sql/src/Contract/Adapter.php b/vendor/ipl/sql/src/Contract/Adapter.php new file mode 100644 index 0000000..6142626 --- /dev/null +++ b/vendor/ipl/sql/src/Contract/Adapter.php @@ -0,0 +1,46 @@ +<?php + +namespace ipl\Sql\Contract; + +use ipl\Sql\Config; +use ipl\Sql\Connection; +use ipl\Sql\QueryBuilder; + +interface Adapter extends Quoter +{ + /** + * Get the DSN string from the given connection configuration + * + * @param Config $config + * + * @return string + */ + public function getDsn(Config $config); + + /** + * Get the PDO connect options based on the specified connection configuration + * + * @param Config $config + * + * @return array + */ + public function getOptions(Config $config); + + /** + * Set the client time zone + * + * @param Connection $db + * + * @return $this + */ + public function setClientTimezone(Connection $db); + + /** + * Register callbacks for query builder events + * + * @param QueryBuilder $queryBuilder + * + * @return $this + */ + public function registerQueryBuilderCallbacks(QueryBuilder $queryBuilder); +} diff --git a/vendor/ipl/sql/src/Contract/Quoter.php b/vendor/ipl/sql/src/Contract/Quoter.php new file mode 100644 index 0000000..79c4c78 --- /dev/null +++ b/vendor/ipl/sql/src/Contract/Quoter.php @@ -0,0 +1,21 @@ +<?php + +namespace ipl\Sql\Contract; + +interface Quoter +{ + /** + * Quote an identifier so that it can be safely used as table or column name, even if it is a reserved name + * + * If a string is passed that contains dots, the parts separated by them are quoted individually. + * (e.g. `myschema.mytable` turns into `"myschema"."mytable"`) If an array is passed, the entries + * are quoted as-is. (e.g. `[myschema.my, table]` turns into `"myschema.my"."table"`) + * + * The quote character depends on the underlying database adapter that is being used. + * + * @param string|string[] $identifiers + * + * @return string + */ + public function quoteIdentifier($identifiers); +} diff --git a/vendor/ipl/sql/src/Cursor.php b/vendor/ipl/sql/src/Cursor.php new file mode 100644 index 0000000..85c5b1c --- /dev/null +++ b/vendor/ipl/sql/src/Cursor.php @@ -0,0 +1,106 @@ +<?php + +namespace ipl\Sql; + +use ipl\Stdlib\Contract\Paginatable; +use IteratorAggregate; +use Traversable; + +/** + * Cursor for ipl SQL queries + */ +class Cursor implements IteratorAggregate, Paginatable +{ + /** @var Connection */ + protected $db; + + /** @var Select */ + protected $select; + + /** @var array */ + protected $fetchModeAndArgs = []; + + /** + * Create a new cursor for the given connection and query + * + * @param Connection $db + * @param Select $select + */ + public function __construct(Connection $db, Select $select) + { + $this->db = $db; + $this->select = $select; + } + + /** + * Get the fetch mode + * + * @return array + */ + public function getFetchMode() + { + return $this->fetchModeAndArgs; + } + + /** + * Set the fetch mode + * + * @param int $fetchMode Fetch mode as one of the PDO fetch mode constants. + * Please see {@link https://www.php.net/manual/en/pdostatement.setfetchmode} for details + * @param mixed ...$args Fetch mode arguments + * + * @return $this + */ + public function setFetchMode($fetchMode, ...$args) + { + array_unshift($args, $fetchMode); + + $this->fetchModeAndArgs = $args; + + return $this; + } + + public function getIterator(): Traversable + { + return $this->db->yieldAll($this->select, ...$this->getFetchMode()); + } + + public function hasLimit() + { + return $this->select->hasLimit(); + } + + public function getLimit() + { + return $this->select->getLimit(); + } + + public function limit($limit) + { + $this->select->limit($limit); + + return $this; + } + + public function hasOffset() + { + return $this->select->hasOffset(); + } + + public function getOffset() + { + return $this->select->getOffset(); + } + + public function offset($offset) + { + $this->select->offset($offset); + + return $this; + } + + public function count(): int + { + return $this->db->select($this->select->getCountQuery())->fetchColumn(0); + } +} diff --git a/vendor/ipl/sql/src/Delete.php b/vendor/ipl/sql/src/Delete.php new file mode 100644 index 0000000..53736b8 --- /dev/null +++ b/vendor/ipl/sql/src/Delete.php @@ -0,0 +1,52 @@ +<?php + +namespace ipl\Sql; + +/** + * SQL DELETE query + */ +class Delete implements CommonTableExpressionInterface, WhereInterface +{ + use CommonTableExpression; + use Where; + + /** @var array|null The FROM part of the DELETE query */ + protected $from; + + /** + * Get the FROM part of the DELETE query + * + * @return array|null + */ + public function getFrom() + { + return $this->from; + } + + /** + * Set the FROM part of the DELETE query + * + * Note that this method does NOT quote the table you specify for the DELETE FROM. + * If you allow user input here, you must protected yourself against SQL injection using + * {@link Connection::quoteIdentifier()} for the table names passed to this method. + * If you are using special table names, e.g. reserved keywords for your DBMS, you are required to use + * {@link Connection::quoteIdentifier()} as well. + * + * @param string|array $table The table to delete data from. The table specification must be in one of the + * following formats: 'table', 'table alias', ['alias' => 'table'] + * + * @return $this + */ + public function from($table) + { + $this->from = ! is_array($table) ? [$table] : $table; + + return $this; + } + + public function __clone() + { + $this->cloneCte(); + $this->cloneWhere(); + } +} diff --git a/vendor/ipl/sql/src/Expression.php b/vendor/ipl/sql/src/Expression.php new file mode 100644 index 0000000..cd508ac --- /dev/null +++ b/vendor/ipl/sql/src/Expression.php @@ -0,0 +1,52 @@ +<?php + +namespace ipl\Sql; + +/** + * A database expression that does need quoting or escaping, e.g. new Expression('NOW()'); + */ +class Expression implements ExpressionInterface +{ + /** @var string The statement of the expression */ + protected $statement; + + /** @var array The columns used by the expression */ + protected $columns; + + /** @var array The values for the expression */ + protected $values; + + /** + * Create a new database expression + * + * @param string $statement The statement of the expression + * @param array $columns The columns used by the expression + * @param mixed ...$values The values for the expression + */ + public function __construct($statement, array $columns = null, ...$values) + { + $this->statement = $statement; + $this->columns = $columns; + $this->values = $values; + } + + public function getStatement() + { + return $this->statement; + } + + public function getColumns() + { + return $this->columns ?: []; + } + + public function setColumns(array $columns) + { + $this->columns = $columns; + } + + public function getValues() + { + return $this->values; + } +} diff --git a/vendor/ipl/sql/src/ExpressionInterface.php b/vendor/ipl/sql/src/ExpressionInterface.php new file mode 100644 index 0000000..9ebe5ee --- /dev/null +++ b/vendor/ipl/sql/src/ExpressionInterface.php @@ -0,0 +1,39 @@ +<?php + +namespace ipl\Sql; + +/** + * Interface for database expressions that do need quoting or escaping, e.g. new Expression('NOW()'); + */ +interface ExpressionInterface +{ + /** + * Get the statement of the expression + * + * @return string + */ + public function getStatement(); + + /** + * Get the columns used by the expression + * + * @return array + */ + public function getColumns(); + + /** + * Set the columns to use by the expression + * + * @param array $columns + * + * @return $this + */ + public function setColumns(array $columns); + + /** + * Get the values for the expression + * + * @return array + */ + public function getValues(); +} diff --git a/vendor/ipl/sql/src/Filter/Exists.php b/vendor/ipl/sql/src/Filter/Exists.php new file mode 100644 index 0000000..e1951d0 --- /dev/null +++ b/vendor/ipl/sql/src/Filter/Exists.php @@ -0,0 +1,14 @@ +<?php + +namespace ipl\Sql\Filter; + +use ipl\Sql\Select; +use ipl\Stdlib\Filter; + +class Exists extends Filter\Condition +{ + public function __construct(Select $select) + { + parent::__construct('', $select); + } +} diff --git a/vendor/ipl/sql/src/Filter/NotExists.php b/vendor/ipl/sql/src/Filter/NotExists.php new file mode 100644 index 0000000..bb8be35 --- /dev/null +++ b/vendor/ipl/sql/src/Filter/NotExists.php @@ -0,0 +1,14 @@ +<?php + +namespace ipl\Sql\Filter; + +use ipl\Sql\Select; +use ipl\Stdlib\Filter; + +class NotExists extends Filter\Condition +{ + public function __construct(Select $select) + { + parent::__construct('', $select); + } +} diff --git a/vendor/ipl/sql/src/Insert.php b/vendor/ipl/sql/src/Insert.php new file mode 100644 index 0000000..738a842 --- /dev/null +++ b/vendor/ipl/sql/src/Insert.php @@ -0,0 +1,172 @@ +<?php + +namespace ipl\Sql; + +use InvalidArgumentException; + +use function ipl\Stdlib\arrayval; + +/** + * SQL INSERT query + */ +class Insert implements CommonTableExpressionInterface +{ + use CommonTableExpression; + + /** @var string|null The table for the INSERT INTO query */ + protected $into; + + /** @var array|null The columns for which the query provides values */ + protected $columns; + + /** @var array|null The values to insert */ + protected $values; + + /** @var Select|null The select query for INSERT INTO ... SELECT queries */ + protected $select; + + /** + * Get the table for the INSERT INTo query + * + * @return string|null + */ + public function getInto() + { + return $this->into; + } + + /** + * Set the table for the INSERT INTO query + * + * Note that this method does NOT quote the table you specify for the INSERT INTO. + * If you allow user input here, you must protected yourself against SQL injection using + * {@link Connection::quoteIdentifier()} for the table name passed to this method. + * If you are using special table names, e.g. reserved keywords for your DBMS, you are required to use + * {@link Connection::quoteIdentifier()} as well. + * + * @param string $table The table to insert data into. The table specification must be in one of the following + * formats: 'table' or 'schema.table' + * + * @return $this + */ + public function into($table) + { + $this->into = $table; + + return $this; + } + + /** + * Get the columns for which the statement provides values + * + * @return array + */ + public function getColumns() + { + if (! empty($this->columns)) { + return array_keys($this->columns); + } + + if (! empty($this->values)) { + return array_keys($this->values); + } + + return []; + } + + /** + * Set the columns for which the query provides values + * + * Note that this method does NOT quote the columns you specify for the INSERT INTO. + * If you allow user input here, you must protected yourself against SQL injection using + * {@link Connection::quoteIdentifier()} for the column names passed to this method. + * If you are using special column names, e.g. reserved keywords for your DBMS, you are required to use + * {@link Connection::quoteIdentifier()} as well. + * + * If you do not set the columns for which the query provides values using this method, you must pass the values to + * {@link values()} in terms of column-value pairs in order to provide the column names. + * + * @param array $columns + * + * @return $this + */ + public function columns(array $columns) + { + $this->columns = array_flip($columns); + + return $this; + } + + /** + * Get the values to insert + * + * @return array + */ + public function getValues() + { + return array_values($this->values ?: []); + } + + /** + * Set the values to INSERT INTO - either plain values or expressions or scalar subqueries + * + * If you do not set the columns for which the query provides values using {@link columns()}, you must specify + * the values in terms of column-value pairs in order to provide the column names. Please note that the same + * restriction regarding quoting applies here. If you use {@link columns()} to set the columns and specify the + * values in terms of column-value pairs, the columns from {@link columns()} will be used nonetheless. + * + * @param iterable $values List of values or associative set of column-value pairs + * + * @return $this + * + * @throws InvalidArgumentException If values type is invalid + */ + public function values($values) + { + $this->values = arrayval($values); + + return $this; + } + + /** + * Create a INSERT INTO ... SELECT statement + * + * @param Select $select + * + * @return $this + */ + public function select(Select $select) + { + $this->select = $select; + + return $this; + } + + /** + * Get the select query for the INSERT INTO ... SELECT statement + * + * @return Select|null + */ + public function getSelect() + { + return $this->select; + } + + public function __clone() + { + $this->cloneCte(); + + if ($this->values !== null) { + foreach ($this->values as &$value) { + if ($value instanceof ExpressionInterface || $value instanceof Select) { + $value = clone $value; + } + } + unset($value); + } + + if ($this->select !== null) { + $this->select = clone $this->select; + } + } +} diff --git a/vendor/ipl/sql/src/LimitOffset.php b/vendor/ipl/sql/src/LimitOffset.php new file mode 100644 index 0000000..99c30a2 --- /dev/null +++ b/vendor/ipl/sql/src/LimitOffset.php @@ -0,0 +1,89 @@ +<?php + +namespace ipl\Sql; + +/** + * Implementation for the {@link LimitOffsetInterface} to allow pagination via {@link limit()} and {@link offset()} + */ +trait LimitOffset +{ + /** + * The maximum number of how many items to return + * + * If unset or lower than 0, no limit will be applied. + * + * @var int|null + */ + protected $limit; + + /** + * Offset from where to start the result set + * + * If unset or lower than 0, the result set will start from the beginning. + * + * @var int|null + */ + protected $offset; + + public function hasLimit() + { + return $this->limit !== null; + } + + public function getLimit() + { + return $this->limit; + } + + public function limit($limit) + { + if ($limit !== null) { + $limit = (int) $limit; + if ($limit < 0) { + $limit = null; + } + } + + $this->limit = $limit; + + return $this; + } + + public function resetLimit() + { + $this->limit = null; + + return $this; + } + + public function hasOffset() + { + return $this->offset !== null; + } + + public function getOffset() + { + return $this->offset; + } + + public function offset($offset) + { + if ($offset !== null) { + $offset = (int) $offset; + if ($offset <= 0) { + $offset = null; + } + } + + $this->offset = $offset; + + return $this; + } + + public function resetOffset() + { + $this->offset = null; + + return $this; + } +} diff --git a/vendor/ipl/sql/src/LimitOffsetInterface.php b/vendor/ipl/sql/src/LimitOffsetInterface.php new file mode 100644 index 0000000..94628c4 --- /dev/null +++ b/vendor/ipl/sql/src/LimitOffsetInterface.php @@ -0,0 +1,71 @@ +<?php + +namespace ipl\Sql; + +/** + * Interface for pagination via {@link limit()} and {@link offset()} + */ +interface LimitOffsetInterface +{ + /** + * Get whether a limit is configured + * + * @return bool + */ + public function hasLimit(); + + /** + * Get the limit + * + * @return int|null + */ + public function getLimit(); + + /** + * Set the limit + * + * @param int|null $limit Maximum number of items to return. + * If you want to disable the limit, use null or a negative value + * + * @return $this + */ + public function limit($limit); + + /** + * Reset the limit + * + * @return $this + */ + public function resetLimit(); + + /** + * Get whether an offset is configured + * + * @return bool + */ + public function hasOffset(); + + /** + * Get the offset + * + * @return int|null + */ + public function getOffset(); + + /** + * Set the offset + * + * @param int|null $offset Start result set after this many rows. + * If you want to disable the offset, use null, 0, or a negative value + * + * @return $this + */ + public function offset($offset); + + /** + * Reset the offset + * + * @return $this + */ + public function resetOffset(); +} diff --git a/vendor/ipl/sql/src/OrderBy.php b/vendor/ipl/sql/src/OrderBy.php new file mode 100644 index 0000000..0721ad5 --- /dev/null +++ b/vendor/ipl/sql/src/OrderBy.php @@ -0,0 +1,74 @@ +<?php + +namespace ipl\Sql; + +/** + * Trait for the ORDER BY part of a query + */ +trait OrderBy +{ + /** @var array ORDER BY part of the query */ + protected $orderBy; + + public function hasOrderBy() + { + return $this->orderBy !== null; + } + + public function getOrderBy() + { + return $this->orderBy; + } + + public function orderBy($orderBy, $direction = null) + { + if (! is_array($orderBy)) { + $orderBy = [$orderBy]; + } + + foreach ($orderBy as $column => $dir) { + if (is_int($column)) { + $column = $dir; + $dir = $direction; + } + + if (is_array($column) && count($column) === 2) { + list($column, $dir) = $column; + } + + if ($dir === SORT_ASC) { + $dir = 'ASC'; + } elseif ($dir === SORT_DESC) { + $dir = 'DESC'; + } + + $this->orderBy[] = [$column, $dir]; + } + + return $this; + } + + public function resetOrderBy() + { + $this->orderBy = null; + + return $this; + } + + /** + * Clone the properties provided by this trait + * + * Shall be called by using classes in their __clone() + */ + protected function cloneOrderBy() + { + if ($this->orderBy !== null) { + foreach ($this->orderBy as &$orderBy) { + if ($orderBy[0] instanceof ExpressionInterface || $orderBy[0] instanceof Select) { + $orderBy[0] = clone $orderBy[0]; + } + } + unset($orderBy); + } + } +} diff --git a/vendor/ipl/sql/src/OrderByInterface.php b/vendor/ipl/sql/src/OrderByInterface.php new file mode 100644 index 0000000..9cce3d0 --- /dev/null +++ b/vendor/ipl/sql/src/OrderByInterface.php @@ -0,0 +1,51 @@ +<?php + +namespace ipl\Sql; + +/** + * Interface for the ORDER BY part of a query + */ +interface OrderByInterface +{ + /** + * Get whether a ORDER BY part is configured + * + * @return bool + */ + public function hasOrderBy(); + + /** + * Get the ORDER BY part of the query + * + * @return array|null + */ + public function getOrderBy(); + + /** + * Set the ORDER BY part of the query - either plain columns or expressions or scalar subqueries + * + * Note that this method does not override an already set ORDER BY part. Instead, each call to this function + * appends the specified ORDER BY part to an already existing one. + * + * This method does NOT quote the columns you specify for the ORDER BY. + * If you allow user input here, you must protected yourself against SQL injection using + * {@link Connection::quoteIdentifier()} for the field names passed to this method. + * If you are using special field names, e.g. reserved keywords for your DBMS, you are required to use + * {@link Connection::quoteIdentifier()} as well. + * + * @param string|array $orderBy The ORDER BY part. The items can be in any format of the following: + * ['column', 'column' => 'DESC', 'column' => SORT_DESC, ['column', 'DESC']] + * @param string|int $direction The default direction. Can be any of the following: + * 'ASC', 'DESC', SORT_ASC, SORT_DESC + * + * @return $this + */ + public function orderBy($orderBy, $direction = null); + + /** + * Reset the ORDER BY part of the query + * + * @return $this + */ + public function resetOrderBy(); +} diff --git a/vendor/ipl/sql/src/QueryBuilder.php b/vendor/ipl/sql/src/QueryBuilder.php new file mode 100644 index 0000000..3a551d2 --- /dev/null +++ b/vendor/ipl/sql/src/QueryBuilder.php @@ -0,0 +1,910 @@ +<?php + +namespace ipl\Sql; + +use InvalidArgumentException; +use ipl\Sql\Adapter\Mssql; +use ipl\Sql\Contract\Adapter; +use ipl\Stdlib\Events; + +use function ipl\Stdlib\get_php_type; + +class QueryBuilder +{ + use Events; + + /** + * Event raised when a {@link Select} object is assembled into a SQL statement string + * + * The {@link Select} object is passed as parameter to the event callbacks. + * + * **Example usage:** + * + * ``` + * $queryBuilder->on(QueryBuilder::ON_ASSEMBLE_SELECT, function (Select $select) { + * // ... + * }); + * ``` + */ + const ON_ASSEMBLE_SELECT = 'assembleSelect'; + + /** + * Event raised after a {@link Select} object is assembled into a SQL statement string + * + * The assembled SQL statement string and the values to bind to the statement are passed as parameters by reference + * to the event callbacks. + * + * **Example usage:** + * + * ``` + * $queryBuilder->on(QueryBuilder::ON_SELECT_ASSEMBLED, function (&$sql, &$values) { + * // ... + * }); + * ``` + */ + const ON_SELECT_ASSEMBLED = 'selectAssembled'; + + /** + * Event raised before an {@see Insert} object is assembled into a SQL statement string + * + * **Example usage:** + * + * ``` + * $queryBuilder->on(QueryBuilder::ON_ASSEMBLE_INSERT, function (Insert $insert) { + * // ... + * }); + * ``` + * + * @var string + */ + const ON_ASSEMBLE_INSERT = 'assembleInsert'; + + /** + * Event raised after an {@see Insert} object is assembled into a SQL statement string + * + * The assembled SQL statement string and the prepared values are passed by reference to the event callbacks + * + * **Example usage:** + * + * ``` + * $queryBuilder->on(QueryBuilder::ON_INSERT_ASSEMBLED, function (&$sql, &$values) { + * // ... + * }); + * ``` + * + * @var string + */ + const ON_INSERT_ASSEMBLED = 'insertAssembled'; + + /** + * Event raised before an {@see Update} object is assembled into a SQL statement string + * + * **Example usage:** + * + * ``` + * $queryBuilder->on(QueryBuilder::ON_ASSEMBLE_UPDATE, function (Update $update) { + * // ... + * }); + * ``` + * + * @var string + */ + const ON_ASSEMBLE_UPDATE = 'assembleUpdate'; + + /** + * Event raised after an {@see Update} object is assembled into a SQL statement string + * + * The assembled SQL statement string and the prepared values are passed by reference to the event callbacks + * + * **Example usage:** + * + * ``` + * $queryBuilder->on(QueryBuilder::ON_UPDATE_ASSEMBLED, function (&$sql, &$values) { + * // ... + * }); + * ``` + * + * @var string + */ + const ON_UPDATE_ASSEMBLED = 'updateAssembled'; + + /** + * Event raised before a {@see Delete} object is assembled into a SQL statement string + * + * **Example usage:** + * + * ``` + * $queryBuilder->on(QueryBuilder::ON_ASSEMBLE_DELETE, function (Delete $delete) { + * // ... + * }); + * ``` + * + * @var string + */ + const ON_ASSEMBLE_DELETE = 'assembleDelete'; + + /** + * Event raised after a {@see Delete} object is assembled into a SQL statement string + * + * The assembled SQL statement string and the prepared values are passed by reference to the event callbacks + * + * **Example usage:** + * + * ``` + * $queryBuilder->on(QueryBuilder::ON_DELETE_ASSEMBLED, function (&$sql, &$values) { + * // ... + * }); + * ``` + * + * @var string + */ + const ON_DELETE_ASSEMBLED = 'deleteAssembled'; + + /** @var Adapter */ + protected $adapter; + + protected $separator = " "; + + /** + * Create a new query builder for the specified database adapter + * + * @param Adapter $adapter + */ + public function __construct(Adapter $adapter) + { + $adapter->registerQueryBuilderCallbacks($this); + + $this->adapter = $adapter; + } + + /** + * Assemble the given statement + * + * @param Delete|Insert|Select|Update $stmt + * + * @return array + * + * @throw InvalidArgumentException If statement type is invalid + */ + public function assemble($stmt) + { + switch (true) { + case $stmt instanceof Delete: + return $this->assembleDelete($stmt); + case $stmt instanceof Insert: + return $this->assembleInsert($stmt); + case $stmt instanceof Select: + return $this->assembleSelect($stmt); + case $stmt instanceof Update: + return $this->assembleUpdate($stmt); + default: + throw new InvalidArgumentException(sprintf( + __METHOD__ . ' expects instances of Delete, Insert, Select or Update. Got %s instead.', + get_php_type($stmt) + )); + } + } + + /** + * Assemble a DELETE query + * + * @param Delete $delete + * + * @return array + */ + public function assembleDelete(Delete $delete) + { + $values = []; + + $this->emit(self::ON_ASSEMBLE_DELETE, [$delete]); + + $sql = array_filter([ + $this->buildWith($delete->getWith(), $values), + $this->buildDeleteFrom($delete->getFrom()), + $this->buildWhere($delete->getWhere(), $values) + ]); + + $sql = implode($this->separator, $sql); + + $this->emit(static::ON_DELETE_ASSEMBLED, [&$sql, &$values]); + + return [$sql, $values]; + } + + /** + * Assemble a INSERT statement + * + * @param Insert $insert + * + * @return array + */ + public function assembleInsert(Insert $insert) + { + $values = []; + + $this->emit(static::ON_ASSEMBLE_INSERT, [$insert]); + + $select = $insert->getSelect(); + + $sql = array_filter([ + $this->buildWith($insert->getWith(), $values), + $this->buildInsertInto($insert->getInto()), + $select + ? $this->buildInsertIntoSelect($insert->getColumns(), $select, $values) + : $this->buildInsertColumnsAndValues($insert->getColumns(), $insert->getValues(), $values) + ]); + + $sql = implode($this->separator, $sql); + + $this->emit(static::ON_INSERT_ASSEMBLED, [&$sql, &$values]); + + return [$sql, $values]; + } + + /** + * Assemble a SELECT query + * + * @param Select $select + * @param array $values + * + * @return array + */ + public function assembleSelect(Select $select, array &$values = []) + { + $select = clone $select; + + $this->emit(static::ON_ASSEMBLE_SELECT, [$select]); + + $sql = array_filter([ + $this->buildWith($select->getWith(), $values), + $this->buildSelect($select->getColumns(), $select->getDistinct(), $values), + $this->buildFrom($select->getFrom(), $values), + $this->buildJoin($select->getJoin(), $values), + $this->buildWhere($select->getWhere(), $values), + $this->buildGroupBy($select->getGroupBy(), $values), + $this->buildHaving($select->getHaving(), $values), + $this->buildOrderBy($select->getOrderBy(), $values), + $this->buildLimitOffset($select->getLimit(), $select->getOffset()) + ]); + + $sql = implode($this->separator, $sql); + + $unions = $this->buildUnions($select->getUnion(), $values); + if ($unions) { + list($unionKeywords, $selects) = $unions; + + if ($sql) { + $sql = "($sql)"; + + $requiresUnionKeyword = true; + } else { + $requiresUnionKeyword = false; + } + + do { + $unionKeyword = array_shift($unionKeywords); + $select = array_shift($selects); + + if ($requiresUnionKeyword) { + $sql .= "{$this->separator}$unionKeyword{$this->separator}"; + } + + $sql .= "($select)"; + + $requiresUnionKeyword = true; + } while (! empty($unionKeywords)); + } + + $this->emit(static::ON_SELECT_ASSEMBLED, [&$sql, &$values]); + + return [$sql, $values]; + } + + /** + * Assemble a UPDATE query + * + * @param Update $update + * + * @return array + */ + public function assembleUpdate(Update $update) + { + $values = []; + + $this->emit(self::ON_ASSEMBLE_UPDATE, [$update]); + + $sql = array_filter([ + $this->buildWith($update->getWith(), $values), + $this->buildUpdateTable($update->getTable()), + $this->buildUpdateSet($update->getSet(), $values), + $this->buildWhere($update->getWhere(), $values) + ]); + + $sql = implode($this->separator, $sql); + + $this->emit(static::ON_UPDATE_ASSEMBLED, [&$sql, &$values]); + + return [$sql, $values]; + } + + /** + * Build the WITH part of a query + * + * @param array $with + * @param array $values + * + * @return string The WITH part of a query + */ + public function buildWith(array $with, array &$values) + { + if (empty($with)) { + return ''; + } + + $ctes = []; + $hasRecursive = false; + + foreach ($with as $cte) { + list($query, $alias, $recursive) = $cte; + list($cteSql, $cteValues) = $this->assembleSelect($query); + + $ctes[] = "$alias AS ($cteSql)"; + + $values = array_merge($values, $cteValues); + $hasRecursive |= $recursive; + } + + return ($hasRecursive ? 'WITH RECURSIVE ' : 'WITH ') . implode(', ', $ctes); + } + + /** + * Build the DELETE FROM part of a query + * + * @param array $from + * + * @return string The DELETE FROM part of a query + */ + public function buildDeleteFrom(array $from = null) + { + if ($from === null) { + return ''; + } + + $deleteFrom = 'DELETE FROM'; + + reset($from); + $alias = key($from); + $table = current($from); + + if (is_int($alias)) { + $deleteFrom .= " $table"; + } else { + $deleteFrom .= " $table $alias"; + } + + return $deleteFrom; + } + + /** + * Outsourced logic of {@link buildCondition()} + * + * @param string $expression + * @param array $values + * + * @return array + */ + public function unpackCondition($expression, array $values) + { + $placeholders = preg_match_all('/(\?)/', $expression, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER); + + if ($placeholders === 0) { + return [$expression, []]; + } + + if ($placeholders === 1) { + $offset = $matches[0][1][1]; + $expression = substr($expression, 0, $offset) + . implode(', ', array_fill(0, count($values), '?')) + . substr($expression, $offset + 1); + + return [$expression, $values]; + } + + $unpackedExpression = []; + $unpackedValues = []; + $offset = 0; + + foreach ($matches as $match) { + $value = array_shift($values); + $unpackedExpression[] = substr($expression, $offset, $match[1][1] - $offset); + if (is_array($value)) { + $unpackedExpression[] = implode(', ', array_fill(0, count($value), '?')); + $unpackedValues = array_merge($unpackedValues, $value); + } else { + $unpackedExpression[] = '?'; + $unpackedValues[] = $value; + } + $offset = $match[1][1] + 1; // 1 is the length of '?' + } + + $unpackedExpression[] = substr($expression, $offset); + + return [implode('', array_filter($unpackedExpression)), $unpackedValues]; + } + + /** + * Outsourced logic {@link buildWhere()} and {@link buildHaving()} have in common + * + * @param array $condition + * @param array $values + * + * @return string + */ + public function buildCondition(array $condition, array &$values) + { + $sql = []; + + $operator = array_shift($condition); + $conditions = array_shift($condition); + + foreach ($conditions as $expression => $value) { + if (is_array($value)) { + if (is_int($expression)) { + // Operator format + $sql[] = $this->buildCondition($value, $values); + } else { + list($unpackedExpression, $unpackedValues) = $this->unpackCondition($expression, $value); + $sql[] = $unpackedExpression; + $values = array_merge($values, $unpackedValues); + } + } else { + if ($value instanceof ExpressionInterface) { + $sql[] = $this->buildExpression($value, $values); + } elseif ($value instanceof Select) { + $stmt = '(' . $this->assembleSelect($value, $values)[0] . ')'; + if (is_int($expression)) { + $sql[] = $stmt; + } else { + $sql[] = str_replace('?', $stmt, $expression); + } + } elseif (is_int($expression)) { + $sql[] = $value; + } else { + $sql[] = $expression; + $values[] = $value; + } + } + } + + if ($operator === Sql::NOT_ALL || $operator === Sql::NOT_ANY) { + return 'NOT (' . implode(") $operator (", $sql) . ')'; + } + + return count($sql) === 1 ? $sql[0] : '(' . implode(") $operator (", $sql) . ')'; + } + + /** + * Build the WHERE part of a query + * + * @param array $where + * @oaram array $values + * + * @return string The WHERE part of the query + */ + public function buildWhere(array $where = null, array &$values = []) + { + if ($where === null) { + return ''; + } + + return 'WHERE ' . $this->buildCondition($where, $values); + } + + /** + * Build the INSERT INTO part of a INSERT INTO ... statement + * + * @param string|null $into + * + * @return string The INSERT INTO part of a INSERT INTO ... statement + */ + public function buildInsertInto($into) + { + if (empty($into)) { + return ''; + } + + return "INSERT INTO $into"; + } + + /** + * Build the columns and SELECT part of a INSERT INTO ... SELECT statement + * + * @param array $columns + * @param Select $select + * @param array $values + * + * @return string The columns and SELECT part of the INSERT INTO ... SELECT statement + */ + public function buildInsertIntoSelect(array $columns, Select $select, array &$values) + { + $sql = [ + '(' . implode(',', $columns) . ')', + $this->assembleSelect($select, $values)[0] + ]; + + return implode($this->separator, $sql); + } + + /** + * Build the columns and values part of a INSERT INTO ... statement + * + * @param array $columns + * @param array $insertValues + * @param array $values + * + * @return string The columns and values part of a INSERT INTO ... statement + */ + public function buildInsertColumnsAndValues(array $columns, array $insertValues, array &$values) + { + $sql = ['(' . implode(',', $columns) . ')']; + + $preparedValues = []; + + foreach ($insertValues as $value) { + if ($value instanceof ExpressionInterface) { + $preparedValues[] = $this->buildExpression($value, $values); + } elseif ($value instanceof Select) { + $preparedValues[] = "({$this->assembleSelect($value, $values)[0]})"; + } else { + $preparedValues[] = '?'; + $values[] = $value; + } + } + + $sql[] = 'VALUES(' . implode(',', $preparedValues) . ')'; + + return implode($this->separator, $sql); + } + + /** + * Build the SELECT part of a query + * + * @param array $columns + * @param bool $distinct + * @param array $values + * + * @return string The SELECT part of the query + */ + public function buildSelect(array $columns, $distinct, array &$values) + { + if (empty($columns)) { + return ''; + } + + $select = 'SELECT'; + + if ($distinct) { + $select .= ' DISTINCT'; + } + + if (empty($columns)) { + return "$select *"; + } + + $sql = []; + + foreach ($columns as $alias => $column) { + if ($column instanceof ExpressionInterface) { + $column = "({$this->buildExpression($column, $values)})"; + } elseif ($column instanceof Select) { + $column = "({$this->assembleSelect($column, $values)[0]})"; + } + + if (is_int($alias)) { + $sql[] = $column; + } else { + $sql[] = "$column AS $alias"; + } + } + + return "$select " . implode(', ', $sql); + } + + /** + * Build the FROM part of a query + * + * @param array $from + * @param array $values + * + * @return string The FROM part of the query + */ + public function buildFrom(array $from = null, array &$values = []) + { + if ($from === null) { + return ''; + } + + $sql = []; + + foreach ($from as $alias => $table) { + if ($table instanceof Select) { + $table = "({$this->assembleSelect($table, $values)[0]})"; + } + + if (is_int($alias) || $alias === $table) { + $sql[] = $table; + } else { + $sql[] = "$table $alias"; + } + } + + return 'FROM ' . implode(', ', $sql); + } + + /** + * Build the JOIN part(s) of a query + * + * @param array $joins + * @oaram array $values + * + * @return string The JOIN part(s) of the query + */ + public function buildJoin($joins, array &$values) + { + if ($joins === null) { + return ''; + } + + $sql = []; + foreach ($joins as $join) { + list($joinType, $table, $condition) = $join; + + if (is_array($table)) { + foreach ($table as $alias => $tableName) { + break; + } + } else { + $alias = null; + $tableName = $table; + } + + if ($tableName instanceof Select) { + $tableName = "({$this->assembleSelect($tableName, $values)[0]})"; + } + + if (is_array($condition)) { + $condition = $this->buildCondition($condition, $values); + } + + if (empty($alias) || $alias === $tableName) { + $sql[] = "$joinType JOIN $tableName ON $condition"; + } else { + $sql[] = "$joinType JOIN $tableName $alias ON $condition"; + } + } + + return implode($this->separator, $sql); + } + + /** + * Build the GROUP BY part of a query + * + * @param array $groupBy + * @param array $values + * + * @return string The GROUP BY part of the query + */ + public function buildGroupBy(array $groupBy = null, array &$values = []) + { + if ($groupBy === null) { + return ''; + } + + foreach ($groupBy as &$column) { + if ($column instanceof ExpressionInterface) { + $column = $this->buildExpression($column, $values); + } elseif ($column instanceof Select) { + $column = "({$this->assembleSelect($column, $values)[0]})"; + } + } + + return 'GROUP BY ' . implode(', ', $groupBy); + } + + /** + * Build the HAVING part of a query + * + * @param array $having + * @param array $values + * + * @return string The HAVING part of the query + */ + public function buildHaving(array $having = null, array &$values = []) + { + if ($having === null) { + return ''; + } + + return 'HAVING ' . $this->buildCondition($having, $values); + } + + /** + * Build the ORDER BY part of a query + * + * @param array $orderBy + * @param array $values + * + * @return string The ORDER BY part of the query + */ + public function buildOrderBy(array $orderBy = null, array &$values = []) + { + if ($orderBy === null) { + return ''; + } + + $sql = []; + + foreach ($orderBy as $column) { + list($column, $direction) = $column; + + if ($column instanceof ExpressionInterface) { + $column = $this->buildExpression($column, $values); + } elseif ($column instanceof Select) { + $column = "({$this->assembleSelect($column, $values)[0]})"; + } + + if ($direction !== null) { + $sql[] = "$column $direction"; + } else { + $sql[] = $column; + } + } + + return 'ORDER BY ' . implode(', ', $sql); + } + + /** + * Build the LIMIT and OFFSET part of a query + * + * @param int $limit + * @param int $offset + * + * @return string The LIMIT and OFFSET part of the query + */ + public function buildLimitOffset($limit = null, $offset = null) + { + $sql = []; + + if ($this->adapter instanceof Mssql) { + if ($offset !== null || $limit !== null) { + // If offset is null, sprintf will convert it to 0 + $sql[] = sprintf('OFFSET %d ROWS', $offset); + } + + if ($limit !== null) { + // FETCH FIRST n ROWS ONLY for OFFSET 0 would be an alternative here + $sql[] = "FETCH NEXT $limit ROWS ONLY"; + } + } else { + if ($limit !== null) { + $sql[] = "LIMIT $limit"; + } + + if ($offset !== null) { + $sql[] = "OFFSET $offset"; + } + } + + return implode($this->separator, $sql); + } + + /** + * Build the UNION parts of a query + * + * @param array $unions + * @param array $values + * + * @return array|null The UNION parts of the query + */ + public function buildUnions(array $unions = null, array &$values = []) + { + if ($unions === null) { + return null; + } + + $unionKeywords = []; + $selects = []; + + foreach ($unions as $union) { + list($select, $all) = $union; + + if ($select instanceof Select) { + list($select, $values) = $this->assembleSelect($select, $values); + } + + $unionKeywords[] = ($all ? 'UNION ALL' : 'UNION'); + $selects[] = $select; + } + + return [$unionKeywords, $selects]; + } + + /** + * Build the UPDATE {table} part of a query + * + * @param array $updateTable The table to UPDATE + * + * @return string The UPDATE {table} part of the query + */ + public function buildUpdateTable(array $updateTable = null) + { + if ($updateTable === null) { + return ''; + } + + $update = 'UPDATE'; + + reset($updateTable); + $alias = key($updateTable); + $table = current($updateTable); + + if (is_int($alias)) { + $update .= " $table"; + } else { + $update .= " $table $alias"; + } + + return $update; + } + + /** + * Build the SET part of a UPDATE query + * + * @param array $set + * @param array $values + * + * @return string The SET part of a UPDATE query + */ + public function buildUpdateSet(array $set = null, array &$values = []) + { + if (empty($set)) { + return ''; + } + + $sql = []; + + foreach ($set as $column => $value) { + if ($value instanceof ExpressionInterface) { + $sql[] = "$column = {$this->buildExpression($value, $values)}"; + } elseif ($value instanceof Select) { + $sql[] = "$column = ({$this->assembleSelect($value, $values)[0]})"; + } else { + $sql[] = "$column = ?"; + $values[] = $value; + } + } + + return 'SET ' . implode(', ', $sql); + } + + /** + * Build expression + * + * @param ExpressionInterface $expression + * @param array $values + * + * @return string The expression's statement + */ + public function buildExpression(ExpressionInterface $expression, array &$values = []) + { + $stmt = $expression->getStatement(); + $columns = $expression->getColumns(); + if (! empty($columns)) { + $stmt = vsprintf($stmt, $columns); + } + + $values = array_merge($values, $expression->getValues()); + + return $stmt; + } +} diff --git a/vendor/ipl/sql/src/Select.php b/vendor/ipl/sql/src/Select.php new file mode 100644 index 0000000..77a50ee --- /dev/null +++ b/vendor/ipl/sql/src/Select.php @@ -0,0 +1,562 @@ +<?php + +namespace ipl\Sql; + +/** + * SQL SELECT query + */ +class Select implements CommonTableExpressionInterface, LimitOffsetInterface, OrderByInterface, WhereInterface +{ + use CommonTableExpression; + use LimitOffset; + use OrderBy; + use Where; + + /** @var bool Whether the query is DISTINCT */ + protected $distinct = false; + + /** @var array|null The columns for the SELECT query */ + protected $columns; + + /** @var array|null FROM part of the query, i.e. the table names to select data from */ + protected $from; + + /** + * The tables to JOIN + * + * [ + * [ $joinType, $tableName, $condition ], + * ... + * ] + * + * @var array + */ + protected $join; + + /** @var array|null The columns for the GROUP BY part of the query */ + protected $groupBy; + + /** @var array|null Internal representation for the HAVING part of the query */ + protected $having; + + /** + * The queries to UNION + * + * [ + * [ new Select(), (bool) 'UNION ALL' ], + * ... + * ] + * + * @var array + */ + protected $union; + + /** + * Get whether to SELECT DISTINCT + * + * @return bool + */ + public function getDistinct() + { + return $this->distinct; + } + + /** + * Set whether to SELECT DISTINCT + * + * @param bool $distinct + * + * @return $this + */ + public function distinct($distinct = true) + { + $this->distinct = $distinct; + + return $this; + } + + /** + * Get the columns for the SELECT query + * + * @return array + */ + public function getColumns() + { + return $this->columns ?: []; + } + + /** + * Add columns to the SELECT query + * + * Multiple calls to this method will not overwrite the previous set columns but append the columns to the query. + * + * Note that this method does NOT quote the columns you specify for the SELECT. + * If you allow user input here, you must protected yourself against SQL injection using + * {@link Connection::quoteIdentifier()} for the column names passed to this method. + * If you are using special column names, e.g. reserved keywords for your DBMS, you are required to use + * {@link Connection::quoteIdentifier()} as well. + * + * @param string|ExpressionInterface|Select|array $columns The column(s) to add to the SELECT. + * The items can be any mix of the following: 'column', + * 'column as alias', ['alias' => 'column'] + * + * @return $this + */ + public function columns($columns) + { + if (! is_array($columns)) { + $columns = [$columns]; + } + + $this->columns = array_merge($this->columns ?: [], $columns); + + return $this; + } + + /** + * Get the FROM part of the query + * + * @return array|null + */ + public function getFrom() + { + return $this->from; + } + + /** + * Add a FROM part to the query + * + * Multiple calls to this method will not overwrite the previous set FROM part but append the tables to the FROM. + * + * Note that this method does NOT quote the tables you specify for the FROM. + * If you allow user input here, you must protected yourself against SQL injection using + * {@link Connection::quoteIdentifier()} for the table names passed to this method. + * If you are using special table names, e.g. reserved keywords for your DBMS, you are required to use + * {@link Connection::quoteIdentifier()} as well. + * + * @param string|Select|array $tables The table(s) to add to the FROM part. The items can be any mix of the + * following: ['table', 'table alias', 'alias' => 'table'] + * + * @return $this + */ + public function from($tables) + { + if (! is_array($tables)) { + $tables = [$tables]; + } + + $this->from = array_merge($this->from ?: [], $tables); + + return $this; + } + + /** + * Get the JOIN part(s) of the query + * + * @return array|null + */ + public function getJoin() + { + return $this->join; + } + + /** + * Add a INNER JOIN part to the query + * + * @param string|Select|array $table The table to be joined, can be any of the following: + * 'table' 'table alias' ['alias' => 'table'] + * @param string|ExpressionInterface|Select|array $condition The join condition, i.e. the ON part of the JOIN. + * Please see {@link WhereInterface::where()} + * for the supported formats and + * restrictions regarding quoting of the field names. + * @param string $operator The operator to combine multiple conditions with, + * if the condition is in the array format + * + * @return $this + */ + public function join($table, $condition, $operator = Sql::ALL) + { + $this->join[] = ['INNER', $table, $this->buildCondition($condition, $operator)]; + + return $this; + } + + /** + * Add a LEFT JOIN part to the query + * + * @param string|Select|array $table The table to be joined, can be any of the following: + * 'table' 'table alias' ['alias' => 'table'] + * @param string|ExpressionInterface|Select|array $condition The join condition, i.e. the ON part of the JOIN. + * Please see {@link WhereInterface::where()} + * for the supported formats and + * restrictions regarding quoting of the field names. + * @param string $operator The operator to combine multiple conditions with, + * if the condition is in the array format + * + * @return $this + */ + public function joinLeft($table, $condition, $operator = Sql::ALL) + { + $this->join[] = ['LEFT', $table, $this->buildCondition($condition, $operator)]; + + return $this; + } + + /** + * Add a RIGHT JOIN part to the query + * + * @param string|Select|array $table The table to be joined, can be any of the following: + * 'table' 'table alias' ['alias' => 'table'] + * @param string|ExpressionInterface|Select|array $condition The join condition, i.e. the ON part of the JOIN. + * Please see {@link WhereInterface::where()} + * for the supported formats and + * restrictions regarding quoting of the field names. + * @param string $operator The operator to combine multiple conditions with, + * if the condition is in the array format + * + * @return $this + */ + public function joinRight($table, $condition, $operator = Sql::ALL) + { + $this->join[] = ['RIGHT', $table, $this->buildCondition($condition, $operator)]; + + return $this; + } + + /** + * Get the GROUP BY part of the query + * + * @return array|null + */ + public function getGroupBy() + { + return $this->groupBy; + } + + /** + * Add a GROUP BY part to the query - either plain columns or expressions or scalar subqueries + * + * This method does NOT quote the columns you specify for the GROUP BY. + * If you allow user input here, you must protected yourself against SQL injection using + * {@link Connection::quoteIdentifier()} for the field names passed to this method. + * If you are using special field names, e.g. reserved keywords for your DBMS, you are required to use + * {@link Connection::quoteIdentifier()} as well. + * + * Note that this method does not override an already set GROUP BY part. Instead, multiple calls to this function + * add the specified GROUP BY part. + * + * @param string|ExpressionInterface|Select|array $groupBy + * + * @return $this + */ + public function groupBy($groupBy) + { + $this->groupBy = array_merge( + $this->groupBy === null ? [] : $this->groupBy, + is_array($groupBy) ? $groupBy : [$groupBy] + ); + + return $this; + } + + /** + * Get the HAVING part of the query + * + * @return array|null + */ + public function getHaving() + { + return $this->having; + } + + /** + * Add a HAVING part of the query + * + * This method lets you specify the HAVING part of the query using one of the two following supported formats: + * * String format, e.g. 'id = 1' + * * Array format, e.g. ['id' => 1, ...] + * + * This method does NOT quote the columns you specify for the HAVING. + * If you allow user input here, you must protected yourself against SQL injection using + * {@link Connection::quoteIdentifier()} for the field names passed to this method. + * If you are using special field names, e.g. reserved keywords for your DBMS, you are required to use + * {@link Connection::quoteIdentifier()} as well. + * + * Note that this method does not override an already set HAVING part. Instead, multiple calls to this function add + * the specified HAVING part using the AND operator. + * + * @param string|ExpressionInterface|Select|array $condition The HAVING condition + * @param string $operator The operator to combine multiple conditions with, + * if the condition is in the array format + * + * @return $this + */ + public function having($condition, $operator = Sql::ALL) + { + $this->mergeCondition($this->having, $this->buildCondition($condition, $operator), Sql::ALL); + + return $this; + } + + /** + * Add a OR part to the HAVING part of the query + * + * Please see {@link having()} for the supported formats and restrictions regarding quoting of the field names. + * + * @param string|ExpressionInterface|Select|array $condition The HAVING condition + * @param string $operator The operator to combine multiple conditions with, + * if the condition is in the array format + * + * @return $this + */ + public function orHaving($condition, $operator = Sql::ALL) + { + $this->mergeCondition($this->having, $this->buildCondition($condition, $operator), Sql::ANY); + + return $this; + } + + /** + * Add a AND NOT part to the HAVING part of the query + * + * Please see {@link having()} for the supported formats and restrictions regarding quoting of the field names. + * + * @param string|ExpressionInterface|Select|array $condition The HAVING condition + * @param string $operator The operator to combine multiple conditions with, + * if the condition is in the array format + * + * @return $this + */ + public function notHaving($condition, $operator = Sql::ALL) + { + $this->mergeCondition($this->having, $this->buildCondition($condition, $operator), Sql::NOT_ALL); + + return $this; + } + + /** + * Add a OR NOT part to the HAVING part of the query + * + * Please see {@link having()} for the supported formats and restrictions regarding quoting of the field names. + * + * @param string|ExpressionInterface|Select|array $condition The HAVING condition + * @param string $operator The operator to combine multiple conditions with, + * if the condition is in the array format + * + * @return $this + */ + public function orNotHaving($condition, $operator = Sql::ALL) + { + $this->mergeCondition($this->having, $this->buildCondition($condition, $operator), Sql::NOT_ANY); + + return $this; + } + + /** + * Get the UNION parts of the query + * + * @return array|null + */ + public function getUnion() + { + return $this->union; + } + + /** + * Combine a query with UNION + * + * @param Select|string $query + * + * @return $this + */ + public function union($query) + { + $this->union[] = [$query, false]; + + return $this; + } + + /** + * Combine a query with UNION ALL + * + * @param Select|string $query + * + * @return $this + */ + public function unionAll($query) + { + $this->union[] = [$query, true]; + + return $this; + } + + /** + * Reset the DISTINCT part of the query + * + * @return $this + */ + public function resetDistinct() + { + $this->distinct = false; + + return $this; + } + + /** + * Reset the columns of the query + * + * @return $this + */ + public function resetColumns() + { + $this->columns = null; + + return $this; + } + + /** + * Reset the FROM part of the query + * + * @return $this + */ + public function resetFrom() + { + $this->from = null; + + return $this; + } + + /** + * Reset the JOIN parts of the query + * + * @return $this + */ + public function resetJoin() + { + $this->join = null; + + return $this; + } + + /** + * Reset the GROUP BY part of the query + * + * @return $this + */ + public function resetGroupBy() + { + $this->groupBy = null; + + return $this; + } + + /** + * Reset the HAVING part of the query + * + * @return $this + */ + public function resetHaving() + { + $this->having = null; + + return $this; + } + + /** + * Reset queries combined with UNION and UNION ALL + * + * @return $this + */ + public function resetUnion() + { + $this->union = null; + + return $this; + } + + /** + * Get the count query + * + * @return Select + */ + public function getCountQuery() + { + $countQuery = clone $this; + + $countQuery->orderBy = null; + $countQuery->limit = null; + $countQuery->offset = null; + + if (! empty($countQuery->groupBy) || $countQuery->getDistinct()) { + $countQuery = (new Select())->from(['s' => $countQuery]); + $countQuery->distinct(false); + } + + $countQuery->columns = ['cnt' => 'COUNT(*)']; + + return $countQuery; + } + + public function __clone() + { + $this->cloneCte(); + $this->cloneOrderBy(); + $this->cloneWhere(); + + if ($this->columns !== null) { + foreach ($this->columns as &$value) { + if ($value instanceof ExpressionInterface || $value instanceof Select) { + $value = clone $value; + } + } + unset($value); + } + + if ($this->from !== null) { + foreach ($this->from as &$from) { + if ($from instanceof Select) { + $from = clone $from; + } + } + unset($from); + } + + if ($this->join !== null) { + foreach ($this->join as &$join) { + if (is_array($join[1])) { + foreach ($join[1] as &$table) { + if ($table instanceof Select) { + $table = clone $table; + } + } + unset($table); + } elseif ($join[1] instanceof Select) { + $join[1] = clone $join[1]; + } + + $this->cloneCondition($join[2]); + } + unset($join); + } + + if ($this->groupBy !== null) { + foreach ($this->groupBy as &$value) { + if ($value instanceof ExpressionInterface || $value instanceof Select) { + $value = clone $value; + } + } + unset($value); + } + + if ($this->having !== null) { + $this->cloneCondition($this->having); + } + + if ($this->union !== null) { + foreach ($this->union as &$union) { + $union[0] = clone $union[0]; + } + unset($union); + } + } +} diff --git a/vendor/ipl/sql/src/Sql.php b/vendor/ipl/sql/src/Sql.php new file mode 100644 index 0000000..8170382 --- /dev/null +++ b/vendor/ipl/sql/src/Sql.php @@ -0,0 +1,70 @@ +<?php + +namespace ipl\Sql; + +/** + * The SQL helper provides a set of static methods for quoting and escaping identifiers to make their use safe in SQL + * queries or fragments + */ +class Sql +{ + /** + * SQL AND operator + */ + const ALL = 'AND'; + + /** + * SQL OR operator + */ + const ANY = 'OR'; + + /** + * SQL AND NOT operator + */ + const NOT_ALL = 'AND NOT'; + + /** + * SQL OR NOT operator + */ + const NOT_ANY = 'OR NOT'; + + /** + * Create and return a DELETE statement + * + * @return Delete + */ + public static function delete() + { + return new Delete(); + } + + /** + * Create and return a INSERT statement + * + * @return Insert + */ + public static function insert() + { + return new Insert(); + } + + /** + * Create and return a SELECT statement + * + * @return Select + */ + public static function select() + { + return new Select(); + } + + /** + * Create and return a UPDATE statement + * + * @return Update + */ + public static function update() + { + return new Update(); + } +} diff --git a/vendor/ipl/sql/src/Update.php b/vendor/ipl/sql/src/Update.php new file mode 100644 index 0000000..356a610 --- /dev/null +++ b/vendor/ipl/sql/src/Update.php @@ -0,0 +1,100 @@ +<?php + +namespace ipl\Sql; + +use InvalidArgumentException; + +use function ipl\Stdlib\arrayval; + +/** + * SQL UPDATE query + */ +class Update implements CommonTableExpressionInterface, WhereInterface +{ + use CommonTableExpression; + use Where; + + /** @var array|null The table for the UPDATE query */ + protected $table; + + /** @var array|null The columns to update in terms of column-value pairs */ + protected $set = []; + + /** + * Get the table for the UPDATE query + * + * @return array|null + */ + public function getTable() + { + return $this->table; + } + + /** + * Set the table for the UPDATE query + * + * Note that this method does NOT quote the table you specify for the UPDATE. + * If you allow user input here, you must protected yourself against SQL injection using + * {@link Connection::quoteIdentifier()} for the table names passed to this method. + * If you are using special table names, e.g. reserved keywords for your DBMS, you are required to use + * {@link Connection::quoteIdentifier()} as well. + * + * @param string|array $table The table to update. The table specification must be in one of the + * following formats: 'table', 'table alias', ['alias' => 'table'] + * + * @return $this + */ + public function table($table) + { + $this->table = is_array($table) ? $table : [$table]; + + return $this; + } + + /** + * Get the columns to update in terms of column-value pairs + * + * @return array|null + */ + public function getSet() + { + return $this->set; + } + + /** + * Set the columns to update in terms of column-value pairs + * + * Values may either be plain or expressions or scalar subqueries. + * + * Note that this method does NOT quote the columns you specify for the UPDATE. + * If you allow user input here, you must protected yourself against SQL injection using + * {@link Connection::quoteIdentifier()} for the column names passed to this method. + * If you are using special column names, e.g. reserved keywords for your DBMS, you are required to use + * {@link Connection::quoteIdentifier()} as well. + * + * @param iterable $set Associative set of column-value pairs + * + * @return $this + * + * @throws InvalidArgumentException If set type is invalid + */ + public function set($set) + { + $this->set = arrayval($set); + + return $this; + } + + public function __clone() + { + $this->cloneCte(); + $this->cloneWhere(); + + foreach ($this->set as &$value) { + if ($value instanceof ExpressionInterface || $value instanceof Select) { + $value = clone $value; + } + } + unset($value); + } +} diff --git a/vendor/ipl/sql/src/Where.php b/vendor/ipl/sql/src/Where.php new file mode 100644 index 0000000..f862846 --- /dev/null +++ b/vendor/ipl/sql/src/Where.php @@ -0,0 +1,158 @@ +<?php + +namespace ipl\Sql; + +/** + * Implementation for the {@link WhereInterface} + */ +trait Where +{ + /** @var array|null Internal representation for the WHERE part of the query */ + protected $where; + + public function getWhere() + { + return $this->where; + } + + public function where($condition, ...$args) + { + list($condition, $operator) = $this->prepareConditionArguments($condition, $args); + $this->mergeCondition($this->where, $this->buildCondition($condition, $operator), Sql::ALL); + + return $this; + } + + public function orWhere($condition, ...$args) + { + list($condition, $operator) = $this->prepareConditionArguments($condition, $args); + $this->mergeCondition($this->where, $this->buildCondition($condition, $operator), Sql::ANY); + + return $this; + } + + public function notWhere($condition, ...$args) + { + list($condition, $operator) = $this->prepareConditionArguments($condition, $args); + $this->mergeCondition($this->where, $this->buildCondition($condition, $operator), Sql::NOT_ALL); + + return $this; + } + + public function orNotWhere($condition, ...$args) + { + list($condition, $operator) = $this->prepareConditionArguments($condition, $args); + $this->mergeCondition($this->where, $this->buildCondition($condition, $operator), Sql::NOT_ANY); + + return $this; + } + + public function resetWhere() + { + $this->where = null; + + return $this; + } + + /** + * Make $condition an array and build an array like this: [$operator, [$condition]] + * + * If $condition is empty, replace it with a boolean constant depending on the operator. + * + * @param string|array $condition + * @param string $operator + * + * @return array + */ + protected function buildCondition($condition, $operator) + { + if (is_array($condition)) { + if (empty($condition)) { + $condition = [$operator === Sql::ALL ? '1' : '0']; + } elseif (in_array(reset($condition), [Sql::ALL, Sql::ANY, Sql::NOT_ALL, Sql::NOT_ANY], true)) { + return $condition; + } + } else { + $condition = [$condition]; + } + + return [$operator, $condition]; + } + + /** + * Merge the given condition with ours via the given operator + * + * @param mixed $base Our condition + * @param array $condition As returned by {@link buildCondition()} + * @param string $operator + */ + protected function mergeCondition(&$base, array $condition, $operator) + { + if ($base === null) { + $base = [$operator, [$condition]]; + } else { + if ($base[0] === $operator) { + $base[1][] = $condition; + } elseif ($operator === Sql::NOT_ALL) { + $base = [Sql::ALL, [$base, [$operator, [$condition]]]]; + } elseif ($operator === Sql::NOT_ANY) { + $base = [Sql::ANY, [$base, [$operator, [$condition]]]]; + } else { + $base = [$operator, [$base, $condition]]; + } + } + } + + /** + * Prepare condition arguments from the different supported where styles + * + * @param mixed $condition + * @param array $args + * + * @return array + */ + protected function prepareConditionArguments($condition, array $args) + { + // Default operator + $operator = Sql::ALL; + + if (! is_array($condition) && ! empty($args)) { + // Variadic + $condition = [(string) $condition => $args]; + } else { + // Array or string format + $operator = array_shift($args) ?: $operator; + } + + return [$condition, $operator]; + } + + /** + * Clone the properties provided by this trait + * + * Shall be called by using classes in their __clone() + */ + protected function cloneWhere() + { + if ($this->where !== null) { + $this->cloneCondition($this->where); + } + } + + /** + * Clone a condition in-place + * + * @param array $condition As returned by {@link buildCondition()} + */ + protected function cloneCondition(array &$condition) + { + foreach ($condition as &$subCondition) { + if (is_array($subCondition)) { + $this->cloneCondition($subCondition); + } elseif ($subCondition instanceof ExpressionInterface || $subCondition instanceof Select) { + $subCondition = clone $subCondition; + } + } + unset($subCondition); + } +} diff --git a/vendor/ipl/sql/src/WhereInterface.php b/vendor/ipl/sql/src/WhereInterface.php new file mode 100644 index 0000000..e724465 --- /dev/null +++ b/vendor/ipl/sql/src/WhereInterface.php @@ -0,0 +1,84 @@ +<?php + +namespace ipl\Sql; + +/** + * Interface for the WHERE part of a query + */ +interface WhereInterface +{ + /** + * Get the WHERE part of the query + * + * @return array|null + */ + public function getWhere(); + + /** + * Add a WHERE part of the query + * + * This method lets you specify the WHERE part of the query using one of the two following supported formats: + * * String format, e.g. 'id = 1', i.e. `where(string $condition [, mixed ...$args])` + * * Array format, e.g. ['id = ?' => 1, ...], i.e. `where(array $condition [, string $operator])` + * + * This method does NOT quote the columns you specify for the WHERE. + * If you allow user input here, you must protected yourself against SQL injection using + * {@link Connection::quoteIdentifier()} for the field names passed to this method. + * If you are using special field names, e.g. reserved keywords for your DBMS, you are required to use + * {@link Connection::quoteIdentifier()} as well. + * + * Note that this method does not override an already set WHERE part. Instead, multiple calls to this function add + * the specified WHERE part using the AND operator. + * + * @param string|ExpressionInterface|Select|array $condition The WHERE condition + * @param mixed $args If condition is a string, parameter values for placeholders in the condition can be passed. + * If condition is an array, the only argument that is allowed is the operator to use to combine + * these conditions. By default, this operator is {@link Sql::ALL} (AND) + * + * @return $this + */ + public function where($condition, ...$args); + + /** + * Add a OR part to the WHERE part of the query + * + * Please see {@link where()} for the supported formats and restrictions regarding quoting of the field names. + * + * @param string|ExpressionInterface|Select|array $condition The WHERE condition + * @param mixed ...$args Please see {@link where()} for details + * + * @return $this + */ + public function orWhere($condition, ...$args); + + /** + * Add a AND NOT part to the WHERE part of the query + * + * Please see {@link where()} for the supported formats and restrictions regarding quoting of the field names. + * + * @param string|ExpressionInterface|Select|array $condition The WHERE condition + * @param mixed ...$args Please see {@link where()} for details + * + * @return $this + */ + public function notWhere($condition, ...$args); + + /** + * Add a OR NOT part to the WHERE part of the query + * + * Please see {@link where()} for the supported formats and restrictions regarding quoting of the field names. + * + * @param string|ExpressionInterface|Select|array $condition The WHERE condition + * @param mixed ...$args Please see {@link where()} for details + * + * @return $this + */ + public function orNotWhere($condition, ...$args); + + /** + * Reset the WHERE part of the query + * + * @return $this + */ + public function resetWhere(); +} diff --git a/vendor/ipl/stdlib/LICENSE b/vendor/ipl/stdlib/LICENSE new file mode 100644 index 0000000..58005ec --- /dev/null +++ b/vendor/ipl/stdlib/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2018 Icinga GmbH https://www.icinga.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/vendor/ipl/stdlib/composer.json b/vendor/ipl/stdlib/composer.json new file mode 100644 index 0000000..d32c7d3 --- /dev/null +++ b/vendor/ipl/stdlib/composer.json @@ -0,0 +1,22 @@ +{ + "name": "ipl/stdlib", + "description": "ipl Standard Library", + "type": "library", + "license": "MIT", + "autoload": { + "files": ["src/functions_include.php"], + "psr-4": { + "ipl\\Stdlib\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "ipl\\Tests\\Stdlib\\": "tests" + } + }, + "require": { + "php": ">=7.2", + "ext-openssl": "*", + "evenement/evenement": "^3" + } +} diff --git a/vendor/ipl/stdlib/src/BaseFilter.php b/vendor/ipl/stdlib/src/BaseFilter.php new file mode 100644 index 0000000..267decb --- /dev/null +++ b/vendor/ipl/stdlib/src/BaseFilter.php @@ -0,0 +1,45 @@ +<?php + +namespace ipl\Stdlib; + +use ipl\Stdlib\Filter\Rule; + +trait BaseFilter +{ + /** @var Rule Base filter */ + private $baseFilter; + + /** + * Get whether a base filter has been set + * + * @return bool + */ + public function hasBaseFilter(): bool + { + return $this->baseFilter !== null; + } + + /** + * Get the base filter + * + * @return ?Rule + */ + public function getBaseFilter() + { + return $this->baseFilter; + } + + /** + * Set the base filter + * + * @param Rule $baseFilter + * + * @return $this + */ + public function setBaseFilter(Rule $baseFilter = null): self + { + $this->baseFilter = $baseFilter; + + return $this; + } +} diff --git a/vendor/ipl/stdlib/src/Contract/Filterable.php b/vendor/ipl/stdlib/src/Contract/Filterable.php new file mode 100644 index 0000000..2a6316a --- /dev/null +++ b/vendor/ipl/stdlib/src/Contract/Filterable.php @@ -0,0 +1,63 @@ +<?php + +namespace ipl\Stdlib\Contract; + +use ipl\Stdlib\Filter; + +interface Filterable +{ + /** + * Get the filter of the query + * + * @return Filter\Chain + */ + public function getFilter(); + + /** + * Add a filter to the query + * + * Note that this method does not override an already set filter. Instead, multiple calls to this function add + * the specified filter using a {@see Filter\All} chain. + * + * @param Filter\Rule $filter + * + * @return $this + */ + public function filter(Filter\Rule $filter); + + /** + * Add a filter to the query + * + * Note that this method does not override an already set filter. Instead, multiple calls to this function add + * the specified filter using a {@see Filter\Any} chain. + * + * @param Filter\Rule $filter + * + * @return $this + */ + public function orFilter(Filter\Rule $filter); + + /** + * Add a filter to the query + * + * Note that this method does not override an already set filter. Instead, multiple calls to this function add + * the specified filter wrapped by a {@see Filter\None} chain and using a {@see Filter\All} chain. + * + * @param Filter\Rule $filter + * + * @return $this + */ + public function notFilter(Filter\Rule $filter); + + /** + * Add a filter to the query + * + * Note that this method does not override an already set filter. Instead, multiple calls to this function add + * the specified filter wrapped by a {@see Filter\None} chain and using a {@see Filter\Any} chain. + * + * @param Filter\Rule $filter + * + * @return $this + */ + public function orNotFilter(Filter\Rule $filter); +} diff --git a/vendor/ipl/stdlib/src/Contract/Paginatable.php b/vendor/ipl/stdlib/src/Contract/Paginatable.php new file mode 100644 index 0000000..3e2a4ee --- /dev/null +++ b/vendor/ipl/stdlib/src/Contract/Paginatable.php @@ -0,0 +1,56 @@ +<?php + +namespace ipl\Stdlib\Contract; + +use Countable; + +interface Paginatable extends Countable +{ + /** + * Get whether a limit is set + * + * @return bool + */ + public function hasLimit(); + + /** + * Get the limit + * + * @return int|null + */ + public function getLimit(); + + /** + * Set the limit + * + * @param int|null $limit Maximum number of items to return. If you want to disable the limit, + * it is best practice to use null or a negative value + * + * @return $this + */ + public function limit($limit); + + /** + * Get whether an offset is set + * + * @return bool + */ + public function hasOffset(); + + /** + * Get the offset + * + * @return int|null + */ + public function getOffset(); + + /** + * Set the offset + * + * @param int|null $offset Start result set after this many rows. If you want to disable the offset, + * it is best practice to use null or a negative value + * + * @return $this + */ + public function offset($offset); +} diff --git a/vendor/ipl/stdlib/src/Contract/PaginationInterface.php b/vendor/ipl/stdlib/src/Contract/PaginationInterface.php new file mode 100644 index 0000000..b00fa66 --- /dev/null +++ b/vendor/ipl/stdlib/src/Contract/PaginationInterface.php @@ -0,0 +1,8 @@ +<?php + +namespace ipl\Stdlib\Contract; + +/** @deprecated Use {@link Paginatable} instead */ +interface PaginationInterface extends Paginatable +{ +} diff --git a/vendor/ipl/stdlib/src/Contract/PluginLoader.php b/vendor/ipl/stdlib/src/Contract/PluginLoader.php new file mode 100644 index 0000000..1be779c --- /dev/null +++ b/vendor/ipl/stdlib/src/Contract/PluginLoader.php @@ -0,0 +1,21 @@ +<?php + +namespace ipl\Stdlib\Contract; + +/** + * Representation of plugin loaders + * + * Plugin loaders must implement the {@link load()} method in order to provide the fully qualified class name of a + * plugin to load. + */ +interface PluginLoader +{ + /** + * Load the class file for a given plugin name + * + * @param string $name Name of the plugin + * + * @return string|false FQN of the plugin's class if found, false otherwise + */ + public function load($name); +} diff --git a/vendor/ipl/stdlib/src/Contract/Translator.php b/vendor/ipl/stdlib/src/Contract/Translator.php new file mode 100644 index 0000000..85ab515 --- /dev/null +++ b/vendor/ipl/stdlib/src/Contract/Translator.php @@ -0,0 +1,65 @@ +<?php + +namespace ipl\Stdlib\Contract; + +/** + * Representation of translators + */ +interface Translator +{ + /** + * Translate a message + * + * @param string $message + * @param string $context Message context + * + * @return string Translated message or original message if no translation is found + */ + public function translate($message, $context = null); + + /** + * Translate a message in the given domain + * + * If no translation is found in the specified domain, the translation is also searched for in the default domain. + * + * @param string $domain + * @param string $message + * @param string $context Message context + * + * @return string Translated message or original message if no translation is found + */ + public function translateInDomain($domain, $message, $context = null); + + /** + * Translate a plural message + * + * The returned message is based on the given number to decide between the singular and plural forms. + * That is also the case if no translation is found. + * + * @param string $singular Singular message + * @param string $plural Plural message + * @param int $number Number to decide between the returned singular and plural forms + * @param string $context Message context + * + * @return string Translated message or original message if no translation is found + */ + public function translatePlural($singular, $plural, $number, $context = null); + + /** + * Translate a plural message in the given domain + * + * If no translation is found in the specified domain, the translation is also searched for in the default domain. + * + * The returned message is based on the given number to decide between the singular and plural forms. + * That is also the case if no translation is found. + * + * @param string $domain + * @param string $singular Singular message + * @param string $plural Plural message + * @param int $number Number to decide between the returned singular and plural forms + * @param string $context Message context + * + * @return string Translated message or original message if no translation is found + */ + public function translatePluralInDomain($domain, $singular, $plural, $number, $context = null); +} diff --git a/vendor/ipl/stdlib/src/Contract/Validator.php b/vendor/ipl/stdlib/src/Contract/Validator.php new file mode 100644 index 0000000..c03efab --- /dev/null +++ b/vendor/ipl/stdlib/src/Contract/Validator.php @@ -0,0 +1,22 @@ +<?php + +namespace ipl\Stdlib\Contract; + +interface Validator +{ + /** + * Get whether the given value is valid + * + * @param mixed $value + * + * @return bool + */ + public function isValid($value); + + /** + * Get the validation error messages + * + * @return array + */ + public function getMessages(); +} diff --git a/vendor/ipl/stdlib/src/Contract/ValidatorInterface.php b/vendor/ipl/stdlib/src/Contract/ValidatorInterface.php new file mode 100644 index 0000000..36cf55e --- /dev/null +++ b/vendor/ipl/stdlib/src/Contract/ValidatorInterface.php @@ -0,0 +1,8 @@ +<?php + +namespace ipl\Stdlib\Contract; + +/** @deprecated Use {@link Validator} instead */ +interface ValidatorInterface extends Validator +{ +} diff --git a/vendor/ipl/stdlib/src/Data.php b/vendor/ipl/stdlib/src/Data.php new file mode 100644 index 0000000..0edb224 --- /dev/null +++ b/vendor/ipl/stdlib/src/Data.php @@ -0,0 +1,89 @@ +<?php + +namespace ipl\Stdlib; + +class Data +{ + /** @var array */ + protected $data = []; + + /** + * Check whether there's any data + * + * @return bool + */ + public function isEmpty() + { + return empty($this->data); + } + + /** + * Check whether the given data exists + * + * @param string $name The name of the data + * + * @return bool + */ + public function has($name) + { + return array_key_exists($name, $this->data); + } + + /** + * Get the value of the given data + * + * @param string $name The name of the data + * @param mixed $default The value to return if there's no such data + * + * @return mixed + */ + public function get($name, $default = null) + { + if ($this->has($name)) { + return $this->data[$name]; + } + + return $default; + } + + /** + * Set the value of the given data + * + * @param string $name The name of the data + * @param mixed $value + * + * @return $this + */ + public function set($name, $value) + { + $this->data[$name] = $value; + + return $this; + } + + /** + * Merge the given data + * + * @param Data $with + * + * @return $this + */ + public function merge(self $with) + { + $this->data = array_merge($this->data, $with->data); + + return $this; + } + + /** + * Clear all data + * + * @return $this + */ + public function clear() + { + $this->data = []; + + return $this; + } +} diff --git a/vendor/ipl/stdlib/src/EventEmitter.php b/vendor/ipl/stdlib/src/EventEmitter.php new file mode 100644 index 0000000..6d189ee --- /dev/null +++ b/vendor/ipl/stdlib/src/EventEmitter.php @@ -0,0 +1,9 @@ +<?php + +namespace ipl\Stdlib; + +/** @deprecated Use {@link Events} instead */ +trait EventEmitter +{ + use Events; +} diff --git a/vendor/ipl/stdlib/src/Events.php b/vendor/ipl/stdlib/src/Events.php new file mode 100644 index 0000000..3405086 --- /dev/null +++ b/vendor/ipl/stdlib/src/Events.php @@ -0,0 +1,57 @@ +<?php + +namespace ipl\Stdlib; + +use Evenement\EventEmitterTrait; +use InvalidArgumentException; + +trait Events +{ + use EventEmitterTrait { + EventEmitterTrait::on as private evenementUnvalidatedOn; + } + + /** @var array */ + protected $eventsEmittedOnce = []; + + /** + * @param string $event + * @param array $arguments + */ + protected function emitOnce($event, array $arguments = []) + { + if (! isset($this->eventsEmittedOnce[$event])) { + $this->eventsEmittedOnce[$event] = true; + $this->emit($event, $arguments); + } + } + + /** + * @param string $event + * @param callable $listener + * @return $this + */ + public function on($event, callable $listener) + { + $this->assertValidEvent($event); + $this->evenementUnvalidatedOn($event, $listener); + + return $this; + } + + protected function assertValidEvent($event) + { + if (! $this->isValidEvent($event)) { + throw new InvalidArgumentException("$event is not a valid event"); + } + } + + /** + * @param string $event + * @return bool + */ + public function isValidEvent($event) + { + return true; + } +} diff --git a/vendor/ipl/stdlib/src/Filter.php b/vendor/ipl/stdlib/src/Filter.php new file mode 100644 index 0000000..9b60f8e --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter.php @@ -0,0 +1,578 @@ +<?php + +namespace ipl\Stdlib; + +use Exception; +use InvalidArgumentException; +use ipl\Stdlib\Filter\All; +use ipl\Stdlib\Filter\Any; +use ipl\Stdlib\Filter\Chain; +use ipl\Stdlib\Filter\Condition; +use ipl\Stdlib\Filter\Equal; +use ipl\Stdlib\Filter\GreaterThan; +use ipl\Stdlib\Filter\GreaterThanOrEqual; +use ipl\Stdlib\Filter\LessThan; +use ipl\Stdlib\Filter\LessThanOrEqual; +use ipl\Stdlib\Filter\Like; +use ipl\Stdlib\Filter\None; +use ipl\Stdlib\Filter\Rule; +use ipl\Stdlib\Filter\Unequal; +use ipl\Stdlib\Filter\Unlike; + +class Filter +{ + /** + * protected - This is only a factory class + */ + protected function __construct() + { + } + + /** + * Return whether the given rule matches the given item + * + * @param Rule $rule + * @param array|object $row + * + * @return bool + */ + public static function match(Rule $rule, $row) + { + if (! is_object($row)) { + if (is_array($row)) { + $row = (object) $row; + } else { + throw new InvalidArgumentException(sprintf( + 'Object or array expected, got %s instead', + get_php_type($row) + )); + } + } + + return (new self())->performMatch($rule, $row); + } + + /** + * Create a rule that matches if **all** of the given rules do + * + * @param Rule ...$rules + * + * @return Chain + */ + public static function all(Rule ...$rules) + { + return new All(...$rules); + } + + /** + * Return whether the given rules all match the given item + * + * @param All $rules + * @param object $row + * + * @return bool + */ + protected function matchAll(All $rules, $row) + { + foreach ($rules as $rule) { + if (! $this->performMatch($rule, $row)) { + return false; + } + } + + return true; + } + + /** + * Create a rule that matches if **any** of the given rules do + * + * @param Rule ...$rules + * + * @return Chain + */ + public static function any(Rule ...$rules) + { + return new Any(...$rules); + } + + /** + * Return whether any of the given rules match the given item + * + * @param Any $rules + * @param object $row + * + * @return bool + */ + protected function matchAny(Any $rules, $row) + { + foreach ($rules as $rule) { + if ($this->performMatch($rule, $row)) { + return true; + } + } + + return false; + } + + /** + * Create a rule that matches if **none** of the given rules do + * + * @param Rule ...$rules + * + * @return Chain + */ + public static function none(Rule ...$rules) + { + return new None(...$rules); + } + + /** + * Return whether none of the given rules match the given item + * + * @param None $rules + * @param object $row + * + * @return bool + */ + protected function matchNone(None $rules, $row) + { + foreach ($rules as $rule) { + if ($this->performMatch($rule, $row)) { + return false; + } + } + + return true; + } + + /** + * Create a rule that matches rows with a column that **equals** the given value + * + * @param string $column + * @param array|bool|float|int|string $value + * + * @return Condition + */ + public static function equal($column, $value) + { + return new Equal($column, $value); + } + + /** + * Return whether the given rule's value equals the given item's value + * + * @param Equal|Unequal $rule + * @param object $row + * + * @return bool + */ + protected function matchEqual($rule, $row) + { + if (! $rule instanceof Equal && ! $rule instanceof Unequal) { + throw new InvalidArgumentException(sprintf( + 'Rule must be of type %s or %s, got %s instead', + Equal::class, + Unequal::class, + get_php_type($rule) + )); + } + + $rowValue = $this->extractValue($rule->getColumn(), $row); + $value = $rule->getValue(); + $this->normalizeTypes($rowValue, $value); + + if (! is_array($rowValue)) { + $rowValue = [$rowValue]; + } + + foreach ($rowValue as $rowVal) { + if ($this->performEqualityMatch($value, $rowVal, $rule->ignoresCase())) { + return true; + } + } + + return false; + } + + /** + * Create a rule that matches rows with a column that is **similar** to the given value + * + * Performs a wildcard search if the value contains asterisks. + * + * @param string $column + * @param string|string[] $value + * + * @return Condition + */ + public static function like($column, $value) + { + return new Like($column, $value); + } + + /** + * Return whether the given rule's value is similar to the given item's value + * + * @param Like|Unlike $rule + * @param object $row + * + * @return bool + */ + protected function matchSimilar($rule, $row) + { + if (! $rule instanceof Like && ! $rule instanceof Unlike) { + throw new InvalidArgumentException(sprintf( + 'Rule must be of type %s or %s, got %s instead', + Like::class, + Unlike::class, + get_php_type($rule) + )); + } + + $rowValue = $this->extractValue($rule->getColumn(), $row); + $value = $rule->getValue(); + $this->normalizeTypes($rowValue, $value); + + if (! is_array($rowValue)) { + $rowValue = [$rowValue]; + } + + foreach ($rowValue as $rowVal) { + if ($this->performSimilarityMatch($value, $rowVal, $rule->ignoresCase())) { + return true; + } + } + + return false; + } + + /** + * Apply equality matching rules on the given row value + * + * @param mixed $value + * @param mixed $rowValue + * @param bool $ignoreCase + * + * @return bool + */ + protected function performEqualityMatch($value, $rowValue, $ignoreCase = false) + { + if ($ignoreCase && is_string($rowValue)) { + $rowValue = strtolower($rowValue); + $value = is_array($value) + ? array_map('strtolower', $value) + : strtolower($value); + } + + if (is_array($value)) { + return in_array($rowValue, $value, true); + } elseif (! is_string($value)) { + if (is_string($rowValue)) { + $value = (string) $value; + } + } + + return $rowValue === $value; + } + + /** + * Apply similarity matching rules on the given row value + * + * @param string|string[] $value + * @param string $rowValue + * @param bool $ignoreCase + * + * @return bool + */ + protected function performSimilarityMatch($value, $rowValue, $ignoreCase = false) + { + if ($ignoreCase) { + $rowValue = strtolower($rowValue); + $value = is_array($value) + ? array_map('strtolower', $value) + : strtolower($value); + } + + if (is_array($value)) { + return in_array($rowValue, $value, true); + } + + $wildcardSubSegments = preg_split('~\*~', $value); + if (count($wildcardSubSegments) === 1) { + return $rowValue === $value; + } + + $parts = []; + foreach ($wildcardSubSegments as $part) { + $parts[] = preg_quote($part, '~'); + } + + $pattern = '~^' . join('.*', $parts) . '$~'; + + return (bool) preg_match($pattern, $rowValue); + } + + /** + * Create a rule that matches rows with a column that is **unequal** with the given value + * + * @param string $column + * @param array|bool|float|int|string $value + * + * @return Condition + */ + public static function unequal($column, $value) + { + return new Unequal($column, $value); + } + + /** + * Return whether the given rule's value does not equal the given item's value + * + * @param Unequal $rule + * @param object $row + * + * @return bool + */ + protected function matchUnequal(Unequal $rule, $row) + { + return ! $this->matchEqual($rule, $row); + } + + /** + * Create a rule that matches rows with a column that is **unlike** with the given value + * + * Performs a wildcard search if the value contains asterisks. + * + * @param string $column + * @param string|string[] $value + * + * @return Condition + */ + public static function unlike($column, $value) + { + return new Unlike($column, $value); + } + + /** + * Return whether the given rule's value is unlike the given item's value + * + * @param Unlike $rule + * @param object $row + * + * @return bool + */ + protected function matchUnlike(Unlike $rule, $row) + { + return ! $this->matchSimilar($rule, $row); + } + + /** + * Create a rule that matches rows with a column that is **greater** than the given value + * + * @param string $column + * @param float|int|string $value + * + * @return Condition + */ + public static function greaterThan($column, $value) + { + return new GreaterThan($column, $value); + } + + /** + * Return whether the given rule's value is greater than the given item's value + * + * @param GreaterThan $rule + * @param object $row + * + * @return bool + */ + protected function matchGreaterThan(GreaterThan $rule, $row) + { + return $this->extractValue($rule->getColumn(), $row) > $rule->getValue(); + } + + /** + * Create a rule that matches rows with a column that is **less** than the given value + * + * @param string $column + * @param float|int|string $value + * + * @return Condition + */ + public static function lessThan($column, $value) + { + return new LessThan($column, $value); + } + + /** + * Return whether the given rule's value is less than the given item's value + * + * @param LessThan $rule + * @param object $row + * + * @return bool + */ + protected function matchLessThan(LessThan $rule, $row) + { + $rowValue = $this->extractValue($rule->getColumn(), $row); + if ($rowValue === null) { + return false; + } + + return $rowValue < $rule->getValue(); + } + + /** + * Create a rule that matches rows with a column that is **greater** than or **equal** to the given value + * + * @param string $column + * @param float|int|string $value + * + * @return Condition + */ + public static function greaterThanOrEqual($column, $value) + { + return new GreaterThanOrEqual($column, $value); + } + + /** + * Return whether the given rule's value is greater than or equals the given item's value + * + * @param GreaterThanOrEqual $rule + * @param object $row + * + * @return bool + */ + protected function matchGreaterThanOrEqual(GreaterThanOrEqual $rule, $row) + { + return $this->extractValue($rule->getColumn(), $row) >= $rule->getValue(); + } + + /** + * Create a rule that matches rows with a column that is **less** than or **equal** to the given value + * + * @param string $column + * @param float|int|string $value + * + * @return Condition + */ + public static function lessThanOrEqual($column, $value) + { + return new LessThanOrEqual($column, $value); + } + + /** + * Return whether the given rule's value is less than or equals the given item's value + * + * @param LessThanOrEqual $rule + * @param object $row + * + * @return bool + */ + protected function matchLessThanOrEqual(LessThanOrEqual $rule, $row) + { + $rowValue = $this->extractValue($rule->getColumn(), $row); + if ($rowValue === null) { + return false; + } + + return $rowValue <= $rule->getValue(); + } + + /** + * Perform the appropriate match for the given rule on the given item + * + * @param Rule $rule + * @param object $row + * + * @return bool + */ + protected function performMatch(Rule $rule, $row) + { + switch (true) { + case $rule instanceof All: + return $this->matchAll($rule, $row); + case $rule instanceof Any: + return $this->matchAny($rule, $row); + case $rule instanceof Like: + return $this->matchSimilar($rule, $row); + case $rule instanceof Equal: + return $this->matchEqual($rule, $row); + case $rule instanceof GreaterThan: + return $this->matchGreaterThan($rule, $row); + case $rule instanceof GreaterThanOrEqual: + return $this->matchGreaterThanOrEqual($rule, $row); + case $rule instanceof LessThan: + return $this->matchLessThan($rule, $row); + case $rule instanceof LessThanOrEqual: + return $this->matchLessThanOrEqual($rule, $row); + case $rule instanceof None: + return $this->matchNone($rule, $row); + case $rule instanceof Unequal: + return $this->matchUnequal($rule, $row); + case $rule instanceof Unlike: + return $this->matchUnlike($rule, $row); + default: + throw new InvalidArgumentException(sprintf( + 'Unable to match filter. Rule type %s is unknown', + get_class($rule) + )); + } + } + + /** + * Return a value from the given row suitable to work with + * + * @param string $column + * @param object $row + * + * @return mixed + */ + protected function extractValue($column, $row) + { + try { + return $row->{$column}; + } catch (Exception $_) { + return null; + } + } + + /** + * Normalize type of $value to the one of $rowValue + * + * For details on how this works please see the corresponding test + * {@see \ipl\Tests\Stdlib\FilterTest::testConditionsAreValueTypeAgnostic} + * + * @param mixed $rowValue + * @param mixed $value + * + * @return void + */ + protected function normalizeTypes($rowValue, &$value) + { + if ($rowValue === null || $value === null) { + return; + } + + if (is_array($rowValue)) { + if (empty($rowValue)) { + return; + } + + $rowValue = array_shift($rowValue); + } + + if (is_array($value)) { + if (is_bool($rowValue) && ! empty($value) && is_string(array_values($value)[0])) { + return; + } + + $rowValueType = gettype($rowValue); + foreach ($value as &$val) { + settype($val, $rowValueType); + } + } elseif (! is_bool($rowValue) || ! is_string($value)) { + settype($value, gettype($rowValue)); + } + } +} diff --git a/vendor/ipl/stdlib/src/Filter/All.php b/vendor/ipl/stdlib/src/Filter/All.php new file mode 100644 index 0000000..67b47b6 --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/All.php @@ -0,0 +1,7 @@ +<?php + +namespace ipl\Stdlib\Filter; + +class All extends Chain +{ +} diff --git a/vendor/ipl/stdlib/src/Filter/Any.php b/vendor/ipl/stdlib/src/Filter/Any.php new file mode 100644 index 0000000..5d47ebe --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/Any.php @@ -0,0 +1,7 @@ +<?php + +namespace ipl\Stdlib\Filter; + +class Any extends Chain +{ +} diff --git a/vendor/ipl/stdlib/src/Filter/Chain.php b/vendor/ipl/stdlib/src/Filter/Chain.php new file mode 100644 index 0000000..a64f095 --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/Chain.php @@ -0,0 +1,179 @@ +<?php + +namespace ipl\Stdlib\Filter; + +use ArrayIterator; +use Countable; +use IteratorAggregate; +use OutOfBoundsException; +use Traversable; + +abstract class Chain implements Rule, MetaDataProvider, IteratorAggregate, Countable +{ + use MetaData; + + /** @var Rule[] */ + protected $rules = []; + + /** + * Create a new Chain + * + * @param Rule ...$rules + */ + public function __construct(Rule ...$rules) + { + foreach ($rules as $rule) { + $this->add($rule); + } + } + + /** + * Clone this chain's meta data and rules + */ + public function __clone() + { + if ($this->metaData !== null) { + $this->metaData = clone $this->metaData; + } + + foreach ($this->rules as $i => $rule) { + $this->rules[$i] = clone $rule; + } + } + + /** + * Get an iterator this chain's rules + * + * @return ArrayIterator + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->rules); + } + + /** + * Add a rule to this chain + * + * @param Rule $rule + * + * @return $this + */ + public function add(Rule $rule) + { + $this->rules[] = $rule; + + return $this; + } + + /** + * Prepend a rule to an existing rule in this chain + * + * @param Rule $rule + * @param Rule $before + * + * @throws OutOfBoundsException In case no existing rule is found + * @return $this + */ + public function insertBefore(Rule $rule, Rule $before) + { + $ruleAt = array_search($before, $this->rules, true); + if ($ruleAt === false) { + throw new OutOfBoundsException('Reference rule not found'); + } + + array_splice($this->rules, $ruleAt, 0, [$rule]); + + return $this; + } + + /** + * Append a rule to an existing rule in this chain + * + * @param Rule $rule + * @param Rule $after + * + * @throws OutOfBoundsException In case no existing rule is found + * @return $this + */ + public function insertAfter(Rule $rule, Rule $after) + { + $ruleAt = array_search($after, $this->rules, true); + if ($ruleAt === false) { + throw new OutOfBoundsException('Reference rule not found'); + } + + array_splice($this->rules, $ruleAt + 1, 0, [$rule]); + + return $this; + } + + /** + * Get whether this chain contains the given rule + * + * @param Rule $rule + * + * @return bool + */ + public function has(Rule $rule) + { + return array_search($rule, $this->rules, true) !== false; + } + + /** + * Replace a rule with another one in this chain + * + * @param Rule $rule + * @param Rule $replacement + * + * @throws OutOfBoundsException In case no existing rule is found + * @return $this + */ + public function replace(Rule $rule, Rule $replacement) + { + $ruleAt = array_search($rule, $this->rules, true); + if ($ruleAt === false) { + throw new OutOfBoundsException('Rule to replace not found'); + } + + array_splice($this->rules, $ruleAt, 1, [$replacement]); + + return $this; + } + + /** + * Remove a rule from this chain + * + * @param Rule $rule + * + * @return $this + */ + public function remove(Rule $rule) + { + $ruleAt = array_search($rule, $this->rules, true); + if ($ruleAt !== false) { + array_splice($this->rules, $ruleAt, 1, []); + } + + return $this; + } + + /** + * Get whether this chain has any rules + * + * @return bool + */ + public function isEmpty() + { + return empty($this->rules); + } + + /** + * Count this chain's rules + * + * @return int + */ + public function count(): int + { + return count($this->rules); + } +} diff --git a/vendor/ipl/stdlib/src/Filter/Condition.php b/vendor/ipl/stdlib/src/Filter/Condition.php new file mode 100644 index 0000000..cc35610 --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/Condition.php @@ -0,0 +1,84 @@ +<?php + +namespace ipl\Stdlib\Filter; + +abstract class Condition implements Rule, MetaDataProvider +{ + use MetaData; + + /** @var string */ + protected $column; + + /** @var mixed */ + protected $value; + + /** + * Create a new Condition + * + * @param string $column + * @param mixed $value + */ + public function __construct($column, $value) + { + $this->setColumn($column) + ->setValue($value); + } + + /** + * Clone this condition's meta data + */ + public function __clone() + { + if ($this->metaData !== null) { + $this->metaData = clone $this->metaData; + } + } + + /** + * Set this condition's column + * + * @param string $column + * + * @return $this + */ + public function setColumn($column) + { + $this->column = $column; + + return $this; + } + + /** + * Get this condition's column + * + * @return string + */ + public function getColumn() + { + return $this->column; + } + + /** + * Set this condition's value + * + * @param mixed $value + * + * @return $this + */ + public function setValue($value) + { + $this->value = $value; + + return $this; + } + + /** + * Get this condition's value + * + * @return mixed + */ + public function getValue() + { + return $this->value; + } +} diff --git a/vendor/ipl/stdlib/src/Filter/Equal.php b/vendor/ipl/stdlib/src/Filter/Equal.php new file mode 100644 index 0000000..71da490 --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/Equal.php @@ -0,0 +1,31 @@ +<?php + +namespace ipl\Stdlib\Filter; + +class Equal extends Condition +{ + /** @var bool */ + protected $ignoreCase = false; + + /** + * Ignore case on both sides of the equation + * + * @return $this + */ + public function ignoreCase() + { + $this->ignoreCase = true; + + return $this; + } + + /** + * Return whether this rule ignores case + * + * @return bool + */ + public function ignoresCase() + { + return $this->ignoreCase; + } +} diff --git a/vendor/ipl/stdlib/src/Filter/GreaterThan.php b/vendor/ipl/stdlib/src/Filter/GreaterThan.php new file mode 100644 index 0000000..fd8190c --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/GreaterThan.php @@ -0,0 +1,7 @@ +<?php + +namespace ipl\Stdlib\Filter; + +class GreaterThan extends Condition +{ +} diff --git a/vendor/ipl/stdlib/src/Filter/GreaterThanOrEqual.php b/vendor/ipl/stdlib/src/Filter/GreaterThanOrEqual.php new file mode 100644 index 0000000..4cd4a73 --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/GreaterThanOrEqual.php @@ -0,0 +1,7 @@ +<?php + +namespace ipl\Stdlib\Filter; + +class GreaterThanOrEqual extends Condition +{ +} diff --git a/vendor/ipl/stdlib/src/Filter/LessThan.php b/vendor/ipl/stdlib/src/Filter/LessThan.php new file mode 100644 index 0000000..297493f --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/LessThan.php @@ -0,0 +1,7 @@ +<?php + +namespace ipl\Stdlib\Filter; + +class LessThan extends Condition +{ +} diff --git a/vendor/ipl/stdlib/src/Filter/LessThanOrEqual.php b/vendor/ipl/stdlib/src/Filter/LessThanOrEqual.php new file mode 100644 index 0000000..ef35974 --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/LessThanOrEqual.php @@ -0,0 +1,7 @@ +<?php + +namespace ipl\Stdlib\Filter; + +class LessThanOrEqual extends Condition +{ +} diff --git a/vendor/ipl/stdlib/src/Filter/Like.php b/vendor/ipl/stdlib/src/Filter/Like.php new file mode 100644 index 0000000..7a06279 --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/Like.php @@ -0,0 +1,31 @@ +<?php + +namespace ipl\Stdlib\Filter; + +class Like extends Condition +{ + /** @var bool */ + protected $ignoreCase = false; + + /** + * Ignore case on both sides of the equation + * + * @return $this + */ + public function ignoreCase() + { + $this->ignoreCase = true; + + return $this; + } + + /** + * Return whether this rule ignores case + * + * @return bool + */ + public function ignoresCase() + { + return $this->ignoreCase; + } +} diff --git a/vendor/ipl/stdlib/src/Filter/MetaData.php b/vendor/ipl/stdlib/src/Filter/MetaData.php new file mode 100644 index 0000000..6fe2523 --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/MetaData.php @@ -0,0 +1,20 @@ +<?php + +namespace ipl\Stdlib\Filter; + +use ipl\Stdlib\Data; + +trait MetaData +{ + /** @var Data */ + protected $metaData; + + public function metaData() + { + if ($this->metaData === null) { + $this->metaData = new Data(); + } + + return $this->metaData; + } +} diff --git a/vendor/ipl/stdlib/src/Filter/MetaDataProvider.php b/vendor/ipl/stdlib/src/Filter/MetaDataProvider.php new file mode 100644 index 0000000..ef9557e --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/MetaDataProvider.php @@ -0,0 +1,15 @@ +<?php + +namespace ipl\Stdlib\Filter; + +use ipl\Stdlib\Data; + +interface MetaDataProvider +{ + /** + * Get this rule's meta data + * + * @return Data + */ + public function metaData(); +} diff --git a/vendor/ipl/stdlib/src/Filter/None.php b/vendor/ipl/stdlib/src/Filter/None.php new file mode 100644 index 0000000..a1b14f7 --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/None.php @@ -0,0 +1,7 @@ +<?php + +namespace ipl\Stdlib\Filter; + +class None extends Chain +{ +} diff --git a/vendor/ipl/stdlib/src/Filter/Rule.php b/vendor/ipl/stdlib/src/Filter/Rule.php new file mode 100644 index 0000000..dc83c80 --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/Rule.php @@ -0,0 +1,7 @@ +<?php + +namespace ipl\Stdlib\Filter; + +interface Rule +{ +} diff --git a/vendor/ipl/stdlib/src/Filter/Unequal.php b/vendor/ipl/stdlib/src/Filter/Unequal.php new file mode 100644 index 0000000..5e37cbd --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/Unequal.php @@ -0,0 +1,31 @@ +<?php + +namespace ipl\Stdlib\Filter; + +class Unequal extends Condition +{ + /** @var bool */ + protected $ignoreCase = false; + + /** + * Ignore case on both sides of the equation + * + * @return $this + */ + public function ignoreCase() + { + $this->ignoreCase = true; + + return $this; + } + + /** + * Return whether this rule ignores case + * + * @return bool + */ + public function ignoresCase() + { + return $this->ignoreCase; + } +} diff --git a/vendor/ipl/stdlib/src/Filter/Unlike.php b/vendor/ipl/stdlib/src/Filter/Unlike.php new file mode 100644 index 0000000..16b9fb3 --- /dev/null +++ b/vendor/ipl/stdlib/src/Filter/Unlike.php @@ -0,0 +1,31 @@ +<?php + +namespace ipl\Stdlib\Filter; + +class Unlike extends Condition +{ + /** @var bool */ + protected $ignoreCase = false; + + /** + * Ignore case on both sides of the equation + * + * @return $this + */ + public function ignoreCase() + { + $this->ignoreCase = true; + + return $this; + } + + /** + * Return whether this rule ignores case + * + * @return bool + */ + public function ignoresCase() + { + return $this->ignoreCase; + } +} diff --git a/vendor/ipl/stdlib/src/Filters.php b/vendor/ipl/stdlib/src/Filters.php new file mode 100644 index 0000000..defff43 --- /dev/null +++ b/vendor/ipl/stdlib/src/Filters.php @@ -0,0 +1,58 @@ +<?php + +namespace ipl\Stdlib; + +trait Filters +{ + /** @var Filter\Chain */ + protected $filter; + + public function getFilter() + { + return $this->filter ?: Filter::all(); + } + + public function filter(Filter\Rule $filter) + { + $currentFilter = $this->getFilter(); + if ($currentFilter instanceof Filter\All) { + $this->filter = $currentFilter->add($filter); + } else { + $this->filter = Filter::all($filter); + if (! $currentFilter->isEmpty()) { + $this->filter->insertBefore($currentFilter, $filter); + } + } + + return $this; + } + + public function orFilter(Filter\Rule $filter) + { + $currentFilter = $this->getFilter(); + if ($currentFilter instanceof Filter\Any) { + $this->filter = $currentFilter->add($filter); + } else { + $this->filter = Filter::any($filter); + if (! $currentFilter->isEmpty()) { + $this->filter->insertBefore($currentFilter, $filter); + } + } + + return $this; + } + + public function notFilter(Filter\Rule $filter) + { + $this->filter(Filter::none($filter)); + + return $this; + } + + public function orNotFilter(Filter\Rule $filter) + { + $this->orFilter(Filter::none($filter)); + + return $this; + } +} diff --git a/vendor/ipl/stdlib/src/Loader/AutoloadingPluginLoader.php b/vendor/ipl/stdlib/src/Loader/AutoloadingPluginLoader.php new file mode 100644 index 0000000..ba195c6 --- /dev/null +++ b/vendor/ipl/stdlib/src/Loader/AutoloadingPluginLoader.php @@ -0,0 +1,52 @@ +<?php + +namespace ipl\Stdlib\Loader; + +use ipl\Stdlib\Contract\PluginLoader; + +/** + * Plugin loader that makes use of registered PHP autoloaders + */ +class AutoloadingPluginLoader implements PluginLoader +{ + /** @var string Namespace of the plugins */ + protected $namespace; + + /** @var string Class name postfix */ + protected $postfix; + + /** + * Create a new autoloading plugin loader + * + * @param string $namespace Namespace of the plugins + * @param string $postfix Class name postfix + */ + public function __construct($namespace, $postfix = '') + { + $this->namespace = $namespace; + $this->postfix = $postfix; + } + + /** + * Get the FQN of a plugin + * + * @param string $name Name of the plugin + * + * @return string + */ + protected function getFqn($name) + { + return $this->namespace . '\\' . ucfirst($name) . $this->postfix; + } + + public function load($name) + { + $class = $this->getFqn($name); + + if (! class_exists($class)) { + return false; + } + + return $class; + } +} diff --git a/vendor/ipl/stdlib/src/MessageContainer.php b/vendor/ipl/stdlib/src/MessageContainer.php new file mode 100644 index 0000000..3b383b1 --- /dev/null +++ b/vendor/ipl/stdlib/src/MessageContainer.php @@ -0,0 +1,9 @@ +<?php + +namespace ipl\Stdlib; + +/** @deprecated Use {@link Messages} instead */ +trait MessageContainer +{ + use Messages; +} diff --git a/vendor/ipl/stdlib/src/Messages.php b/vendor/ipl/stdlib/src/Messages.php new file mode 100644 index 0000000..b601c1d --- /dev/null +++ b/vendor/ipl/stdlib/src/Messages.php @@ -0,0 +1,92 @@ +<?php + +namespace ipl\Stdlib; + +trait Messages +{ + /** @var array */ + protected $messages = []; + + /** + * Get whether there are any messages + * + * @return bool + */ + public function hasMessages() + { + return ! empty($this->messages); + } + + /** + * Get all messages + * + * @return array + */ + public function getMessages() + { + return $this->messages; + } + + /** + * Set the given messages overriding existing ones + * + * @param string[] $messages + * + * @return $this + */ + public function setMessages(array $messages) + { + $this->clearMessages(); + + foreach ($messages as $message) { + $this->addMessage($message); + } + + return $this; + } + + /** + * Add a single message + * + * @param string $message + * @param mixed ...$args Optional args for sprintf-style messages + * + * @return $this + */ + public function addMessage($message, ...$args) + { + if (empty($args)) { + $this->messages[] = $message; + } else { + $this->messages[] = vsprintf($message, $args); + } + + return $this; + } + + /** + * Add the given messages + * + * @param array $messages + * + * @return $this + */ + public function addMessages(array $messages) + { + $this->messages = array_merge($this->messages, $messages); + + return $this; + } + + /** + * Drop any existing message + * + * @return $this + */ + public function clearMessages() + { + $this->messages = []; + + return $this; + } +} diff --git a/vendor/ipl/stdlib/src/Plugins.php b/vendor/ipl/stdlib/src/Plugins.php new file mode 100644 index 0000000..a5dbb77 --- /dev/null +++ b/vendor/ipl/stdlib/src/Plugins.php @@ -0,0 +1,95 @@ +<?php + +namespace ipl\Stdlib; + +use ipl\Stdlib\Contract\PluginLoader; +use ipl\Stdlib\Loader\AutoloadingPluginLoader; + +trait Plugins +{ + /** @var array Registered plugin loaders by type */ + protected $pluginLoaders = []; + + /** + * Factory for plugin loaders + * + * @param PluginLoader|string $loaderOrNamespace + * @param string $postfix + * + * @return PluginLoader + */ + public static function wantPluginLoader($loaderOrNamespace, $postfix = '') + { + if ($loaderOrNamespace instanceof PluginLoader) { + $loader = $loaderOrNamespace; + } else { + $loader = new AutoloadingPluginLoader($loaderOrNamespace, $postfix); + } + + return $loader; + } + + /** + * Get whether a plugin loader for the given type exists + * + * @param string $type + * + * @return bool + */ + public function hasPluginLoader($type) + { + return isset($this->pluginLoaders[$type]); + } + + /** + * Add a plugin loader for the given type + * + * @param string $type + * @param PluginLoader|string $loaderOrNamespace + * @param string $postfix + * + * @return $this + */ + public function addPluginLoader($type, $loaderOrNamespace, $postfix = '') + { + $loader = static::wantPluginLoader($loaderOrNamespace, $postfix); + + if (! isset($this->pluginLoaders[$type])) { + $this->pluginLoaders[$type] = []; + } + + array_unshift($this->pluginLoaders[$type], $loader); + + return $this; + } + + /** + * Load the class file of the given plugin + * + * @param string $type + * @param string $name + * + * @return string|false + */ + public function loadPlugin($type, $name) + { + if ($this->hasPluginLoader($type)) { + /** @var PluginLoader $loader */ + foreach ($this->pluginLoaders[$type] as $loader) { + $class = $loader->load($name); + if ($class) { + return $class; + } + } + } + + return false; + } + + protected function addDefaultPluginLoader($type, $loaderOrNamespace, $postfix) + { + $this->pluginLoaders[$type][] = static::wantPluginLoader($loaderOrNamespace, $postfix); + + return $this; + } +} diff --git a/vendor/ipl/stdlib/src/PriorityQueue.php b/vendor/ipl/stdlib/src/PriorityQueue.php new file mode 100644 index 0000000..a50b31d --- /dev/null +++ b/vendor/ipl/stdlib/src/PriorityQueue.php @@ -0,0 +1,41 @@ +<?php + +namespace ipl\Stdlib; + +use Generator; +use SplPriorityQueue; + +/** + * Stable priority queue that also maintains insertion order for items with the same priority + */ +class PriorityQueue extends SplPriorityQueue +{ + protected $serial = PHP_INT_MAX; + + /** + * @inheritDoc + * + * Maintains insertion order for items with the same priority. + */ + public function insert($value, $priority): bool + { + return parent::insert($value, [$priority, $this->serial--]); + } + + /** + * Yield all items as priority-value pairs + * + * @return Generator + */ + public function yieldAll() + { + // Clone queue because the SplPriorityQueue acts as a heap and thus items are removed upon iteration + $queue = clone $this; + + $queue->setExtractFlags(static::EXTR_BOTH); + + foreach ($queue as $item) { + yield $item['priority'][0] => $item['data']; + } + } +} diff --git a/vendor/ipl/stdlib/src/Properties.php b/vendor/ipl/stdlib/src/Properties.php new file mode 100644 index 0000000..5726af3 --- /dev/null +++ b/vendor/ipl/stdlib/src/Properties.php @@ -0,0 +1,205 @@ +<?php + +namespace ipl\Stdlib; + +use OutOfBoundsException; +use Traversable; + +/** + * Trait for property access, mutation and array access. + */ +trait Properties +{ + /** @var array */ + private $properties = []; + + /** + * Get whether this class has any properties + * + * @return bool + */ + public function hasProperties() + { + return ! empty($this->properties); + } + + /** + * Get whether a property with the given key exists + * + * @param string $key + * + * @return bool + */ + public function hasProperty($key) + { + return array_key_exists($key, $this->properties); + } + + /** + * Set the given properties + * + * @param array $properties + * + * @return $this + */ + public function setProperties(array $properties) + { + foreach ($properties as $key => $value) { + $this->setProperty($key, $value); + } + + return $this; + } + + /** + * Get the property by the given key + * + * @param string $key + * + * @return mixed + * + * @throws OutOfBoundsException If the property by the given key does not exist + */ + protected function getProperty($key) + { + if (array_key_exists($key, $this->properties)) { + return $this->properties[$key]; + } + + throw new OutOfBoundsException("Can't access property '$key'. Property does not exist"); + } + + /** + * Set a property with the given key and value + * + * @param string $key + * @param mixed $value + * + * @return $this + */ + protected function setProperty($key, $value) + { + $this->properties[$key] = $value; + + return $this; + } + + /** + * Iterate over all existing properties + * + * @return Traversable + */ + public function getIterator(): Traversable + { + foreach ($this->properties as $key => $value) { + yield $key => $value; + } + } + + /** + * Check whether an offset exists + * + * @param mixed $offset + * + * @return bool + */ + public function offsetExists($offset): bool + { + return isset($this->properties[$offset]); + } + + /** + * Get the value for an offset + * + * @param mixed $offset + * + * @return mixed + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + return $this->getProperty($offset); + } + + /** + * Set the value for an offset + * + * @param mixed $offset + * @param mixed $value + */ + public function offsetSet($offset, $value): void + { + $this->setProperty($offset, $value); + } + + /** + * Unset the value for an offset + * + * @param mixed $offset + */ + public function offsetUnset($offset): void + { + unset($this->properties[$offset]); + } + + /** + * Get the value of a non-public property + * + * This is a PHP magic method which is implicitly called upon access to non-public properties, + * e.g. `$value = $object->property;`. + * Do not call this method directly. + * + * @param mixed $key + * + * @return mixed + */ + public function __get($key) + { + return $this->getProperty($key); + } + + /** + * Set the value of a non-public property + * + * This is a PHP magic method which is implicitly called upon access to non-public properties, + * e.g. `$object->property = $value;`. + * Do not call this method directly. + * + * @param string $key + * @param mixed $value + */ + public function __set($key, $value) + { + $this->setProperty($key, $value); + } + + /** + * Check whether a non-public property is defined and not null + * + * This is a PHP magic method which is implicitly called upon access to non-public properties, + * e.g. `isset($object->property);`. + * Do not call this method directly. + * + * @param string $key + * + * @return bool + */ + public function __isset($key) + { + return $this->offsetExists($key); + } + + /** + * Unset the value of a non-public property + * + * This is a PHP magic method which is implicitly called upon access to non-public properties, + * e.g. `unset($object->property);`. This method does nothing if the property does not exist. + * Do not call this method directly. + * + * @param string $key + */ + public function __unset($key) + { + $this->offsetUnset($key); + } +} diff --git a/vendor/ipl/stdlib/src/Seq.php b/vendor/ipl/stdlib/src/Seq.php new file mode 100644 index 0000000..0f40c93 --- /dev/null +++ b/vendor/ipl/stdlib/src/Seq.php @@ -0,0 +1,108 @@ +<?php + +namespace ipl\Stdlib; + +/** + * Collection of utilities for traversables + */ +class Seq +{ + /** + * Check if the traversable contains the given needle + * + * @param array|iterable $traversable + * @param mixed $needle Might also be a closure + * @param bool $caseSensitive Whether strings should be compared case-sensitive + * + * @return bool + */ + public static function contains($traversable, $needle, $caseSensitive = true) + { + return self::find($traversable, $needle, $caseSensitive)[0] !== null; + } + + /** + * Search in the traversable for the given needle and return its key and value + * + * @param array|iterable $traversable + * @param mixed $needle Might also be a closure + * @param bool $caseSensitive Whether strings should be compared case-sensitive + * + * @return array An array with two entries, the first is the key, then the value. Both are null if nothing is found. + */ + public static function find($traversable, $needle, $caseSensitive = true) + { + $usesCallback = is_callable($needle); + if (! $usesCallback && $caseSensitive && is_array($traversable)) { + return [array_search($needle, $traversable, true), $needle]; + } + + if (! $caseSensitive && is_string($needle) && ! $usesCallback) { + $needle = strtolower($needle); + } + + foreach ($traversable as $key => $item) { + $originalItem = $item; + if (! $caseSensitive && is_string($item)) { + $item = strtolower($item); + } + + if ($usesCallback && $needle($item)) { + return [$key, $originalItem]; + } elseif ($item === $needle) { + return [$key, $originalItem]; + } + } + + return [null, null]; + } + + /** + * Search in the traversable for the given needle and return its key + * + * @param array|iterable $traversable + * @param mixed $needle Might also be a closure + * @param bool $caseSensitive Whether strings should be compared case-sensitive + * + * @return mixed|null Null if nothing is found + */ + public static function findKey($traversable, $needle, $caseSensitive = true) + { + return self::find($traversable, $needle, $caseSensitive)[0]; + } + + /** + * Search in the traversable for the given needle and return its value + * + * @param array|iterable $traversable + * @param mixed $needle Might also be a closure + * @param bool $caseSensitive Whether strings should be compared case-sensitive + * + * @return mixed|null Null if nothing is found + */ + public static function findValue($traversable, $needle, $caseSensitive = true) + { + $usesCallback = is_callable($needle); + if (! $usesCallback && $caseSensitive && is_array($traversable)) { + return isset($traversable[$needle]) ? $traversable[$needle] : null; + } + + if (! $caseSensitive && is_string($needle) && ! $usesCallback) { + $needle = strtolower($needle); + } + + foreach ($traversable as $key => $item) { + if (! $caseSensitive && is_string($key)) { + $key = strtolower($key); + } + + if ($usesCallback && $needle($key)) { + return $item; + } elseif ($key === $needle) { + return $item; + } + } + + return null; + } +} diff --git a/vendor/ipl/stdlib/src/Str.php b/vendor/ipl/stdlib/src/Str.php new file mode 100644 index 0000000..d46989e --- /dev/null +++ b/vendor/ipl/stdlib/src/Str.php @@ -0,0 +1,93 @@ +<?php + +namespace ipl\Stdlib; + +/** + * Collection of string manipulation functions + */ +class Str +{ + /** + * Convert the given string to camel case + * + * The given string may be delimited by the following characters: '_' (underscore), '-' (dash), ' ' (space). + * + * @param string $subject + * + * @return string + */ + public static function camel($subject) + { + if ($subject === null) { + return ''; + } + + $normalized = str_replace(['-', '_'], ' ', $subject); + + return lcfirst(str_replace(' ', '', ucwords(strtolower($normalized)))); + } + + /** + * Check if the given string starts with the specified substring + * + * @param string $subject + * @param string $start + * @param bool $caseSensitive + * + * @return bool + */ + public static function startsWith($subject, string $start, bool $caseSensitive = true) + { + $subject = $subject ?? ''; + if (! $caseSensitive) { + return strncasecmp($subject, $start, strlen($start)) === 0; + } + + return substr($subject, 0, strlen($start)) === $start; + } + + /** + * Split string into an array padded to the size specified by limit + * + * This method is a perfect fit if you need default values for symmetric array destructuring. + * + * @param string $subject + * @param string $delimiter + * @param int $limit + * @param mixed $default + * + * @return array + */ + public static function symmetricSplit($subject, string $delimiter, int $limit, $default = null) + { + if ($subject === null) { + return []; + } + + return array_pad(explode($delimiter, $subject, $limit), $limit, $default); + } + + /** + * Split string into an array and trim spaces + * + * @param string $subject + * @param string $delimiter + * @param int $limit + * + * @return array + */ + public static function trimSplit($subject, string $delimiter = ',', int $limit = null) + { + if ($subject === null) { + return []; + } + + if ($limit !== null) { + $exploded = explode($delimiter, $subject, $limit); + } else { + $exploded = explode($delimiter, $subject); + } + + return array_map('trim', $exploded); + } +} diff --git a/vendor/ipl/stdlib/src/functions.php b/vendor/ipl/stdlib/src/functions.php new file mode 100644 index 0000000..2091c2c --- /dev/null +++ b/vendor/ipl/stdlib/src/functions.php @@ -0,0 +1,71 @@ +<?php + +namespace ipl\Stdlib; + +use InvalidArgumentException; +use Traversable; +use stdClass; + +/** + * Detect and return the PHP type of the given subject + * + * If subject is an object, the name of the object's class is returned, otherwise the subject's type. + * + * @param $subject + * + * @return string + */ +function get_php_type($subject) +{ + if (is_object($subject)) { + return get_class($subject); + } else { + return gettype($subject); + } +} + +/** + * Get the array value of the given subject + * + * @param array|object|Traversable $subject + * + * @return array + * + * @throws InvalidArgumentException If subject type is invalid + */ +function arrayval($subject) +{ + if (is_array($subject)) { + return $subject; + } + + if ($subject instanceof stdClass) { + return (array) $subject; + } + + if ($subject instanceof Traversable) { + // Works for generators too + return iterator_to_array($subject); + } + + throw new InvalidArgumentException(sprintf( + 'arrayval expects arrays, objects or instances of Traversable. Got %s instead.', + get_php_type($subject) + )); +} + +/** + * Get the first key of an iterable + * + * @param iterable $iterable + * + * @return mixed The first key of the iterable if it is not empty, null otherwise + */ +function iterable_key_first($iterable) +{ + foreach ($iterable as $key => $_) { + return $key; + } + + return null; +} diff --git a/vendor/ipl/stdlib/src/functions_include.php b/vendor/ipl/stdlib/src/functions_include.php new file mode 100644 index 0000000..9a2dc6f --- /dev/null +++ b/vendor/ipl/stdlib/src/functions_include.php @@ -0,0 +1,6 @@ +<?php + +// Don't redefine the functions if included multiple times +if (! function_exists('ipl\Stdlib\get_php_type')) { + require __DIR__ . '/functions.php'; +} diff --git a/vendor/ipl/validator/LICENSE b/vendor/ipl/validator/LICENSE new file mode 100644 index 0000000..b247ccf --- /dev/null +++ b/vendor/ipl/validator/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2020 Icinga GmbH https://www.icinga.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/vendor/ipl/validator/composer.json b/vendor/ipl/validator/composer.json new file mode 100644 index 0000000..0d66bee --- /dev/null +++ b/vendor/ipl/validator/composer.json @@ -0,0 +1,23 @@ +{ + "name": "ipl/validator", + "type": "library", + "description": "Icinga PHP Library - Common validators and validator chaining", + "homepage": "https://github.com/Icinga/ipl-validator", + "license": "MIT", + "require": { + "php": ">=7.2", + "ext-openssl": "*", + "ipl/stdlib": ">=0.12.0", + "ipl/i18n": ">=0.2.0" + }, + "autoload": { + "psr-4": { + "ipl\\Validator\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "ipl\\Tests\\Validator\\": "tests" + } + } +} diff --git a/vendor/ipl/validator/src/BaseValidator.php b/vendor/ipl/validator/src/BaseValidator.php new file mode 100644 index 0000000..8faa79b --- /dev/null +++ b/vendor/ipl/validator/src/BaseValidator.php @@ -0,0 +1,11 @@ +<?php + +namespace ipl\Validator; + +use ipl\Stdlib\Contract\Validator; +use ipl\Stdlib\Messages; + +abstract class BaseValidator implements Validator +{ + use Messages; +} diff --git a/vendor/ipl/validator/src/CallbackValidator.php b/vendor/ipl/validator/src/CallbackValidator.php new file mode 100644 index 0000000..611a45e --- /dev/null +++ b/vendor/ipl/validator/src/CallbackValidator.php @@ -0,0 +1,45 @@ +<?php + +namespace ipl\Validator; + +/** + * Validator that uses a callback for the actual validation + * + * # Example Usage + * ``` + * $dedup = new CallbackValidator(function ($value, CallbackValidator $validator) { + * if (already_exists_in_database($value)) { + * $validator->addMessage('Record already exists in database'); + * + * return false; + * } + * + * return true; + * }); + * + * $dedup->isValid($id); + * ``` + */ +class CallbackValidator extends BaseValidator +{ + /** @var callable Validation callback */ + protected $callback; + + /** + * Create a new callback validator + * + * @param callable $callback Validation callback + */ + public function __construct(callable $callback) + { + $this->callback = $callback; + } + + public function isValid($value) + { + // Multiple isValid() calls must not stack validation messages + $this->clearMessages(); + + return call_user_func($this->callback, $value, $this); + } +} diff --git a/vendor/ipl/validator/src/DateTimeValidator.php b/vendor/ipl/validator/src/DateTimeValidator.php new file mode 100644 index 0000000..1e35d61 --- /dev/null +++ b/vendor/ipl/validator/src/DateTimeValidator.php @@ -0,0 +1,65 @@ +<?php + +namespace ipl\Validator; + +use DateTime; +use ipl\I18n\Translation; + +/** + * Validator for date-and-time input controls + */ +class DateTimeValidator extends BaseValidator +{ + use Translation; + + /** @var string Default date time format */ + const FORMAT = 'Y-m-d\TH:i:s'; + + /** @var bool Whether to use the default date time format */ + protected $local; + + /** + * Create a new date-and-time input control validator + * + * @param bool $local + */ + public function __construct($local = true) + { + $this->local = (bool) $local; + } + + /** + * Check whether the given date time is valid + * + * @param string|DateTime $value + * + * @return bool + */ + public function isValid($value) + { + // Multiple isValid() calls must not stack validation messages + $this->clearMessages(); + + if (! $value instanceof DateTime && ! is_string($value)) { + $this->addMessage($this->translate('Invalid date/time given.')); + + return false; + } + + if (! $value instanceof DateTime) { + $format = $this->local === true ? static::FORMAT : DateTime::RFC3339; + $dateTime = DateTime::createFromFormat($format, $value); + + if ($dateTime === false || $dateTime->format($format) !== $value) { + $this->addMessage(sprintf( + $this->translate("Date/time string not in the expected format: %s"), + $format + )); + + return false; + } + } + + return true; + } +} diff --git a/vendor/ipl/validator/src/PrivateKeyValidator.php b/vendor/ipl/validator/src/PrivateKeyValidator.php new file mode 100644 index 0000000..b629398 --- /dev/null +++ b/vendor/ipl/validator/src/PrivateKeyValidator.php @@ -0,0 +1,33 @@ +<?php + +namespace ipl\Validator; + +use ipl\I18n\Translation; + +/** + * Validates a private key + */ +class PrivateKeyValidator extends BaseValidator +{ + use Translation; + + public function isValid($value) + { + // Multiple isValid() calls must not stack validation messages + $this->clearMessages(); + + if (preg_match('/\A\s*\w+:/', $value)) { + $this->addMessage($this->translate('URLs are not allowed')); + + return false; + } + + if (openssl_pkey_get_private($value) === false) { + $this->addMessage($this->translate('Not a valid PEM-encoded private key')); + + return false; + } + + return true; + } +} diff --git a/vendor/ipl/validator/src/ValidatorChain.php b/vendor/ipl/validator/src/ValidatorChain.php new file mode 100644 index 0000000..2860a12 --- /dev/null +++ b/vendor/ipl/validator/src/ValidatorChain.php @@ -0,0 +1,284 @@ +<?php + +namespace ipl\Validator; + +use Countable; +use InvalidArgumentException; +use ipl\Stdlib\Contract\Validator; +use ipl\Stdlib\Messages; +use ipl\Stdlib\Plugins; +use ipl\Stdlib\PriorityQueue; +use IteratorAggregate; +use SplObjectStorage; +use Traversable; +use UnexpectedValueException; + +use function ipl\Stdlib\get_php_type; + +class ValidatorChain implements Countable, IteratorAggregate, Validator +{ + use Messages; + use Plugins; + + /** Default priority at which validators are added */ + const DEFAULT_PRIORITY = 1; + + /** @var PriorityQueue Validator chain */ + protected $validators; + + /** @var SplObjectStorage Validators that break the chain on failure */ + protected $validatorsThatBreakTheChain; + + /** + * Create a new validator chain + */ + public function __construct() + { + $this->validators = new PriorityQueue(); + $this->validatorsThatBreakTheChain = new SplObjectStorage(); + + $this->addDefaultPluginLoader('validator', __NAMESPACE__, 'Validator'); + } + + /** + * Get the validators that break the chain + * + * @return SplObjectStorage + */ + public function getValidatorsThatBreakTheChain() + { + return $this->validatorsThatBreakTheChain; + } + + /** + * Add a validator to the chain + * + * If $breakChainOnFailure is true and the validator fails, subsequent validators won't be executed. + * + * @param Validator $validator + * @param bool $breakChainOnFailure + * @param int $priority Priority at which to add validator + * + * @return $this + * + */ + public function add(Validator $validator, $breakChainOnFailure = false, $priority = self::DEFAULT_PRIORITY) + { + $this->validators->insert($validator, $priority); + + if ($breakChainOnFailure) { + $this->validatorsThatBreakTheChain->attach($validator); + } + + return $this; + } + + /** + * Add the validators from the given validator specification to the chain + * + * @param iterable $validators + * + * @return $this + * + * @throws InvalidArgumentException If $validators is not iterable or if the validator specification is invalid + */ + public function addValidators($validators) + { + if ($validators instanceof static) { + return $this->merge($validators); + } + + if (! is_iterable($validators)) { + throw new InvalidArgumentException(sprintf( + '%s expects parameter one to be iterable, got %s instead', + __METHOD__, + get_php_type($validators) + )); + } + + foreach ($validators as $name => $validator) { + $breakChainOnFailure = false; + + if (! $validator instanceof Validator) { + if (is_int($name)) { + if (! is_array($validator)) { + $name = $validator; + $validator = null; + } else { + if (! isset($validator['name'])) { + throw new InvalidArgumentException( + 'Invalid validator array specification: Key "name" is missing' + ); + } + + $name = $validator['name']; + unset($validator['name']); + } + } + + if (is_array($validator)) { + if (isset($validator['options'])) { + $options = $validator['options']; + + unset($validator['options']); + + $validator = array_merge($validator, $options); + } + + if (isset($validator['break_chain_on_failure'])) { + $breakChainOnFailure = $validator['break_chain_on_failure']; + + unset($validator['break_chain_on_failure']); + } + } + + $validator = $this->createValidator($name, $validator); + } + + $this->add($validator, $breakChainOnFailure); + } + + return $this; + } + + /** + * Add a validator loader + * + * @param string $namespace Namespace of the validators + * @param string $postfix Validator name postfix, if any + * + * @return $this + */ + public function addValidatorLoader($namespace, $postfix = null) + { + $this->addPluginLoader('validator', $namespace, $postfix); + + return $this; + } + + /** + * Remove all validators from the chain + * + * @return $this + */ + public function clearValidators() + { + $this->validators = new PriorityQueue(); + $this->validatorsThatBreakTheChain = new SplObjectStorage(); + + return $this; + } + + /** + * Create a validator from the given name and options + * + * @param string $name + * @param mixed $options + * + * @return Validator + * + * @throws InvalidArgumentException If the validator to load is unknown + * @throws UnexpectedValueException If a validator loader did not return an instance of {@link Validator} + */ + public function createValidator($name, $options = null) + { + $class = $this->loadPlugin('validator', $name); + + if (! $class) { + throw new InvalidArgumentException(sprintf( + "Can't load validator '%s'. Validator unknown", + $name + )); + } + + if (empty($options)) { + $validator = new $class(); + } else { + $validator = new $class($options); + } + + if (! $validator instanceof Validator) { + throw new UnexpectedValueException(sprintf( + "%s expects loader to return an instance of %s for validator '%s', got %s instead", + __METHOD__, + Validator::class, + $name, + get_php_type($validator) + )); + } + + return $validator; + } + + /** + * Merge all validators from the given chain into this one + * + * @param ValidatorChain $validatorChain + * + * @return $this + */ + public function merge(ValidatorChain $validatorChain) + { + $validatorsThatBreakTheChain = $validatorChain->getValidatorsThatBreakTheChain(); + + foreach ($validatorChain->validators->yieldAll() as $priority => $validator) { + $this->add($validator, $validatorsThatBreakTheChain->contains($validator), $priority); + } + + return $this; + } + + public function __clone() + { + $this->validators = clone $this->validators; + } + + /** + * Export the chain as array + * + * @return array + */ + public function toArray() + { + return array_values(iterator_to_array($this)); + } + + public function count(): int + { + return count($this->validators); + } + + /** + * Get an iterator for traversing the validators + * + * @return Validator[]|PriorityQueue + */ + public function getIterator(): Traversable + { + // Clone validators because the PriorityQueue acts as a heap and thus items are removed upon iteration + return clone $this->validators; + } + + public function isValid($value) + { + $this->clearMessages(); + + $valid = true; + + foreach ($this as $validator) { + if ($validator->isValid($value)) { + continue; + } + + $valid = false; + + $this->addMessages($validator->getMessages()); + + if ($this->validatorsThatBreakTheChain->contains($validator)) { + break; + } + } + + return $valid; + } +} diff --git a/vendor/ipl/validator/src/X509CertValidator.php b/vendor/ipl/validator/src/X509CertValidator.php new file mode 100644 index 0000000..7dfc4f7 --- /dev/null +++ b/vendor/ipl/validator/src/X509CertValidator.php @@ -0,0 +1,33 @@ +<?php + +namespace ipl\Validator; + +use ipl\I18n\Translation; + +/** + * Validates an X.509 certificate + */ +class X509CertValidator extends BaseValidator +{ + use Translation; + + public function isValid($value) + { + // Multiple isValid() calls must not stack validation messages + $this->clearMessages(); + + if (preg_match('/\A\s*\w+:/', $value)) { + $this->addMessage($this->translate('URLs are not allowed')); + + return false; + } + + if (openssl_x509_parse($value) === false) { + $this->addMessage($this->translate('Not a valid PEM-encoded X.509 certificate')); + + return false; + } + + return true; + } +} diff --git a/vendor/ipl/web/LICENSE b/vendor/ipl/web/LICENSE new file mode 100644 index 0000000..a904102 --- /dev/null +++ b/vendor/ipl/web/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2018 Icinga GmbH https://icinga.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/vendor/ipl/web/composer.json b/vendor/ipl/web/composer.json new file mode 100644 index 0000000..90173f1 --- /dev/null +++ b/vendor/ipl/web/composer.json @@ -0,0 +1,26 @@ +{ + "name": "ipl/web", + "type": "library", + "description": "Icinga PHP Library - Web Components", + "keywords": ["html"], + "homepage": "https://github.com/Icinga/ipl-web", + "license": "MIT", + "autoload": { + "psr-4": { + "ipl\\Web\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "ipl\\Tests\\Web\\": "tests" + } + }, + "require": { + "php": ">=7.2", + "ext-json": "*", + "ipl/html": ">=0.6.0", + "ipl/i18n": ">=0.2.0", + "ipl/stdlib": ">=0.12.0", + "fortawesome/font-awesome": "^6" + } +} diff --git a/vendor/ipl/web/src/Common/BaseTarget.php b/vendor/ipl/web/src/Common/BaseTarget.php new file mode 100644 index 0000000..db9be28 --- /dev/null +++ b/vendor/ipl/web/src/Common/BaseTarget.php @@ -0,0 +1,33 @@ +<?php + +namespace ipl\Web\Common; + +/** + * @method \ipl\Html\Attributes getAttributes() + */ +trait BaseTarget +{ + /** + * Get the data-base-target attribute + * + * @return string|null + */ + public function getBaseTarget() + { + return $this->getAttributes()->get('data-base-target')->getValue(); + } + + /** + * Set the data-base-target attribute + * + * @param string $target + * + * @return $this + */ + public function setBaseTarget($target) + { + $this->getAttributes()->set('data-base-target', $target); + + return $this; + } +} diff --git a/vendor/ipl/web/src/Common/Card.php b/vendor/ipl/web/src/Common/Card.php new file mode 100644 index 0000000..e62bab5 --- /dev/null +++ b/vendor/ipl/web/src/Common/Card.php @@ -0,0 +1,55 @@ +<?php + +namespace ipl\Web\Common; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; + +abstract class Card extends BaseHtmlElement +{ + protected $tag = 'section'; + + abstract protected function assembleBody(BaseHtmlElement $body); + + abstract protected function assembleFooter(BaseHtmlElement $footer); + + abstract protected function assembleHeader(BaseHtmlElement $header); + + protected function createBody() + { + $body = Html::tag('div', ['class' => 'card-body']); + + $this->assembleBody($body); + + return $body; + } + + protected function createFooter() + { + $footer = Html::tag('div', ['class' => 'card-footer']); + + $this->assembleFooter($footer); + + return $footer; + } + + protected function createHeader() + { + $header = Html::tag('div', ['class' => 'card-header']); + + $this->assembleHeader($header); + + return $header; + } + + protected function assemble() + { + $this->addAttributes(['class' => 'card']); + + $this->add([ + $this->createHeader(), + $this->createBody(), + $this->createFooter() + ]); + } +} diff --git a/vendor/ipl/web/src/Common/CsrfCounterMeasure.php b/vendor/ipl/web/src/Common/CsrfCounterMeasure.php new file mode 100644 index 0000000..348c4ee --- /dev/null +++ b/vendor/ipl/web/src/Common/CsrfCounterMeasure.php @@ -0,0 +1,48 @@ +<?php + +namespace ipl\Web\Common; + +use ipl\Html\Contract\FormElement; +use ipl\Html\Form; + +trait CsrfCounterMeasure +{ + /** + * Create a form element to counter measure CSRF attacks + * + * @param string $uniqueId A unique ID that persists through different requests + * + * @return FormElement + */ + protected function createCsrfCounterMeasure($uniqueId) + { + $hashAlgo = in_array('sha3-256', hash_algos(), true) ? 'sha3-256' : 'sha256'; + + $seed = random_bytes(16); + $token = base64_encode($seed) . '|' . hash($hashAlgo, $uniqueId . $seed); + + /** @var Form $this */ + return $this->createElement( + 'hidden', + 'CSRFToken', + [ + 'ignore' => true, + 'required' => true, + 'value' => $token, + 'validators' => ['Callback' => function ($token) use ($uniqueId, $hashAlgo) { + if (strpos($token, '|') === false) { + die('Invalid CSRF token provided'); + } + + list($seed, $hash) = explode('|', $token); + + if ($hash !== hash($hashAlgo, $uniqueId . base64_decode($seed))) { + die('Invalid CSRF token provided'); + } + + return true; + }] + ] + ); + } +} diff --git a/vendor/ipl/web/src/Common/FormUid.php b/vendor/ipl/web/src/Common/FormUid.php new file mode 100644 index 0000000..8453dd2 --- /dev/null +++ b/vendor/ipl/web/src/Common/FormUid.php @@ -0,0 +1,59 @@ +<?php + +namespace ipl\Web\Common; + +use ipl\Html\Form; +use ipl\Html\Contract\FormElement; +use LogicException; + +trait FormUid +{ + protected $uidElementName = 'uid'; + + /** + * Create a form element to make this form distinguishable from others + * + * You'll have to define a name for the form for this to work. + * + * @return FormElement + */ + protected function createUidElement() + { + /** @var Form $this */ + $element = $this->createElement('hidden', $this->uidElementName); + $element->getAttributes()->registerAttributeCallback('value', function () { + /** @var Form $this */ + return $this->getAttributes()->get('name')->getValue(); + }); + + return $element; + } + + /** + * Get whether the form has been sent + * + * A form is considered sent if the request's method equals the form's method + * and the sent UID is the form's UID. + * + * @return bool + */ + public function hasBeenSent() + { + if (! parent::hasBeenSent()) { + return false; + } elseif ($this->getMethod() === 'GET') { + // Get forms are unlikely to require a UID. If they do, change this. + return true; + } + + /** @var Form $this */ + $name = $this->getAttributes()->get('name')->getValue(); + if (! $name) { + throw new LogicException('Form has no name'); + } + + $values = $this->getRequest()->getParsedBody(); + + return isset($values[$this->uidElementName]) && $values[$this->uidElementName] === $name; + } +} diff --git a/vendor/ipl/web/src/Common/RedirectOption.php b/vendor/ipl/web/src/Common/RedirectOption.php new file mode 100644 index 0000000..0d73ef8 --- /dev/null +++ b/vendor/ipl/web/src/Common/RedirectOption.php @@ -0,0 +1,41 @@ +<?php + +namespace ipl\Web\Common; + +use ipl\Html\Contract\FormElement; +use ipl\Html\Form; +use LogicException; + +trait RedirectOption +{ + /** + * Create a form element to retrieve the redirect target upon form submit + * + * @return FormElement + */ + protected function createRedirectOption() + { + /** @var Form $this */ + return $this->createElement('hidden', 'redirect'); + } + + /** + * @see Form::getRedirectUrl() + */ + public function getRedirectUrl() + { + /** @var Form $this */ + $redirectOption = $this->getValue('redirect'); + if (! $redirectOption) { + return parent::getRedirectUrl(); + } + + if (! $this->hasElement('CSRFToken') || ! $this->getElement('CSRFToken')->isValid()) { + throw new LogicException( + 'It is not safe to accept redirect targets from submit values without CSRF protection' + ); + } + + return $redirectOption; + } +} diff --git a/vendor/ipl/web/src/Common/StateBadges.php b/vendor/ipl/web/src/Common/StateBadges.php new file mode 100644 index 0000000..5898492 --- /dev/null +++ b/vendor/ipl/web/src/Common/StateBadges.php @@ -0,0 +1,184 @@ +<?php + +namespace ipl\Web\Common; + +use Icinga\Data\Filter\Filter; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Stdlib\BaseFilter; +use ipl\Web\Filter\QueryString; +use ipl\Web\Url; +use ipl\Web\Widget\Link; +use ipl\Web\Widget\StateBadge; + +abstract class StateBadges extends BaseHtmlElement +{ + use BaseFilter; + + /** @var object $item */ + protected $item; + + /** @var string */ + protected $type; + + /** @var string Prefix */ + protected $prefix; + + /** @var Url Badge link */ + protected $url; + + protected $tag = 'ul'; + + protected $defaultAttributes = ['class' => 'state-badges']; + + /** + * Create a new widget for state badges + * + * @param object $item + */ + public function __construct($item) + { + $this->item = $item; + $this->type = $this->getType(); + $this->prefix = $this->getPrefix(); + $this->url = $this->getBaseUrl(); + } + + /** + * Get the badge base URL + * + * @return Url + */ + abstract protected function getBaseUrl(): Url; + + /** + * Get the type of the items + * + * @return string + */ + abstract protected function getType(): string; + + /** + * Get the prefix for accessing state information + * + * @return string + */ + abstract protected function getPrefix(): string; + + /** + * Get the integer of the given state text + * + * @param string $state + * + * @return int + */ + abstract protected function getStateInt(string $state): int; + + /** + * Get the badge URL + * + * @return Url + */ + public function getUrl(): Url + { + return $this->url; + } + + /** + * Set the badge URL + * + * @param Url $url + * + * @return $this + */ + public function setUrl(Url $url): self + { + $this->url = $url; + + return $this; + } + + /** + * Create a badge link + * + * @param $content + * @param array $params + * + * @return Link + */ + public function createLink($content, array $params = null): Link + { + $url = clone $this->getUrl(); + + if (! empty($params)) { + $url->getParams()->mergeValues($params); + } + + if ($this->hasBaseFilter()) { + $url->addFilter(Filter::fromQueryString(QueryString::render($this->getBaseFilter()))); + } + + return new Link($content, $url); + } + + /** + * Create a state bade + * + * @param string $state + * + * @return ?BaseHtmlElement + */ + protected function createBadge(string $state) + { + $key = $this->prefix . "_{$state}"; + + if (isset($this->item->$key) && $this->item->$key) { + return Html::tag('li', $this->createLink( + new StateBadge($this->item->$key, $state), + [$this->type . '.state.soft_state' => $this->getStateInt($state)] + )); + } + + return null; + } + + /** + * Create a state group + * + * @param string $state + * + * @return ?BaseHtmlElement + */ + protected function createGroup(string $state) + { + $content = []; + $handledKey = $this->prefix . "_{$state}_handled"; + $unhandledKey = $this->prefix . "_{$state}_unhandled"; + + if (isset($this->item->$unhandledKey) && $this->item->$unhandledKey) { + $content[] = Html::tag('li', $this->createLink( + new StateBadge($this->item->$unhandledKey, $state), + [ + $this->type . '.state.soft_state' => $this->getStateInt($state), + $this->type . '.state.is_handled' => 'n' + ] + )); + } + + if (isset($this->item->$handledKey) && $this->item->$handledKey) { + $content[] = Html::tag('li', $this->createLink( + new StateBadge($this->item->$handledKey, $state, true), + [ + $this->type . '.state.soft_state' => $this->getStateInt($state), + $this->type . '.state.is_handled' => 'y' + ] + )); + } + + if (empty($content)) { + return null; + } + + return Html::tag('li', Html::tag('ul', $content)); + } +} diff --git a/vendor/ipl/web/src/Compat/CompatController.php b/vendor/ipl/web/src/Compat/CompatController.php new file mode 100644 index 0000000..08e69f1 --- /dev/null +++ b/vendor/ipl/web/src/Compat/CompatController.php @@ -0,0 +1,329 @@ +<?php + +namespace ipl\Web\Compat; + +use GuzzleHttp\Psr7\ServerRequest; +use InvalidArgumentException; +use Icinga\Web\Controller; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlString; +use ipl\Html\ValidHtml; +use ipl\Web\Control\PaginationControl; +use ipl\Web\Control\SearchBar; +use ipl\Web\Layout\Content; +use ipl\Web\Layout\Controls; +use ipl\Web\Layout\Footer; +use ipl\Web\Url; +use ipl\Web\Widget\Tabs; +use LogicException; +use Psr\Http\Message\ServerRequestInterface; + +class CompatController extends Controller +{ + /** @var Content */ + protected $content; + + /** @var Controls */ + protected $controls; + + /** @var HtmlDocument */ + protected $document; + + /** @var Footer */ + protected $footer; + + /** @var Tabs */ + protected $tabs; + + /** @var array */ + protected $parts; + + protected function prepareInit() + { + parent::prepareInit(); + + $this->params->shift('isIframe'); + $this->params->shift('showFullscreen'); + $this->params->shift('showCompact'); + $this->params->shift('renderLayout'); + $this->params->shift('_disableLayout'); + $this->params->shift('_dev'); + if ($this->params->get('view') === 'compact') { + $this->params->remove('view'); + } + + $this->document = new HtmlDocument(); + $this->document->setSeparator("\n"); + $this->controls = new Controls(); + $this->controls->setAttribute('id', $this->getRequest()->protectId('controls')); + $this->content = new Content(); + $this->content->setAttribute('id', $this->getRequest()->protectId('content')); + $this->footer = new Footer(); + $this->footer->setAttribute('id', $this->getRequest()->protectId('footer')); + $this->tabs = new Tabs(); + $this->tabs->setAttribute('id', $this->getRequest()->protectId('tabs')); + $this->parts = []; + + $this->view->tabs = $this->tabs; + $this->controls->setTabs($this->tabs); + + ViewRenderer::inject(); + + $this->view->document = $this->document; + } + + /** + * Get the current server request + * + * @return ServerRequestInterface + */ + public function getServerRequest() + { + return ServerRequest::fromGlobals(); + } + + /** + * Get the document + * + * @return HtmlDocument + */ + public function getDocument() + { + return $this->document; + } + + /** + * Get the tabs + * + * @return Tabs + */ + public function getTabs() + { + return $this->tabs; + } + + /** + * Add content + * + * @param ValidHtml $content + * + * @return $this + */ + protected function addContent(ValidHtml $content) + { + $this->content->add($content); + + return $this; + } + + /** + * Add a control + * + * @param ValidHtml $control + * + * @return $this + */ + protected function addControl(ValidHtml $control) + { + $this->controls->add($control); + + return $this; + } + + /** + * Add footer + * + * @param ValidHtml $footer + * + * @return $this + */ + protected function addFooter(ValidHtml $footer) + { + $this->footer->add($footer); + + return $this; + } + + /** + * Add a part to be served as multipart-content + * + * If an id is passed the element is used as-is as the part's content. + * Otherwise (no id given) the element's content is used instead. + * + * @param ValidHtml $element + * @param string $id If not given, this is taken from $element + * + * @throws InvalidArgumentException If no id is given and the element also does not have one + * + * @return $this + */ + protected function addPart(ValidHtml $element, $id = null) + { + $part = new Multipart(); + + if ($id === null) { + if (! $element instanceof BaseHtmlElement) { + throw new InvalidArgumentException('If no id is given, $element must be a BaseHtmlElement'); + } + + $id = $element->getAttributes()->get('id')->getValue(); + if (! $id) { + throw new InvalidArgumentException('Element has no id'); + } + + $part->addFrom($element); + } else { + $part->add($element); + } + + $this->parts[] = $part->setFor($id); + + return $this; + } + + /** + * Set the given title as the window's title + * + * @param string $title + * @param mixed ...$args + * + * @return $this + */ + protected function setTitle($title, ...$args) + { + if (! empty($args)) { + $title = vsprintf($title, $args); + } + + $this->view->title = $title; + + return $this; + } + + /** + * Add an active tab with the given title and set it as the window's title too + * + * @param string $title + * @param mixed ...$args + * + * @return $this + */ + protected function addTitleTab($title, ...$args) + { + $this->setTitle($title, ...$args); + + $tabName = uniqid(); + $this->getTabs()->add($tabName, [ + 'label' => $this->view->title, + 'url' => $this->getRequest()->getUrl() + ])->activate($tabName); + + return $this; + } + + /** + * Send a multipart update instead of a standard response + * + * As part of a multipart update, the tabs, content and footer as well as selected controls are + * transmitted in a way the client can render them exclusively instead of a full column reload. + * + * By default the only control included in the response is the pagination control, if added. + * + * @param BaseHtmlElement ...$additionalControls Additional controls to include + * + * @throws LogicException In case an additional control has not been added + */ + public function sendMultipartUpdate(BaseHtmlElement ...$additionalControls) + { + $searchBar = null; + $pagination = null; + $redirectUrl = null; + foreach ($this->controls->getContent() as $control) { + if ($control instanceof PaginationControl) { + $pagination = $control; + } elseif ($control instanceof SearchBar) { + $searchBar = $control; + $redirectUrl = $control->getRedirectUrl(); /** @var Url $redirectUrl */ + } + } + + if ($searchBar !== null && ($changes = $searchBar->getChanges()) !== null) { + $this->addPart(HtmlString::create(json_encode($changes)), 'Behavior:InputEnrichment'); + } + + foreach ($additionalControls as $control) { + $this->addPart($control); + } + + if ($searchBar !== null && $this->content->isEmpty() && ! $searchBar->isValid()) { + // No content and an invalid search bar? That's it then, further updates are not required + return; + } + + if ($this->tabs->count() > 0) { + if ($redirectUrl !== null) { + $this->tabs->setRefreshUrl($redirectUrl); + $this->tabs->getActiveTab()->setUrl($redirectUrl); + + // As long as we still depend on the legacy tab implementation + // there is no other way to influence what the tab extensions + // use as url. (https://github.com/Icinga/icingadb-web/issues/373) + $oldPathInfo = $this->getRequest()->getPathInfo(); + $oldQuery = $_SERVER['QUERY_STRING']; + $this->getRequest()->setPathInfo('/' . $redirectUrl->getPath()); + $_SERVER['QUERY_STRING'] = $redirectUrl->getParams()->toString(); + $this->tabs->ensureAssembled(); + $this->getRequest()->setPathInfo($oldPathInfo); + $_SERVER['QUERY_STRING'] = $oldQuery; + } + + $this->addPart($this->tabs); + } + + if ($pagination !== null) { + if ($redirectUrl !== null) { + $pagination->setUrl(clone $redirectUrl); + } + + $this->addPart($pagination); + } + + if (! $this->content->isEmpty()) { + $this->addPart($this->content); + } + + if (! $this->footer->isEmpty()) { + $this->addPart($this->footer); + } + + if ($redirectUrl !== null) { + $this->getResponse()->setHeader('X-Icinga-Location-Query', $redirectUrl->getParams()->toString()); + } + } + + public function postDispatch() + { + if (empty($this->parts)) { + if (! $this->content->isEmpty()) { + $this->document->prepend($this->content); + + if (! $this->view->compact && ! $this->controls->isEmpty()) { + $this->document->prepend($this->controls); + } + + if (! $this->footer->isEmpty()) { + $this->document->add($this->footer); + } + } + } else { + $partSeparator = base64_encode(random_bytes(16)); + $this->getResponse()->setHeader('X-Icinga-Multipart-Content', $partSeparator); + + $this->document->setSeparator("\n$partSeparator\n"); + $this->document->add($this->parts); + } + + parent::postDispatch(); + } +} diff --git a/vendor/ipl/web/src/Compat/CompatDecorator.php b/vendor/ipl/web/src/Compat/CompatDecorator.php new file mode 100644 index 0000000..856b758 --- /dev/null +++ b/vendor/ipl/web/src/Compat/CompatDecorator.php @@ -0,0 +1,14 @@ +<?php + +namespace ipl\Web\Compat; + +use ipl\Web\FormDecorator\IcingaFormDecorator; + +/** + * Compat form element decorator based on div elements + * + * @deprecated Use {@see \ipl\Web\FormDecorator\IcingaFormDecorator} instead + */ +class CompatDecorator extends IcingaFormDecorator +{ +} diff --git a/vendor/ipl/web/src/Compat/CompatForm.php b/vendor/ipl/web/src/Compat/CompatForm.php new file mode 100644 index 0000000..b759b8f --- /dev/null +++ b/vendor/ipl/web/src/Compat/CompatForm.php @@ -0,0 +1,25 @@ +<?php + +namespace ipl\Web\Compat; + +use ipl\Html\Form; +use ipl\I18n\Translation; +use ipl\Web\FormDecorator\IcingaFormDecorator; + +class CompatForm extends Form +{ + use Translation; + + protected $defaultAttributes = ['class' => 'icinga-form icinga-controls']; + + public function hasDefaultElementDecorator() + { + if (parent::hasDefaultElementDecorator()) { + return true; + } + + $this->setDefaultElementDecorator(new IcingaFormDecorator()); + + return true; + } +} diff --git a/vendor/ipl/web/src/Compat/Multipart.php b/vendor/ipl/web/src/Compat/Multipart.php new file mode 100644 index 0000000..432f837 --- /dev/null +++ b/vendor/ipl/web/src/Compat/Multipart.php @@ -0,0 +1,33 @@ +<?php + +namespace ipl\Web\Compat; + +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlString; + +class Multipart extends HtmlDocument +{ + /** @var string */ + protected $for; + + protected $contentSeparator = "\n"; + + /** + * Set the container's id which this part is for + * + * @param string $id + * + * @return $this + */ + public function setFor($id) + { + $this->for = $id; + + return $this; + } + + protected function assemble() + { + $this->prepend(HtmlString::create(sprintf('for=%s', $this->for))); + } +} diff --git a/vendor/ipl/web/src/Compat/SearchControls.php b/vendor/ipl/web/src/Compat/SearchControls.php new file mode 100644 index 0000000..c40204d --- /dev/null +++ b/vendor/ipl/web/src/Compat/SearchControls.php @@ -0,0 +1,269 @@ +<?php + +namespace ipl\Web\Compat; + +use GuzzleHttp\Psr7\ServerRequest; +use ipl\Orm\Exception\InvalidRelationException; +use ipl\Orm\Query; +use ipl\Stdlib\Seq; +use ipl\Web\Control\SearchBar; +use ipl\Web\Control\SearchEditor; +use ipl\Web\Filter\QueryString; +use ipl\Web\Url; +use ipl\Stdlib\Filter; + +trait SearchControls +{ + /** + * Fetch available filter columns for the given query + * + * @param Query $query + * + * @return array<string, string> Keys are column paths, values are labels + */ + abstract public function fetchFilterColumns(Query $query); + + /** + * Get whether {@see SearchControls::createSearchBar()} and {@see SearchControls::createSearchEditor()} + * should handle form submits. + * + * @return bool + */ + private function callHandleRequest() + { + return true; + } + + /** + * Create and return the SearchBar + * + * @param Query $query The query being filtered + * @param Url $redirectUrl Url to redirect to upon success + * @param array $preserveParams Query params to preserve when redirecting + * + * @return SearchBar + */ + public function createSearchBar(Query $query, ...$params): SearchBar + { + $requestUrl = Url::fromRequest(); + $preserveParams = array_pop($params) ?? []; + $redirectUrl = array_pop($params); + + if ($redirectUrl !== null) { + $redirectUrl->addParams($requestUrl->onlyWith($preserveParams)->getParams()->toArray(false)); + } else { + $redirectUrl = $requestUrl->onlyWith($preserveParams); + } + + $filter = QueryString::fromString((string) $this->params) + ->on(QueryString::ON_CONDITION, function (Filter\Condition $condition) use ($query) { + $this->enrichFilterCondition($condition, $query); + }) + ->parse(); + + $searchBar = new SearchBar(); + $searchBar->setFilter($filter); + $searchBar->setRedirectUrl($redirectUrl); + $searchBar->setAction($redirectUrl->getAbsoluteUrl()); + $searchBar->setIdProtector([$this->getRequest(), 'protectId']); + + $moduleName = $this->getRequest()->getModuleName(); + $controllerName = $this->getRequest()->getControllerName(); + + if (method_exists($this, 'completeAction')) { + $searchBar->setSuggestionUrl(Url::fromPath( + "$moduleName/$controllerName/complete", + ['_disableLayout' => true, 'showCompact' => true] + )); + } + + if (method_exists($this, 'searchEditorAction')) { + $searchBar->setEditorUrl(Url::fromPath( + "$moduleName/$controllerName/search-editor" + )->setParams($redirectUrl->getParams())); + } + + $filterColumns = $this->fetchFilterColumns($query); + $columnValidator = function (SearchBar\ValidatedColumn $column) use ($query, $filterColumns) { + $searchPath = $column->getSearchValue(); + if (strpos($searchPath, '.') === false) { + $column->setSearchValue($query->getResolver()->qualifyPath( + $searchPath, + $query->getModel()->getTableName() + )); + } + + try { + $definition = $query->getResolver()->getColumnDefinition($searchPath); + } catch (InvalidRelationException $_) { + list($columnPath, $columnLabel) = Seq::find($filterColumns, $searchPath, false); + if ($columnPath === null) { + $column->setMessage(t('Is not a valid column')); + $column->setSearchValue($searchPath); // Resets the qualification made above + } else { + $column->setSearchValue($columnPath); + $column->setLabel($columnLabel); + } + } + + if (isset($definition)) { + $column->setLabel($definition->getLabel()); + } + }; + + $searchBar->on(SearchBar::ON_ADD, $columnValidator) + ->on(SearchBar::ON_INSERT, $columnValidator) + ->on(SearchBar::ON_SAVE, $columnValidator) + ->on(SearchBar::ON_SENT, function (SearchBar $form) { + /** @var Url $redirectUrl */ + $redirectUrl = $form->getRedirectUrl(); + $existingParams = $redirectUrl->getParams(); + $redirectUrl->setQueryString(QueryString::render($form->getFilter())); + foreach ($existingParams->toArray(false) as $name => $value) { + if (is_int($name)) { + $name = $value; + $value = true; + } + + $redirectUrl->getParams()->addEncoded($name, $value); + } + + $form->setRedirectUrl($redirectUrl); + })->on(SearchBar::ON_SUCCESS, function (SearchBar $form) { + $this->getResponse()->redirectAndExit($form->getRedirectUrl()); + }); + + if ($this->callHandleRequest()) { + $searchBar->handleRequest(ServerRequest::fromGlobals()); + } + + return $searchBar; + } + + /** + * Create and return the SearchEditor + * + * @param Query $query The query being filtered + * @param Url $redirectUrl Url to redirect to upon success + * @param array $preserveParams Query params to preserve when redirecting + * + * @return SearchEditor + */ + public function createSearchEditor(Query $query, ...$params): SearchEditor + { + $requestUrl = Url::fromRequest(); + $preserveParams = array_pop($params) ?? []; + $redirectUrl = array_pop($params); + $moduleName = $this->getRequest()->getModuleName(); + $controllerName = $this->getRequest()->getControllerName(); + + if ($redirectUrl !== null) { + $redirectUrl->addParams($requestUrl->onlyWith($preserveParams)->getParams()->toArray(false)); + } else { + $redirectUrl = Url::fromPath("$moduleName/$controllerName"); + if (! empty($preserveParams)) { + $redirectUrl->setParams($requestUrl->onlyWith($preserveParams)->getParams()); + } + } + + $editor = new SearchEditor(); + $editor->setRedirectUrl($redirectUrl); + $editor->setAction($requestUrl->getAbsoluteUrl()); + $editor->setQueryString((string) $this->params->without($preserveParams)); + + if (method_exists($this, 'completeAction')) { + $editor->setSuggestionUrl(Url::fromPath( + "$moduleName/$controllerName/complete", + ['_disableLayout' => true, 'showCompact' => true] + )); + } + + $editor->getParser()->on(QueryString::ON_CONDITION, function (Filter\Condition $condition) use ($query) { + if ($condition->getColumn()) { + $this->enrichFilterCondition($condition, $query); + } + }); + + $filterColumns = $this->fetchFilterColumns($query); + $editor->on(SearchEditor::ON_VALIDATE_COLUMN, function ( + Filter\Condition $condition + ) use ( + $query, + $filterColumns + ) { + $searchPath = $condition->getColumn(); + if (strpos($searchPath, '.') === false) { + $condition->setColumn($query->getResolver()->qualifyPath( + $searchPath, + $query->getModel()->getTableName() + )); + } + + try { + $query->getResolver()->getColumnDefinition($searchPath); + } catch (InvalidRelationException $_) { + $columnPath = Seq::findKey( + $filterColumns, + $condition->metaData()->get('columnLabel', $searchPath), + false + ); + if ($columnPath === null) { + $condition->setColumn($searchPath); + throw new SearchBar\SearchException(t('Is not a valid column')); + } else { + $condition->setColumn($columnPath); + } + } + })->on(SearchEditor::ON_SUCCESS, function (SearchEditor $form) { + /** @var Url $redirectUrl */ + $redirectUrl = $form->getRedirectUrl(); + $existingParams = $redirectUrl->getParams(); + $redirectUrl->setQueryString(QueryString::render($form->getFilter())); + foreach ($existingParams->toArray(false) as $name => $value) { + if (is_int($name)) { + $name = $value; + $value = true; + } + + $redirectUrl->getParams()->addEncoded($name, $value); + } + + $this->getResponse() + ->setHeader('X-Icinga-Container', '_self') + ->redirectAndExit($redirectUrl); + }); + + if ($this->callHandleRequest()) { + $editor->handleRequest(ServerRequest::fromGlobals()); + } + + return $editor; + } + + /** + * Enrich the filter condition with meta data from the query + * + * @param Filter\Condition $condition + * @param Query $query + * + * @return void + */ + protected function enrichFilterCondition(Filter\Condition $condition, Query $query) + { + $path = $condition->getColumn(); + if (strpos($path, '.') === false) { + $path = $query->getResolver()->qualifyPath($path, $query->getModel()->getTableName()); + $condition->setColumn($path); + } + + try { + $label = $query->getResolver()->getColumnDefinition($path)->getLabel(); + } catch (InvalidRelationException $_) { + $label = null; + } + + if (isset($label)) { + $condition->metaData()->set('columnLabel', $label); + } + } +} diff --git a/vendor/ipl/web/src/Compat/ViewRenderer.php b/vendor/ipl/web/src/Compat/ViewRenderer.php new file mode 100644 index 0000000..48ddcc3 --- /dev/null +++ b/vendor/ipl/web/src/Compat/ViewRenderer.php @@ -0,0 +1,60 @@ +<?php + +namespace ipl\Web\Compat; + +use Zend_Controller_Action_Helper_ViewRenderer as Zf1ViewRenderer; +use Zend_Controller_Action_HelperBroker as Zf1HelperBroker; + +class ViewRenderer extends Zf1ViewRenderer +{ + /** + * Inject the view renderer + */ + public static function inject() + { + /** @var \Zend_Controller_Action_Helper_ViewRenderer $viewRenderer */ + $viewRenderer = Zf1HelperBroker::getStaticHelper('ViewRenderer'); + + $inject = new static(); + + foreach (get_object_vars($viewRenderer) as $property => $value) { + if ($property === '_inflector') { + continue; + } + + $inject->$property = $value; + } + + Zf1HelperBroker::removeHelper('ViewRenderer'); + Zf1HelperBroker::addHelper($inject); + } + + public function getName() + { + return 'ViewRenderer'; + } + + /** + * Render the view w/o using a view script + * + * {@inheritdoc} + */ + public function render($action = null, $name = null, $noController = null) + { + $view = $this->view; + + if ($view->document->isEmpty() || $this->getRequest()->getParam('error_handler') !== null) { + parent::render($action, $name, $noController); + + return; + } + + if ($name === null) { + $name = $this->getResponseSegment(); + } + + $this->getResponse()->appendBody($view->document->render(), $name); + + $this->setNoRender(); + } +} diff --git a/vendor/ipl/web/src/Control/LimitControl.php b/vendor/ipl/web/src/Control/LimitControl.php new file mode 100644 index 0000000..b390a0a --- /dev/null +++ b/vendor/ipl/web/src/Control/LimitControl.php @@ -0,0 +1,123 @@ +<?php + +namespace ipl\Web\Control; + +use ipl\Web\Compat\CompatForm; +use ipl\Web\Url; + +/** + * Allows to adjust the limit of the number of items to display + */ +class LimitControl extends CompatForm +{ + /** @var int Default limit */ + const DEFAULT_LIMIT = 25; + + /** @var string Default limit param */ + const DEFAULT_LIMIT_PARAM = 'limit'; + + /** @var int[] Selectable default limits */ + public static $limits = [ + '25' => '25', + '50' => '50', + '100' => '100', + '500' => '500' + ]; + + /** @var string Name of the URL parameter which stores the limit */ + protected $limitParam = self::DEFAULT_LIMIT_PARAM; + + /** @var int */ + protected $defaultLimit; + + /** @var Url */ + protected $url; + + protected $method = 'GET'; + + public function __construct(Url $url) + { + $this->url = $url; + } + + /** + * Get the name of the URL parameter which stores the limit + * + * @return string + */ + public function getLimitParam() + { + return $this->limitParam; + } + + /** + * Set the name of the URL parameter which stores the limit + * + * @param string $limitParam + * + * @return $this + */ + public function setLimitParam($limitParam) + { + $this->limitParam = $limitParam; + + return $this; + } + + /** + * Get the default limit + * + * @return int + */ + public function getDefaultLimit() + { + return $this->defaultLimit ?: static::DEFAULT_LIMIT; + } + + /** + * Set the default limit + * + * @param int $limit + * + * @return $this + */ + public function setDefaultLimit($limit) + { + $this->defaultLimit = $limit; + + return $this; + } + + /** + * Get the limit + * + * @return int + */ + public function getLimit() + { + return $this->url->getParam($this->getLimitParam(), $this->getDefaultLimit()); + } + + protected function assemble() + { + $this->addAttributes(['class' => 'limit-control inline']); + + $limits = static::$limits; + if ($this->defaultLimit && ! isset($limits[$this->defaultLimit])) { + $limits[$this->defaultLimit] = $this->defaultLimit; + } + + $limit = $this->getLimit(); + if (! isset($limits[$limit])) { + $limits[$limit] = $limit; + } + + $this->addElement('select', $this->getLimitParam(), [ + 'class' => 'autosubmit', + 'label' => '#', + 'options' => $limits, + 'title' => t('Change item count per page'), + 'value' => $limit + ]); + } +} diff --git a/vendor/ipl/web/src/Control/PaginationControl.php b/vendor/ipl/web/src/Control/PaginationControl.php new file mode 100644 index 0000000..00f5c20 --- /dev/null +++ b/vendor/ipl/web/src/Control/PaginationControl.php @@ -0,0 +1,523 @@ +<?php + +namespace ipl\Web\Control; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Stdlib\Contract\Paginatable; +use ipl\Web\Compat\CompatForm; +use ipl\Web\Url; +use ipl\Web\Widget\Icon; + +/** + * The pagination control displays a list of links that point to different pages of the current view + * + * The default HTML markup (tag and attributes) for the paginator look like the following: + * <div class="pagination-control" role="navigation">...</div> + */ +class PaginationControl extends BaseHtmlElement +{ + /** @var int Default maximum number of items which should be shown per page */ + protected $defaultPageSize; + + /** @var string Name of the URL parameter which stores the current page number */ + protected $pageParam = 'page'; + + /** @var string Name of the URL parameter which holds the page size. If given, overrides {@link $defaultPageSize} */ + protected $pageSizeParam = 'limit'; + + /** @var string */ + protected $pageSpacer = '…'; + + /** @var Paginatable The pagination adapter which handles the underlying data source */ + protected $paginatable; + + /** @var Url The URL to base off pagination URLs */ + protected $url; + + /** @var int Cache for the total number of items */ + private $totalCount; + + protected $tag = 'div'; + + protected $defaultAttributes = [ + 'class' => 'pagination-control', + 'role' => 'navigation' + ]; + + /** + * Create a pagination control + * + * @param Paginatable $paginatable The paginatable + * @param Url $url The URL to base off paging URLs + */ + public function __construct(Paginatable $paginatable, Url $url) + { + $this->paginatable = $paginatable; + $this->url = $url; + } + + /** + * Set the URL to base off paging URLs + * + * @param Url $url + * + * @return $this + */ + public function setUrl(Url $url) + { + $this->url = $url; + + return $this; + } + + /** + * Get the default page size + * + * @return int + */ + public function getDefaultPageSize() + { + return $this->defaultPageSize ?: LimitControl::DEFAULT_LIMIT; + } + + /** + * Set the default page size + * + * @param int $defaultPageSize + * + * @return $this + */ + public function setDefaultPageSize($defaultPageSize) + { + $this->defaultPageSize = $defaultPageSize; + + return $this; + } + + /** + * Get the name of the URL parameter which stores the current page number + * + * @return string + */ + public function getPageParam() + { + return $this->pageParam; + } + + /** + * Set the name of the URL parameter which stores the current page number + * + * @param string $pageParam + * + * @return $this + */ + public function setPageParam($pageParam) + { + $this->pageParam = $pageParam; + + return $this; + } + + /** + * Get the name of the URL parameter which stores the page size + * + * @return string + */ + public function getPageSizeParam() + { + return $this->pageSizeParam; + } + /** + * Set the name of the URL parameter which stores the page size + * + * @param string $pageSizeParam + * + * @return $this + */ + public function setPageSizeParam($pageSizeParam) + { + $this->pageSizeParam = $pageSizeParam; + + return $this; + } + + /** + * Get the total number of items + * + * @return int + */ + public function getTotalCount() + { + if ($this->totalCount === null) { + $this->totalCount = $this->paginatable->count(); + } + + return $this->totalCount; + } + + /** + * Get the current page number + * + * @return int + */ + public function getCurrentPageNumber() + { + return (int) $this->url->getParam($this->pageParam, 1); + } + + /** + * Get the configured page size + * + * @return int + */ + public function getPageSize() + { + return (int) $this->url->getParam($this->pageSizeParam, $this->getDefaultPageSize()); + } + + /** + * Get the total page count + * + * @return int + */ + public function getPageCount() + { + $pageSize = $this->getPageSize(); + + if ($pageSize === 0) { + return 0; + } + + if ($pageSize < 0) { + return 1; + } + + return (int) ceil($this->getTotalCount() / $pageSize); + } + + /** + * Get the limit + * + * Use this method to set the LIMIT part of a query for fetching the current page. + * + * @return int If the page size is infinite, -1 will be returned + */ + public function getLimit() + { + $pageSize = $this->getPageSize(); + + return $pageSize < 0 ? -1 : $pageSize; + } + + /** + * Get the offset + * + * Use this method to set the OFFSET part of a query for fetching the current page. + * + * @return int + */ + public function getOffset() + { + $currentPageNumber = $this->getCurrentPageNumber(); + $pageSize = $this->getPageSize(); + + return $currentPageNumber <= 1 ? 0 : ($currentPageNumber - 1) * $pageSize; + } + + /** + * Apply limit and offset on the paginatable + * + * @return $this + */ + public function apply() + { + $this->paginatable->limit($this->getLimit()); + $this->paginatable->offset($this->getOffset()); + + return $this; + } + + /** + * Create a URL for paging from the given page number + * + * @param int $pageNumber The page number + * @param int $pageSize The number of items per page. If you want to stick to the defaults, + * don't set this parameter + * + * @return Url + */ + public function createUrl($pageNumber, $pageSize = null) + { + $params = [$this->getPageParam() => $pageNumber]; + + if ($pageSize !== null) { + $params[$this->getPageSizeParam()] = $pageSize; + } + + return $this->url->with($params); + } + + /** + * Get the first item number of the given page + * + * @param int $pageNumber + * + * @return int + */ + protected function getFirstItemNumberOfPage($pageNumber) + { + return ($pageNumber - 1) * $this->getPageSize() + 1; + } + + /** + * Get the last item number of the given page + * + * @param int $pageNumber + * + * @return int + */ + protected function getLastItemNumberOfPage($pageNumber) + { + return min($pageNumber * $this->getPageSize(), $this->getTotalCount()); + } + + /** + * Create the label for the given page number + * + * @param int $pageNumber + * + * @return string + */ + protected function createLabel($pageNumber) + { + return sprintf( + $this->translate('Show items %u to %u of %u'), + $this->getFirstItemNumberOfPage($pageNumber), + $this->getLastItemNumberOfPage($pageNumber), + $this->getTotalCount() + ); + } + + /** + * Create and return the previous page item + * + * @return BaseHtmlElement + */ + protected function createPreviousPageItem() + { + $prevIcon = new Icon('angle-left'); + + $currentPageNumber = $this->getCurrentPageNumber(); + + if ($currentPageNumber > 1) { + $prevItem = Html::tag('li', ['class' => 'nav-item']); + + $prevItem->add(Html::tag( + 'a', + [ + 'class' => 'previous-page', + 'href' => $this->createUrl($currentPageNumber - 1), + 'title' => $this->createLabel($currentPageNumber - 1) + ], + $prevIcon + )); + } else { + $prevItem = Html::tag( + 'li', + [ + 'aria-hidden' => true, + 'class' => 'nav-item disabled' + ] + ); + + $prevItem->add(Html::tag('span', ['class' => 'previous-page'], $prevIcon)); + } + + return $prevItem; + } + + /** + * Create and return the next page item + * + * @return BaseHtmlElement + */ + protected function createNextPageItem() + { + $nextIcon = new Icon('angle-right'); + + $currentPageNumber = $this->getCurrentPageNumber(); + + if ($currentPageNumber < $this->getPageCount()) { + $nextItem = Html::tag('li', ['class' => 'nav-item']); + + $nextItem->add(Html::tag( + 'a', + [ + 'class' => 'next-page', + 'href' => $this->createUrl($currentPageNumber + 1), + 'title' => $this->createLabel($currentPageNumber + 1) + ], + $nextIcon + )); + } else { + $nextItem = Html::tag( + 'li', + [ + 'aria-hidden' => true, + 'class' => 'nav-item disabled' + ] + ); + + $nextItem->add(Html::tag('span', ['class' => 'next-page'], $nextIcon)); + } + + return $nextItem; + } + + /** @TODO(el): Use ipl-translation when it's ready instead */ + private function translate($message) + { + return $message; + } + + /** + * Create and return the first page item + * + * @return BaseHtmlElement + */ + protected function createFirstPageItem() + { + $currentPageNumber = $this->getCurrentPageNumber(); + + $url = clone $this->url; + + $firstItem = Html::tag('li', ['class' => 'nav-item']); + + if ($currentPageNumber === 1) { + $firstItem->addAttributes(['class' => 'disabled']); + $firstItem->add(Html::tag( + 'span', + ['class' => 'first-page'], + $this->getFirstItemNumberOfPage(1) + )); + } else { + $firstItem->add(Html::tag( + 'a', + [ + 'class' => 'first-page', + 'href' => $url->remove(['page'])->getAbsoluteUrl(), + 'title' => $this->createLabel(1) + ], + $this->getFirstItemNumberOfPage(1) + )); + } + + return $firstItem; + } + + /** + * Create and return the last page item + * + * @return BaseHtmlElement + */ + protected function createLastPageItem() + { + $currentPageNumber = $this->getCurrentPageNumber(); + $lastItem = Html::tag('li', ['class' => 'nav-item']); + + if ($currentPageNumber === $this->getPageCount()) { + $lastItem->addAttributes(['class' => 'disabled']); + $lastItem->add(Html::tag( + 'span', + ['class' => 'last-page'], + $this->getPageCount() + )); + } else { + $lastItem->add(Html::tag( + 'a', + [ + 'class' => 'last-page', + 'href' => $this->url->setParam('page', $this->getPageCount()), + 'title' => $this->createLabel($this->getPageCount()) + ], + $this->getPageCount() + )); + } + + return $lastItem; + } + + /** + * Create and return the page selector item + * + * @return BaseHtmlElement + */ + protected function createPageSelectorItem() + { + $currentPageNumber = $this->getCurrentPageNumber(); + + $form = new CompatForm($this->url); + $form->addAttributes(['class' => 'inline']); + $form->setMethod('GET'); + + $select = Html::tag('select', [ + 'name' => $this->getPageParam(), + 'class' => 'autosubmit', + 'title' => t('Go to page …') + ]); + + if (isset($currentPageNumber)) { + if ($currentPageNumber === 1 || $currentPageNumber === $this->getPageCount()) { + $select->add(Html::tag('option', ['disabled' => '', 'selected' => ''], '…')); + } + } + + foreach (range(2, $this->getPageCount() - 1) as $page) { + $option = Html::tag('option', [ + 'value' => $page + ], $page); + + if ($page == $currentPageNumber) { + $option->addAttributes(['selected' => '']); + } + + $select->add($option); + } + + $form->add($select); + + $pageSelectorItem = Html::tag('li', $form); + + return $pageSelectorItem; + } + + protected function assemble() + { + if ($this->getPageCount() < 2) { + return; + } + + // Accessibility info + $this->add(Html::tag( + 'h2', + [ + 'class' => 'sr-only', + 'tabindex' => '-1' + ], + $this->translate('Pagination') + )); + + $paginator = Html::tag('ul', ['class' => 'tab-nav nav']); + + $paginator->add([ + $this->createFirstPageItem(), + $this->createPreviousPageItem(), + $this->createPageSelectorItem(), + $this->createNextPageItem(), + $this->createLastPageItem() + ]); + + $this->add($paginator); + } +} diff --git a/vendor/ipl/web/src/Control/SearchBar.php b/vendor/ipl/web/src/Control/SearchBar.php new file mode 100644 index 0000000..2b18a83 --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchBar.php @@ -0,0 +1,522 @@ +<?php + +namespace ipl\Web\Control; + +use ipl\Html\Attributes; +use ipl\Html\Form; +use ipl\Html\FormElement\HiddenElement; +use ipl\Html\FormElement\InputElement; +use ipl\Html\FormElement\SubmitElement; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlElement; +use ipl\Stdlib\Filter; +use ipl\Validator\CallbackValidator; +use ipl\Web\Common\FormUid; +use ipl\Web\Control\SearchBar\Terms; +use ipl\Web\Control\SearchBar\ValidatedColumn; +use ipl\Web\Control\SearchBar\ValidatedOperator; +use ipl\Web\Control\SearchBar\ValidatedValue; +use ipl\Web\Filter\ParseException; +use ipl\Web\Filter\QueryString; +use ipl\Web\Url; +use ipl\Web\Widget\Icon; + +class SearchBar extends Form +{ + use FormUid; + + /** @var string Emitted in case the user added a new condition */ + const ON_ADD = 'on_add'; + + /** @var string Emitted in case the user inserted a new condition */ + const ON_INSERT = 'on_insert'; + + /** @var string Emitted in case the user changed an existing condition */ + const ON_SAVE = 'on_save'; + + /** @var string Emitted in case the user removed a condition */ + const ON_REMOVE = 'on_remove'; + + protected $defaultAttributes = [ + 'data-enrichment-type' => 'search-bar', + 'class' => 'search-bar', + 'name' => 'search-bar', + 'role' => 'search' + ]; + + /** @var Url */ + protected $editorUrl; + + /** @var Filter\Rule */ + protected $filter; + + /** @var string */ + protected $searchParameter; + + /** @var Url */ + protected $suggestionUrl; + + /** @var string */ + protected $submitLabel; + + /** @var callable */ + protected $protector; + + /** @var array */ + protected $changes; + + /** + * Set the url from which to load the editor + * + * @param Url $url + * + * @return $this + */ + public function setEditorUrl(Url $url) + { + $this->editorUrl = $url; + + return $this; + } + + /** + * Get the url from which to load the editor + * + * @return Url + */ + public function getEditorUrl() + { + return $this->editorUrl; + } + + /** + * Set the filter to use + * + * @param Filter\Rule $filter + * @return $this + */ + public function setFilter(Filter\Rule $filter) + { + $this->filter = $filter; + + return $this; + } + + /** + * Get the filter in use + * + * @return Filter\Rule + */ + public function getFilter() + { + return $this->filter; + } + + /** + * Set the search parameter to use + * + * @param string $name + * @return $this + */ + public function setSearchParameter($name) + { + $this->searchParameter = $name; + + return $this; + } + + /** + * Get the search parameter in use + * + * @return string + */ + public function getSearchParameter() + { + return $this->searchParameter ?: 'q'; + } + + /** + * Set the suggestion url + * + * @param Url $url + * @return $this + */ + public function setSuggestionUrl(Url $url) + { + $this->suggestionUrl = $url; + + return $this; + } + + /** + * Get the suggestion url + * + * @return Url + */ + public function getSuggestionUrl() + { + return $this->suggestionUrl; + } + + /** + * Set the submit label + * + * @param string $label + * @return $this + */ + public function setSubmitLabel($label) + { + $this->submitLabel = $label; + + return $this; + } + + /** + * Get the submit label + * + * @return string + */ + public function getSubmitLabel() + { + return $this->submitLabel; + } + + /** + * Set callback to protect ids with + * + * @param callable $protector + * + * @return $this + */ + public function setIdProtector($protector) + { + $this->protector = $protector; + + return $this; + } + + /** + * Get changes to be applied on the client + * + * @return array + */ + public function getChanges() + { + return $this->changes; + } + + private function protectId($id) + { + if (is_callable($this->protector)) { + return call_user_func($this->protector, $id); + } + + return $id; + } + + public function isValidEvent($event) + { + switch ($event) { + case self::ON_ADD: + case self::ON_SAVE: + case self::ON_INSERT: + case self::ON_REMOVE: + return true; + default: + return parent::isValidEvent($event); + } + } + + private function validateCondition($eventType, $indices, $termsData, &$changes) + { + // TODO: In case of the query string validation, all three are guaranteed to be set. + // The Parser also provides defaults, why shouldn't we here? + $column = ValidatedColumn::fromTermData($termsData[0]); + $operator = isset($termsData[1]) + ? ValidatedOperator::fromTermData($termsData[1]) + : null; + $value = isset($termsData[2]) + ? ValidatedValue::fromTermData($termsData[2]) + : null; + + $this->emit($eventType, [$column, $operator, $value]); + + if ($eventType !== self::ON_REMOVE) { + if (! $column->isValid() || $column->hasBeenChanged()) { + $changes[$indices[0]] = array_merge($termsData[0], $column->toTermData()); + } + + if ($operator && ! $operator->isValid()) { + $changes[$indices[1]] = array_merge($termsData[1], $operator->toTermData()); + } + + if ($value && (! $value->isValid() || $value->hasBeenChanged())) { + $changes[$indices[2]] = array_merge($termsData[2], $value->toTermData()); + } + } + + return $column->isValid() && (! $operator || $operator->isValid()) && (! $value || $value->isValid()); + } + + + protected function assemble() + { + $termContainerId = $this->protectId('terms'); + $termInputId = $this->protectId('term-input'); + $dataInputId = $this->protectId('data-input'); + $searchInputId = $this->protectId('search-input'); + $suggestionsId = $this->protectId('suggestions'); + + $termContainer = (new Terms())->setAttribute('id', $termContainerId); + $termInput = new HiddenElement($this->getSearchParameter(), [ + 'id' => $termInputId, + 'disabled' => true + ]); + + if (! $this->getRequest()->getHeaderLine('X-Icinga-Autorefresh')) { + $termContainer->setFilter(function () { + return $this->getFilter(); + }); + $termInput->getAttributes()->registerAttributeCallback('value', function () { + return QueryString::render($this->getFilter()); + }); + } + + $dataInput = new HiddenElement('data', [ + 'id' => $dataInputId, + 'validators' => [ + new CallbackValidator(function ($data, CallbackValidator $_) use ($termContainer, $searchInputId) { + $data = $data ? json_decode($data, true) : null; + if (empty($data)) { + return true; + } + + switch ($data['type']) { + case 'add': + case 'exchange': + $type = self::ON_ADD; + + break; + case 'insert': + $type = self::ON_INSERT; + + break; + case 'save': + $type = self::ON_SAVE; + + break; + case 'remove': + $type = self::ON_REMOVE; + + break; + default: + return true; + } + + $changes = []; + $invalid = false; + $indices = [null, null, null]; + $termsData = [null, null, null]; + foreach (isset($data['terms']) ? $data['terms'] : [] as $termIndex => $termData) { + switch ($termData['type']) { + case 'column': + $indices[0] = $termIndex; + $termsData[0] = $termData; + + break; + case 'operator': + $indices[1] = $termIndex; + $termsData[1] = $termData; + + break; + case 'value': + $indices[2] = $termIndex; + $termsData[2] = $termData; + + break; + default: + if ($termsData[0] !== null) { + if (! $this->validateCondition($type, $indices, $termsData, $changes)) { + $invalid = true; + } + } + + $indices = $termsData = [null, null, null]; + } + } + + if ($termsData[0] !== null) { + if (! $this->validateCondition($type, $indices, $termsData, $changes)) { + $invalid = true; + } + } + + if (! empty($changes)) { + $this->changes = ['#' . $searchInputId, $changes]; + $termContainer->applyChanges($changes); + } + + return ! $invalid; + }) + ] + ]); + $this->registerElement($dataInput); + + $filterInput = new InputElement($this->getSearchParameter(), [ + 'type' => 'text', + 'placeholder' => 'Type to search. Use * as wildcard.', + 'class' => 'filter-input', + 'id' => $searchInputId, + 'autocomplete' => 'off', + 'data-enrichment-type' => 'filter', + 'data-data-input' => '#' . $dataInputId, + 'data-term-input' => '#' . $termInputId, + 'data-term-container' => '#' . $termContainerId, + 'data-term-suggestions' => '#' . $suggestionsId, + 'data-missing-log-op' => t('Please add a logical operator on the left.'), + 'data-incomplete-group' => t('Please close or remove this group.'), + 'data-choose-template' => t('Please type one of: %s', '..<comma separated list>'), + 'data-choose-column' => t('Please enter a valid column.'), + 'validators' => [ + new CallbackValidator(function ($q, CallbackValidator $validator) use ($searchInputId) { + $submitted = $this->hasBeenSubmitted(); + $invalid = false; + $changes = []; + + $parser = QueryString::fromString($q); + $parser->on(QueryString::ON_CONDITION, function (Filter\Condition $condition) use ( + &$invalid, + &$changes, + $submitted + ) { + $columnIndex = $condition->metaData()->get('columnIndex'); + if (isset($this->changes[1][$columnIndex])) { + $change = $this->changes[1][$columnIndex]; + $condition->setColumn($change['search']); + } elseif (empty($this->changes)) { + $column = ValidatedColumn::fromFilterCondition($condition); + $operator = ValidatedOperator::fromFilterCondition($condition); + $value = ValidatedValue::fromFilterCondition($condition); + $this->emit(self::ON_ADD, [$column, $operator, $value]); + + $condition->setColumn($column->getSearchValue()); + $condition->setValue($value->getSearchValue()); + + if (! $column->isValid()) { + $invalid = true; + + if ($submitted) { + $condition->metaData()->merge($column->toMetaData()); + } else { + $changes[$columnIndex] = $column->toTermData(); + } + } + + if (! $operator->isValid()) { + $invalid = true; + + if ($submitted) { + $condition->metaData()->merge($operator->toMetaData()); + } else { + $changes[$condition->metaData()->get('operatorIndex')] = $operator->toTermData(); + } + } + + if (! $value->isValid()) { + $invalid = true; + + if ($submitted) { + $condition->metaData()->merge($value->toMetaData()); + } else { + $changes[$condition->metaData()->get('valueIndex')] = $value->toTermData(); + } + } + } + }); + + try { + $filter = $parser->parse(); + } catch (ParseException $e) { + $charAt = $e->getCharPos() - 1; + $char = $e->getChar(); + + $this->getElement($this->getSearchParameter()) + ->setValue(substr($q, $charAt)) + ->addAttributes([ + 'title' => sprintf(t('Unexpected %s at start of input'), $char), + 'pattern' => sprintf('^(?!%s).*', $char === ')' ? '\)' : $char), + 'data-has-syntax-error' => true + ]); + + $probablyValidQueryString = substr($q, 0, $charAt); + $this->setFilter(QueryString::parse($probablyValidQueryString)); + return false; + } + + $this->getElement($this->getSearchParameter())->setValue(''); + $this->setFilter($filter); + + if (! empty($changes)) { + $this->changes = ['#' . $searchInputId, $changes]; + } + + return ! $invalid; + }) + ] + ]); + if ($this->getSuggestionUrl() !== null) { + $filterInput->getAttributes()->registerAttributeCallback('data-suggest-url', function () { + return (string) $this->getSuggestionUrl(); + }); + } + + $this->registerElement($filterInput); + + $submitButton = new SubmitElement('submit', ['label' => $this->getSubmitLabel() ?: 'hidden']); + $this->registerElement($submitButton); + + $editorOpener = null; + if ($this->getEditorUrl() !== null) { + $editorOpener = new HtmlElement( + 'button', + Attributes::create([ + 'type' => 'button', + 'class' => 'search-editor-opener control-button', + 'title' => t('Adjust Filter') + ])->registerAttributeCallback('data-search-editor-url', function () { + return (string) $this->getEditorUrl(); + }), + new Icon('cog') + ); + } + + $this->addHtml( + new HtmlElement( + 'button', + Attributes::create(['type' => 'button', 'class' => 'search-options']), + new Icon('search') + ), + new HtmlElement( + 'div', + Attributes::create(['class' => 'filter-input-area']), + $termContainer, + new HtmlElement('label', Attributes::create(['data-label' => '']), $filterInput) + ), + $dataInput, + $termInput, + $submitButton, + $this->createUidElement(), + new HtmlElement('div', Attributes::create([ + 'id' => $suggestionsId, + 'class' => 'search-suggestions', + 'data-base-target' => $suggestionsId + ])) + ); + + // Render the editor container outside of this form. It will contain a form as well later on + // loaded by XHR and HTML prohibits nested forms. It's style-wise also better... + $doc = new HtmlDocument(); + $this->setWrapper($doc); + $doc->addHtml($this, ...($editorOpener ? [$editorOpener] : [])); + } +} diff --git a/vendor/ipl/web/src/Control/SearchBar/SearchException.php b/vendor/ipl/web/src/Control/SearchBar/SearchException.php new file mode 100644 index 0000000..a89c6ce --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchBar/SearchException.php @@ -0,0 +1,9 @@ +<?php + +namespace ipl\Web\Control\SearchBar; + +use Exception; + +class SearchException extends Exception +{ +} diff --git a/vendor/ipl/web/src/Control/SearchBar/Suggestions.php b/vendor/ipl/web/src/Control/SearchBar/Suggestions.php new file mode 100644 index 0000000..eae4c97 --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchBar/Suggestions.php @@ -0,0 +1,447 @@ +<?php + +namespace ipl\Web\Control\SearchBar; + +use Countable; +use Generator; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\FormattedString; +use ipl\Html\FormElement\ButtonElement; +use ipl\Html\FormElement\InputElement; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\Stdlib\Contract\Paginatable; +use ipl\Stdlib\Filter; +use ipl\Web\Control\SearchEditor; +use ipl\Web\Filter\QueryString; +use IteratorIterator; +use LimitIterator; +use OuterIterator; +use Psr\Http\Message\ServerRequestInterface; +use Traversable; + +use function ipl\I18n\t; + +abstract class Suggestions extends BaseHtmlElement +{ + const DEFAULT_LIMIT = 50; + const SUGGESTION_TITLE_CLASS = 'suggestion-title'; + + protected $tag = 'ul'; + + /** @var string */ + protected $searchTerm; + + /** @var Traversable */ + protected $data; + + /** @var array */ + protected $default; + + /** @var string */ + protected $type; + + /** @var string */ + protected $failureMessage; + + public function setSearchTerm($term) + { + $this->searchTerm = $term; + + return $this; + } + + public function setData($data) + { + $this->data = $data; + + return $this; + } + + public function setDefault($default) + { + $this->default = $default; + + return $this; + } + + public function setType($type) + { + $this->type = $type; + + return $this; + } + + public function setFailureMessage($message) + { + $this->failureMessage = $message; + + return $this; + } + + /** + * Return whether the relation should be shown for the given column + * + * @param string $column + * + * @return bool + */ + protected function shouldShowRelationFor(string $column): bool + { + return false; + } + + /** + * Create a filter to provide as default for column suggestions + * + * @param string $searchTerm + * + * @return Filter\Rule + */ + abstract protected function createQuickSearchFilter($searchTerm); + + /** + * Fetch value suggestions for a particular column + * + * @param string $column + * @param string $searchTerm + * @param Filter\Chain $searchFilter + * + * @return Traversable + */ + abstract protected function fetchValueSuggestions($column, $searchTerm, Filter\Chain $searchFilter); + + /** + * Fetch column suggestions + * + * @param string $searchTerm + * + * @return Traversable + */ + abstract protected function fetchColumnSuggestions($searchTerm); + + protected function filterToTerms(Filter\Chain $filter) + { + $logicalSep = [ + 'label' => QueryString::getRuleSymbol($filter), + 'search' => QueryString::getRuleSymbol($filter), + 'class' => 'logical_operator', + 'type' => 'logical_operator' + ]; + + $terms = []; + foreach ($filter as $child) { + if ($child instanceof Filter\Chain) { + $terms[] = [ + 'search' => '(', + 'label' => '(', + 'type' => 'grouping_operator', + 'class' => 'grouping_operator_open' + ]; + $terms = array_merge($terms, $this->filterToTerms($child)); + $terms[] = [ + 'search' => ')', + 'label' => ')', + 'type' => 'grouping_operator', + 'class' => 'grouping_operator_close' + ]; + } else { + /** @var Filter\Condition $child */ + + $terms[] = [ + 'search' => $child->getColumn(), + 'label' => $child->metaData()->get('columnLabel'), + 'type' => 'column' + ]; + $terms[] = [ + 'search' => QueryString::getRuleSymbol($child), + 'label' => QueryString::getRuleSymbol($child), + 'type' => 'operator' + ]; + $terms[] = [ + 'search' => $child->getValue(), + 'label' => $child->getValue(), + 'type' => 'value' + ]; + } + + $terms[] = $logicalSep; + } + + array_pop($terms); + return $terms; + } + + protected function assembleDefault() + { + if ($this->default === null) { + return; + } + + $attributes = [ + 'type' => 'button', + 'tabindex' => -1, + 'data-label' => $this->default['search'], + 'value' => $this->default['search'] + ]; + if (isset($this->default['type'])) { + $attributes['data-type'] = $this->default['type']; + } elseif ($this->type !== null) { + $attributes['data-type'] = $this->type; + } + + $button = new ButtonElement(null, $attributes); + if (isset($this->default['type']) && $this->default['type'] === 'terms') { + $terms = $this->filterToTerms($this->default['terms']); + $list = new HtmlElement('ul', Attributes::create(['class' => 'comma-separated'])); + foreach ($terms as $data) { + if ($data['type'] === 'column') { + $list->addHtml(new HtmlElement( + 'li', + null, + new HtmlElement('em', null, Text::create($data['label'])) + )); + } + } + + $button->setAttribute('data-terms', json_encode($terms)); + $button->addHtml(FormattedString::create( + t('Search for %s in: %s'), + new HtmlElement('em', null, Text::create($this->default['search'])), + $list + )); + } else { + $button->addHtml(FormattedString::create( + t('Search for %s'), + new HtmlElement('em', null, Text::create($this->default['search'])) + )); + } + + $this->prependHtml(new HtmlElement('li', Attributes::create(['class' => 'default']), $button)); + } + + protected function assemble() + { + if ($this->failureMessage !== null) { + $this->addHtml(new HtmlElement( + 'li', + Attributes::create(['class' => 'failure-message']), + new HtmlElement('em', null, Text::create(t('Can\'t search:'))), + Text::create($this->failureMessage) + )); + return; + } + + if ($this->data === null) { + $data = []; + } elseif ($this->data instanceof Paginatable) { + $this->data->limit(self::DEFAULT_LIMIT); + $data = $this->data; + } else { + $data = new LimitIterator(new IteratorIterator($this->data), 0, self::DEFAULT_LIMIT); + } + + foreach ($data as $term => $meta) { + if (is_int($term)) { + $term = $meta; + } + + $attributes = [ + 'type' => 'button', + 'tabindex' => -1, + 'data-search' => $term, + 'data-title' => $term + ]; + if ($this->type !== null) { + $attributes['data-type'] = $this->type; + } + + if (is_array($meta)) { + foreach ($meta as $key => $value) { + if ($key === 'label') { + $label = $value; + } + + $attributes['data-' . $key] = $value; + } + } else { + $label = $meta; + $attributes['data-label'] = $meta; + } + + $button = (new ButtonElement(null, $attributes)) + ->setAttribute('value', $label) + ->addHtml(Text::create($label)); + if ($this->type === 'column' && $this->shouldShowRelationFor($term)) { + $relationPath = substr($term, 0, strrpos($term, '.')); + $button->getAttributes()->add('class', 'has-details'); + $button->addHtml(new HtmlElement( + 'span', + Attributes::create(['class' => 'relation-path']), + Text::create($relationPath) + )); + } + + $this->addHtml(new HtmlElement('li', null, $button)); + } + + if ($this->hasMore($data, self::DEFAULT_LIMIT)) { + $this->getAttributes()->add('class', 'has-more'); + } + + $showDefault = true; + if ($this->searchTerm && $this->count() === 1) { + // The default option is only shown if the user's input does not result in an exact match + $input = $this->getFirst('li')->getFirst('button'); + $showDefault = $input->getContent() != $this->searchTerm + && $input->getAttributes()->get('data-search')->getValue() != $this->searchTerm; + } + + if ($this->type === 'column' && ! $this->isEmpty() && ! $this->getFirst('li')->getAttributes()->has('class')) { + // The column title is only added if there are any suggestions and the first item is not a title already + $this->prependHtml(new HtmlElement( + 'li', + Attributes::create(['class' => static::SUGGESTION_TITLE_CLASS]), + Text::create(t('Columns')) + )); + } + + if ($showDefault) { + $this->assembleDefault(); + } + + if (! $this->searchTerm && $this->isEmpty()) { + $this->addHtml(new HtmlElement( + 'li', + Attributes::create(['class' => 'nothing-to-suggest']), + new HtmlElement('em', null, Text::create(t('Nothing to suggest'))) + )); + } + } + + /** + * Load suggestions as requested by the client + * + * @param ServerRequestInterface $request + * + * @return $this + */ + public function forRequest(ServerRequestInterface $request) + { + if ($request->getMethod() !== 'POST') { + return $this; + } + + $requestData = json_decode($request->getBody()->read(8192), true); + if (empty($requestData)) { + return $this; + } + + $search = $requestData['term']['search']; + $label = $requestData['term']['label']; + $type = $requestData['term']['type']; + + $this->setSearchTerm($search); + $this->setType($type); + + switch ($type) { + case 'value': + if (! $requestData['column'] || $requestData['column'] === SearchEditor::FAKE_COLUMN) { + $this->setFailureMessage(t('Missing column name')); + break; + } + + $searchFilter = QueryString::parse( + isset($requestData['searchFilter']) + ? $requestData['searchFilter'] + : '' + ); + if ($searchFilter instanceof Filter\Condition) { + $searchFilter = Filter::all($searchFilter); + } + + try { + $this->setData($this->fetchValueSuggestions($requestData['column'], $label, $searchFilter)); + } catch (SearchException $e) { + $this->setFailureMessage($e->getMessage()); + } + + if ($search) { + $this->setDefault(['search' => $label]); + } + + break; + case 'column': + $this->setData($this->filterColumnSuggestions($this->fetchColumnSuggestions($label), $label)); + + if ($search && isset($requestData['showQuickSearch']) && $requestData['showQuickSearch']) { + $quickFilter = $this->createQuickSearchFilter($label); + if (! $quickFilter instanceof Filter\Chain || ! $quickFilter->isEmpty()) { + $this->setDefault([ + 'search' => $label, + 'type' => 'terms', + 'terms' => $quickFilter + ]); + } + } + } + + return $this; + } + + protected function hasMore($data, $than) + { + if (is_array($data)) { + return count($data) > $than; + } elseif ($data instanceof Countable) { + return $data->count() > $than; + } elseif ($data instanceof OuterIterator) { + return $this->hasMore($data->getInnerIterator(), $than); + } + + return false; + } + + /** + * Filter the given suggestions by the client's input + * + * @param Traversable $data + * @param string $searchTerm + * + * @return Generator + */ + protected function filterColumnSuggestions($data, $searchTerm) + { + foreach ($data as $key => $value) { + if ($this->matchSuggestion($key, $value, $searchTerm)) { + yield $key => $value; + } + } + } + + /** + * Get whether the given suggestion should be provided to the client + * + * @param string $path + * @param string $label + * @param string $searchTerm + * + * @return bool + */ + protected function matchSuggestion($path, $label, $searchTerm) + { + return fnmatch($searchTerm, $label, FNM_CASEFOLD) || fnmatch($searchTerm, $path, FNM_CASEFOLD); + } + + public function renderUnwrapped() + { + $this->ensureAssembled(); + + if ($this->isEmpty()) { + return ''; + } + + return parent::renderUnwrapped(); + } +} diff --git a/vendor/ipl/web/src/Control/SearchBar/Terms.php b/vendor/ipl/web/src/Control/SearchBar/Terms.php new file mode 100644 index 0000000..c81e336 --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchBar/Terms.php @@ -0,0 +1,255 @@ +<?php + +namespace ipl\Web\Control\SearchBar; + +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Stdlib\Filter; +use ipl\Web\Filter\QueryString; +use ipl\Web\Widget\Icon; + +class Terms extends BaseHtmlElement +{ + protected $tag = 'div'; + + protected $defaultAttributes = ['class' => 'terms']; + + /** @var callable|Filter\Rule */ + protected $filter; + + /** @var array */ + protected $changes; + + /** @var int */ + private $changeIndexCorrection = 0; + + /** @var int */ + private $currentIndex = 0; + + public function setFilter($filter) + { + $this->filter = $filter; + + return $this; + } + + /** + * Apply term changes + * + * @param array $changes + * + * @return $this + */ + public function applyChanges(array $changes) + { + $this->changes = $changes; + + return $this; + } + + protected function assemble() + { + $filter = $this->filter; + if (is_callable($filter)) { + $filter = $filter(); + } + + if ($filter === null) { + return; + } + + if ($filter instanceof Filter\Chain) { + if ($filter->isEmpty()) { + return; + } + + if ($filter instanceof Filter\None) { + $this->assembleChain($filter, $this, $filter->count() > 1); + } else { + $this->assembleConditions($filter, $this); + } + } else { + /** @var Filter\Condition $filter */ + $this->assembleCondition($filter, $this); + } + } + + protected function assembleConditions(Filter\Chain $filters, BaseHtmlElement $where) + { + foreach ($filters as $i => $filter) { + if ($i > 0) { + $logicalOperator = QueryString::getRuleSymbol($filters); + $this->assembleTerm([ + 'class' => 'logical_operator', + 'type' => 'logical_operator', + 'search' => $logicalOperator, + 'label' => $logicalOperator + ], $where); + } + + if ($filter instanceof Filter\Chain) { + $this->assembleChain($filter, $where, $filter->count() > 1); + } else { + /** @var Filter\Condition $filter */ + $this->assembleCondition($filter, $where); + } + } + } + + protected function assembleChain(Filter\Chain $chain, BaseHtmlElement $where, $wrap = false) + { + if ($wrap) { + $group = new HtmlElement( + 'div', + Attributes::create(['class' => 'filter-chain', 'data-group-type' => 'chain']) + ); + } else { + $group = $where; + } + + if ($chain instanceof Filter\None) { + $this->assembleTerm([ + 'class' => 'logical_operator', + 'type' => 'negation_operator', + 'search' => '!', + 'label' => '!' + ], $where); + } + + if ($wrap) { + $opening = $this->assembleTerm([ + 'class' => 'grouping_operator_open', + 'type' => 'grouping_operator', + 'search' => '(', + 'label' => '(' + ], $group); + } + + $this->assembleConditions($chain, $group); + + if ($wrap) { + $closing = $this->assembleTerm([ + 'class' => 'grouping_operator_close', + 'type' => 'grouping_operator', + 'search' => ')', + 'label' => ')' + ], $group); + + $opening->addAttributes([ + 'data-counterpart' => $closing->getAttributes()->get('data-index')->getValue() + ]); + $closing->addAttributes([ + 'data-counterpart' => $opening->getAttributes()->get('data-index')->getValue() + ]); + + $where->addHtml($group); + } + } + + protected function assembleCondition(Filter\Condition $filter, BaseHtmlElement $where) + { + $column = $filter->getColumn(); + $operator = QueryString::getRuleSymbol($filter); + $value = $filter->getValue(); + $columnLabel = $filter->metaData()->get('columnLabel', $column); + + $group = new HtmlElement( + 'div', + Attributes::create(['class' => 'filter-condition', 'data-group-type' => 'condition']), + new HtmlElement('button', Attributes::create(['type' => 'button']), new Icon('trash')) + ); + + $columnData = [ + 'class' => 'column', + 'type' => 'column', + 'search' => rawurlencode($column), + 'label' => $columnLabel, + 'title' => $column + ]; + if ($filter->metaData()->has('invalidColumnPattern')) { + $columnData['pattern'] = $filter->metaData()->get('invalidColumnPattern'); + if ($filter->metaData()->has('invalidColumnMessage')) { + $columnData['invalidMsg'] = $filter->metaData()->get('invalidColumnMessage'); + } + } + + $this->assembleTerm($columnData, $group); + + if ($value !== true) { + $operatorData = [ + 'class' => 'operator', + 'type' => 'operator', + 'search' => $operator, + 'label' => $operator + ]; + if ($filter->metaData()->has('invalidOperatorPattern')) { + $operatorData['pattern'] = $filter->metaData()->get('invalidOperatorPattern'); + if ($filter->metaData()->has('invalidOperatorMessage')) { + $operatorData['invalidMsg'] = $filter->metaData()->get('invalidOperatorMessage'); + } + } + + $this->assembleTerm($operatorData, $group); + + if (! empty($value) || ! is_string($value) || ctype_digit($value)) { + $valueData = [ + 'class' => 'value', + 'type' => 'value', + 'search' => rawurlencode($value), + 'label' => $value + ]; + if ($filter->metaData()->has('invalidValuePattern')) { + $valueData['pattern'] = $filter->metaData()->get('invalidValuePattern'); + if ($filter->metaData()->has('invalidValueMessage')) { + $valueData['invalidMsg'] = $filter->metaData()->get('invalidValueMessage'); + } + } + + $this->assembleTerm($valueData, $group); + } + } + + $where->addHtml($group); + } + + protected function assembleTerm(array $data, BaseHtmlElement $where) + { + if (isset($this->changes[$this->currentIndex - $this->changeIndexCorrection])) { + $change = $this->changes[$this->currentIndex - $this->changeIndexCorrection]; + if ($change['type'] !== $data['type']) { + // This can happen because the user didn't insert parentheses but the parser did + $this->changeIndexCorrection++; + } else { + $data = array_merge($data, $change); + } + } + + $term = new HtmlElement('label', Attributes::create([ + 'class' => $data['class'], + 'data-index' => $this->currentIndex++, + 'data-type' => $data['type'], + 'data-search' => $data['search'], + 'data-label' => $data['label'] + ]), new HtmlElement('input', Attributes::create([ + 'type' => 'text', + 'value' => $data['label'] + ]))); + + if (isset($data['title'])) { + $term->setAttribute('title', $data['title']); + } + + if (isset($data['pattern'])) { + $term->getFirst('input')->setAttribute('pattern', $data['pattern']); + + if (isset($data['invalidMsg'])) { + $term->getFirst('input')->setAttribute('data-invalid-msg', $data['invalidMsg']); + } + } + + $where->addHtml($term); + + return $term; + } +} diff --git a/vendor/ipl/web/src/Control/SearchBar/ValidatedColumn.php b/vendor/ipl/web/src/Control/SearchBar/ValidatedColumn.php new file mode 100644 index 0000000..5825790 --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchBar/ValidatedColumn.php @@ -0,0 +1,44 @@ +<?php + +namespace ipl\Web\Control\SearchBar; + +use ipl\Stdlib\Data; +use ipl\Stdlib\Filter\Condition; + +class ValidatedColumn extends ValidatedTerm +{ + /** + * Create a new ValidatedColumn from the given filter condition + * + * @param Condition $condition + * + * @return static + */ + public static function fromFilterCondition(Condition $condition) + { + return new static($condition->getColumn(), $condition->metaData()->get('columnLabel')); + } + + public function toTermData() + { + $termData = parent::toTermData(); + $termData['type'] = 'column'; + + return $termData; + } + + public function toMetaData() + { + $data = new Data(); + if (($label = $this->getLabel()) !== null) { + $data->set('columnLabel', $label); + } + + if (! $this->isValid()) { + $data->set('invalidColumnMessage', $this->getMessage()) + ->set('invalidColumnPattern', $this->getPattern()); + } + + return $data; + } +} diff --git a/vendor/ipl/web/src/Control/SearchBar/ValidatedOperator.php b/vendor/ipl/web/src/Control/SearchBar/ValidatedOperator.php new file mode 100644 index 0000000..2ca6d77 --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchBar/ValidatedOperator.php @@ -0,0 +1,80 @@ +<?php + +namespace ipl\Web\Control\SearchBar; + +use InvalidArgumentException; +use ipl\Stdlib\Data; +use ipl\Stdlib\Filter; +use LogicException; + +class ValidatedOperator extends ValidatedTerm +{ + /** + * Create a new ValidatedColumn from the given filter condition + * + * @param Filter\Condition $condition + * + * @return static + * + * @throws InvalidArgumentException In case the condition type is unknown + */ + public static function fromFilterCondition(Filter\Condition $condition) + { + switch (true) { + case $condition instanceof Filter\Unlike: + case $condition instanceof Filter\Unequal: + $operator = '!='; + break; + case $condition instanceof Filter\Like: + case $condition instanceof Filter\Equal: + $operator = '='; + break; + case $condition instanceof Filter\GreaterThan: + $operator = '>'; + break; + case $condition instanceof Filter\LessThan: + $operator = '<'; + break; + case $condition instanceof Filter\GreaterThanOrEqual: + $operator = '>='; + break; + case $condition instanceof Filter\LessThanOrEqual: + $operator = '<='; + break; + default: + throw new InvalidArgumentException('Unknown condition type'); + } + + return new static($operator); + } + + public function toTermData() + { + $termData = parent::toTermData(); + $termData['type'] = 'operator'; + + return $termData; + } + + public function toMetaData() + { + $data = new Data(); + + if (! $this->isValid()) { + $data->set('invalidOperatorMessage', $this->getMessage()) + ->set('invalidOperatorPattern', $this->getPattern()); + } + + return $data; + } + + public function setSearchValue($searchValue) + { + throw new LogicException('Operators cannot be changed'); + } + + public function setLabel($label) + { + throw new LogicException('Operators cannot be changed'); + } +} diff --git a/vendor/ipl/web/src/Control/SearchBar/ValidatedTerm.php b/vendor/ipl/web/src/Control/SearchBar/ValidatedTerm.php new file mode 100644 index 0000000..e552552 --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchBar/ValidatedTerm.php @@ -0,0 +1,196 @@ +<?php + +namespace ipl\Web\Control\SearchBar; + +use ipl\Stdlib\Data; + +abstract class ValidatedTerm +{ + /** @var string The default validation constraint */ + const DEFAULT_PATTERN = '^\s*(?!%s\b).*\s*$'; + + /** @var mixed The search value */ + protected $searchValue; + + /** @var string The label */ + protected $label; + + /** @var string The validation message */ + protected $message; + + /** @var string The validation constraint */ + protected $pattern; + + /** @var bool Whether the term has been adjusted */ + protected $changed = false; + + /** + * Create a new ValidatedTerm + * + * @param mixed $searchValue The search value + * @param ?string $label The label + */ + public function __construct($searchValue, $label = null) + { + $this->searchValue = $searchValue; + $this->label = $label; + } + + /** + * Create a new ValidatedTerm from the given data + * + * @param array $data + * + * @return static + */ + public static function fromTermData(array $data) + { + return new static($data['search'], isset($data['label']) ? $data['label'] : null); + } + + /** + * Check whether the term is valid + * + * @return bool + */ + public function isValid() + { + return $this->message === null; + } + + /** + * Check whether the term has been adjusted + * + * @return bool + */ + public function hasBeenChanged() + { + return $this->changed; + } + + /** + * Get the search value + * + * @return mixed + */ + public function getSearchValue() + { + return $this->searchValue; + } + + /** + * Set the search value + * + * @param mixed $searchValue + * + * @return $this + */ + public function setSearchValue($searchValue) + { + $this->searchValue = $searchValue; + $this->changed = true; + + return $this; + } + + /** + * Get the label + * + * @return string + */ + public function getLabel() + { + return $this->label; + } + + /** + * Set the label + * + * @param string $label + * + * @return $this + */ + public function setLabel($label) + { + $this->label = (string) $label; + $this->changed = true; + + return $this; + } + + /** + * Get the validation message + * + * @return string + */ + public function getMessage() + { + return $this->message; + } + + /** + * Set the validation message + * + * @param ?string $message + * + * @return $this + */ + public function setMessage($message) + { + $this->message = $message; + + return $this; + } + + /** + * Get the validation constraint + * + * Returns the default constraint if none is set. + * + * @return string + */ + public function getPattern() + { + if ($this->pattern === null) { + return sprintf(self::DEFAULT_PATTERN, $this->getSearchValue()); + } + + return $this->pattern; + } + + /** + * Set the validation constraint + * + * @param string $pattern + * + * @return $this + */ + public function setPattern($pattern) + { + $this->pattern = (string) $pattern; + + return $this; + } + + /** + * Get this term's data + * + * @return array + */ + public function toTermData() + { + return [ + 'search' => $this->getSearchValue(), + 'label' => $this->getLabel() ?: $this->getSearchValue(), + 'invalidMsg' => $this->getMessage(), + 'pattern' => $this->getPattern() + ]; + } + + /** + * Get this term's metadata + * + * @return Data + */ + abstract public function toMetaData(); +} diff --git a/vendor/ipl/web/src/Control/SearchBar/ValidatedValue.php b/vendor/ipl/web/src/Control/SearchBar/ValidatedValue.php new file mode 100644 index 0000000..423102d --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchBar/ValidatedValue.php @@ -0,0 +1,41 @@ +<?php + +namespace ipl\Web\Control\SearchBar; + +use ipl\Stdlib\Data; +use ipl\Stdlib\Filter\Condition; + +class ValidatedValue extends ValidatedTerm +{ + /** + * Create a new ValidatedColumn from the given filter condition + * + * @param Condition $condition + * + * @return static + */ + public static function fromFilterCondition(Condition $condition) + { + return new static($condition->getValue()); + } + + public function toTermData() + { + $termData = parent::toTermData(); + $termData['type'] = 'value'; + + return $termData; + } + + public function toMetaData() + { + $data = new Data(); + + if (! $this->isValid()) { + $data->set('invalidValueMessage', $this->getMessage()) + ->set('invalidValuePattern', $this->getPattern()); + } + + return $data; + } +} diff --git a/vendor/ipl/web/src/Control/SearchEditor.php b/vendor/ipl/web/src/Control/SearchEditor.php new file mode 100644 index 0000000..dfcde2e --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchEditor.php @@ -0,0 +1,617 @@ +<?php + +namespace ipl\Web\Control; + +use ipl\Html\Attributes; +use ipl\Html\Form; +use ipl\Html\FormDecorator\CallbackDecorator; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\Stdlib\Events; +use ipl\Stdlib\Filter; +use ipl\Web\Control\SearchBar\SearchException; +use ipl\Web\Filter\Parser; +use ipl\Web\Filter\QueryString; +use ipl\Web\Filter\Renderer; +use ipl\Web\Url; +use ipl\Web\Widget\Icon; + +class SearchEditor extends Form +{ + use Events; + + /** @var string Emitted for every validated column */ + const ON_VALIDATE_COLUMN = 'validate-column'; + + /** @var string The column name used for empty conditions */ + const FAKE_COLUMN = '_fake_'; + + protected $defaultAttributes = [ + 'data-enrichment-type' => 'search-editor', + 'class' => 'search-editor' + ]; + + /** @var string */ + protected $queryString; + + /** @var Url */ + protected $suggestionUrl; + + /** @var Parser */ + protected $parser; + + /** @var Filter\Rule */ + protected $filter; + + /** @var bool */ + protected $cleared = false; + + /** + * Set the filter query string to populate the form with + * + * Use {@see SearchEditor::getParser()} to subscribe to parser events. + * + * @param string $query + * + * @return $this + */ + public function setQueryString($query) + { + $this->queryString = $query; + + return $this; + } + + /** + * Get the suggestion url + * + * @return ?Url + */ + public function getSuggestionUrl(): ?Url + { + return $this->suggestionUrl; + } + + /** + * Set the suggestion url + * + * @param Url $url + * + * @return $this + */ + public function setSuggestionUrl(Url $url) + { + $this->suggestionUrl = $url; + + return $this; + } + + /** + * Get the query string parser being used + * + * @return Parser + */ + public function getParser() + { + if ($this->parser === null) { + $this->parser = new Parser(); + } + + return $this->parser; + } + + /** + * Get the current filter + * + * @return Filter\Rule + */ + public function getFilter() + { + if ($this->filter === null) { + $this->filter = $this->getParser() + ->setQueryString($this->queryString) + ->parse(); + } + + return $this->filter; + } + + public function populate($values) + { + // applyChanges() is basically this form's own populate implementation, hence + // why it changes $values and needs to run before actually populating the form + $filter = (new Parser(isset($values['filter']) ? $values['filter'] : $this->queryString)) + ->setStrict() + ->parse(); + $filter = $this->applyChanges($filter, $values); + + parent::populate($values); + + $this->filter = $this->applyStructuralChange($filter); + if ($this->filter !== null && ($this->filter instanceof Filter\Condition || ! $this->filter->isEmpty())) { + $this->queryString = (new Renderer($this->filter))->setStrict()->render(); + } else { + $this->queryString = ''; + } + + return $this; + } + + public function hasBeenSubmitted() + { + if (parent::hasBeenSubmitted()) { + return true; + } + + return $this->cleared; + } + + public function validate() + { + if ($this->cleared) { + $this->isValid = true; + } else { + parent::validate(); + } + + return $this; + } + + protected function applyChanges(Filter\Rule $rule, array &$values, array $path = [0]) + { + $identifier = 'rule-' . join('-', $path); + + if ($rule instanceof Filter\Condition) { + $newColumn = $this->popKey($values, $identifier . '-column-search'); + if ($newColumn === null) { + $newColumn = $this->popKey($values, $identifier . '-column'); + } else { + // Make sure we don't forget to present the column labels again + $rule->metaData()->set('columnLabel', $this->popKey($values, $identifier . '-column')); + } + + if ($newColumn !== null && $rule->getColumn() !== $newColumn) { + $rule->setColumn($newColumn ?: static::FAKE_COLUMN); + // TODO: Clear meta data? + } + + $newValue = $this->popKey($values, $identifier . '-value'); + $oldValue = $rule->getValue(); + if ($newValue !== null && $oldValue !== $newValue) { + $rule->setValue($newValue); + } + + $newOperator = $this->popKey($values, $identifier . '-operator'); + if ($newOperator !== null && QueryString::getRuleSymbol($rule) !== $newOperator) { + $value = $rule->getValue(); + $column = $rule->getColumn(); + switch ($newOperator) { + case '=': + if (is_string($value) && strpos($value, '*') !== false) { + return Filter::like($column, $value); + } + + return Filter::equal($column, $value); + case '!=': + if (is_string($value) && strpos($value, '*') !== false) { + return Filter::unlike($column, $value); + } + + return Filter::unequal($column, $value); + case '>': + return Filter::greaterThan($column, $value); + case '>=': + return Filter::greaterThanOrEqual($column, $value); + case '<': + return Filter::lessThan($column, $value); + case '<=': + return Filter::lessThanOrEqual($column, $value); + } + } + + $value = $rule->getValue(); + if ($oldValue !== $value && is_string($value) && strpos($value, '*') !== false) { + if (QueryString::getRuleSymbol($rule) === '=') { + return Filter::like($rule->getColumn(), $value); + } elseif (QueryString::getRuleSymbol($rule) === '!=') { + return Filter::unlike($rule->getColumn(), $value); + } + } + } else { + /** @var Filter\Chain $rule */ + $newGroupOperator = $this->popKey($values, $identifier); + $oldGroupOperator = $rule instanceof Filter\None ? '!' : QueryString::getRuleSymbol($rule); + if ($newGroupOperator !== null && $oldGroupOperator !== $newGroupOperator) { + switch ($newGroupOperator) { + case '&': + $rule = Filter::all(...$rule); + break; + case '|': + $rule = Filter::any(...$rule); + break; + case '!': + $rule = Filter::none(...$rule); + break; + } + } + + $i = 0; + foreach ($rule as $child) { + $childPath = $path; + $childPath[] = $i++; + $newChild = $this->applyChanges($child, $values, $childPath); + if ($child !== $newChild) { + $rule->replace($child, $newChild); + } + } + } + + return $rule; + } + + protected function applyStructuralChange(Filter\Rule $rule) + { + $structuralChange = $this->getPopulatedValue('structural-change'); + if (empty($structuralChange)) { + return $rule; + } elseif (is_array($structuralChange)) { + ksort($structuralChange); + } + + list($type, $where) = explode(':', is_array($structuralChange) + ? array_shift($structuralChange) + : $structuralChange); + $targetPath = explode('-', substr($where, 5)); + + $targetFinder = function ($path) use ($rule) { + $parent = null; + $target = null; + $children = [$rule]; + foreach ($path as $targetPos) { + if ($target !== null) { + $parent = $target; + $children = $parent instanceof Filter\Chain + ? iterator_to_array($parent) + : []; + } + + if (! isset($children[$targetPos])) { + return [null, null]; + } + + $target = $children[$targetPos]; + } + + return [$parent, $target]; + }; + + list($parent, $target) = $targetFinder($targetPath); + if ($target === null) { + return $rule; + } + + $emptyEqual = Filter::equal(static::FAKE_COLUMN, ''); + switch ($type) { + case 'move-rule': + if (! is_array($structuralChange) || empty($structuralChange)) { + return $rule; + } + + list($placement, $moveToPath) = explode(':', array_shift($structuralChange)); + list($moveToParent, $moveToTarget) = $targetFinder(explode('-', substr($moveToPath, 5))); + + $parent->remove($target); + if ($placement === 'to') { + $moveToTarget->add($target); + } elseif ($placement === 'before') { + $moveToParent->insertBefore($target, $moveToTarget); + } else { + $moveToParent->insertAfter($target, $moveToTarget); + } + + break; + case 'add-condition': + $target->add($emptyEqual); + + break; + case 'add-group': + $target->add(Filter::all($emptyEqual)); + + break; + case 'wrap-rule': + if ($parent !== null) { + $parent->replace($target, Filter::all($target)); + } else { + $rule = Filter::all($target); + } + + break; + case 'drop-rule': + if ($parent !== null) { + $parent->remove($target); + } else { + $rule = $emptyEqual; + } + + break; + case 'clear': + $this->cleared = true; + $rule = null; + } + + return $rule; + } + + protected function createTree(Filter\Rule $rule, array $path = [0]) + { + $identifier = 'rule-' . join('-', $path); + + if ($rule instanceof Filter\Condition) { + $parts = [$this->createCondition($rule, $identifier), $this->createButtons($rule, $identifier)]; + + if (count($path) === 1) { + $item = new HtmlElement('ol', null, new HtmlElement( + 'li', + Attributes::create(['id' => $identifier]), + ...$parts + )); + } else { + array_splice($parts, 1, 0, [ + new Icon('bars', ['class' => 'drag-initiator']) + ]); + + $item = (new HtmlDocument())->addHtml(...$parts); + } + } else { + /** @var Filter\Chain $rule */ + $item = new HtmlElement('ul'); + + $groupOperatorInput = $this->createElement('select', $identifier, [ + 'options' => [ + '&' => 'ALL', + '|' => 'ANY', + '!' => 'NONE' + ], + 'value' => $rule instanceof Filter\None ? '!' : QueryString::getRuleSymbol($rule) + ]); + $this->registerElement($groupOperatorInput); + $item->addHtml(HtmlElement::create('li', ['id' => $identifier], [ + $groupOperatorInput, + count($path) > 1 + ? new Icon('bars', ['class' => 'drag-initiator']) + : null, + $this->createButtons($rule, $identifier) + ])); + + $children = new HtmlElement('ol'); + $item->addHtml(new HtmlElement('li', null, $children)); + + $i = 0; + foreach ($rule as $child) { + $childPath = $path; + $childPath[] = $i++; + $children->addHtml(new HtmlElement( + 'li', + Attributes::create([ + 'id' => 'rule-' . join('-', $childPath), + 'class' => $child instanceof Filter\Condition + ? 'filter-condition' + : 'filter-chain' + ]), + $this->createTree($child, $childPath) + )); + } + } + + return $item; + } + + protected function createButtons(Filter\Rule $for, $identifier) + { + $buttons = []; + + if ($for instanceof Filter\Chain) { + $buttons[] = $this->createElement('submitButton', 'structural-change', [ + 'value' => 'add-condition:' . $identifier, + 'label' => t('Add Condition', 'to a group of filter conditions'), + 'formnovalidate' => true + ]); + $buttons[] = $this->createElement('submitButton', 'structural-change', [ + 'value' => 'add-group:' . $identifier, + 'label' => t('Add Group', 'of filter conditions'), + 'formnovalidate' => true + ]); + } + + $buttons[] = $this->createElement('submitButton', 'structural-change', [ + 'value' => 'wrap-rule:' . $identifier, + 'label' => t('Wrap in Group', 'a filter rule'), + 'formnovalidate' => true + ]); + $buttons[] = $this->createElement('submitButton', 'structural-change', [ + 'value' => 'drop-rule:' . $identifier, + 'label' => t('Delete', 'a filter rule'), + 'formnovalidate' => true + ]); + + $ul = new HtmlElement('ul'); + foreach ($buttons as $button) { + $ul->addHtml(new HtmlElement('li', null, $button)); + } + + return new HtmlElement( + 'div', + Attributes::create(['class' => 'buttons']), + $ul, + new Icon('ellipsis-h') + ); + } + + protected function createCondition(Filter\Condition $condition, $identifier) + { + $columnInput = $this->createElement('text', $identifier . '-column', [ + 'value' => $condition->metaData()->get( + 'columnLabel', + $condition->getColumn() !== static::FAKE_COLUMN + ? $condition->getColumn() + : null + ), + 'title' => $condition->getColumn() !== static::FAKE_COLUMN + ? $condition->getColumn() + : null, + 'required' => true, + 'autocomplete' => 'off', + 'data-type' => 'column', + 'data-enrichment-type' => 'completion', + 'data-term-suggestions' => '#search-editor-suggestions' + ]); + $columnInput->getAttributes()->registerAttributeCallback('data-suggest-url', function () { + return (string) $this->getSuggestionUrl(); + }); + (new CallbackDecorator(function ($element) { + $errors = new HtmlElement('ul', Attributes::create(['class' => 'search-errors'])); + + foreach ($element->getMessages() as $message) { + $errors->addHtml(new HtmlElement('li', null, Text::create($message))); + } + + if (! $errors->isEmpty()) { + if (trim($element->getValue())) { + $element->getAttributes()->add( + 'pattern', + sprintf( + '^\s*(?!%s\b).*\s*$', + $element->getValue() + ) + ); + } + + $element->prependWrapper(new HtmlElement( + 'div', + Attributes::create(['class' => 'search-error']), + $element, + $errors + )); + } + }))->decorate($columnInput); + + $columnFakeInput = $this->createElement('hidden', $identifier . '-column-search', [ + 'value' => static::FAKE_COLUMN + ]); + $columnSearchInput = $this->createElement('hidden', $identifier . '-column-search', [ + 'value' => $condition->getColumn() !== static::FAKE_COLUMN + ? $condition->getColumn() + : null, + 'validators' => ['Callback' => function ($value) use ($condition, $columnInput, &$columnSearchInput) { + if (! $this->hasBeenSubmitted()) { + return true; + } + + try { + $this->emit(static::ON_VALIDATE_COLUMN, [$condition]); + } catch (SearchException $e) { + $columnInput->addMessage($e->getMessage()); + return false; + } + + $columnSearchInput->setValue($condition->getColumn()); + $columnInput->setValue($condition->metaData()->get('columnLabel', $condition->getColumn())); + + return true; + }] + ]); + + $operatorInput = $this->createElement('select', $identifier . '-operator', [ + 'options' => [ + '=' => '=', + '!=' => '!=', + '>' => '>', + '<' => '<', + '>=' => '>=', + '<=' => '<=' + ], + 'value' => QueryString::getRuleSymbol($condition) + ]); + + $valueInput = $this->createElement('text', $identifier . '-value', [ + 'value' => $condition->getValue(), + 'autocomplete' => 'off', + 'data-type' => 'value', + 'data-enrichment-type' => 'completion', + 'data-term-suggestions' => '#search-editor-suggestions' + ]); + $valueInput->getAttributes()->registerAttributeCallback('data-suggest-url', function () { + return (string) $this->getSuggestionUrl(); + }); + + $this->registerElement($columnInput); + $this->registerElement($columnSearchInput); + $this->registerElement($operatorInput); + $this->registerElement($valueInput); + + return new HtmlElement( + 'fieldset', + Attributes::create(['name' => $identifier . '-']), + $columnInput, + $columnFakeInput, + $columnSearchInput, + $operatorInput, + $valueInput + ); + } + + protected function assemble() + { + $filterInput = $this->createElement('hidden', 'filter'); + $filterInput->getAttributes()->registerAttributeCallback( + 'value', + function () { + return $this->queryString ?: static::FAKE_COLUMN; + }, + [$this, 'setQueryString'] + ); + $this->addElement($filterInput); + + $filter = $this->getFilter(); + if ($filter instanceof Filter\Chain && $filter->isEmpty()) { + $filter = Filter::equal('', ''); + } + + $this->addHtml($this->createTree($filter)); + $this->addHtml(new HtmlElement('div', Attributes::create([ + 'id' => 'search-editor-suggestions', + 'class' => 'search-suggestions' + ]))); + + if ($this->queryString) { + $this->addHtml($this->createElement('submitButton', 'structural-change', [ + 'value' => 'clear:rule-0', + 'class' => 'cancel-button', + 'label' => t('Clear Filter'), + 'formnovalidate' => true + ])); + } + + $this->addElement('submit', 'btn_submit', [ + 'label' => t('Apply') + ]); + + // Add submit button also as first element to make Web 2 submit + // the form instead of using a structural change to submit if + // the user just presses Enter. + $this->prepend($this->getElement('btn_submit')); + } + + private function popKey(array &$from, $key, $default = null) + { + if (isset($from[$key])) { + $value = $from[$key]; + unset($from[$key]); + + return $value; + } + + return $default; + } +} diff --git a/vendor/ipl/web/src/Control/SortControl.php b/vendor/ipl/web/src/Control/SortControl.php new file mode 100644 index 0000000..19954b3 --- /dev/null +++ b/vendor/ipl/web/src/Control/SortControl.php @@ -0,0 +1,251 @@ +<?php + +namespace ipl\Web\Control; + +use ipl\Html\FormElement\ButtonElement; +use ipl\Orm\Common\SortUtil; +use ipl\Orm\Query; +use ipl\Stdlib\Str; +use ipl\Web\Compat\CompatForm; +use ipl\Web\Url; +use ipl\Web\Widget\Icon; + +/** + * Allows to adjust the order of the items to display + */ +class SortControl extends CompatForm +{ + /** @var string Default sort param */ + const DEFAULT_SORT_PARAM = 'sort'; + + protected $defaultAttributes = ['class' => 'icinga-form inline sort-control']; + + /** @var string Name of the URL parameter which stores the sort column */ + protected $sortParam = self::DEFAULT_SORT_PARAM; + + /** @var Url Request URL */ + protected $url; + + /** @var array Possible sort columns as sort string-value pairs */ + private $columns; + + /** @var string Default sort string */ + private $default; + + protected $method = 'GET'; + + /** + * Create a new sort control + * + * @param Url $url Request URL + */ + public function __construct(Url $url) + { + $this->url = $url; + } + + /** + * Create a new sort control with the given options + * + * @param array<string,string> $options A sort spec to label map + * + * @return static + */ + public static function create(array $options) + { + $normalized = []; + foreach ($options as $spec => $label) { + $normalized[SortUtil::normalizeSortSpec($spec)] = $label; + } + + return (new static(Url::fromRequest())) + ->setColumns($normalized); + } + + /** + * Get the possible sort columns + * + * @return array Sort string-value pairs + */ + public function getColumns() + { + return $this->columns; + } + + /** + * Set the possible sort columns + * + * @param array $columns Sort string-value pairs + * + * @return $this + */ + public function setColumns(array $columns) + { + // We're working with lowercase keys throughout the sort control + $this->columns = array_change_key_case($columns, CASE_LOWER); + + return $this; + } + + /** + * Get the default sort string + * + * @return string + */ + public function getDefault() + { + return $this->default; + } + + /** + * Set the default sort string + * + * @param array|string $default + * + * @return $this + */ + public function setDefault($default) + { + // We're working with lowercase keys throughout the sort control + $this->default = strtolower($default); + + return $this; + } + + /** + * Get the name of the URL parameter which stores the sort + * + * @return string + */ + public function getSortParam() + { + return $this->sortParam; + } + + /** + * Set the name of the URL parameter which stores the sort + * + * @param string $sortParam + * + * @return $this + */ + public function setSortParam($sortParam) + { + $this->sortParam = $sortParam; + + return $this; + } + + /** + * Get the sort string + * + * @return string|null + */ + public function getSort() + { + $sort = $this->url->getParam($this->getSortParam(), $this->getDefault()); + + if (! empty($sort)) { + $columns = $this->getColumns(); + + if (! isset($columns[$sort])) { + // Choose sort string based on the first closest match + foreach (array_keys($columns) as $key) { + if (Str::startsWith($key, $sort)) { + $sort = $key; + + break; + } + } + } + } + + return $sort; + } + + /** + * Sort the given query according to the request + * + * @param Query $query + * + * @return $this + */ + public function apply(Query $query) + { + $default = (array) $query->getModel()->getDefaultSort(); + if (! empty($default)) { + $this->setDefault(SortUtil::normalizeSortSpec($default)); + } + + $sort = $this->getSort(); + if (! empty($sort)) { + $query->orderBy(SortUtil::createOrderBy($sort)); + } + + return $this; + } + + protected function assemble() + { + $columns = $this->getColumns(); + $sort = $this->getSort(); + + if (empty($sort)) { + reset($columns); + $sort = key($columns); + } + + $sort = explode(',', $sort, 2); + list($column, $direction) = Str::symmetricSplit(array_shift($sort), ' ', 2); + + if (! $direction || strtolower($direction) === 'asc') { + $toggleIcon = 'sort-alpha-down'; + $toggleDirection = 'desc'; + } else { + $toggleIcon = 'sort-alpha-down-alt'; + $toggleDirection = 'asc'; + } + + if ($direction !== null) { + $value = implode(',', array_merge(["{$column} {$direction}"], $sort)); + if (! isset($columns[$value])) { + foreach ([$column, "{$column} {$toggleDirection}"] as $key) { + $key = implode(',', array_merge([$key], $sort)); + if (isset($columns[$key])) { + $columns[$value] = $columns[$key]; + unset($columns[$key]); + + break; + } + } + } + } else { + $value = implode(',', array_merge([$column], $sort)); + } + + if (! isset($columns[$value])) { + $columns[$value] = 'Custom'; + } + + $this->addElement('select', $this->getSortParam(), [ + 'class' => 'autosubmit', + 'label' => 'Sort By', + 'options' => $columns, + 'value' => $value + ]) + ->getElement($this->getSortParam()) + ->getWrapper() + ->getAttributes() + ->add('class', 'icinga-controls'); + + $toggleButton = new ButtonElement($this->getSortParam(), [ + 'class' => 'control-button spinner', + 'title' => t('Change sort direction'), + 'type' => 'submit', + 'value' => implode(',', array_merge(["{$column} {$toggleDirection}"], $sort)) + ]); + $toggleButton->add(new Icon($toggleIcon)); + + $this->addElement($toggleButton); + } +} diff --git a/vendor/ipl/web/src/Filter/ParseException.php b/vendor/ipl/web/src/Filter/ParseException.php new file mode 100644 index 0000000..bcafd09 --- /dev/null +++ b/vendor/ipl/web/src/Filter/ParseException.php @@ -0,0 +1,36 @@ +<?php + +namespace ipl\Web\Filter; + +use Exception; + +class ParseException extends Exception +{ + protected $char; + + protected $charPos; + + public function __construct($filter, $char, $charPos, $extra) + { + parent::__construct(sprintf( + 'Invalid filter "%s", unexpected %s at pos %d%s', + $filter, + $char, + $charPos, + $extra + )); + + $this->char = $char; + $this->charPos = $charPos; + } + + public function getChar() + { + return $this->char; + } + + public function getCharPos() + { + return $this->charPos; + } +} diff --git a/vendor/ipl/web/src/Filter/Parser.php b/vendor/ipl/web/src/Filter/Parser.php new file mode 100644 index 0000000..248b41c --- /dev/null +++ b/vendor/ipl/web/src/Filter/Parser.php @@ -0,0 +1,572 @@ +<?php + +namespace ipl\Web\Filter; + +use ipl\Stdlib\Events; +use ipl\Stdlib\Filter; + +class Parser +{ + use Events; + + /** @var string Emitted for every completely parsed condition */ + const ON_CONDITION = 'on_condition'; + + /** @var string Emitted for every completely parsed chain */ + const ON_CHAIN = 'on_chain'; + + /** @var string */ + protected $string; + + /** @var int */ + protected $pos; + + /** @var int */ + protected $termIndex; + + /** @var int */ + protected $length; + + /** @var bool Whether strict mode is enabled */ + protected $strict = false; + + /** + * Create a new Parser + * + * @param string $queryString The string to parse + */ + public function __construct($queryString = null) + { + if ($queryString !== null) { + $this->setQueryString($queryString); + } + } + + /** + * Set the query string to parse + * + * @param string $queryString + * + * @return $this + */ + public function setQueryString($queryString) + { + $this->string = (string) $queryString; + $this->length = strlen($queryString); + + return $this; + } + + /** + * Set whether strict mode is enabled + * + * @param bool $strict + * + * @return $this + */ + public function setStrict($strict = true) + { + $this->strict = (bool) $strict; + + return $this; + } + + /** + * Parse the string and derive a filter rule from it + * + * @return Filter\Rule + */ + public function parse() + { + if ($this->length === 0) { + return Filter::all(); + } + + $this->pos = 0; + $this->termIndex = 0; + + return $this->readFilters(); + } + + /** + * Read filters + * + * @param int $nestingLevel + * @param string $op + * @param array $filters + * @param bool $explicit + * + * @return Filter\Chain|Filter\Condition + * @throws ParseException + */ + protected function readFilters($nestingLevel = 0, $op = null, $filters = null, $explicit = true) + { + $filters = empty($filters) ? [] : $filters; + $isNone = false; + + while ($this->pos < $this->length) { + $filter = $this->readCondition(); + $next = $this->readChar(); + + if ($filter === false) { + if ($next === '!') { + $isNone = true; + $this->termIndex++; + continue; + } + + if ($op === null && ($this->strict || count($filters) > 0) && ($next === '&' || $next === '|')) { + $op = $next; + $this->termIndex++; + continue; + } + + if ($next === false) { + // Nothing more to read + break; + } + + if ($next === ')') { + if ($nestingLevel > 0) { + if (! $explicit) { + // The current chain was not initiated by a `(`, + // so this `)` does not belong to it, but still ends it + $this->pos--; + } else { + $this->termIndex++; + $next = $this->nextChar(); + if ($next !== false && ! in_array($next, ['&', '|', ')'])) { + $this->pos++; + $this->parseError($next, 'Expected logical operator'); + } + } + + break; + } + + $this->parseError($next); + } + + if ($next === '(') { + $this->termIndex++; + + $rule = $this->readFilters($nestingLevel + 1, $isNone ? '!' : null); + if ($this->strict || ! $rule instanceof Filter\Chain || ! $rule->isEmpty()) { + $filters[] = $rule; + } + + $isNone = false; + continue; + } + + if ($next === $op) { + $this->termIndex++; + continue; + } + + if (in_array($next, ['&', '|'])) { + $this->termIndex++; + + // It's a different logical operator, continue parsing based on its precedence + if ($op === '&') { + if (! empty($filters)) { + if (count($filters) > 1) { + $all = Filter::all(...$filters); + $filters = [$all]; + + $this->emit(self::ON_CHAIN, [$all]); + } else { + $filters = [$filters[0]]; + } + } + + $op = $next; + } elseif ($op === '|' || ($op === '!' && $next === '&')) { + $rule = $this->readFilters( + $nestingLevel + 1, + $next, + [array_pop($filters)], + false + ); + if (! $rule instanceof Filter\Chain || ! $rule->isEmpty()) { + $filters[] = $rule; + } + } + + continue; + } + + $this->parseError($next, "$op level $nestingLevel"); + } else { + if ($isNone) { + $isNone = false; + if ($filter->getValue() === true) { + // $filter is a result of `!column` + $filter->setValue(false); + $filters[] = $filter; + + $this->emit(self::ON_CONDITION, [$filter]); + } else { + // $filter is a result of `!column=[value]` + $none = Filter::none($filter); + $filters[] = $none; + + $this->emit(self::ON_CONDITION, [$filter]); + $this->emit(self::ON_CHAIN, [$none]); + } + } else { + $filters[] = $filter; + $this->emit(self::ON_CONDITION, [$filter]); + } + + if ($next === false) { + // Got filter, nothing more to read + break; + } + + if ($next === ')') { + if ($nestingLevel > 0) { + if (! $explicit) { + // The current chain was not initiated by a `(`, + // so this `)` does not belong to it, but still ends it + $this->pos--; + } else { + $this->termIndex++; + $next = $this->nextChar(); + if ($next !== false && ! in_array($next, ['&', '|', ')'])) { + $this->pos++; + $this->parseError($next, 'Expected logical operator'); + } + } + + break; + } + + $this->parseError($next); + } + + if ($next === $op) { + $this->termIndex++; + continue; + } + + if (in_array($next, ['&', '|'])) { + $this->termIndex++; + + // It's a different logical operator, continue parsing based on its precedence + if ($op === null || $op === '&') { + if ($op === '&') { + if (count($filters) > 1) { + $all = Filter::all(...$filters); + $filters = [$all]; + + $this->emit(self::ON_CHAIN, [$all]); + } else { + $filters = [$filters[0]]; + } + } + + $op = $next; + } elseif ($op === '|' || ($op === '!' && $next === '&')) { + $rule = $this->readFilters( + $nestingLevel + 1, + $next, + [array_pop($filters)], + false + ); + if (! $rule instanceof Filter\Chain || ! $rule->isEmpty()) { + $filters[] = $rule; + } + } + + continue; + } + + $this->parseError($next); + } + } + + if ($nestingLevel === 0 && $this->pos < $this->length) { + $this->parseError($op, 'Did not read full filter'); + } + + switch ($op) { + case '&': + $chain = Filter::all(...$filters); + break; + case '|': + $chain = Filter::any(...$filters); + break; + case '!': + $chain = Filter::none(...$filters); + break; + case null: + if ((! $this->strict || $nestingLevel === 0) && ! empty($filters)) { + // There is only one filter expression, no chain + return $filters[0]; + } + + $chain = Filter::all(...$filters); + break; + default: + $this->parseError($op); + } + + $this->emit(self::ON_CHAIN, [$chain]); + + return $chain; + } + + /** + * Read the next condition + * + * @return false|Filter\Condition + * + * @throws ParseException + */ + protected function readCondition() + { + if ('' === ($column = $this->readColumn())) { + return false; + } + + $columnIndex = $this->termIndex++; + + foreach (['<', '>'] as $operator) { + if (($pos = strpos($column, $operator)) !== false) { + if ($this->nextChar() === '=') { + break; + } + + $operatorIndex = $this->termIndex++; + + $value = substr($column, $pos + 1); + $column = substr($column, 0, $pos); + + $valueIndex = null; + if (ctype_digit($value)) { + $value = (float) $value; + $valueIndex = $this->termIndex++; + } elseif ($value) { + $valueIndex = $this->termIndex++; + } + + $condition = $this->createCondition($column, $operator, $value); + $condition->metaData() + ->set('columnIndex', $columnIndex) + ->set('operatorIndex', $operatorIndex) + ->set('valueIndex', $valueIndex); + + return $condition; + } + } + + if (in_array($this->nextChar(), ['=', '>', '<', '!'], true)) { + $operator = $this->readChar(); + } else { + $operator = false; + } + + if ($operator === false) { + $condition = Filter::equal($column, true); + $condition->metaData() + ->set('columnIndex', $columnIndex) + ->set('operatorIndex', null) + ->set('valueIndex', null); + + return $condition; + } + + $operatorIndex = $this->termIndex++; + + $toFloat = false; + if ($operator === '=') { + $last = substr($column, -1); + if ($last === '>' || $last === '<') { + $operator = $last . $operator; + $column = substr($column, 0, -1); + $toFloat = true; + } + } elseif (in_array($operator, ['>', '<', '!'], true)) { + $toFloat = $operator === '>' || $operator === '<'; + if ($this->nextChar() === '=') { + $operator .= $this->readChar(); + } + } + + $valueIndex = null; + $value = $this->readValue(); + if ($toFloat && ctype_digit($value)) { + $value = (float) $value; + $valueIndex = $this->termIndex++; + } elseif ($value) { + $valueIndex = $this->termIndex++; + } + + $condition = $this->createCondition($column, $operator, $value); + $condition->metaData() + ->set('columnIndex', $columnIndex) + ->set('operatorIndex', $operatorIndex) + ->set('valueIndex', $valueIndex); + + return $condition; + } + + /** + * Read the next column + * + * @return false|string false if there is none + */ + protected function readColumn() + { + $str = $this->readUntil('=', '(', ')', '&', '|', '>', '<', '!'); + + if ($str === false) { + return $str; + } + + return rawurldecode($str); + } + + /** + * Read the next value + * + * @return string|string[] + * + * @throws ParseException In case there's a missing `)` + */ + protected function readValue() + { + if ($this->nextChar() === '(') { + $this->readChar(); + $var = array_map('rawurldecode', preg_split('~\|~', $this->readUntil(')'))); + + if ($this->readChar() !== ')') { + $this->parseError(null, 'Expected ")"'); + } + } else { + $var = rawurldecode($this->readUntil(')', '&', '|', '>', '<')); + } + + return $var; + } + + /** + * Read until any of the given chars appears + * + * @param string ...$chars + * + * @return string + */ + protected function readUntil(...$chars) + { + $buffer = ''; + while (($c = $this->readChar()) !== false) { + if (in_array($c, $chars, true)) { + $this->pos--; + break; + } + + $buffer .= $c; + } + + return $buffer; + } + + /** + * Read a single character + * + * @return false|string false if there is no character left + */ + protected function readChar() + { + if ($this->length > $this->pos) { + return $this->string[$this->pos++]; + } + + return false; + } + + /** + * Look at the next character + * + * @return false|string false if there is no character left + */ + protected function nextChar() + { + if ($this->length > $this->pos) { + return $this->string[$this->pos]; + } + + return false; + } + + /** + * Create and return a condition + * + * @param string $column + * @param string $operator + * @param mixed $value + * + * @return Filter\Condition + */ + protected function createCondition($column, $operator, $value) + { + $column = trim($column); + + switch ($operator) { + case '=': + if (is_string($value) && strpos($value, "*") !== false) { + return Filter::like($column, $value); + } + + return Filter::equal($column, $value); + case '!=': + if (is_string($value) && strpos($value, '*') !== false) { + return Filter::unlike($column, $value); + } + + return Filter::unequal($column, $value); + case '>': + return Filter::greaterThan($column, $value); + case '>=': + return Filter::greaterThanOrEqual($column, $value); + case '<': + return Filter::lessThan($column, $value); + case '<=': + return Filter::lessThanOrEqual($column, $value); + } + } + + /** + * Throw a parse exception + * + * @param string $char + * @param string $extraMsg + * + * @throws ParseException + */ + protected function parseError($char = null, $extraMsg = null) + { + if ($extraMsg === null) { + $extra = ''; + } else { + $extra = ': ' . $extraMsg; + } + + if ($char === null) { + if ($this->pos < $this->length) { + $char = $this->string[$this->pos]; + } else { + $char = $this->string[--$this->pos]; + } + } + + throw new ParseException( + $this->string, + $char, + $this->pos, + $extra + ); + } +} diff --git a/vendor/ipl/web/src/Filter/QueryString.php b/vendor/ipl/web/src/Filter/QueryString.php new file mode 100644 index 0000000..235bf38 --- /dev/null +++ b/vendor/ipl/web/src/Filter/QueryString.php @@ -0,0 +1,92 @@ +<?php + +namespace ipl\Web\Filter; + +use InvalidArgumentException; +use ipl\Stdlib\Filter; + +final class QueryString +{ + /** @var string Emitted for every completely parsed condition */ + const ON_CONDITION = Parser::ON_CONDITION; + + /** @var string Emitted for every completely parsed chain */ + const ON_CHAIN = Parser::ON_CHAIN; + + /** + * This class is only a factory / helper + */ + private function __construct() + { + } + + /** + * Derive a rule from the given query string + * + * @param string $string + * + * @return Parser + */ + public static function fromString($string) + { + return new Parser($string); + } + + /** + * Derive a rule from the given query string + * + * @param string $string + * + * @return Filter\Rule + */ + public static function parse($string) + { + return (new Parser($string))->parse(); + } + + /** + * Assemble a query string for the given rule + * + * @param Filter\Rule $rule + * + * @return string + */ + public static function render(Filter\Rule $rule) + { + return (new Renderer($rule))->render(); + } + + /** + * Get the symbol associated with the given rule + * + * @param Filter\Rule $rule + * + * @return string + */ + public static function getRuleSymbol(Filter\Rule $rule) + { + switch (true) { + case $rule instanceof Filter\Unequal: + case $rule instanceof Filter\Unlike: + return '!='; + case $rule instanceof Filter\Equal: + case $rule instanceof Filter\Like: + return '='; + case $rule instanceof Filter\GreaterThan: + return '>'; + case $rule instanceof Filter\LessThan: + return '<'; + case $rule instanceof Filter\GreaterThanOrEqual: + return '>='; + case $rule instanceof Filter\LessThanOrEqual: + return '<='; + case $rule instanceof Filter\All: + return '&'; + case $rule instanceof Filter\Any: + case $rule instanceof Filter\None: + return '|'; + default: + throw new InvalidArgumentException('Unknown rule type provided'); + } + } +} diff --git a/vendor/ipl/web/src/Filter/Renderer.php b/vendor/ipl/web/src/Filter/Renderer.php new file mode 100644 index 0000000..f01ccf4 --- /dev/null +++ b/vendor/ipl/web/src/Filter/Renderer.php @@ -0,0 +1,182 @@ +<?php + +namespace ipl\Web\Filter; + +use ipl\Stdlib\Filter; + +class Renderer +{ + /** @var Filter\Rule */ + protected $filter; + + /** @var string */ + protected $string; + + /** @var bool Whether strict mode is enabled */ + protected $strict = false; + + /** + * Create a new filter Renderer + * + * @param Filter\Rule $filter + */ + public function __construct(Filter\Rule $filter) + { + $this->filter = $filter; + } + + /** + * Set whether strict mode is enabled + * + * @param bool $strict + * + * @return $this + */ + public function setStrict($strict = true) + { + $this->strict = (bool) $strict; + + return $this; + } + + /** + * Assemble and return the filter as query string + * + * @return string + */ + public function render() + { + if ($this->string !== null) { + return $this->string; + } + + $this->string = ''; + $filter = $this->filter; + + if ($filter instanceof Filter\Chain) { + $this->renderChain($filter, $this->strict); + } else { + /** @var Filter\Condition $filter */ + $this->renderCondition($filter); + } + + return $this->string; + } + + /** + * Assemble the given filter Chain + * + * @param Filter\Chain $chain + * @param bool $wrap + * + * @return void + */ + protected function renderChain(Filter\Chain $chain, $wrap = false) + { + if (! $this->strict && $chain->isEmpty()) { + return; + } + + $chainOperator = null; + switch (true) { + case $chain instanceof Filter\All: + $chainOperator = '&'; + break; + case $chain instanceof Filter\None: + $this->string .= '!'; + + // Force wrap, it may be the root node + if (! $wrap) { + if ($chain->count() > 1) { + $wrap = true; + } else { + $iterator = $chain->getIterator(); + $wrap = $iterator->current() instanceof Filter\None; + } + } + + // None shares the operator with Any + case $chain instanceof Filter\Any: + $chainOperator = '|'; + break; + } + + if ($wrap) { + $this->string .= '('; + } + + foreach ($chain as $rule) { + if ($rule instanceof Filter\Chain) { + $this->renderChain($rule, $this->strict || $rule->count() > 1); + } else { + /** @var Filter\Condition $rule */ + $this->renderCondition($rule); + } + + $this->string .= $chainOperator; + } + + if (! $chain->isEmpty() && (! $this->strict || ! ($chain instanceof Filter\Any && $chain->count() === 1))) { + // Remove redundant chain operator added last + $this->string = substr($this->string, 0, -1); + } elseif ($chain->isEmpty() && $chain instanceof Filter\Any) { + // If the chain is empty and strict mode is on, we need a + // chain operator to designate it's an OR, not an AND + $this->string .= $chainOperator; + } + + if ($wrap) { + $this->string .= ')'; + } + } + + /** + * Assemble the given filter Condition + * + * @param Filter\Condition $condition + * + * @return void + */ + protected function renderCondition(Filter\Condition $condition) + { + $value = $condition->getValue(); + if (is_bool($value) && ! $value) { + $this->string .= '!'; + } + + $this->string .= rawurlencode($condition->getColumn()); + + if (is_bool($value)) { + return; + } + + switch (true) { + case $condition instanceof Filter\Unequal: + case $condition instanceof Filter\Unlike: + $this->string .= '!='; + break; + case $condition instanceof Filter\Equal: + case $condition instanceof Filter\Like: + $this->string .= '='; + break; + case $condition instanceof Filter\GreaterThan: + $this->string .= rawurlencode('>'); + break; + case $condition instanceof Filter\LessThan: + $this->string .= rawurlencode('<'); + break; + case $condition instanceof Filter\GreaterThanOrEqual: + $this->string .= rawurlencode('>') . '='; + break; + case $condition instanceof Filter\LessThanOrEqual: + $this->string .= rawurlencode('<') . '='; + break; + } + + if (is_array($value)) { + $this->string .= '(' . join('|', array_map('rawurlencode', $value)) . ')'; + } elseif ($value !== null) { + $this->string .= rawurlencode($value); + } + } +} diff --git a/vendor/ipl/web/src/FormDecorator/IcingaFormDecorator.php b/vendor/ipl/web/src/FormDecorator/IcingaFormDecorator.php new file mode 100644 index 0000000..f936338 --- /dev/null +++ b/vendor/ipl/web/src/FormDecorator/IcingaFormDecorator.php @@ -0,0 +1,96 @@ +<?php + +namespace ipl\Web\FormDecorator; + +use Icinga\Web\Window; +use ipl\Html\Attributes; +use ipl\Html\Contract\FormSubmitElement; +use ipl\Html\FormDecorator\DivDecorator; +use ipl\Html\FormElement\CheckboxElement; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\Web\Widget\Icon; + +class IcingaFormDecorator extends DivDecorator +{ + const SUBMIT_ELEMENT_CLASS = 'form-controls'; + const INPUT_ELEMENT_CLASS = 'control-group'; + const ERROR_CLASS = 'errors'; + + protected function assembleElement() + { + if ($this->formElement instanceof FormSubmitElement) { + $this->formElement->getAttributes()->add('class', 'btn-primary'); + } + + $element = parent::assembleElement(); + + if ($element instanceof CheckboxElement) { + return $this->createCheckbox($element); + } + + return $element; + } + + protected function createCheckbox(CheckboxElement $checkbox) + { + if (! $checkbox->getAttributes()->has('id')) { + $checkbox->setAttribute('id', Window::generateId()); + } + + $checkbox->getAttributes()->add('class', 'sr-only'); + + $classes = ['toggle-switch']; + if ($checkbox->getAttributes()->get('disabled')->getValue()) { + $classes[] = 'disabled'; + } + + return [ + $checkbox, + new HtmlElement('label', Attributes::create([ + 'class' => $classes, + 'aria-hidden' => 'true', + 'for' => $checkbox->getAttributes()->get('id')->getValue() + ]), new HtmlElement('span', Attributes::create(['class' => 'toggle-slider']))) + ]; + } + + protected function assembleLabel() + { + $label = parent::assembleLabel(); + if ($label !== null) { + $label->addWrapper(new HtmlElement('div', Attributes::create(['class' => 'control-label-group']))); + } + + return $label; + } + + protected function assembleDescription() + { + if (($description = $this->formElement->getDescription()) !== null) { + $iconAttributes = [ + 'class' => 'control-info', + 'role' => 'image', + 'title' => $description + ]; + + $describedBy = null; + if ($this->formElement->getAttributes()->has('id')) { + $iconAttributes['aria-hidden'] = 'true'; + + $descriptionId = 'desc_' . $this->formElement->getAttributes()->get('id')->getValue(); + $describedBy = new HtmlElement('span', Attributes::create([ + 'id' => $descriptionId, + 'class' => 'sr-only' + ]), Text::create($description)); + + $this->formElement->getAttributes()->set('aria-describedby', $descriptionId); + } + + return [ + new Icon('info-circle', $iconAttributes), + $describedBy + ]; + } + } +} diff --git a/vendor/ipl/web/src/Layout/Content.php b/vendor/ipl/web/src/Layout/Content.php new file mode 100644 index 0000000..bded4ab --- /dev/null +++ b/vendor/ipl/web/src/Layout/Content.php @@ -0,0 +1,17 @@ +<?php + +namespace ipl\Web\Layout; + +use ipl\Html\BaseHtmlElement; + +/** + * Container for content + */ +class Content extends BaseHtmlElement +{ + protected $contentSeparator = "\n"; + + protected $defaultAttributes = ['class' => 'content']; + + protected $tag = 'div'; +} diff --git a/vendor/ipl/web/src/Layout/Controls.php b/vendor/ipl/web/src/Layout/Controls.php new file mode 100644 index 0000000..8763775 --- /dev/null +++ b/vendor/ipl/web/src/Layout/Controls.php @@ -0,0 +1,59 @@ +<?php + +namespace ipl\Web\Layout; + +use ipl\Html\BaseHtmlElement; +use ipl\Web\Widget\Tabs; + +/** + * Container for controls + */ +class Controls extends BaseHtmlElement +{ + /** @var Tabs */ + protected $tabs; + + protected $contentSeparator = "\n"; + + protected $defaultAttributes = ['class' => 'controls']; + + protected $tag = 'div'; + + /** + * Get the tabs + * + * @return Tabs + */ + public function getTabs() + { + return $this->tabs; + } + + /** + * Set the tabs + * + * @param Tabs $tabs + * + * @return $this + */ + public function setTabs(Tabs $tabs) + { + $this->tabs = $tabs; + + return $this; + } + + public function isEmpty() + { + if (! parent::isEmpty()) { + return false; + } + + return $this->tabs->count() === 0; + } + + protected function assemble() + { + $this->prepend($this->getTabs()); + } +} diff --git a/vendor/ipl/web/src/Layout/Footer.php b/vendor/ipl/web/src/Layout/Footer.php new file mode 100644 index 0000000..21bf262 --- /dev/null +++ b/vendor/ipl/web/src/Layout/Footer.php @@ -0,0 +1,17 @@ +<?php + +namespace ipl\Web\Layout; + +use ipl\Html\BaseHtmlElement; + +/** + * Container for footer + */ +class Footer extends BaseHtmlElement +{ + protected $contentSeparator = "\n"; + + protected $defaultAttributes = ['class' => 'footer']; + + protected $tag = 'div'; +} diff --git a/vendor/ipl/web/src/Url.php b/vendor/ipl/web/src/Url.php new file mode 100644 index 0000000..52620c4 --- /dev/null +++ b/vendor/ipl/web/src/Url.php @@ -0,0 +1,14 @@ +<?php + +namespace ipl\Web; + +/** + * @TODO(el): Don't depend on Icinga Web's Url + */ +class Url extends \Icinga\Web\Url +{ + public function __toString() + { + return $this->getAbsoluteUrl('&'); + } +} diff --git a/vendor/ipl/web/src/Widget/ActionBar.php b/vendor/ipl/web/src/Widget/ActionBar.php new file mode 100644 index 0000000..bf31845 --- /dev/null +++ b/vendor/ipl/web/src/Widget/ActionBar.php @@ -0,0 +1,51 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Web\Common\BaseTarget; +use ipl\Web\Url; + +/** + * Action bar element for displaying a list of links + */ +class ActionBar extends BaseHtmlElement +{ + use BaseTarget; + + protected $contentSeparator = ' '; + + protected $defaultAttributes = [ + 'class' => 'action-bar', + 'data-base-target' => '_self' + ]; + + protected $tag = 'div'; + + /** + * Create a action bar + * + * @param Attributes|array $attributes + */ + public function __construct($attributes = null) + { + $this->getAttributes()->add($attributes); + } + + /** + * Add a link to the action bar + * + * @param mixed $content + * @param Url|string $url + * @param string $icon + * + * @return $this + */ + public function addLink($content, $url, $icon = null) + { + $this->add(new ActionLink($content, $url, $icon)); + + return $this; + } +} diff --git a/vendor/ipl/web/src/Widget/ActionLink.php b/vendor/ipl/web/src/Widget/ActionLink.php new file mode 100644 index 0000000..289d700 --- /dev/null +++ b/vendor/ipl/web/src/Widget/ActionLink.php @@ -0,0 +1,31 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\Attributes; +use ipl\Web\Url; + +/** + * Link generally pointing to CRUD actions + */ +class ActionLink extends Link +{ + protected $defaultAttributes = ['class' => 'action-link']; + + /** + * Create a action link + * + * @param mixed $content + * @param Url|string $url + * @param string $icon + * @param Attributes|array $attributes + */ + public function __construct($content, $url, $icon = null, $attributes = null) + { + parent::__construct($content, $url, $attributes); + + if ($icon !== null) { + $this->prepend(new Icon($icon)); + } + } +} diff --git a/vendor/ipl/web/src/Widget/ButtonLink.php b/vendor/ipl/web/src/Widget/ButtonLink.php new file mode 100644 index 0000000..2da5dfd --- /dev/null +++ b/vendor/ipl/web/src/Widget/ButtonLink.php @@ -0,0 +1,14 @@ +<?php + +namespace ipl\Web\Widget; + +/** + * Button like link generally pointing to CRUD actions + */ +class ButtonLink extends ActionLink +{ + protected $defaultAttributes = [ + 'class' => 'button-link', + 'data-base-target' => '_main' + ]; +} diff --git a/vendor/ipl/web/src/Widget/ContinueWith.php b/vendor/ipl/web/src/Widget/ContinueWith.php new file mode 100644 index 0000000..4aaf52e --- /dev/null +++ b/vendor/ipl/web/src/Widget/ContinueWith.php @@ -0,0 +1,72 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Stdlib\Filter; +use ipl\Web\Common\BaseTarget; +use ipl\Web\Filter\QueryString; +use ipl\Web\Url; + +class ContinueWith extends BaseHtmlElement +{ + use BaseTarget; + + protected $tag = 'span'; + + protected $defaultAttributes = ['class' => 'continue-with']; + + /** @var Url */ + protected $url; + + /** @var Filter\Rule|callable */ + protected $filter; + + /** @var string */ + protected $title; + + public function __construct(Url $url, $filter) + { + $this->url = $url; + $this->filter = $filter; + } + + /** + * Set title for the anchor + * + * @param string $title + * + * @return $this + */ + public function setTitle($title) + { + $this->title = $title; + + return $this; + } + + public function assemble() + { + $filter = $this->filter; + if (is_callable($filter)) { + $filter = $filter(); /** @var Filter\Rule $filter */ + } + + if ($filter instanceof Filter\Chain && $filter->isEmpty()) { + $this->addHtml(new HtmlElement( + 'span', + Attributes::create(['class' => ['control-button', 'disabled']]), + new Icon('share') + )); + } else { + $this->addHtml(new ActionLink( + null, + $this->url->setQueryString(QueryString::render($filter)), + 'share', + ['class' => 'control-button', 'title' => $this->title] + )); + } + } +} diff --git a/vendor/ipl/web/src/Widget/Dropdown.php b/vendor/ipl/web/src/Widget/Dropdown.php new file mode 100644 index 0000000..b6eb20d --- /dev/null +++ b/vendor/ipl/web/src/Widget/Dropdown.php @@ -0,0 +1,63 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Web\Url; + +/** + * Toggleable overlay dropdown element for displaying a list of links + */ +class Dropdown extends BaseHtmlElement +{ + /** @var array */ + protected $links = []; + + protected $defaultAttributes = ['class' => 'dropdown']; + + protected $tag = 'div'; + + /** + * Create a dropdown element + * + * @param mixed $content + * @param Attributes|array $attributes + */ + public function __construct($content, $attributes = null) + { + $toggle = new ActionLink($content, '#', null, [ + 'aria-expanded' => false, + 'aria-haspopup' => true, + 'class' => 'dropdown-toggle', + 'role' => 'button' + ]); + + $this + ->setContent($toggle) + ->getAttributes() + ->add($attributes); + } + + /** + * Add a link to the dropdown + * + * @param mixed $content + * @param Url|string $url + * @param string $icon + * + * @return $this + */ + public function addLink($content, $url, $icon = null) + { + $this->links[] = new ActionLink($content, $url, $icon, ['class' => 'dropdown-item']); + + return $this; + } + + protected function assemble() + { + $this->add(Html::tag('div', ['class' => 'dropdown-menu'], $this->links)); + } +} diff --git a/vendor/ipl/web/src/Widget/HorizontalKeyValue.php b/vendor/ipl/web/src/Widget/HorizontalKeyValue.php new file mode 100644 index 0000000..1d1195e --- /dev/null +++ b/vendor/ipl/web/src/Widget/HorizontalKeyValue.php @@ -0,0 +1,31 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; + +class HorizontalKeyValue extends BaseHtmlElement +{ + protected $key; + + protected $value; + + protected $defaultAttributes = ['class' => 'horizontal-key-value']; + + protected $tag = 'div'; + + public function __construct($key, $value) + { + $this->key = $key; + $this->value = $value; + } + + protected function assemble() + { + $this->add([ + Html::tag('div', ['class' => 'key'], $this->key), + Html::tag('div', ['class' => 'value'], $this->value) + ]); + } +} diff --git a/vendor/ipl/web/src/Widget/IcingaIcon.php b/vendor/ipl/web/src/Widget/IcingaIcon.php new file mode 100644 index 0000000..8002dcc --- /dev/null +++ b/vendor/ipl/web/src/Widget/IcingaIcon.php @@ -0,0 +1,26 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\Attributes; + +class IcingaIcon extends Icon +{ + /** + * Create a icon element + * + * Creates a icon element from the given name and HTML attributes. The icon element's tag will be <i>. The given + * name will be used as automatically added CSS class for the icon element in the format 'iicon-$name'. In addition, + * the CSS class 'icon' will be automatically added too. + * + * @param string $name The name of the icon + * @param Attributes|array $attributes The HTML attributes for the element + */ + public function __construct($name, $attributes = null) + { + $this + ->getAttributes() + ->add('class', ['icon', "iicon-$name"]) + ->add($attributes); + } +} diff --git a/vendor/ipl/web/src/Widget/Icon.php b/vendor/ipl/web/src/Widget/Icon.php new file mode 100644 index 0000000..2198c87 --- /dev/null +++ b/vendor/ipl/web/src/Widget/Icon.php @@ -0,0 +1,32 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; + +/** + * Icon element + */ +class Icon extends BaseHtmlElement +{ + protected $tag = 'i'; + + /** + * Create a icon element + * + * Creates a icon element from the given name and HTML attributes. The icon element's tag will be <i>. The given + * name will be used as automatically added CSS class for the icon element in the format 'icon-$name'. In addition, + * the CSS class 'icon' will be automatically added too. + * + * @param string $name The name of the icon + * @param Attributes|array $attributes The HTML attributes for the element + */ + public function __construct($name, $attributes = null) + { + $this + ->getAttributes() + ->add('class', ['icon', 'fa', "fa-$name"]) + ->add($attributes); + } +} diff --git a/vendor/ipl/web/src/Widget/Link.php b/vendor/ipl/web/src/Widget/Link.php new file mode 100644 index 0000000..2d7c9f5 --- /dev/null +++ b/vendor/ipl/web/src/Widget/Link.php @@ -0,0 +1,83 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\Attribute; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Web\Common\BaseTarget; +use ipl\Web\Url; + +/** + * Link element, i.e. <a href="... + */ +class Link extends BaseHtmlElement +{ + use BaseTarget; + + /** @var Url */ + protected $url; + + protected $tag = 'a'; + + /** + * Create a link element + * + * @param mixed $content + * @param Url|string $url + * @param Attributes|array $attributes + */ + public function __construct($content, $url, $attributes = null) + { + $this + ->setContent($content) + ->setUrl($url) + ->getAttributes() + ->add($attributes) + ->registerAttributeCallback('href', [$this, 'createHrefAttribute']); + } + + /** + * Get the URL of the link + * + * @return Url + */ + public function getUrl() + { + return $this->url; + } + + /** + * Set the URL of the link + * + * @param Url|string $url + * + * @return $this + */ + public function setUrl($url) + { + if (! $url instanceof Url) { + try { + $url = Url::fromPath($url); + } catch (\Exception $e) { + $url = 'invalid'; + } + } + + $this->url = $url; + + return $this; + } + + /** + * Create and return the href attribute + * + * Used as attribute callback for the href attribute. + * + * @return Attribute + */ + public function createHrefAttribute() + { + return new Attribute('href', (string) $this->getUrl()); + } +} diff --git a/vendor/ipl/web/src/Widget/StateBadge.php b/vendor/ipl/web/src/Widget/StateBadge.php new file mode 100644 index 0000000..908a348 --- /dev/null +++ b/vendor/ipl/web/src/Widget/StateBadge.php @@ -0,0 +1,47 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\BaseHtmlElement; + +class StateBadge extends BaseHtmlElement +{ + protected $defaultAttributes = ['class' => 'state-badge']; + + /** @var mixed Badge content */ + protected $content; + + /** @var bool Whether the state is handled */ + protected $isHandled; + + /** @var string Textual representation of a state */ + protected $state; + + /** + * Create a new state badge + * + * @param mixed $content Content of the badge + * @param string $state Textual representation of a state + * @param bool $isHandled True if state is handled + */ + public function __construct($content, string $state, bool $isHandled = false) + { + $this->content = $content; + $this->isHandled = $isHandled; + $this->state = $state; + } + + protected function assemble() + { + $this->setTag('span'); + + $class = "state-{$this->state}"; + if ($this->isHandled) { + $class .= ' handled'; + } + + $this->addAttributes(['class' => $class]); + + $this->add($this->content); + } +} diff --git a/vendor/ipl/web/src/Widget/StateBall.php b/vendor/ipl/web/src/Widget/StateBall.php new file mode 100644 index 0000000..5a1216d --- /dev/null +++ b/vendor/ipl/web/src/Widget/StateBall.php @@ -0,0 +1,43 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\BaseHtmlElement; + +/** + * State ball element that supports different sizes and colors + */ +class StateBall extends BaseHtmlElement +{ + const SIZE_TINY = 'xs'; + const SIZE_SMALL = 's'; + const SIZE_MEDIUM = 'm'; + const SIZE_MEDIUM_LARGE = 'ml'; + const SIZE_BIG = 'l'; + const SIZE_LARGE = 'xl'; + + protected $tag = 'span'; + + /** + * Create a new state ball element + * + * @param string $state + * @param string $size + */ + public function __construct($state = 'none', $size = self::SIZE_SMALL) + { + $state = trim($state); + + if (empty($state)) { + $state = 'none'; + } + + $size = trim($size); + + if (empty($size)) { + $size = self::SIZE_MEDIUM; + } + + $this->defaultAttributes = ['class' => "state-ball state-$state ball-size-$size"]; + } +} diff --git a/vendor/ipl/web/src/Widget/Tabs.php b/vendor/ipl/web/src/Widget/Tabs.php new file mode 100644 index 0000000..32ba8e9 --- /dev/null +++ b/vendor/ipl/web/src/Widget/Tabs.php @@ -0,0 +1,190 @@ +<?php + +namespace ipl\Web\Widget; + +use Exception; +use Icinga\Web\Widget\Tabextension\DashboardAction; +use Icinga\Web\Widget\Tabextension\MenuAction; +use Icinga\Web\Widget\Tabextension\OutputFormat; +use Icinga\Web\Widget\Tabextension\Tabextension; +use InvalidArgumentException; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlString; +use ipl\Web\Url; + +/** + * @TODO(el): Don't depend on Icinga Web's Tabs + */ +class Tabs extends BaseHtmlElement +{ + protected $tag = 'ul'; + + protected $defaultAttributes = ['class' => 'tabs primary-nav nav']; + + /** @var \Icinga\Web\Widget\Tabs */ + protected $tabs; + + /** @var bool Whether data exports are enabled */ + protected $dataExportsEnabled = false; + + /** @var bool Whether the legacy extensions should be shown by default */ + protected $legacyExtensionsEnabled = true; + + /** @var Url */ + protected $refreshUrl; + + public function __construct() + { + $this->tabs = new \Icinga\Web\Widget\Tabs(); + } + + /** + * Don't show legacy extensions by default + */ + public function disableLegacyExtensions() + { + $this->legacyExtensionsEnabled = false; + } + + /** + * Show export actions for JSON and CSV + */ + public function enableDataExports() + { + $this->dataExportsEnabled = true; + } + + /** + * Set the url for the refresh button + * + * @param Url $url + * + * @return $this + */ + public function setRefreshUrl(Url $url) + { + $this->refreshUrl = $url; + + return $this; + } + + protected function assemble() + { + if ($this->legacyExtensionsEnabled) { + $this->tabs->extend(new OutputFormat( + $this->dataExportsEnabled + ? [] + : [OutputFormat::TYPE_CSV, OutputFormat::TYPE_JSON] + )) + ->extend(new DashboardAction()) + ->extend(new MenuAction()); + } + + $tabHtml = substr($this->tabs->render(), 34, -5); + if ($this->refreshUrl !== null) { + $tabHtml = preg_replace( + '/(?<=class="refresh-container-control spinner" href=")([^"]*)/', + $this->refreshUrl->getAbsoluteUrl(), + $tabHtml + ); + } + + parent::add(HtmlString::create($tabHtml)); + } + + /** + * Activate the tab with the given name + * + * @param string $name + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function activate($name) + { + try { + $this->tabs->activate($name); + } catch (Exception $e) { + throw new InvalidArgumentException($e->getMessage()); + } + + return $this; + } + + /** + * Get active tab + * + * @return \Icinga\Web\Widget\Tab + */ + public function getActiveTab() + { + return $this->tabs->get($this->tabs->getActiveName()); + } + + /** + * Add the given tab + * + * @param string $name + * @param mixed $tab + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function add($name, $tab = null) + { + if ($tab === null) { + throw new InvalidArgumentException('Argument $tab is required'); + } + + try { + $this->tabs->add($name, $tab); + } catch (Exception $e) { + throw new InvalidArgumentException($e->getMessage()); + } + + if (is_array($tab) && isset($tab['active']) && $tab['active']) { + // Otherwise Tabs::getActiveName() returns null + $this->tabs->activate($name); + } + + return $this; + } + + /** + * Get a tab + * + * @param string $name + * + * @return \Icinga\Web\Widget\Tab|null + */ + public function get($name) + { + return $this->tabs->get($name); + } + + /** + * Count tabs + * + * @return int + */ + public function count(): int + { + return $this->tabs->count(); + } + + /** + * Apply a Tabextension on $this->tabs object not on this class + * + * @param Tabextension $extension + * + * @return $this + */ + public function extend(Tabextension $extension) + { + $this->tabs->extend($extension); + + return $this; + } +} diff --git a/vendor/ipl/web/src/Widget/TimeAgo.php b/vendor/ipl/web/src/Widget/TimeAgo.php new file mode 100644 index 0000000..cbd0dad --- /dev/null +++ b/vendor/ipl/web/src/Widget/TimeAgo.php @@ -0,0 +1,33 @@ +<?php + +namespace ipl\Web\Widget; + +use Icinga\Date\DateFormatter; +use ipl\Html\BaseHtmlElement; + +class TimeAgo extends BaseHtmlElement +{ + /** @var int */ + protected $ago; + + protected $tag = 'time'; + + protected $defaultAttributes = ['class' => 'time-ago']; + + public function __construct($ago) + { + $this->ago = (int) $ago; + } + + protected function assemble() + { + $dateTime = DateFormatter::formatDateTime($this->ago); + + $this->addAttributes([ + 'datetime' => $dateTime, + 'title' => $dateTime + ]); + + $this->add(DateFormatter::timeAgo($this->ago)); + } +} diff --git a/vendor/ipl/web/src/Widget/TimeSince.php b/vendor/ipl/web/src/Widget/TimeSince.php new file mode 100644 index 0000000..308e358 --- /dev/null +++ b/vendor/ipl/web/src/Widget/TimeSince.php @@ -0,0 +1,33 @@ +<?php + +namespace ipl\Web\Widget; + +use Icinga\Date\DateFormatter; +use ipl\Html\BaseHtmlElement; + +class TimeSince extends BaseHtmlElement +{ + /** @var int */ + protected $since; + + protected $tag = 'time'; + + protected $defaultAttributes = ['class' => 'time-since']; + + public function __construct($since) + { + $this->since = (int) $since; + } + + protected function assemble() + { + $dateTime = DateFormatter::formatDateTime($this->since); + + $this->addAttributes([ + 'datetime' => $dateTime, + 'title' => $dateTime + ]); + + $this->add(DateFormatter::timeSince($this->since)); + } +} diff --git a/vendor/ipl/web/src/Widget/TimeUntil.php b/vendor/ipl/web/src/Widget/TimeUntil.php new file mode 100644 index 0000000..ba1f527 --- /dev/null +++ b/vendor/ipl/web/src/Widget/TimeUntil.php @@ -0,0 +1,33 @@ +<?php + +namespace ipl\Web\Widget; + +use Icinga\Date\DateFormatter; +use ipl\Html\BaseHtmlElement; + +class TimeUntil extends BaseHtmlElement +{ + /** @var int */ + protected $until; + + protected $tag = 'time'; + + protected $defaultAttributes = ['class' => 'time-until']; + + public function __construct($until) + { + $this->until = (int) $until; + } + + protected function assemble() + { + $dateTime = DateFormatter::formatDateTime($this->until); + + $this->addAttributes([ + 'datetime' => $dateTime, + 'title' => $dateTime + ]); + + $this->add(DateFormatter::timeUntil($this->until)); + } +} diff --git a/vendor/ipl/web/src/Widget/VerticalKeyValue.php b/vendor/ipl/web/src/Widget/VerticalKeyValue.php new file mode 100644 index 0000000..388c740 --- /dev/null +++ b/vendor/ipl/web/src/Widget/VerticalKeyValue.php @@ -0,0 +1,32 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; + +class VerticalKeyValue extends BaseHtmlElement +{ + protected $key; + + protected $value; + + protected $tag = 'div'; + + protected $defaultAttributes = ['class' => 'vertical-key-value']; + + public function __construct($key, $value) + { + $this->key = $key; + $this->value = $value; + } + + protected function assemble() + { + $this->add([ + Html::tag('span', ['class' => 'value'], $this->value), + Html::tag('br'), + Html::tag('span', ['class' => 'key'], $this->key), + ]); + } +} |