From 4ce65d59ca91871cfd126497158200a818720bce Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sat, 13 Apr 2024 13:30:08 +0200 Subject: Adding upstream version 0.13.1. Signed-off-by: Daniel Baumann --- vendor/ipl/orm/LICENSE | 21 + vendor/ipl/orm/composer.json | 34 + vendor/ipl/orm/src/AliasedExpression.php | 36 + vendor/ipl/orm/src/Behavior.php | 12 + vendor/ipl/orm/src/Behavior/Binary.php | 101 +++ vendor/ipl/orm/src/Behavior/BoolCast.php | 147 ++++ .../ipl/orm/src/Behavior/MillisecondTimestamp.php | 41 + vendor/ipl/orm/src/Behaviors.php | 238 ++++++ vendor/ipl/orm/src/ColumnDefinition.php | 80 ++ .../ipl/orm/src/Common/PropertiesWithDefaults.php | 31 + vendor/ipl/orm/src/Common/SortUtil.php | 65 ++ vendor/ipl/orm/src/Compat/FilterProcessor.php | 375 +++++++++ vendor/ipl/orm/src/Contract/PersistBehavior.php | 18 + vendor/ipl/orm/src/Contract/PropertyBehavior.php | 102 +++ vendor/ipl/orm/src/Contract/QueryAwareBehavior.php | 18 + vendor/ipl/orm/src/Contract/RetrieveBehavior.php | 18 + .../ipl/orm/src/Contract/RewriteColumnBehavior.php | 39 + .../ipl/orm/src/Contract/RewriteFilterBehavior.php | 25 + .../ipl/orm/src/Contract/RewritePathBehavior.php | 20 + vendor/ipl/orm/src/Defaults.php | 52 ++ .../orm/src/Exception/InvalidColumnException.php | 53 ++ .../orm/src/Exception/InvalidRelationException.php | 53 ++ .../orm/src/Exception/ValueConversionException.php | 12 + vendor/ipl/orm/src/Hydrator.php | 197 +++++ vendor/ipl/orm/src/Model.php | 143 ++++ vendor/ipl/orm/src/Query.php | 846 +++++++++++++++++++++ vendor/ipl/orm/src/Relation.php | 336 ++++++++ vendor/ipl/orm/src/Relation/BelongsTo.php | 13 + vendor/ipl/orm/src/Relation/BelongsToMany.php | 211 +++++ vendor/ipl/orm/src/Relation/BelongsToOne.php | 13 + vendor/ipl/orm/src/Relation/HasMany.php | 13 + vendor/ipl/orm/src/Relation/HasOne.php | 12 + vendor/ipl/orm/src/Relation/Junction.php | 43 ++ vendor/ipl/orm/src/Relations.php | 235 ++++++ vendor/ipl/orm/src/ResolvedExpression.php | 49 ++ vendor/ipl/orm/src/Resolver.php | 803 +++++++++++++++++++ vendor/ipl/orm/src/ResultSet.php | 146 ++++ vendor/ipl/orm/src/UnionModel.php | 29 + vendor/ipl/orm/src/UnionQuery.php | 61 ++ 39 files changed, 4741 insertions(+) create mode 100644 vendor/ipl/orm/LICENSE create mode 100644 vendor/ipl/orm/composer.json create mode 100644 vendor/ipl/orm/src/AliasedExpression.php create mode 100644 vendor/ipl/orm/src/Behavior.php create mode 100644 vendor/ipl/orm/src/Behavior/Binary.php create mode 100644 vendor/ipl/orm/src/Behavior/BoolCast.php create mode 100644 vendor/ipl/orm/src/Behavior/MillisecondTimestamp.php create mode 100644 vendor/ipl/orm/src/Behaviors.php create mode 100644 vendor/ipl/orm/src/ColumnDefinition.php create mode 100644 vendor/ipl/orm/src/Common/PropertiesWithDefaults.php create mode 100644 vendor/ipl/orm/src/Common/SortUtil.php create mode 100644 vendor/ipl/orm/src/Compat/FilterProcessor.php create mode 100644 vendor/ipl/orm/src/Contract/PersistBehavior.php create mode 100644 vendor/ipl/orm/src/Contract/PropertyBehavior.php create mode 100644 vendor/ipl/orm/src/Contract/QueryAwareBehavior.php create mode 100644 vendor/ipl/orm/src/Contract/RetrieveBehavior.php create mode 100644 vendor/ipl/orm/src/Contract/RewriteColumnBehavior.php create mode 100644 vendor/ipl/orm/src/Contract/RewriteFilterBehavior.php create mode 100644 vendor/ipl/orm/src/Contract/RewritePathBehavior.php create mode 100644 vendor/ipl/orm/src/Defaults.php create mode 100644 vendor/ipl/orm/src/Exception/InvalidColumnException.php create mode 100644 vendor/ipl/orm/src/Exception/InvalidRelationException.php create mode 100644 vendor/ipl/orm/src/Exception/ValueConversionException.php create mode 100644 vendor/ipl/orm/src/Hydrator.php create mode 100644 vendor/ipl/orm/src/Model.php create mode 100644 vendor/ipl/orm/src/Query.php create mode 100644 vendor/ipl/orm/src/Relation.php create mode 100644 vendor/ipl/orm/src/Relation/BelongsTo.php create mode 100644 vendor/ipl/orm/src/Relation/BelongsToMany.php create mode 100644 vendor/ipl/orm/src/Relation/BelongsToOne.php create mode 100644 vendor/ipl/orm/src/Relation/HasMany.php create mode 100644 vendor/ipl/orm/src/Relation/HasOne.php create mode 100644 vendor/ipl/orm/src/Relation/Junction.php create mode 100644 vendor/ipl/orm/src/Relations.php create mode 100644 vendor/ipl/orm/src/ResolvedExpression.php create mode 100644 vendor/ipl/orm/src/Resolver.php create mode 100644 vendor/ipl/orm/src/ResultSet.php create mode 100644 vendor/ipl/orm/src/UnionModel.php create mode 100644 vendor/ipl/orm/src/UnionQuery.php (limited to 'vendor/ipl/orm') diff --git a/vendor/ipl/orm/LICENSE b/vendor/ipl/orm/LICENSE new file mode 100644 index 0000000..9233b0f --- /dev/null +++ b/vendor/ipl/orm/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2019 Icinga GmbH https://www.icinga.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/vendor/ipl/orm/composer.json b/vendor/ipl/orm/composer.json new file mode 100644 index 0000000..71f17de --- /dev/null +++ b/vendor/ipl/orm/composer.json @@ -0,0 +1,34 @@ +{ + "name": "ipl/orm", + "type": "library", + "description": "Icinga PHP Library - ORM", + "license": "MIT", + "keywords": [ + "sql", + "database", + "orm" + ], + "homepage": "https://github.com/Icinga/ipl-orm", + "require": { + "php": ">=7.2", + "ext-pdo": "*", + "ipl/sql": ">=0.7.0", + "ipl/stdlib": ">=0.12.0" + }, + "autoload": { + "psr-4": { + "ipl\\Orm\\": "src" + } + }, + "require-dev": { + "ext-pdo_sqlite": "*", + "ipl/sql": "dev-main", + "ipl/stdlib": "dev-main" + }, + "autoload-dev": { + "psr-4": { + "ipl\\Tests\\Orm\\": "tests", + "ipl\\Tests\\Sql\\": "vendor/ipl/sql/tests" + } + } +} diff --git a/vendor/ipl/orm/src/AliasedExpression.php b/vendor/ipl/orm/src/AliasedExpression.php new file mode 100644 index 0000000..ed733a2 --- /dev/null +++ b/vendor/ipl/orm/src/AliasedExpression.php @@ -0,0 +1,36 @@ +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 @@ +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 @@ +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 @@ +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 @@ +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 @@ +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 @@ +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 @@ + 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 @@ +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 @@ +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 @@ + 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 @@ +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 @@ +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 @@ +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 @@ +hasProperties()) { + $this->setProperties($properties); + } + + $this->init(); + } + + /** + * Get the related database table's name + * + * @return string + */ + abstract public function getTableName(); + + /** + * Get the column name(s) of the primary key + * + * @return string|array Array if the primary key is compound, string otherwise + */ + abstract public function getKeyName(); + + /** + * Get the model's queryable columns + * + * @return array + */ + abstract public function getColumns(); + + /** + * Get the 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 @@ +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 @@ +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 @@ +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 @@ +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 @@ +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 @@ +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 @@ +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 @@ +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 @@ +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 @@ +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; + } +} -- cgit v1.2.3