summaryrefslogtreecommitdiffstats
path: root/vendor/ipl/orm/src
diff options
context:
space:
mode:
Diffstat (limited to 'vendor/ipl/orm/src')
-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.php101
-rw-r--r--vendor/ipl/orm/src/Behavior/BoolCast.php147
-rw-r--r--vendor/ipl/orm/src/Behavior/MillisecondTimestamp.php41
-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.php375
-rw-r--r--vendor/ipl/orm/src/Contract/PersistBehavior.php18
-rw-r--r--vendor/ipl/orm/src/Contract/PropertyBehavior.php102
-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/Exception/ValueConversionException.php12
-rw-r--r--vendor/ipl/orm/src/Hydrator.php197
-rw-r--r--vendor/ipl/orm/src/Model.php143
-rw-r--r--vendor/ipl/orm/src/Query.php846
-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.php211
-rw-r--r--vendor/ipl/orm/src/Relation/BelongsToOne.php13
-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.php235
-rw-r--r--vendor/ipl/orm/src/ResolvedExpression.php49
-rw-r--r--vendor/ipl/orm/src/Resolver.php803
-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.php61
37 files changed, 4686 insertions, 0 deletions
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..c43082a
--- /dev/null
+++ b/vendor/ipl/orm/src/Behavior/Binary.php
@@ -0,0 +1,101 @@
+<?php
+
+namespace ipl\Orm\Behavior;
+
+use ipl\Orm\Contract\PropertyBehavior;
+use ipl\Orm\Contract\QueryAwareBehavior;
+use ipl\Orm\Contract\RewriteFilterBehavior;
+use ipl\Orm\Exception\ValueConversionException;
+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
+{
+ /** @var bool Whether the query is using a pgsql adapter */
+ protected $isPostgres = true;
+
+ public function fromDb($value, $key, $_)
+ {
+ if (! $this->isPostgres) {
+ return $value;
+ }
+
+ if ($value !== null) {
+ if (is_resource($value)) {
+ return stream_get_contents($value);
+ }
+
+ return $value;
+ }
+
+ return null;
+ }
+
+ /**
+ * @throws ValueConversionException If value is a resource
+ */
+ public function toDb($value, $key, $_)
+ {
+ if (! $this->isPostgres) {
+ return $value;
+ }
+
+ if (is_resource($value)) {
+ throw new ValueConversionException(sprintf('Unexpected resource for %s', $key));
+ }
+
+ if ($value === '*') {
+ /**
+ * Support IS (NOT) NULL filter transformation.
+ * {@see \ipl\Sql\Compat\FilterProcessor::assemblePredicate()}
+ */
+ return $value;
+ }
+
+ return sprintf('\\x%s', bin2hex($value));
+ }
+
+ public function setQuery(Query $query)
+ {
+ $this->isPostgres = $query->getDb()->getAdapter() instanceof Pgsql;
+
+ return $this;
+ }
+
+ 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->properties[$column])) {
+ $value = $condition->metaData()->get('originalValue');
+
+ if ($this->isPostgres && is_resource($value)) {
+ 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 (! $this->isPostgres) {
+ $condition->setValue(hex2bin($value));
+ } elseif (substr($value, 0, 2) !== '\\x') {
+ $condition->setValue(sprintf('\\x%s', $value));
+ }
+ }
+ }
+ }
+}
diff --git a/vendor/ipl/orm/src/Behavior/BoolCast.php b/vendor/ipl/orm/src/Behavior/BoolCast.php
new file mode 100644
index 0000000..ad1748a
--- /dev/null
+++ b/vendor/ipl/orm/src/Behavior/BoolCast.php
@@ -0,0 +1,147 @@
+<?php
+
+namespace ipl\Orm\Behavior;
+
+use InvalidArgumentException;
+use ipl\Orm\Contract\PropertyBehavior;
+use ipl\Orm\Exception\ValueConversionException;
+
+use function ipl\Stdlib\get_php_type;
+
+/**
+ * Convert specific database values from and to boolean
+ *
+ * To unify the support of boolean values in different database systems,
+ * specific database values are converted to and from boolean values,
+ * e.g. by default `n` is converted to `false` and `y` to `true` and vice versa respectively,
+ * which could be stored as `ENUM('n', 'y')`.
+ */
+class BoolCast extends PropertyBehavior
+{
+ /** @var mixed Database value for boolean `false` */
+ protected $falseValue = 'n';
+
+ /** @var mixed Database value for boolean `true` */
+ protected $trueValue = 'y';
+
+ /** @var bool Whether to throw an exception if the value is not equal to the value for false or true */
+ protected $strict = true;
+
+ /**
+ * Get the database value representing boolean `false`
+ *
+ * @return mixed
+ */
+ public function getFalseValue()
+ {
+ return $this->falseValue;
+ }
+
+ /**
+ * Set the database value representing boolean `false`
+ *
+ * @param mixed $falseValue
+ *
+ * @return $this
+ */
+ public function setFalseValue($falseValue): self
+ {
+ $this->falseValue = $falseValue;
+
+ return $this;
+ }
+
+ /**
+ * Get the database value representing boolean `true`
+ *
+ * @return mixed
+ */
+ public function getTrueValue()
+ {
+ return $this->trueValue;
+ }
+
+ /**
+ * Get the database value representing boolean `true`
+ *
+ * @param mixed $trueValue
+ *
+ * @return $this
+ */
+ public function setTrueValue($trueValue): self
+ {
+ $this->trueValue = $trueValue;
+
+ return $this;
+ }
+
+ /**
+ * Get whether to throw an exception if the value is not equal to the value for false or true
+ *
+ * @return bool
+ */
+ public function isStrict(): bool
+ {
+ return $this->strict;
+ }
+
+ /**
+ * Set whether to throw an exception if the value is not equal to the value for false or true
+ *
+ * @param bool $strict
+ *
+ * @return $this
+ */
+ public function setStrict(bool $strict): self
+ {
+ $this->strict = $strict;
+
+ return $this;
+ }
+
+ public function fromDb($value, $key, $_)
+ {
+ switch (true) {
+ case $this->trueValue === $value:
+ return true;
+ case $this->falseValue === $value:
+ return false;
+ default:
+ if ($this->isStrict() && $value !== null) {
+ throw new InvalidArgumentException(sprintf(
+ 'Expected %s or %s, got %s instead',
+ $this->trueValue,
+ $this->falseValue,
+ $value
+ ));
+ }
+
+ return $value;
+ }
+ }
+
+ public function toDb($value, $key, $_)
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ if (! is_bool($value)) {
+ if (
+ $this->isStrict()
+ && $value !== '*'
+ && $value !== $this->getFalseValue()
+ && $value !== $this->getTrueValue()
+ ) {
+ throw new ValueConversionException(sprintf(
+ 'Expected bool, got %s instead',
+ get_php_type($value)
+ ));
+ }
+
+ return $value;
+ }
+
+ return $value ? $this->trueValue : $this->falseValue;
+ }
+}
diff --git a/vendor/ipl/orm/src/Behavior/MillisecondTimestamp.php b/vendor/ipl/orm/src/Behavior/MillisecondTimestamp.php
new file mode 100644
index 0000000..65d8033
--- /dev/null
+++ b/vendor/ipl/orm/src/Behavior/MillisecondTimestamp.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace ipl\Orm\Behavior;
+
+use DateTime;
+use DateTimeZone;
+use Exception;
+use ipl\Orm\Contract\PropertyBehavior;
+use ipl\Orm\Exception\ValueConversionException;
+
+class MillisecondTimestamp extends PropertyBehavior
+{
+ public function fromDb($value, $key, $context)
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ $datetime = DateTime::createFromFormat('U.u', sprintf('%F', $value / 1000.0));
+ $datetime->setTimezone(new DateTimeZone(date_default_timezone_get()));
+
+ return $datetime;
+ }
+
+ public function toDb($value, $key, $context)
+ {
+ if (is_numeric($value)) {
+ return (int) ($value * 1000.0);
+ }
+
+ if (! $value instanceof DateTime) {
+ try {
+ $value = new DateTime($value);
+ } catch (Exception $err) {
+ throw new ValueConversionException(sprintf('Invalid date time format provided: %s', $value));
+ }
+ }
+
+ return (int) ($value->format('U.u') * 1000.0);
+ }
+}
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..a14ea2b
--- /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<int, mixed> Sort column(s) and direction(s) suitable for {@link OrderByInterface::orderBy()}
+ */
+ public static function createOrderBy($sort): array
+ {
+ $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..7956898
--- /dev/null
+++ b/vendor/ipl/orm/src/Compat/FilterProcessor.php
@@ -0,0 +1,375 @@
+<?php
+
+namespace ipl\Orm\Compat;
+
+use AppendIterator;
+use ArrayIterator;
+use ipl\Orm\Exception\InvalidColumnException;
+use ipl\Orm\Exception\ValueConversionException;
+use ipl\Orm\Query;
+use ipl\Orm\Relation;
+use ipl\Orm\UnionQuery;
+use ipl\Sql\Filter\Exists;
+use ipl\Sql\Filter\In;
+use ipl\Sql\Filter\NotExists;
+use ipl\Sql\Filter\NotIn;
+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\Chain || ! $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
+ || $filter instanceof In
+ || $filter instanceof NotIn
+ ) {
+ return;
+ }
+
+ $resolver = $query->getResolver();
+ $baseTable = $query->getModel()->getTableAlias();
+ $column = $resolver->qualifyPath(
+ $filter->metaData()->get('columnName', $filter->getColumn()),
+ $baseTable
+ );
+
+ $filter->metaData()->set('columnPath', $column);
+
+ list($relationPath, $columnName) = preg_split('/\.(?=[^.]+$)/', $column);
+
+ $subject = null;
+ $relations = new AppendIterator();
+ $relations->append(new ArrayIterator([$baseTable => null]));
+ $relations->append($resolver->resolveRelations($relationPath));
+ $behaviorsApplied = $filter->metaData()->get('behaviorsApplied', false);
+ 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);
+ // This is only used within the Binary behavior in rewriteCondition().
+ $filter->metaData()->set('originalValue', $filter->getValue());
+
+ if (! $behaviorsApplied) {
+ try {
+ // Prepare filter as if it were final to allow full control for rewrite filter behaviors
+ $filter->setValue($subjectBehaviors->persistProperty($filter->getValue(), $columnName));
+ } catch (ValueConversionException $_) {
+ // The search bar may submit values with wildcards or whatever the user has entered.
+ // In this case, we can simply ignore this error instead of rendering a stack trace.
+ }
+ }
+
+ $filter->setColumn($resolver->getAlias($subject) . '.' . $columnName);
+ $filter->metaData()->set('columnName', $columnName);
+ $filter->metaData()->set('relationPath', $path);
+
+ if (! $behaviorsApplied) {
+ $rewrittenFilter = $subjectBehaviors->rewriteCondition($filter, $path . '.');
+ if ($rewrittenFilter !== null) {
+ return $this->requireAndResolveFilterColumns($rewrittenFilter, $query, $forceOptimization)
+ ?: $rewrittenFilter;
+ }
+ }
+ }
+
+ /**
+ * We have applied all the subject behaviors for this filter condition, so set this metadata to prevent
+ * the behaviors from being applied for the same filter condition over again later in case of a subquery.
+ * The behaviors are processed again due to $subQueryFilter being evaluated by this processor as part of
+ * the subquery. The reason for this is the application of aliases used in said subquery. Since this is
+ * part of the filter column qualification, and the behaviors are not, this should be separately done.
+ * There's a similar comment in {@see Query::createSubQuery()} which should be considered when working
+ * on improving this.
+ */
+ $filter->metaData()->set('behaviorsApplied', true);
+
+ 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 = [];
+ /** @var Filter\Rule[] $outsourcedRules */
+ $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()->getTableAlias() // 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($columns[$column][$conditionClass]);
+ }
+ }
+
+ if (empty($columns[$column])) {
+ 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, null, false);
+ $subQuery->getResolver()->setAliasPrefix('sub_');
+
+ $subQuery->filter($subQueryFilter);
+
+ $subQuerySelect = $subQuery->assembleSelect()->resetOrderBy();
+
+ if ($count !== null && ($negate || $filter instanceof Filter\All)) {
+ $targetKeys = join(
+ ',',
+ array_values(
+ $subQuery->getResolver()->qualifyColumns(
+ (array) $subQuery->getModel()->getKeyName(),
+ $subQuery->getModel()
+ )
+ )
+ );
+
+ $subQuerySelect->having(["COUNT(DISTINCT $targetKeys) >= ?" => $count]);
+ $subQuerySelect->groupBy(array_values($subQuerySelect->getColumns()));
+ }
+
+ // TODO: Qualification is only necessary since the `In` and `NotIn` conditions are ignored by
+ // requireAndResolveFilterColumns(). In case it supports not only single columns but also
+ // multiple, this might be reduced to: $keyTuple = (array) $query->getModel()->getKeyName()
+ $keyTuple = array_values(
+ $query->getResolver()->qualifyColumns(
+ (array) $query->getModel()->getKeyName(),
+ $query->getModel()
+ )
+ );
+
+ if ($negate) {
+ $filter->add(new NotIn($keyTuple, $subQuerySelect));
+ } else {
+ $filter->add(new In($keyTuple, $subQuerySelect));
+ }
+ }
+ }
+
+ 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\Chain || ! $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..c828458
--- /dev/null
+++ b/vendor/ipl/orm/src/Contract/PropertyBehavior.php
@@ -0,0 +1,102 @@
+<?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) {
+ if ($model->hasProperty($key)) {
+ $model[$key] = $this->fromDb($model[$key], $key, $ctx);
+ }
+ }
+ }
+
+ 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/Exception/ValueConversionException.php b/vendor/ipl/orm/src/Exception/ValueConversionException.php
new file mode 100644
index 0000000..499d9f3
--- /dev/null
+++ b/vendor/ipl/orm/src/Exception/ValueConversionException.php
@@ -0,0 +1,12 @@
+<?php
+
+namespace ipl\Orm\Exception;
+
+use Exception;
+
+/**
+ * Exception thrown if values to be converted don't meet their constraints when reading or writing to the database
+ */
+class ValueConversionException extends Exception
+{
+}
diff --git a/vendor/ipl/orm/src/Hydrator.php b/vendor/ipl/orm/src/Hydrator.php
new file mode 100644
index 0000000..e3cd23d
--- /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->getTableAlias()) {
+ $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..44baff7
--- /dev/null
+++ b/vendor/ipl/orm/src/Model.php
@@ -0,0 +1,143 @@
+<?php
+
+namespace ipl\Orm;
+
+use ipl\Orm\Common\PropertiesWithDefaults;
+use ipl\Sql\Connection;
+use ipl\Sql\ExpressionInterface;
+
+/**
+ * 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<string> Array if the primary key is compound, string otherwise
+ */
+ abstract public function getKeyName();
+
+ /**
+ * Get the model's queryable columns
+ *
+ * @return array<int|string, string|ExpressionInterface>
+ */
+ abstract public function getColumns();
+
+ /**
+ * Get the configured table alias. (Default {@see static::getTableName()})
+ *
+ * @return string
+ */
+ public function getTableAlias(): string
+ {
+ return $this->getTableName();
+ }
+
+ /**
+ * 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..0e19dd1
--- /dev/null
+++ b/vendor/ipl/orm/src/Query.php
@@ -0,0 +1,846 @@
+<?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 array Columns not to select from the model (or its relations) */
+ protected $withoutColumns = [];
+
+ /** @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->getTableAlias());
+
+ 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.
+ * Any of the given columns is guaranteed to be selected even if previously excluded via {@see withoutColumns()}.
+ * Any previously column specified via {@see withColumns()} will not be selected if not part of the given columns.
+ *
+ * @param string|array $columns The column(s) to select
+ *
+ * @return $this
+ */
+ public function columns($columns)
+ {
+ $this->columns = (array) $columns;
+ $this->withColumns = [];
+ $this->withoutColumns = [];
+
+ 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.
+ * Any of the given columns is guaranteed to be selected even if previously excluded via {@see withoutColumns()}.
+ *
+ * @param string|array $columns The column(s) to select
+ *
+ * @return $this
+ */
+ public function withColumns($columns)
+ {
+ $tableName = $this->getModel()->getTableAlias();
+
+ $qualifiedColumns = [];
+ foreach ((array) $columns as $alias => $column) {
+ if (! $column instanceof ExpressionInterface) {
+ $qualifiedColumns[$alias] = $this->getResolver()->qualifyPath($column, $tableName);
+ } else {
+ $qualifiedColumns[$alias] = $column;
+ }
+ }
+
+ $this->withColumns = array_merge($this->withColumns, $qualifiedColumns);
+ $this->withoutColumns = array_diff($this->withoutColumns, array_filter($this->withColumns, 'is_string'));
+
+ return $this;
+ }
+
+ /**
+ * Set columns not to select from the model (or its relations)
+ *
+ * Multiple calls to this method will not overwrite the previous set columns but append the new ones to the set.
+ *
+ * @param string|string[] $columns The column(s) not to select
+ *
+ * @return $this
+ */
+ public function withoutColumns($columns): self
+ {
+ $tableName = $this->getModel()->getTableAlias();
+
+ $qualifiedColumns = [];
+ foreach ((array) $columns as $column) {
+ if (is_string($column)) {
+ $qualifiedColumns[] = $this->getResolver()->qualifyPath($column, $tableName);
+ }
+ }
+
+ $this->withoutColumns = array_merge($this->withoutColumns, $qualifiedColumns);
+
+ 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()->getTableAlias();
+ 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()->getTableAlias();
+ 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()->getTableAlias());
+ $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()->getTableAlias());
+ 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));
+ $omitted = $this->groupColumnsByTarget($resolver->requireAndResolveColumns($this->withoutColumns));
+ foreach ($resolved as $target) {
+ $targetColumns = $resolved[$target]->getArrayCopy();
+ if (isset($omitted[$target])) {
+ $targetColumns = array_diff($targetColumns, $omitted[$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()->getTableAlias());
+ 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->getTableAlias()),
+ $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
+ * @param bool $link Whether the query should be linked to the parent query
+ *
+ * @return static
+ */
+ public function createSubQuery(Model $target, $targetPath, Model $from = null, bool $link = true)
+ {
+ $subQuery = (new static())
+ ->setDb($this->getDb())
+ ->setModel($target);
+
+ $sourceParts = array_reverse(explode('.', $targetPath));
+ $sourceParts[0] = $target->getTableAlias();
+
+ $subQueryResolver = $subQuery->getResolver();
+ $sourcePath = join('.', $sourceParts);
+ $subQueryTarget = $subQueryResolver->resolveRelation($sourcePath)->getTarget();
+
+ $subQuery->utilize($sourcePath); // TODO: Don't join if there's a matching foreign key
+
+ if (! $link) {
+ return $subQuery->columns(array_map(function ($keyName) use ($sourcePath) {
+ return "$sourcePath.$keyName";
+ }, (array) $subQueryTarget->getKeyName()));
+ }
+
+ // 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_');
+
+ $resolver = $this->getResolver();
+ $baseAlias = $resolver->getAlias($this->getModel());
+ $sourceAlias = $subQueryResolver->getAlias($subQueryTarget);
+
+ $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);
+ }
+
+ $columns = [];
+ $directions = [];
+ $orderByResolved = [];
+ $resolver = $this->getResolver();
+ $selectedColumns = $select->getColumns();
+
+ foreach ($orderBy as $part) {
+ list($column, $direction) = $part;
+
+ if (! $column instanceof ExpressionInterface && 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()
+ $columns[] = $column;
+ $directions[] = $direction;
+ }
+ }
+
+ foreach ($resolver->requireAndResolveColumns($columns) as list($model, $alias, $column)) {
+ $direction = array_shift($directions);
+ $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);
+ } elseif ($column instanceof ResolvedExpression) {
+ // We are doing this in an else if, since a resolved expression can't be an aliased
+ // expression at the same time and thus doesn't influence the functionality in any way.
+ $column->setColumns($resolver->qualifyColumns($column->getResolvedColumns()));
+ }
+
+ 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];
+ }
+
+ $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..aad4f03
--- /dev/null
+++ b/vendor/ipl/orm/src/Relation/BelongsToMany.php
@@ -0,0 +1,211 @@
+<?php
+
+namespace ipl\Orm\Relation;
+
+use ipl\Orm\Model;
+use ipl\Orm\Relation;
+use ipl\Orm\Relations;
+use LogicException;
+
+use function ipl\Stdlib\get_php_type;
+
+/**
+ * Many-to-many relationship
+ */
+class BelongsToMany extends Relation
+{
+ /** @var string Relation class */
+ protected const RELATION_CLASS = HasMany::class;
+
+ 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(): ?string
+ {
+ return $this->throughClass;
+ }
+
+ /**
+ * Set the join table name or junction model class
+ *
+ * @param string $through
+ *
+ * @return $this
+ */
+ public function through(string $through): self
+ {
+ $this->throughClass = $through;
+
+ return $this;
+ }
+
+ /**
+ * Get the junction model
+ *
+ * @return Model|Junction
+ */
+ public function getThrough(): Model
+ {
+ if ($this->through === null) {
+ $throughClass = $this->getThroughClass();
+ if ($throughClass === null) {
+ throw new LogicException(
+ 'You cannot use a many-to-many relation without a through class or a table name for the'
+ . ' junction model'
+ );
+ }
+
+ 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(Model $through): self
+ {
+ $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): self
+ {
+ $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): self
+ {
+ $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->getTableAlias())) {
+ $sourceRelation = $relations->get($source->getTableAlias());
+
+ $possibleCandidateKey[] = $sourceRelation->getForeignKey();
+ $possibleForeignKey[] = $sourceRelation->getCandidateKey();
+ }
+
+ if ($relations->has($target->getTableAlias())) {
+ $targetRelation = $relations->get($target->getTableAlias());
+
+ $possibleTargetCandidateKey[] = $targetRelation->getCandidateKey();
+ $possibleTargetForeignKey[] = $targetRelation->getForeignKey();
+ }
+ }
+
+ $junctionClass = static::RELATION_CLASS;
+ $toJunction = (new $junctionClass())
+ ->setName($junction->getTableAlias())
+ ->setSource($source)
+ ->setTarget($junction)
+ ->setCandidateKey($this->extractKey($possibleCandidateKey))
+ ->setForeignKey($this->extractKey($possibleForeignKey));
+
+ $targetClass = static::RELATION_CLASS;
+ $toTarget = (new $targetClass())
+ ->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/BelongsToOne.php b/vendor/ipl/orm/src/Relation/BelongsToOne.php
new file mode 100644
index 0000000..afdfbec
--- /dev/null
+++ b/vendor/ipl/orm/src/Relation/BelongsToOne.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace ipl\Orm\Relation;
+
+/**
+ * One-to-one relationship with a junction table
+ */
+class BelongsToOne extends BelongsToMany
+{
+ protected const RELATION_CLASS = HasOne::class;
+
+ protected $isOne = true;
+}
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..1f676c4
--- /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 [];
+ }
+
+ public function getColumns()
+ {
+ return [];
+ }
+}
diff --git a/vendor/ipl/orm/src/Relations.php b/vendor/ipl/orm/src/Relations.php
new file mode 100644
index 0000000..e19306e
--- /dev/null
+++ b/vendor/ipl/orm/src/Relations.php
@@ -0,0 +1,235 @@
+<?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\BelongsToOne;
+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|BelongsToOne|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)
+ {
+ /** @var HasOne $relation */
+ $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)
+ {
+ /** @var HasMany $relation */
+ $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)
+ {
+ /** @var BelongsTo $relation */
+ $relation = $this->create(BelongsTo::class, $name, $targetClass);
+
+ $this->add($relation);
+
+ return $relation;
+ }
+
+ /**
+ * Define a one-to-one relationship with a junction table
+ *
+ * @param string $name Name of the relation
+ * @param string $targetClass Target model class
+ *
+ * @return BelongsToOne
+ */
+ public function belongsToOne(string $name, string $targetClass): BelongsToOne
+ {
+ /** @var BelongsToOne $relation */
+ $relation = $this->create(BelongsToOne::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)
+ {
+ /** @var BelongsToMany $relation */
+ $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..a3b99b3
--- /dev/null
+++ b/vendor/ipl/orm/src/Resolver.php
@@ -0,0 +1,803 @@
+<?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\Orm\Relation\BelongsToOne;
+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();
+ $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->getTableAlias()) {
+ array_unshift($parts, $model->getTableAlias());
+ }
+
+ 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->getTableAlias()) {
+ throw new InvalidArgumentException(sprintf(
+ 'Cannot resolve relation path "%s". Base table alias/name is missing.',
+ $path
+ ));
+ }
+
+ $resolvedRelations = [];
+ if ($this->resolvedRelations->contains($subject)) {
+ $resolvedRelations = $this->resolvedRelations[$subject];
+ }
+
+ $target = $subject;
+ $pathBeingResolved = null;
+ $relation = 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->getTableAlias()]
+ )));
+ }
+
+ $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->getTableAlias();
+
+ $baseTableColumns = [];
+ 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:
+ $relationPath = null;
+ $hydrationPath = substr($columnPath, 0, $dot);
+ $columnPath = substr($columnPath, $dot + 1); // Updates also $column or $alias
+
+ if ($hydrationPath !== $tableName) {
+ $relation = null;
+ $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;
+ $hydrationPath = null;
+ $target = $model;
+
+ if (! $column instanceof ExpressionInterface) {
+ $column = $this->getBehaviors($target)->rewriteColumn($column) ?: $column;
+ }
+
+ if (is_int($alias) && ! $column instanceof AliasedExpression) {
+ if (! isset($baseTableColumns[$columnPath])) {
+ $baseTableColumns[$columnPath] = true;
+ } else {
+ // Don't yield base table columns multiple times.
+ // Duplicate columns without an alias may lead to SQL errors
+ continue 2;
+ }
+ }
+ }
+
+ 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) {
+ $qualifier = $relationPath ? "$hydrationPath." : '';
+
+ $column = new ResolvedExpression(
+ $column,
+ $this->requireAndResolveColumns(array_map(function ($c) use ($qualifier) {
+ return $qualifier . $c;
+ }, $column->getColumns()), $model)
+ );
+ }
+ }
+ }
+
+ 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..6f3823d
--- /dev/null
+++ b/vendor/ipl/orm/src/UnionQuery.php
@@ -0,0 +1,61 @@
+<?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 = [];
+
+ /** @var UnionModel $model */
+ $model = $this->getModel();
+ foreach ($model->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;
+ }
+}