summaryrefslogtreecommitdiffstats
path: root/vendor/ipl
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--vendor/ipl/html/LICENSE21
-rw-r--r--vendor/ipl/html/composer.json27
-rw-r--r--vendor/ipl/html/src/Attribute.php301
-rw-r--r--vendor/ipl/html/src/Attributes.php521
-rw-r--r--vendor/ipl/html/src/BaseHtmlElement.php355
-rw-r--r--vendor/ipl/html/src/Contract/FormElement.php125
-rw-r--r--vendor/ipl/html/src/Contract/FormElementDecorator.php18
-rw-r--r--vendor/ipl/html/src/Contract/FormSubmitElement.php13
-rw-r--r--vendor/ipl/html/src/Contract/ValueCandidates.php22
-rw-r--r--vendor/ipl/html/src/Contract/Wrappable.php45
-rw-r--r--vendor/ipl/html/src/DeferredText.php114
-rw-r--r--vendor/ipl/html/src/Error.php117
-rw-r--r--vendor/ipl/html/src/Form.php384
-rw-r--r--vendor/ipl/html/src/FormDecorator/CallbackDecorator.php41
-rw-r--r--vendor/ipl/html/src/FormDecorator/DdDtDecorator.php141
-rw-r--r--vendor/ipl/html/src/FormDecorator/DecoratorInterface.php19
-rw-r--r--vendor/ipl/html/src/FormDecorator/DivDecorator.php132
-rw-r--r--vendor/ipl/html/src/FormElement/BaseFormElement.php348
-rw-r--r--vendor/ipl/html/src/FormElement/ButtonElement.php8
-rw-r--r--vendor/ipl/html/src/FormElement/CheckboxElement.php125
-rw-r--r--vendor/ipl/html/src/FormElement/DateElement.php8
-rw-r--r--vendor/ipl/html/src/FormElement/FormElements.php502
-rw-r--r--vendor/ipl/html/src/FormElement/HiddenElement.php8
-rw-r--r--vendor/ipl/html/src/FormElement/InputElement.php49
-rw-r--r--vendor/ipl/html/src/FormElement/LocalDateTimeElement.php52
-rw-r--r--vendor/ipl/html/src/FormElement/NumberElement.php8
-rw-r--r--vendor/ipl/html/src/FormElement/PasswordElement.php55
-rw-r--r--vendor/ipl/html/src/FormElement/SelectElement.php144
-rw-r--r--vendor/ipl/html/src/FormElement/SelectOption.php45
-rw-r--r--vendor/ipl/html/src/FormElement/SubFormElement.php57
-rw-r--r--vendor/ipl/html/src/FormElement/SubmitButtonElement.php27
-rw-r--r--vendor/ipl/html/src/FormElement/SubmitElement.php50
-rw-r--r--vendor/ipl/html/src/FormElement/TextElement.php8
-rw-r--r--vendor/ipl/html/src/FormElement/TextareaElement.php24
-rw-r--r--vendor/ipl/html/src/FormElement/TimeElement.php8
-rw-r--r--vendor/ipl/html/src/FormattedString.php101
-rw-r--r--vendor/ipl/html/src/Html.php241
-rw-r--r--vendor/ipl/html/src/HtmlDocument.php538
-rw-r--r--vendor/ipl/html/src/HtmlElement.php43
-rw-r--r--vendor/ipl/html/src/HtmlString.php13
-rw-r--r--vendor/ipl/html/src/Table.php226
-rw-r--r--vendor/ipl/html/src/TemplateString.php175
-rw-r--r--vendor/ipl/html/src/Text.php116
-rw-r--r--vendor/ipl/html/src/ValidHtml.php17
-rw-r--r--vendor/ipl/i18n/LICENSE21
-rw-r--r--vendor/ipl/i18n/composer.json25
-rw-r--r--vendor/ipl/i18n/src/GettextTranslator.php351
-rw-r--r--vendor/ipl/i18n/src/Locale.php124
-rw-r--r--vendor/ipl/i18n/src/NoopTranslator.php31
-rw-r--r--vendor/ipl/i18n/src/StaticTranslator.php14
-rw-r--r--vendor/ipl/i18n/src/Translation.php101
-rw-r--r--vendor/ipl/i18n/src/functions.php34
-rw-r--r--vendor/ipl/i18n/src/functions_include.php6
-rw-r--r--vendor/ipl/orm/LICENSE21
-rw-r--r--vendor/ipl/orm/composer.json31
-rw-r--r--vendor/ipl/orm/src/AliasedExpression.php36
-rw-r--r--vendor/ipl/orm/src/Behavior.php12
-rw-r--r--vendor/ipl/orm/src/Behavior/Binary.php116
-rw-r--r--vendor/ipl/orm/src/Behaviors.php238
-rw-r--r--vendor/ipl/orm/src/ColumnDefinition.php80
-rw-r--r--vendor/ipl/orm/src/Common/PropertiesWithDefaults.php31
-rw-r--r--vendor/ipl/orm/src/Common/SortUtil.php65
-rw-r--r--vendor/ipl/orm/src/Compat/FilterProcessor.php329
-rw-r--r--vendor/ipl/orm/src/Contract/PersistBehavior.php18
-rw-r--r--vendor/ipl/orm/src/Contract/PropertyBehavior.php104
-rw-r--r--vendor/ipl/orm/src/Contract/QueryAwareBehavior.php18
-rw-r--r--vendor/ipl/orm/src/Contract/RetrieveBehavior.php18
-rw-r--r--vendor/ipl/orm/src/Contract/RewriteColumnBehavior.php39
-rw-r--r--vendor/ipl/orm/src/Contract/RewriteFilterBehavior.php25
-rw-r--r--vendor/ipl/orm/src/Contract/RewritePathBehavior.php20
-rw-r--r--vendor/ipl/orm/src/Defaults.php52
-rw-r--r--vendor/ipl/orm/src/Exception/InvalidColumnException.php53
-rw-r--r--vendor/ipl/orm/src/Exception/InvalidRelationException.php53
-rw-r--r--vendor/ipl/orm/src/Hydrator.php197
-rw-r--r--vendor/ipl/orm/src/Model.php132
-rw-r--r--vendor/ipl/orm/src/Query.php787
-rw-r--r--vendor/ipl/orm/src/Relation.php336
-rw-r--r--vendor/ipl/orm/src/Relation/BelongsTo.php13
-rw-r--r--vendor/ipl/orm/src/Relation/BelongsToMany.php199
-rw-r--r--vendor/ipl/orm/src/Relation/HasMany.php13
-rw-r--r--vendor/ipl/orm/src/Relation/HasOne.php12
-rw-r--r--vendor/ipl/orm/src/Relation/Junction.php43
-rw-r--r--vendor/ipl/orm/src/Relations.php212
-rw-r--r--vendor/ipl/orm/src/ResolvedExpression.php49
-rw-r--r--vendor/ipl/orm/src/Resolver.php783
-rw-r--r--vendor/ipl/orm/src/ResultSet.php146
-rw-r--r--vendor/ipl/orm/src/UnionModel.php29
-rw-r--r--vendor/ipl/orm/src/UnionQuery.php59
-rw-r--r--vendor/ipl/sql/LICENSE21
-rw-r--r--vendor/ipl/sql/composer.json26
-rw-r--r--vendor/ipl/sql/src/Adapter/BaseAdapter.php117
-rw-r--r--vendor/ipl/sql/src/Adapter/Mssql.php78
-rw-r--r--vendor/ipl/sql/src/Adapter/Mysql.php57
-rw-r--r--vendor/ipl/sql/src/Adapter/Oracle.php39
-rw-r--r--vendor/ipl/sql/src/Adapter/Pgsql.php15
-rw-r--r--vendor/ipl/sql/src/Adapter/Sqlite.php13
-rw-r--r--vendor/ipl/sql/src/CommonTableExpression.php53
-rw-r--r--vendor/ipl/sql/src/CommonTableExpressionInterface.php39
-rw-r--r--vendor/ipl/sql/src/Compat/FilterProcessor.php110
-rw-r--r--vendor/ipl/sql/src/Config.php72
-rw-r--r--vendor/ipl/sql/src/Connection.php554
-rw-r--r--vendor/ipl/sql/src/Contract/Adapter.php46
-rw-r--r--vendor/ipl/sql/src/Contract/Quoter.php21
-rw-r--r--vendor/ipl/sql/src/Cursor.php106
-rw-r--r--vendor/ipl/sql/src/Delete.php52
-rw-r--r--vendor/ipl/sql/src/Expression.php52
-rw-r--r--vendor/ipl/sql/src/ExpressionInterface.php39
-rw-r--r--vendor/ipl/sql/src/Filter/Exists.php14
-rw-r--r--vendor/ipl/sql/src/Filter/NotExists.php14
-rw-r--r--vendor/ipl/sql/src/Insert.php172
-rw-r--r--vendor/ipl/sql/src/LimitOffset.php89
-rw-r--r--vendor/ipl/sql/src/LimitOffsetInterface.php71
-rw-r--r--vendor/ipl/sql/src/OrderBy.php74
-rw-r--r--vendor/ipl/sql/src/OrderByInterface.php51
-rw-r--r--vendor/ipl/sql/src/QueryBuilder.php910
-rw-r--r--vendor/ipl/sql/src/Select.php562
-rw-r--r--vendor/ipl/sql/src/Sql.php70
-rw-r--r--vendor/ipl/sql/src/Update.php100
-rw-r--r--vendor/ipl/sql/src/Where.php158
-rw-r--r--vendor/ipl/sql/src/WhereInterface.php84
-rw-r--r--vendor/ipl/stdlib/LICENSE21
-rw-r--r--vendor/ipl/stdlib/composer.json22
-rw-r--r--vendor/ipl/stdlib/src/BaseFilter.php45
-rw-r--r--vendor/ipl/stdlib/src/Contract/Filterable.php63
-rw-r--r--vendor/ipl/stdlib/src/Contract/Paginatable.php56
-rw-r--r--vendor/ipl/stdlib/src/Contract/PaginationInterface.php8
-rw-r--r--vendor/ipl/stdlib/src/Contract/PluginLoader.php21
-rw-r--r--vendor/ipl/stdlib/src/Contract/Translator.php65
-rw-r--r--vendor/ipl/stdlib/src/Contract/Validator.php22
-rw-r--r--vendor/ipl/stdlib/src/Contract/ValidatorInterface.php8
-rw-r--r--vendor/ipl/stdlib/src/Data.php89
-rw-r--r--vendor/ipl/stdlib/src/EventEmitter.php9
-rw-r--r--vendor/ipl/stdlib/src/Events.php57
-rw-r--r--vendor/ipl/stdlib/src/Filter.php578
-rw-r--r--vendor/ipl/stdlib/src/Filter/All.php7
-rw-r--r--vendor/ipl/stdlib/src/Filter/Any.php7
-rw-r--r--vendor/ipl/stdlib/src/Filter/Chain.php179
-rw-r--r--vendor/ipl/stdlib/src/Filter/Condition.php84
-rw-r--r--vendor/ipl/stdlib/src/Filter/Equal.php31
-rw-r--r--vendor/ipl/stdlib/src/Filter/GreaterThan.php7
-rw-r--r--vendor/ipl/stdlib/src/Filter/GreaterThanOrEqual.php7
-rw-r--r--vendor/ipl/stdlib/src/Filter/LessThan.php7
-rw-r--r--vendor/ipl/stdlib/src/Filter/LessThanOrEqual.php7
-rw-r--r--vendor/ipl/stdlib/src/Filter/Like.php31
-rw-r--r--vendor/ipl/stdlib/src/Filter/MetaData.php20
-rw-r--r--vendor/ipl/stdlib/src/Filter/MetaDataProvider.php15
-rw-r--r--vendor/ipl/stdlib/src/Filter/None.php7
-rw-r--r--vendor/ipl/stdlib/src/Filter/Rule.php7
-rw-r--r--vendor/ipl/stdlib/src/Filter/Unequal.php31
-rw-r--r--vendor/ipl/stdlib/src/Filter/Unlike.php31
-rw-r--r--vendor/ipl/stdlib/src/Filters.php58
-rw-r--r--vendor/ipl/stdlib/src/Loader/AutoloadingPluginLoader.php52
-rw-r--r--vendor/ipl/stdlib/src/MessageContainer.php9
-rw-r--r--vendor/ipl/stdlib/src/Messages.php92
-rw-r--r--vendor/ipl/stdlib/src/Plugins.php95
-rw-r--r--vendor/ipl/stdlib/src/PriorityQueue.php41
-rw-r--r--vendor/ipl/stdlib/src/Properties.php205
-rw-r--r--vendor/ipl/stdlib/src/Seq.php108
-rw-r--r--vendor/ipl/stdlib/src/Str.php93
-rw-r--r--vendor/ipl/stdlib/src/functions.php71
-rw-r--r--vendor/ipl/stdlib/src/functions_include.php6
-rw-r--r--vendor/ipl/validator/LICENSE21
-rw-r--r--vendor/ipl/validator/composer.json23
-rw-r--r--vendor/ipl/validator/src/BaseValidator.php11
-rw-r--r--vendor/ipl/validator/src/CallbackValidator.php45
-rw-r--r--vendor/ipl/validator/src/DateTimeValidator.php65
-rw-r--r--vendor/ipl/validator/src/PrivateKeyValidator.php33
-rw-r--r--vendor/ipl/validator/src/ValidatorChain.php284
-rw-r--r--vendor/ipl/validator/src/X509CertValidator.php33
-rw-r--r--vendor/ipl/web/LICENSE21
-rw-r--r--vendor/ipl/web/composer.json26
-rw-r--r--vendor/ipl/web/src/Common/BaseTarget.php33
-rw-r--r--vendor/ipl/web/src/Common/Card.php55
-rw-r--r--vendor/ipl/web/src/Common/CsrfCounterMeasure.php48
-rw-r--r--vendor/ipl/web/src/Common/FormUid.php59
-rw-r--r--vendor/ipl/web/src/Common/RedirectOption.php41
-rw-r--r--vendor/ipl/web/src/Common/StateBadges.php184
-rw-r--r--vendor/ipl/web/src/Compat/CompatController.php329
-rw-r--r--vendor/ipl/web/src/Compat/CompatDecorator.php14
-rw-r--r--vendor/ipl/web/src/Compat/CompatForm.php25
-rw-r--r--vendor/ipl/web/src/Compat/Multipart.php33
-rw-r--r--vendor/ipl/web/src/Compat/SearchControls.php269
-rw-r--r--vendor/ipl/web/src/Compat/ViewRenderer.php60
-rw-r--r--vendor/ipl/web/src/Control/LimitControl.php123
-rw-r--r--vendor/ipl/web/src/Control/PaginationControl.php523
-rw-r--r--vendor/ipl/web/src/Control/SearchBar.php522
-rw-r--r--vendor/ipl/web/src/Control/SearchBar/SearchException.php9
-rw-r--r--vendor/ipl/web/src/Control/SearchBar/Suggestions.php447
-rw-r--r--vendor/ipl/web/src/Control/SearchBar/Terms.php255
-rw-r--r--vendor/ipl/web/src/Control/SearchBar/ValidatedColumn.php44
-rw-r--r--vendor/ipl/web/src/Control/SearchBar/ValidatedOperator.php80
-rw-r--r--vendor/ipl/web/src/Control/SearchBar/ValidatedTerm.php196
-rw-r--r--vendor/ipl/web/src/Control/SearchBar/ValidatedValue.php41
-rw-r--r--vendor/ipl/web/src/Control/SearchEditor.php617
-rw-r--r--vendor/ipl/web/src/Control/SortControl.php251
-rw-r--r--vendor/ipl/web/src/Filter/ParseException.php36
-rw-r--r--vendor/ipl/web/src/Filter/Parser.php572
-rw-r--r--vendor/ipl/web/src/Filter/QueryString.php92
-rw-r--r--vendor/ipl/web/src/Filter/Renderer.php182
-rw-r--r--vendor/ipl/web/src/FormDecorator/IcingaFormDecorator.php96
-rw-r--r--vendor/ipl/web/src/Layout/Content.php17
-rw-r--r--vendor/ipl/web/src/Layout/Controls.php59
-rw-r--r--vendor/ipl/web/src/Layout/Footer.php17
-rw-r--r--vendor/ipl/web/src/Url.php14
-rw-r--r--vendor/ipl/web/src/Widget/ActionBar.php51
-rw-r--r--vendor/ipl/web/src/Widget/ActionLink.php31
-rw-r--r--vendor/ipl/web/src/Widget/ButtonLink.php14
-rw-r--r--vendor/ipl/web/src/Widget/ContinueWith.php72
-rw-r--r--vendor/ipl/web/src/Widget/Dropdown.php63
-rw-r--r--vendor/ipl/web/src/Widget/HorizontalKeyValue.php31
-rw-r--r--vendor/ipl/web/src/Widget/IcingaIcon.php26
-rw-r--r--vendor/ipl/web/src/Widget/Icon.php32
-rw-r--r--vendor/ipl/web/src/Widget/Link.php83
-rw-r--r--vendor/ipl/web/src/Widget/StateBadge.php47
-rw-r--r--vendor/ipl/web/src/Widget/StateBall.php43
-rw-r--r--vendor/ipl/web/src/Widget/Tabs.php190
-rw-r--r--vendor/ipl/web/src/Widget/TimeAgo.php33
-rw-r--r--vendor/ipl/web/src/Widget/TimeSince.php33
-rw-r--r--vendor/ipl/web/src/Widget/TimeUntil.php33
-rw-r--r--vendor/ipl/web/src/Widget/VerticalKeyValue.php32
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('"', '&quot;', $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('&', '&amp;', $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 &#013; (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),
+ ]);
+ }
+}