diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 12:38:04 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 12:38:04 +0000 |
commit | 1ff5c35de5dbd70a782875a91dd2232fd01b002b (patch) | |
tree | 77d9ce5e1bf78b3e6ef79f8f6e7861e2ced3c09b /vendor/ipl/orm/src | |
parent | Initial commit. (diff) | |
download | icinga-php-library-upstream/0.10.1.tar.xz icinga-php-library-upstream/0.10.1.zip |
Adding upstream version 0.10.1.upstream/0.10.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
33 files changed, 4317 insertions, 0 deletions
diff --git a/vendor/ipl/orm/src/AliasedExpression.php b/vendor/ipl/orm/src/AliasedExpression.php new file mode 100644 index 0000000..ed733a2 --- /dev/null +++ b/vendor/ipl/orm/src/AliasedExpression.php @@ -0,0 +1,36 @@ +<?php + +namespace ipl\Orm; + +use ipl\Sql\Expression; + +class AliasedExpression extends Expression +{ + /** @var string */ + protected $alias; + + /** + * Create a new database expression + * + * @param string $alias The alias to use for the expression, this is expected to be quoted and qualified + * @param string $statement The statement of the expression + * @param ?array $columns The columns used by the expression + * @param mixed ...$values The values for the expression + */ + public function __construct(string $alias, string $statement, array $columns = null, ...$values) + { + parent::__construct($statement, $columns, ...$values); + + $this->alias = $alias; + } + + /** + * Get this expression's alias + * + * @return string + */ + public function getAlias(): string + { + return $this->alias; + } +} diff --git a/vendor/ipl/orm/src/Behavior.php b/vendor/ipl/orm/src/Behavior.php new file mode 100644 index 0000000..45b5e87 --- /dev/null +++ b/vendor/ipl/orm/src/Behavior.php @@ -0,0 +1,12 @@ +<?php + +namespace ipl\Orm; + +/** + * Interface Behavior + * + * @internal Used for type hinting only. Concrete behaviors are supposed to implement contracts from ipl\Orm\Contract + */ +interface Behavior +{ +} diff --git a/vendor/ipl/orm/src/Behavior/Binary.php b/vendor/ipl/orm/src/Behavior/Binary.php new file mode 100644 index 0000000..98a4bf4 --- /dev/null +++ b/vendor/ipl/orm/src/Behavior/Binary.php @@ -0,0 +1,116 @@ +<?php + +namespace ipl\Orm\Behavior; + +use ipl\Orm\Contract\PropertyBehavior; +use ipl\Orm\Contract\QueryAwareBehavior; +use ipl\Orm\Contract\RewriteFilterBehavior; +use ipl\Orm\Query; +use ipl\Sql\Adapter\Pgsql; +use ipl\Stdlib\Filter\Condition; +use UnexpectedValueException; + +use function ipl\Stdlib\get_php_type; + +/** + * Support hex filters for binary columns and PHP resource (in) / bytea hex format (out) transformation for PostgreSQL + */ +class Binary extends PropertyBehavior implements QueryAwareBehavior, RewriteFilterBehavior +{ + /** + * Properties for {@see rewriteCondition()} to support hex filters for each adapter + * + * Set in {@see setQuery()} from the {@see $properties} array because the latter is reset for adapters other than + * {@see Pgsql}, so {@see fromDb()} and {@see toDb()} don't run for them. + * + * @var array + */ + protected $rewriteSubjects; + + public function fromDb($value, $key, $_) + { + if ($value !== null) { + if (is_resource($value)) { + return stream_get_contents($value); + } + + return $value; + } + + return null; + } + + /** + * @throws UnexpectedValueException If value is a resource + */ + public function toDb($value, $key, $_) + { + if (is_resource($value)) { + throw new UnexpectedValueException(sprintf('Unexpected resource for %s', $key)); + } + + if ($value === '*') { + /** + * Support IS (NOT) NULL filter transformation. + * {@see \ipl\Sql\Compat\FilterProcessor::assemblePredicate()} + */ + return $value; + } + + /** + * TODO(lippserd): If the filter is moved to a subquery, the value has already been processed. + * This is because our filter processor is unfortunately doing the transformation twice at the moment: + * + * {@link https://github.com/Icinga/ipl-orm/issues/48} + * + * {@see \ipl\Orm\Compat\FilterProcessor::requireAndResolveFilterColumns()} + */ + if (substr($value, 0, 2) === '\\x') { + return $value; + } + + return sprintf('\\x%s', bin2hex($value)); + } + + public function setQuery(Query $query) + { + $this->rewriteSubjects = $this->properties; + + if (! $query->getDb()->getAdapter() instanceof Pgsql) { + // Only process properties if the adapter is PostgreSQL. + $this->properties = []; + } + } + + public function rewriteCondition(Condition $condition, $relation = null) + { + /** + * TODO(lippserd): Duplicate code because {@see RewriteFilterBehavior}s come after {@see PropertyBehavior}s. + * {@see \ipl\Orm\Compat\FilterProcessor::requireAndResolveFilterColumns()} + */ + $column = $condition->metaData()->get('columnName'); + if (isset($this->rewriteSubjects[$column])) { + $value = $condition->getValue(); + + if (empty($this->properties) && is_resource($value)) { + // Only for PostgreSQL. + throw new UnexpectedValueException(sprintf('Unexpected resource for %s', $column)); + } + + // ctype_xdigit expects strings. + $value = (string) $value; + /** + * Although this code path is also affected by the duplicate behavior evaluation stated in {@see toDb()}, + * no further adjustments are needed as ctype_xdigit returns false for binary and bytea hex strings. + */ + if (ctype_xdigit($value)) { + if (empty($this->properties) && substr($value, 0, 2) !== '\\x') { + // Only for PostgreSQL. + $condition->setValue(sprintf('\\x%s', $value)); + } else { + $condition->setValue(hex2bin($value)); + } + } + } + } +} diff --git a/vendor/ipl/orm/src/Behaviors.php b/vendor/ipl/orm/src/Behaviors.php new file mode 100644 index 0000000..5c54350 --- /dev/null +++ b/vendor/ipl/orm/src/Behaviors.php @@ -0,0 +1,238 @@ +<?php + +namespace ipl\Orm; + +use ArrayIterator; +use ipl\Orm\Contract\PersistBehavior; +use ipl\Orm\Contract\PropertyBehavior; +use ipl\Orm\Contract\RetrieveBehavior; +use ipl\Orm\Contract\RewriteColumnBehavior; +use ipl\Orm\Contract\RewriteFilterBehavior; +use ipl\Orm\Contract\RewritePathBehavior; +use ipl\Stdlib\Filter; +use IteratorAggregate; +use Traversable; + +class Behaviors implements IteratorAggregate +{ + /** @var array Registered behaviors */ + protected $behaviors = []; + + /** @var RetrieveBehavior[] Registered retrieve behaviors */ + protected $retrieveBehaviors = []; + + /** @var PersistBehavior[] Registered persist behaviors */ + protected $persistBehaviors = []; + + /** @var PropertyBehavior[] Registered property behaviors */ + protected $propertyBehaviors = []; + + /** @var RewriteFilterBehavior[] Registered rewrite filter behaviors */ + protected $rewriteFilterBehaviors = []; + + /** @var RewriteColumnBehavior[] Registered rewrite column behaviors */ + protected $rewriteColumnBehaviors = []; + + /** @var RewritePathBehavior[] Registered rewrite path behaviors */ + protected $rewritePathBehaviors = []; + + /** + * Add a behavior + * + * @param PersistBehavior|PropertyBehavior|RetrieveBehavior|RewriteFilterBehavior $behavior + */ + public function add(Behavior $behavior) + { + $this->behaviors[] = $behavior; + + if ($behavior instanceof PropertyBehavior) { + $this->retrieveBehaviors[] = $behavior; + $this->persistBehaviors[] = $behavior; + $this->propertyBehaviors[] = $behavior; + } else { + if ($behavior instanceof RetrieveBehavior) { + $this->retrieveBehaviors[] = $behavior; + } + + if ($behavior instanceof PersistBehavior) { + $this->persistBehaviors[] = $behavior; + } + } + + if ($behavior instanceof RewriteFilterBehavior) { + $this->rewriteFilterBehaviors[] = $behavior; + } + + if ($behavior instanceof RewriteColumnBehavior) { + $this->rewriteColumnBehaviors[] = $behavior; + } + + if ($behavior instanceof RewritePathBehavior) { + $this->rewritePathBehaviors[] = $behavior; + } + } + + /** + * Iterate registered behaviors + * + * @return ArrayIterator + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->behaviors); + } + + /** + * Apply all retrieve behaviors on the given model + * + * @param Model $model + */ + public function retrieve(Model $model) + { + foreach ($this->retrieveBehaviors as $behavior) { + $behavior->retrieve($model); + } + } + + /** + * Apply all persist behaviors on the given model + * + * @param Model $model + */ + public function persist(Model $model) + { + foreach ($this->persistBehaviors as $behavior) { + $behavior->persist($model); + } + } + + /** + * Transform the retrieved key's value by use of all property behaviors + * + * @param mixed $value + * @param string $key + * + * @return mixed + */ + public function retrieveProperty($value, $key) + { + foreach ($this->propertyBehaviors as $behavior) { + $value = $behavior->retrieveProperty($value, $key); + } + + return $value; + } + + /** + * Transform the to be persisted key's value by use of all property behaviors + * + * @param mixed $value + * @param string $key + * + * @return mixed + */ + public function persistProperty($value, $key) + { + foreach ($this->propertyBehaviors as $behavior) { + $value = $behavior->persistProperty($value, $key); + } + + return $value; + } + + /** + * Rewrite the given filter condition + * + * @param Filter\Condition $condition + * @param string $relation Absolute path (with a trailing dot) of the model + * + * @return Filter\Rule|null + */ + public function rewriteCondition(Filter\Condition $condition, $relation = null) + { + $filter = null; + foreach ($this->rewriteFilterBehaviors as $behavior) { + $replacement = $behavior->rewriteCondition($filter ?: $condition, $relation); + if ($replacement !== null) { + $filter = $replacement; + } + } + + return $filter; + } + + /** + * Rewrite the given relation path + * + * @param string $path + * @param string $relation Absolute path of the model + * + * @return string|null + */ + public function rewritePath($path, $relation = null) + { + $newPath = null; + foreach ($this->rewritePathBehaviors as $behavior) { + $replacement = $behavior->rewritePath($newPath ?: $path, $relation); + if ($replacement !== null) { + $newPath = $replacement; + } + } + + return $newPath; + } + + /** + * Rewrite the given column + * + * @param string $column + * @param string $relation Absolute path of the model + * + * @return mixed + */ + public function rewriteColumn($column, $relation = null) + { + $newColumn = null; + foreach ($this->rewriteColumnBehaviors as $behavior) { + $replacement = $behavior->rewriteColumn($newColumn ?: $column, $relation); + if ($replacement !== null) { + $newColumn = $replacement; + } + } + + return $newColumn; + } + + /** + * Rewrite the given column definition + * + * @param ColumnDefinition $def + * @param string $relation Absolute path of the model + * + * @return void + */ + public function rewriteColumnDefinition(ColumnDefinition $def, string $relation): void + { + foreach ($this->rewriteColumnBehaviors as $behavior) { + $behavior->rewriteColumnDefinition($def, $relation); + } + } + + /** + * Get whether the given column is selectable + * + * @param string $column + * + * @return bool + */ + public function isSelectableColumn(string $column): bool + { + foreach ($this->rewriteColumnBehaviors as $behavior) { + if ($behavior->isSelectableColumn($column)) { + return true; + } + } + + return false; + } +} diff --git a/vendor/ipl/orm/src/ColumnDefinition.php b/vendor/ipl/orm/src/ColumnDefinition.php new file mode 100644 index 0000000..ddb8062 --- /dev/null +++ b/vendor/ipl/orm/src/ColumnDefinition.php @@ -0,0 +1,80 @@ +<?php + +namespace ipl\Orm; + +use InvalidArgumentException; +use LogicException; + +class ColumnDefinition +{ + /** @var string The name of the column */ + protected $name; + + /** @var ?string The label of the column */ + protected $label; + + /** + * Create a new column definition + * + * @param string $name + */ + public function __construct(string $name) + { + $this->name = $name; + } + + /** + * Get the column name + * + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * Get the column label + * + * @return ?string + */ + public function getLabel(): ?string + { + return $this->label; + } + + /** + * Set the column label + * + * @param ?string $label + * + * @return $this + */ + public function setLabel(?string $label): self + { + $this->label = $label; + + return $this; + } + + /** + * Create a new column definition based on the given options + * + * @param array $options + * + * @return self + */ + public static function fromArray(array $options): self + { + if (! isset($options['name'])) { + throw new InvalidArgumentException('$options must provide a name'); + } + + $self = new static($options['name']); + if (isset($options['label'])) { + $self->setLabel($options['label']); + } + + return $self; + } +} diff --git a/vendor/ipl/orm/src/Common/PropertiesWithDefaults.php b/vendor/ipl/orm/src/Common/PropertiesWithDefaults.php new file mode 100644 index 0000000..e8d3a84 --- /dev/null +++ b/vendor/ipl/orm/src/Common/PropertiesWithDefaults.php @@ -0,0 +1,31 @@ +<?php + +namespace ipl\Orm\Common; + +use Closure; +use Traversable; + +trait PropertiesWithDefaults +{ + use \ipl\Stdlib\Properties { + \ipl\Stdlib\Properties::getProperty as private parentGetProperty; + } + + protected function getProperty($key) + { + if (isset($this->properties[$key]) && $this->properties[$key] instanceof Closure) { + $this->setProperty($key, $this->properties[$key]($this, $key)); + } + + return $this->parentGetProperty($key); + } + + public function getIterator(): Traversable + { + foreach ($this->properties as $key => $value) { + if (! $value instanceof Closure) { + yield $key => $value; + } + } + } +} diff --git a/vendor/ipl/orm/src/Common/SortUtil.php b/vendor/ipl/orm/src/Common/SortUtil.php new file mode 100644 index 0000000..0e014e9 --- /dev/null +++ b/vendor/ipl/orm/src/Common/SortUtil.php @@ -0,0 +1,65 @@ +<?php + +namespace ipl\Orm\Common; + +use ipl\Stdlib\Str; + +class SortUtil +{ + /** + * Create the sort column(s) and direction(s) from the given sort spec + * + * @param array|string $sort + * + * @return array|null Sort column(s) and direction(s) suitable for {@link OrderByInterface::orderBy()} + */ + public static function createOrderBy($sort) + { + $columnsAndDirections = static::explodeSortSpec($sort); + $orderBy = []; + + foreach ($columnsAndDirections as $columnAndDirection) { + list($column, $direction) = static::splitColumnAndDirection($columnAndDirection); + + $orderBy[] = [$column, $direction]; + } + + return $orderBy; + } + + /** + * Explode the given sort spec into its sort parts + * + * @param array|string $sort + * + * @return array + */ + public static function explodeSortSpec($sort) + { + return Str::trimSplit(implode(',', (array) $sort)); + } + + /** + * Normalize the given sort spec to a sort string + * + * @param array|string $sort + * + * @return string + */ + public static function normalizeSortSpec($sort) + { + return implode(',', static::explodeSortSpec($sort)); + } + + /** + * Explode the given sort part into its sort column and direction + * + * @param string $sort + * + * @return array + */ + public static function splitColumnAndDirection($sort) + { + return Str::symmetricSplit($sort, ' ', 2); + } +} diff --git a/vendor/ipl/orm/src/Compat/FilterProcessor.php b/vendor/ipl/orm/src/Compat/FilterProcessor.php new file mode 100644 index 0000000..0b091f1 --- /dev/null +++ b/vendor/ipl/orm/src/Compat/FilterProcessor.php @@ -0,0 +1,329 @@ +<?php + +namespace ipl\Orm\Compat; + +use AppendIterator; +use ArrayIterator; +use ipl\Orm\Exception\InvalidColumnException; +use ipl\Orm\Query; +use ipl\Orm\Relation; +use ipl\Orm\UnionQuery; +use ipl\Sql\Expression; +use ipl\Sql\Filter\Exists; +use ipl\Sql\Filter\NotExists; +use ipl\Stdlib\Contract\Filterable; +use ipl\Stdlib\Filter\MetaDataProvider; +use ipl\Stdlib\Filter; + +class FilterProcessor extends \ipl\Sql\Compat\FilterProcessor +{ + protected $baseJoins = []; + + protected $madeJoins = []; + + /** + * Require and resolve the filter rule and apply it on the query + * + * Note that this applies the filter to {@see Query::$selectBase} + * directly and bypasses {@see Query::$filter}. If this is not + * desired, utilize the {@see Filterable} functions of the query. + * + * @param Filter\Rule $filter + * @param Query $query + */ + public static function apply(Filter\Rule $filter, Query $query) + { + if ($query instanceof UnionQuery) { + foreach ($query->getUnions() as $union) { + static::apply($filter, $union); + } + + return; + } + + if ($filter instanceof Filter\Condition || ! $filter->isEmpty()) { + $filter = clone $filter; + if (! $filter instanceof Filter\Chain) { + $filter = Filter::all($filter); + } + + static::resolveFilter($filter, $query); + + $where = static::assembleFilter($filter); + + if ($where) { + $operator = array_shift($where); + $conditions = array_shift($where); + + $query->getSelectBase()->where($conditions, $operator); + } + } + } + + /** + * Resolve the filter in order to apply it on the query + * + * @param Filter\Chain $filter + * @param Query $query + * + * @return void + */ + public static function resolveFilter(Filter\Chain $filter, Query $query) + { + $processor = new static(); + foreach ($query->getUtilize() as $path => $_) { + $processor->baseJoins[$path] = true; + } + + $processor->requireAndResolveFilterColumns($filter, $query); + } + + protected function requireAndResolveFilterColumns(Filter\Rule $filter, Query $query, $forceOptimization = null) + { + if ($filter instanceof Filter\Condition) { + if ($filter instanceof Exists || $filter instanceof NotExists) { + return; + } + + $resolver = $query->getResolver(); + $baseTable = $query->getModel()->getTableName(); + $column = $resolver->qualifyPath( + $filter->metaData()->get('columnName', $filter->getColumn()), + $baseTable + ); + + $filter->metaData()->set('columnPath', $column); + + list($relationPath, $columnName) = preg_split('/\.(?=[^.]+$)/', $column); + + $relations = new AppendIterator(); + $relations->append(new ArrayIterator([$baseTable => null])); + $relations->append($resolver->resolveRelations($relationPath)); + foreach ($relations as $path => $relation) { + $columnName = substr($column, strlen($path) + 1); + + if ($path === $baseTable) { + $subject = $query->getModel(); + } else { + /** @var Relation $relation */ + $subject = $relation->getTarget(); + } + + $subjectBehaviors = $resolver->getBehaviors($subject); + + // Prepare filter as if it were final to allow full control for rewrite filter behaviors + $filter->setValue($subjectBehaviors->persistProperty($filter->getValue(), $columnName)); + $filter->setColumn($resolver->getAlias($subject) . '.' . $columnName); + $filter->metaData()->set('columnName', $columnName); + $filter->metaData()->set('relationPath', $path); + + $rewrittenFilter = $subjectBehaviors->rewriteCondition($filter, $path . '.'); + if ($rewrittenFilter !== null) { + return $this->requireAndResolveFilterColumns($rewrittenFilter, $query, $forceOptimization) + ?: $rewrittenFilter; + } + } + + if (! $resolver->hasSelectableColumn($subject, $columnName)) { + throw new InvalidColumnException($columnName, $subject); + } + + if ($relationPath !== $baseTable) { + $query->utilize($relationPath); + $this->madeJoins[$relationPath][] = $filter; + } + } else { + /** @var Filter\Chain $filter */ + + if ($filter->metaData()->has('forceOptimization')) { + // Rules can override the default behavior how it's determined that they need to be + // optimized. If it's done by a chain, it applies to all of its children. + $forceOptimization = $filter->metaData()->get('forceOptimization'); + } + + $subQueryGroups = []; + $outsourcedRules = []; + foreach ($filter as $child) { + /** @var Filter\Rule $child */ + $rewrittenFilter = $this->requireAndResolveFilterColumns($child, $query, $forceOptimization); + if ($rewrittenFilter !== null) { + $filter->replace($child, $rewrittenFilter); + $child = $rewrittenFilter; + } + + $optimizeChild = $forceOptimization; + if ($child instanceof MetaDataProvider && $child->metaData()->has('forceOptimization')) { + $optimizeChild = $child->metaData()->get('forceOptimization'); + } + + // We only optimize rules in a single level, nested chains are ignored + if ($child instanceof Filter\Condition && $child->metaData()->has('relationPath')) { + $relationPath = $child->metaData()->get('relationPath'); + if ( + $relationPath !== $query->getModel()->getTableName() // Not the base table + && ( + $optimizeChild !== null && $optimizeChild + || ( + $optimizeChild === null + && ! isset($query->getWith()[$relationPath]) // Not a selected join + && ! $query->getResolver()->isDistinctRelation($relationPath) // Not a to-one relation + ) + ) + ) { + $subQueryGroups[$relationPath][$child->getColumn()][get_class($child)][] = $child; + + // Register all rules that are going to be put into sub queries, for later cleanup + $outsourcedRules[] = $child; + } + } + } + + foreach ($subQueryGroups as $relationPath => $columns) { + $generalRules = []; + foreach ($columns as $column => & $comparisons) { + if (isset($comparisons[Filter\Unequal::class]) || isset($comparisons[Filter\Unlike::class])) { + // If there's a unequal (!=) comparison for any column, all other comparisons (for the same + // column) also need to be outsourced to their own sub query. Regardless of their amount of + // occurrence. This is because `$generalRules` apply to all comparisons of such a column and + // need to be applied to all sub queries. + continue; + } + + // Single occurring columns don't need their own sub query + foreach ($comparisons as $conditionClass => $rules) { + if (count($rules) === 1) { + $generalRules[] = $rules[0]; + unset($comparisons[$conditionClass]); + } + } + + if (empty($comparisons)) { + unset($columns[$column]); + } + } + + $count = null; + $baseFilters = null; + $subQueryFilters = []; + foreach ($columns as $column => $comparisons) { + foreach ($comparisons as $conditionClass => $rules) { + if ($conditionClass === Filter\Unequal::class || $conditionClass === Filter\Unlike::class) { + // Unequal comparisons are always put into their own sub query + $subQueryFilters[] = [$rules, count($rules), true]; + } elseif (count($rules) > $count) { + // If there are multiple columns used multiple times in the same relation, we have to decide + // which to use as the primary comparison. That is the column that is used most often. + if (! empty($baseFilters)) { + array_push($generalRules, ...$baseFilters); + } + + $count = count($rules); + $baseFilters = $rules; + } else { + array_push($generalRules, ...$rules); + } + } + } + + if (! empty($baseFilters) || ! empty($generalRules)) { + $subQueryFilters[] = [$baseFilters ?: $generalRules, $count, false]; + } + + foreach ($subQueryFilters as list($filters, $count, $negate)) { + $subQueryFilter = null; + if ($count !== null) { + $aggregateFilter = Filter::any(); + foreach ($filters as $condition) { + if ($negate) { + if ($condition instanceof Filter\Unequal) { + $negation = Filter::equal($condition->getColumn(), $condition->getValue()); + } else { // if ($condition instanceof Filter\Unlike) + $negation = Filter::like($condition->getColumn(), $condition->getValue()); + } + + $negation->metaData()->merge($condition->metaData()); + $condition = $negation; + $count = 1; + } + + switch (true) { + case $filter instanceof Filter\All: + $aggregateFilter->add(Filter::all($condition, ...$generalRules)); + break; + case $filter instanceof Filter\Any: + $aggregateFilter->add(Filter::any($condition, ...$generalRules)); + break; + case $filter instanceof Filter\None: + $aggregateFilter->add(Filter::none($condition, ...$generalRules)); + break; + } + } + + $subQueryFilter = $aggregateFilter; + } else { + switch (true) { + case $filter instanceof Filter\All: + $subQueryFilter = Filter::all(...$filters); + break; + case $filter instanceof Filter\Any: + $subQueryFilter = Filter::any(...$filters); + break; + case $filter instanceof Filter\None: + $subQueryFilter = Filter::none(...$filters); + break; + } + } + + $relation = $query->getResolver()->resolveRelation($relationPath); + $subQuery = $query->createSubQuery($relation->getTarget(), $relationPath); + $subQuery->columns([new Expression('1')]); + + if ($count !== null && ($negate || $filter instanceof Filter\All)) { + $targetKeys = join( + ',', + array_values( + $subQuery->getResolver()->qualifyColumns( + (array) $subQuery->getModel()->getKeyName(), + $subQuery->getModel() + ) + ) + ); + + $subQuery->getSelectBase()->having(["COUNT(DISTINCT $targetKeys) >= ?" => $count]); + } + + $subQuery->filter($subQueryFilter); + + if ($negate) { + $filter->add(new NotExists($subQuery->assembleSelect()->resetOrderBy())); + } else { + $filter->add(new Exists($subQuery->assembleSelect()->resetOrderBy())); + } + } + } + + foreach ($outsourcedRules as $rule) { + // Remove joins solely used for filter conditions + foreach ($this->madeJoins as $joinPath => & $madeBy) { + $madeBy = array_filter( + $madeBy, + function ($relationFilter) use ($rule) { + return $rule !== $relationFilter + && ($rule instanceof Filter\Condition || ! $rule->has($relationFilter)); + } + ); + + if (empty($madeBy)) { + if (! isset($this->baseJoins[$joinPath])) { + $query->omit($joinPath); + } + + unset($this->madeJoins[$joinPath]); + } + } + + $filter->remove($rule); + } + } + } +} diff --git a/vendor/ipl/orm/src/Contract/PersistBehavior.php b/vendor/ipl/orm/src/Contract/PersistBehavior.php new file mode 100644 index 0000000..a6db05d --- /dev/null +++ b/vendor/ipl/orm/src/Contract/PersistBehavior.php @@ -0,0 +1,18 @@ +<?php + +namespace ipl\Orm\Contract; + +use ipl\Orm\Behavior; +use ipl\Orm\Model; + +interface PersistBehavior extends Behavior +{ + /** + * Apply this behavior on the given model + * + * Called when the model is persisted in the database. + * + * @param Model $model + */ + public function persist(Model $model); +} diff --git a/vendor/ipl/orm/src/Contract/PropertyBehavior.php b/vendor/ipl/orm/src/Contract/PropertyBehavior.php new file mode 100644 index 0000000..04e5637 --- /dev/null +++ b/vendor/ipl/orm/src/Contract/PropertyBehavior.php @@ -0,0 +1,104 @@ +<?php + +namespace ipl\Orm\Contract; + +use ipl\Orm\Model; +use OutOfBoundsException; + +abstract class PropertyBehavior implements RetrieveBehavior, PersistBehavior +{ + /** @var array Property names of which the value should be processed */ + protected $properties; + + /** + * PropertyBehavior constructor + * + * @param array $properties Property names to process, as values + */ + public function __construct(array $properties) + { + if (is_int(key($properties))) { + $this->properties = array_flip($properties); + } else { + $this->properties = $properties; + } + } + + public function retrieve(Model $model) + { + foreach ($this->properties as $key => $ctx) { + try { + $model[$key] = $this->fromDb($model[$key], $key, $ctx); + } catch (OutOfBoundsException $_) { + // pass + } + } + } + + public function persist(Model $model) + { + foreach ($this->properties as $key => $ctx) { + try { + $model[$key] = $this->toDb($model[$key], $key, $ctx); + } catch (OutOfBoundsException $_) { + // pass + } + } + } + + /** + * Transform the given value, just fetched from the database + * + * @param mixed $value + * @param string $key + * + * @return mixed + */ + public function retrieveProperty($value, $key) + { + if (! isset($this->properties[$key])) { + return $value; + } + + return $this->fromDb($value, $key, $this->properties[$key]); + } + + /** + * Transform the given value, about to be persisted to the database + * + * @param mixed $value + * @param string $key + * + * @return mixed + */ + public function persistProperty($value, $key) + { + if (! isset($this->properties[$key])) { + return $value; + } + + return $this->toDb($value, $key, $this->properties[$key]); + } + + /** + * Transform the given value which has just been fetched from the database + * + * @param mixed $value + * @param string $key + * @param mixed $context + * + * @return mixed + */ + abstract public function fromDb($value, $key, $context); + + /** + * Transform the given value which is about to be persisted to the database + * + * @param mixed $value + * @param string $key + * @param mixed $context + * + * @return mixed + */ + abstract public function toDb($value, $key, $context); +} diff --git a/vendor/ipl/orm/src/Contract/QueryAwareBehavior.php b/vendor/ipl/orm/src/Contract/QueryAwareBehavior.php new file mode 100644 index 0000000..b67bf51 --- /dev/null +++ b/vendor/ipl/orm/src/Contract/QueryAwareBehavior.php @@ -0,0 +1,18 @@ +<?php + +namespace ipl\Orm\Contract; + +use ipl\Orm\Behavior; +use ipl\Orm\Query; + +interface QueryAwareBehavior extends Behavior +{ + /** + * Set the query + * + * @param Query $query + * + * @return $this + */ + public function setQuery(Query $query); +} diff --git a/vendor/ipl/orm/src/Contract/RetrieveBehavior.php b/vendor/ipl/orm/src/Contract/RetrieveBehavior.php new file mode 100644 index 0000000..884d074 --- /dev/null +++ b/vendor/ipl/orm/src/Contract/RetrieveBehavior.php @@ -0,0 +1,18 @@ +<?php + +namespace ipl\Orm\Contract; + +use ipl\Orm\Behavior; +use ipl\Orm\Model; + +interface RetrieveBehavior extends Behavior +{ + /** + * Apply this behavior on the given model + * + * Called when the model is fetched from the database. + * + * @param Model $model + */ + public function retrieve(Model $model); +} diff --git a/vendor/ipl/orm/src/Contract/RewriteColumnBehavior.php b/vendor/ipl/orm/src/Contract/RewriteColumnBehavior.php new file mode 100644 index 0000000..b6f545b --- /dev/null +++ b/vendor/ipl/orm/src/Contract/RewriteColumnBehavior.php @@ -0,0 +1,39 @@ +<?php + +namespace ipl\Orm\Contract; + +use ipl\Orm\ColumnDefinition; + +interface RewriteColumnBehavior extends RewriteFilterBehavior +{ + /** + * Rewrite the given column + * + * The result must be returned otherwise (NULL is returned) the original column is kept as is. + * + * @param mixed $column + * @param ?string $relation The absolute path of the model. For reference only, don't include it in the result + * + * @return mixed + */ + public function rewriteColumn($column, ?string $relation = null); + + /** + * Get whether {@see rewriteColumn} might return an otherwise unknown column or expression + * + * @param string $name + * + * @return bool + */ + public function isSelectableColumn(string $name): bool; + + /** + * Rewrite the given column definition + * + * @param ColumnDefinition $def + * @param string $relation The absolute path of the model. For reference only, don't include it in the result + * + * @return void + */ + public function rewriteColumnDefinition(ColumnDefinition $def, string $relation): void; +} diff --git a/vendor/ipl/orm/src/Contract/RewriteFilterBehavior.php b/vendor/ipl/orm/src/Contract/RewriteFilterBehavior.php new file mode 100644 index 0000000..af6de5b --- /dev/null +++ b/vendor/ipl/orm/src/Contract/RewriteFilterBehavior.php @@ -0,0 +1,25 @@ +<?php + +namespace ipl\Orm\Contract; + +use ipl\Orm\Behavior; +use ipl\Stdlib\Filter; + +interface RewriteFilterBehavior extends Behavior +{ + /** + * Rewrite the given filter condition + * + * The condition can either be adjusted directly or replaced by an entirely new rule. The result must be + * returned otherwise (NULL is returned) processing continues normally. (Isn't restarted) + * + * If a result is returned, it is required to append the given absolute path of the model to the column. + * Processing of the condition will be restarted, hence the column has to be an absolute path again. + * + * @param Filter\Condition $condition + * @param string $relation The absolute path (with a trailing dot) of the model + * + * @return Filter\Rule|null + */ + public function rewriteCondition(Filter\Condition $condition, $relation = null); +} diff --git a/vendor/ipl/orm/src/Contract/RewritePathBehavior.php b/vendor/ipl/orm/src/Contract/RewritePathBehavior.php new file mode 100644 index 0000000..b5b0385 --- /dev/null +++ b/vendor/ipl/orm/src/Contract/RewritePathBehavior.php @@ -0,0 +1,20 @@ +<?php + +namespace ipl\Orm\Contract; + +use ipl\Orm\Behavior; + +interface RewritePathBehavior extends Behavior +{ + /** + * Rewrite the given relation path + * + * The result must be returned otherwise (NULL is returned) the original path is kept as is. + * + * @param string $path + * @param ?string $relation The absolute path of the model. For reference only, don't include it in the result + * + * @return ?string + */ + public function rewritePath(string $path, ?string $relation = null): ?string; +} diff --git a/vendor/ipl/orm/src/Defaults.php b/vendor/ipl/orm/src/Defaults.php new file mode 100644 index 0000000..aa2d517 --- /dev/null +++ b/vendor/ipl/orm/src/Defaults.php @@ -0,0 +1,52 @@ +<?php + +namespace ipl\Orm; + +use IteratorAggregate; +use Traversable; + +class Defaults implements IteratorAggregate +{ + /** @var array<string, mixed> Registered defaults */ + protected $defaults = []; + + /** + * Iterate over the defaults + * + * @return Traversable + */ + public function getIterator(): Traversable + { + foreach ($this->defaults as $column => $default) { + yield $column => $default; + } + } + + /** + * Add a default for the given property + * + * @param string $property + * @param mixed $default If it's a closure, its interface is assumed to be + * ({@see Model} $subject, string $propertyName) + * + * @return $this + */ + public function add(string $property, $default): self + { + $this->defaults[$property] = $default; + + return $this; + } + + /** + * Get whether a default for the given property exists + * + * @param string $property + * + * @return bool + */ + public function has(string $property): bool + { + return array_key_exists($property, $this->defaults); + } +} diff --git a/vendor/ipl/orm/src/Exception/InvalidColumnException.php b/vendor/ipl/orm/src/Exception/InvalidColumnException.php new file mode 100644 index 0000000..cd320c6 --- /dev/null +++ b/vendor/ipl/orm/src/Exception/InvalidColumnException.php @@ -0,0 +1,53 @@ +<?php + +namespace ipl\Orm\Exception; + +use Exception; +use ipl\Orm\Model; + +class InvalidColumnException extends Exception +{ + /** @var string The column name */ + protected $column; + + /** @var Model The target model */ + protected $model; + + /** + * Create a new InvalidColumnException + * + * @param string $column The column name + * @param Model $model The target model + */ + public function __construct($column, Model $model) + { + $this->column = (string) $column; + $this->model = $model; + + parent::__construct(sprintf( + "Can't require column '%s' in model '%s'. Column not found.", + $column, + get_class($model) + )); + } + + /** + * Get the column name + * + * @return string + */ + public function getColumn() + { + return $this->column; + } + + /** + * Get the target model + * + * @return Model + */ + public function getModel() + { + return $this->model; + } +} diff --git a/vendor/ipl/orm/src/Exception/InvalidRelationException.php b/vendor/ipl/orm/src/Exception/InvalidRelationException.php new file mode 100644 index 0000000..51e81bb --- /dev/null +++ b/vendor/ipl/orm/src/Exception/InvalidRelationException.php @@ -0,0 +1,53 @@ +<?php + +namespace ipl\Orm\Exception; + +use Exception; +use ipl\Orm\Model; + +class InvalidRelationException extends Exception +{ + /** @var string The relation name */ + protected $relation; + + /** @var Model The target model */ + protected $model; + + /** + * Create a new InvalidRelationException + * + * @param string $relation The relation name + * @param Model $model The target model + */ + public function __construct($relation, Model $model = null) + { + $this->relation = (string) $relation; + $this->model = $model; + + parent::__construct(sprintf( + 'Cannot join relation "%s"%s. Relation not found.', + $relation, + $model ? ' in model ' . get_class($model) : '' + )); + } + + /** + * Get the relation name + * + * @return string + */ + public function getRelation() + { + return $this->relation; + } + + /** + * Get the target model + * + * @return Model + */ + public function getModel() + { + return $this->model; + } +} diff --git a/vendor/ipl/orm/src/Hydrator.php b/vendor/ipl/orm/src/Hydrator.php new file mode 100644 index 0000000..272e4b6 --- /dev/null +++ b/vendor/ipl/orm/src/Hydrator.php @@ -0,0 +1,197 @@ +<?php + +namespace ipl\Orm; + +use InvalidArgumentException; +use ipl\Orm\Exception\InvalidRelationException; + +/** + * Hydrates raw database rows into concrete model instances. + */ +class Hydrator +{ + /** @var array Additional hydration rules for the model's relations */ + protected $hydrators = []; + + /** @var Query The query the hydration rules are for */ + protected $query; + + /** + * Create a new Hydrator + * + * @param Query $query + */ + public function __construct(Query $query) + { + $this->query = $query; + } + + /** + * Add a hydration rule + * + * @param string $path Model path + * + * @return $this + * + * @throws \InvalidArgumentException If a hydrator for the given path already exists + */ + public function add($path) + { + if (isset($this->hydrators[$path])) { + throw new \InvalidArgumentException("Hydrator for path '$path' already exists"); + } + + $resolver = $this->query->getResolver(); + $target = $this->query->getModel(); + $relation = null; + + if ($path === $target->getTableName()) { + $selectableColumns = $resolver->getSelectableColumns($target); + $columnToPropertyMap = array_combine($selectableColumns, $selectableColumns); + } else { + $relation = $resolver->resolveRelation($path); + $target = $relation->getTarget(); + $selectableColumns = $resolver->getSelectableColumns($target); + $columnToPropertyMap = array_combine( + array_keys($resolver->qualifyColumnsAndAliases($selectableColumns, $target)), + $selectableColumns + ); + } + + $relationLoader = function (Model $subject, string $relationName) { + return $this->query->derive($relationName, $subject); + }; + + $defaults = $this->query->getResolver()->getDefaults($target); + foreach ($resolver->getRelations($target) as $targetRelation) { + $targetRelationName = $targetRelation->getName(); + if (! $defaults->has($targetRelationName)) { + $defaults->add($targetRelationName, $relationLoader); + } + } + + $this->hydrators[$path] = [$target, $relation, $columnToPropertyMap, $defaults]; + + return $this; + } + + /** + * Hydrate the given raw database rows into the specified model + * + * @param array $data + * @param Model $model + * + * @return Model + */ + public function hydrate(array $data, Model $model) + { + $defaultsToApply = []; + foreach ($this->hydrators as $path => $vars) { + list($target, $relation, $columnToPropertyMap, $defaults) = $vars; + + $subject = $model; + if ($relation !== null) { + /** @var Relation $relation */ + + $steps = explode('.', $path); + $baseTable = array_shift($steps); + $relationName = array_pop($steps); + + $parent = $model; + foreach ($steps as $i => $step) { + if (! isset($parent->$step)) { + $intermediateRelation = $this->query->getResolver()->resolveRelation( + $baseTable . '.' . implode('.', array_slice($steps, 0, $i + 1)), + $model + ); + $parentClass = $intermediateRelation->getTargetClass(); + $parent = $parent->$step = new $parentClass(); + } else { + $parent = $parent->$step; + } + } + + if (isset($parent->$relationName)) { + $subject = $parent->$relationName; + } else { + $subjectClass = $relation->getTargetClass(); + $subject = new $subjectClass(); + $parent->$relationName = $subject; + } + } + + $subject->setProperties($this->extractAndMap($data, $columnToPropertyMap)); + $this->query->getResolver()->getBehaviors($target)->retrieve($subject); + $defaultsToApply[] = [$subject, $defaults]; + } + + // If there are any columns left, propagate them to the targeted relation if possible, to the base otherwise + foreach ($data as $column => $value) { + $columnName = $column; + $steps = explode('_', $column); + $baseTable = array_shift($steps); + + $subject = $model; + $target = $this->query->getModel(); + $stepsTaken = []; + foreach ($steps as $step) { + $stepsTaken[] = $step; + $relationPath = "$baseTable." . implode('.', $stepsTaken); + + try { + $relation = $this->query->getResolver()->resolveRelation($relationPath); + } catch (InvalidArgumentException $_) { + // The base table is missing, which means the alias hasn't been qualified and is custom defined + break; + } catch (InvalidRelationException $_) { + array_pop($stepsTaken); + $columnName = implode('_', array_slice($steps, count($stepsTaken))); + break; + } + + if (! $subject->hasProperty($step)) { + $stepClass = $relation->getTargetClass(); + $subject->$step = new $stepClass(); + } + + $subject = $subject->$step; + $target = $relation->getTarget(); + } + + $subject->$columnName = $this->query + ->getResolver() + ->getBehaviors($target) + ->retrieveProperty($value, $columnName); + } + + // Apply defaults last, otherwise we may evaluate them during hydration + foreach ($defaultsToApply as list($subject, $defaults)) { + foreach ($defaults as $name => $default) { + if (! $subject->hasProperty($name)) { + $subject->$name = $default; + } + } + } + + return $model; + } + + /** + * Extract and map the given data based on the specified column to property resolution map + * + * @param array $data + * @param array $columnToPropertyMap + * + * @return array + */ + protected function extractAndMap(array &$data, array $columnToPropertyMap) + { + $extracted = []; + foreach (array_intersect_key($columnToPropertyMap, $data) as $column => $property) { + $extracted[$property] = $data[$column]; + unset($data[$column]); + } + + return $extracted; + } +} diff --git a/vendor/ipl/orm/src/Model.php b/vendor/ipl/orm/src/Model.php new file mode 100644 index 0000000..0a05819 --- /dev/null +++ b/vendor/ipl/orm/src/Model.php @@ -0,0 +1,132 @@ +<?php + +namespace ipl\Orm; + +use ipl\Orm\Common\PropertiesWithDefaults; +use ipl\Sql\Connection; + +/** + * Models represent single database tables or parts of it. + * They are also used to interact with the tables, i.e. in order to query for data. + */ +abstract class Model implements \ArrayAccess, \IteratorAggregate +{ + use PropertiesWithDefaults; + + final public function __construct(array $properties = null) + { + if ($this->hasProperties()) { + $this->setProperties($properties); + } + + $this->init(); + } + + /** + * Get the related database table's name + * + * @return string + */ + abstract public function getTableName(); + + /** + * Get the column name(s) of the primary key + * + * @return string|array Array if the primary key is compound, string otherwise + */ + abstract public function getKeyName(); + + /** + * Get the model's queryable columns + * + * @return array + */ + abstract public function getColumns(); + + /** + * Get the model's column definitions + * + * The array is indexed by column names, values are either strings (labels) or arrays of this format: + * + * [ + * 'label' => 'A Column', + * 'type' => 'enum(y,n)' + * ] + * + * @return array + */ + public function getColumnDefinitions() + { + return []; + } + + /** + * Get a query which is tied to this model and the given database connection + * + * @param Connection $db + * + * @return Query + */ + public static function on(Connection $db) + { + return (new Query()) + ->setDb($db) + ->setModel(new static()); + } + + /** + * Get the model's default sort + * + * @return array|string + */ + public function getDefaultSort() + { + return []; + } + + /** + * Get the model's search columns + * + * @return array + */ + public function getSearchColumns() + { + return []; + } + + /** + * Create the model's behaviors + * + * @param Behaviors $behaviors + */ + public function createBehaviors(Behaviors $behaviors) + { + } + + /** + * Create the model's defaults + * + * @param Defaults $defaults + */ + public function createDefaults(Defaults $defaults) + { + } + + /** + * Create the model's relations + * + * If your model should be associated to other models, override this method and create the model's relations. + */ + public function createRelations(Relations $relations) + { + } + + /** + * Initialize the model + * + * If you want to adjust the model after construction, override this method. + */ + protected function init() + { + } +} diff --git a/vendor/ipl/orm/src/Query.php b/vendor/ipl/orm/src/Query.php new file mode 100644 index 0000000..b55c331 --- /dev/null +++ b/vendor/ipl/orm/src/Query.php @@ -0,0 +1,787 @@ +<?php + +namespace ipl\Orm; + +use ArrayObject; +use Generator; +use InvalidArgumentException; +use ipl\Orm\Common\SortUtil; +use ipl\Orm\Compat\FilterProcessor; +use ipl\Sql\Connection; +use ipl\Sql\ExpressionInterface; +use ipl\Sql\LimitOffset; +use ipl\Sql\LimitOffsetInterface; +use ipl\Sql\OrderBy; +use ipl\Sql\OrderByInterface; +use ipl\Sql\Select; +use ipl\Stdlib\Contract\Filterable; +use ipl\Stdlib\Contract\Paginatable; +use ipl\Stdlib\Events; +use ipl\Stdlib\Filter; +use ipl\Stdlib\Filters; +use IteratorAggregate; +use ReflectionClass; +use SplObjectStorage; +use Traversable; + +/** + * Represents a database query which is associated to a model and a database connection. + */ +class Query implements Filterable, LimitOffsetInterface, OrderByInterface, Paginatable, IteratorAggregate +{ + use Events; + use Filters; + use LimitOffset; + use OrderBy; + + /** + * Event raised after assembling a {@link Select} object for the query + * + * **Example usage:** + * + * ``` + * $query->on(Query::ON_SELECT_ASSEMBLED, function (Select $select) { + * // ... + * }); + * ``` + */ + const ON_SELECT_ASSEMBLED = 'selectAssembled'; + + /** @var int Count cache */ + protected $count; + + /** @var Connection Database connection */ + protected $db; + + /** @var string Class to return results as */ + protected $resultSetClass = ResultSet::class; + + /** @var Model Model to query */ + protected $model; + + /** @var array Columns to select from the model (or its relations). If empty, all columns are selected */ + protected $columns = []; + + /** @var array Additional columns to select from the model (or its relations) */ + protected $withColumns = []; + + /** @var bool Whether to peek ahead for more results */ + protected $peekAhead = false; + + /** @var Resolver Column and relation resolver */ + protected $resolver; + + /** @var Select Base SELECT query */ + protected $selectBase; + + /** @var Relation[] Relations to eager load */ + protected $with = []; + + /** @var Relation[] Relations to utilize (join) */ + protected $utilize = []; + + /** @var bool Whether to disable the default sorts of the model */ + protected $disableDefaultSort = false; + + /** + * Get the database connection + * + * @return Connection + */ + public function getDb() + { + return $this->db; + } + + /** + * Set the database connection + * + * @param Connection $db + * + * @return $this + */ + public function setDb(Connection $db) + { + $this->db = $db; + + return $this; + } + + /** + * Get the class to return results as + * + * @return string + */ + public function getResultSetClass() + { + return $this->resultSetClass; + } + + /** + * Set the class to return results as + * + * @param string $class + * + * @return $this + * + * @throws InvalidArgumentException If class is not an instance of {@link ResultSet} + */ + public function setResultSetClass($class) + { + if (! is_string($class)) { + throw new InvalidArgumentException('Argument $class must be a string'); + } + + if (! (new ReflectionClass($class))->newInstanceWithoutConstructor() instanceof ResultSet) { + throw new InvalidArgumentException( + $class . ' must be an instance of ' . ResultSet::class + ); + } + + $this->resultSetClass = $class; + + return $this; + } + + /** + * Get the model to query + * + * @return Model + */ + public function getModel() + { + return $this->model; + } + + /** + * Set the model to query + * + * @param $model + * + * @return $this + */ + public function setModel(Model $model) + { + $this->model = $model; + $this->getResolver()->setAlias($model, $model->getTableName()); + + return $this; + } + + /** + * Get the columns to select from the model + * + * @return array + */ + public function getColumns() + { + return $this->columns; + } + + /** + * Set the filter of the query + * + * @param Filter\Chain $filter + * + * @return $this + */ + public function setFilter(Filter\Chain $filter) + { + $this->filter = $filter; + + return $this; + } + + /** + * Disable default sorts + * + * Prevents the default sort rules of the source model from being used + * + * @param bool $disable + * + * @return $this + */ + public function disableDefaultSort($disable = true) + { + $this->disableDefaultSort = (bool) $disable; + + return $this; + } + + /** + * Get whether to not use the default sort rules of the source model + * + * @return bool + */ + public function defaultSortDisabled() + { + return $this->disableDefaultSort; + } + + + /** + * Set columns to select from the model (or its relations) + * + * By default, i.e. if you do not specify any columns, all columns of the model and + * any relation added via {@see with()} will be selected. + * Multiple calls to this method will overwrite the previously specified columns. + * If you specify columns from the model's relations, the relations are automatically joined upon querying. + * Note that a call to this method also overwrites any previously column specified via {@see withColumns()}. + * + * @param string|array $columns The column(s) to select + * + * @return $this + */ + public function columns($columns) + { + $this->columns = (array) $columns; + $this->withColumns = []; + + return $this; + } + + /** + * Set additional columns to select from the model (or its relations) + * + * Multiple calls to this method will not overwrite the previous set columns but append the columns to the query. + * + * @param string|array $columns The column(s) to select + * + * @return $this + */ + public function withColumns($columns) + { + $this->withColumns = array_merge($this->withColumns, (array) $columns); + + return $this; + } + + /** + * Get the query's resolver + * + * @return Resolver + */ + public function getResolver() + { + if ($this->resolver === null) { + $this->resolver = new Resolver($this); + } + + return $this->resolver; + } + + /** + * Get the SELECT base query + * + * @return Select + */ + public function getSelectBase() + { + if ($this->selectBase === null) { + $this->selectBase = new Select(); + + $this->selectBase->from([ + $this->getResolver()->getAlias($this->getModel()) => $this->getModel()->getTableName() + ]); + } + + return $this->selectBase; + } + + /** + * Get the relations to eager load + * + * @return Relation[] + */ + public function getWith() + { + return $this->with; + } + + /** + * Add a relation to eager load + * + * @param string|array $relations + * + * @return $this + */ + public function with($relations) + { + $tableName = $this->getModel()->getTableName(); + foreach ((array) $relations as $relation) { + $relation = $this->getResolver()->qualifyPath($relation, $tableName); + $this->with[$relation] = $this->getResolver()->resolveRelation($relation); + } + + return $this; + } + + /** + * Remove an eager loaded relation + * + * @param string|array $relations + * + * @return $this + */ + public function without($relations) + { + $tableName = $this->getModel()->getTableName(); + foreach ((array) $relations as $relation) { + $relation = $this->getResolver()->qualifyPath($relation, $tableName); + unset($this->with[$relation]); + } + + return $this; + } + + /** + * Get utilized (joined) relations + * + * @return Relation[] + */ + public function getUtilize() + { + return $this->utilize; + } + + /** + * Add a relation to utilize (join) + * + * @param string $path + * + * @return $this + */ + public function utilize($path) + { + $path = $this->getResolver()->qualifyPath($path, $this->getModel()->getTableName()); + $this->utilize[$path] = $this->getResolver()->resolveRelation($path); + + return $this; + } + + /** + * Remove a utilized (joined) relation + * + * @param string $path + * + * @return $this + */ + public function omit($path) + { + $path = $this->getResolver()->qualifyPath($path, $this->getModel()->getTableName()); + unset($this->utilize[$path]); + + return $this; + } + + /** + * Assemble and return the SELECT query + * + * @return Select + */ + public function assembleSelect() + { + $columns = $this->getColumns(); + $model = $this->getModel(); + $resolver = $this->getResolver(); + $select = clone $this->getSelectBase(); + + if (empty($columns)) { + $columns = $resolver->getSelectColumns($model); + + foreach ($this->getWith() as $path => $relation) { + foreach ($resolver->getSelectColumns($relation->getTarget()) as $alias => $column) { + if (is_int($alias)) { + $columns[] = "$path.$column"; + } else { + $columns[] = "$path.$alias"; + } + } + } + + $columns = array_merge($columns, $this->withColumns); + $customAliases = array_flip(array_filter(array_keys($this->withColumns), 'is_string')); + } else { + $columns = array_merge($columns, $this->withColumns); + $customAliases = array_flip(array_filter(array_keys($columns), 'is_string')); + } + + $resolved = $this->groupColumnsByTarget($resolver->requireAndResolveColumns($columns)); + foreach ($resolved as $target) { + $targetColumns = $resolved[$target]->getArrayCopy(); + if (! empty($customAliases)) { + $customColumns = array_intersect_key($targetColumns, $customAliases); + $targetColumns = array_diff_key($targetColumns, $customAliases); + + $select->columns($resolver->qualifyColumns($customColumns, $target)); + } + + $select->columns( + $resolver->qualifyColumnsAndAliases( + $targetColumns, + $target, + $target !== $model + ) + ); + } + + $filter = clone $this->getFilter(); + FilterProcessor::resolveFilter($filter, $this); + $where = FilterProcessor::assembleFilter($filter); + if ($where) { + $select->where(...array_reverse($where)); + } + + $joinedRelations = []; + foreach ($this->getWith() + $this->getUtilize() as $path => $_) { + foreach ($resolver->resolveRelations($path) as $relationPath => $relation) { + if (isset($joinedRelations[$relationPath])) { + continue; + } + + foreach ($relation->resolve() as list($source, $target, $relatedKeys)) { + /** @var Model $source */ + /** @var Model $target */ + + $sourceAlias = $resolver->getAlias($source); + $targetAlias = $resolver->getAlias($target); + + $conditions = []; + foreach ($relatedKeys as $fk => $ck) { + $conditions[] = sprintf( + '%s = %s', + $resolver->qualifyColumn($fk, $targetAlias), + $resolver->qualifyColumn($ck, $sourceAlias) + ); + } + + $table = [$targetAlias => $target->getTableName()]; + + switch ($relation->getJoinType()) { + case 'LEFT': + $select->joinLeft($table, $conditions); + + break; + case 'RIGHT': + $select->joinRight($table, $conditions); + + break; + case 'INNER': + default: + $select->join($table, $conditions); + } + } + + $joinedRelations[$relationPath] = true; + } + } + + if ($this->hasLimit()) { + $limit = $this->getLimit(); + + if ($this->peekAhead) { + ++$limit; + } + + $select->limit($limit); + } + if ($this->hasOffset()) { + $select->offset($this->getOffset()); + } + + $this->order($select); + + $this->emit(static::ON_SELECT_ASSEMBLED, [$select]); + + return $select; + } + + /** + * Create and return the hydrator + * + * @return Hydrator + */ + public function createHydrator() + { + $hydrator = new Hydrator($this); + + $hydrator->add($this->getModel()->getTableName()); + foreach ($this->getWith() as $path => $_) { + $hydrator->add($path); + } + + return $hydrator; + } + + /** + * Derive a new query to load the specified relation from a concrete model + * + * @param string $relation + * @param Model $source + * + * @return static + * + * @throws InvalidArgumentException If the relation with the given name does not exist + */ + public function derive($relation, Model $source) + { + // TODO: Think of a way to merge derive() and createSubQuery() + return $this->createSubQuery( + $this->getResolver()->getRelations($source)->get($relation)->getTarget(), + $this->getResolver()->qualifyPath($relation, $source->getTableName()), + $source + ); + } + + /** + * Create a sub-query linked to rows of this query + * + * @param Model $target The model to query + * @param string $targetPath The target's absolute relation path + * @param Model $from The source model + * + * @return static + */ + public function createSubQuery(Model $target, $targetPath, Model $from = null) + { + $subQuery = (new static()) + ->setDb($this->getDb()) + ->setModel($target); + + $resolver = $this->getResolver(); + $sourceParts = array_reverse(explode('.', $targetPath)); + $sourceParts[0] = $target->getTableName(); + + $subQueryResolver = $subQuery->getResolver(); + $sourcePath = join('.', $sourceParts); + $subQuery->utilize($sourcePath); // TODO: Don't join if there's a matching foreign key + + // TODO: Should be done by the caller. Though, that's not possible until we've got a filter abstraction + // which allows to post-pone filter column qualification. + $subQueryResolver->setAliasPrefix('sub_'); + + $baseAlias = $resolver->getAlias($this->getModel()); + $sourceAlias = $subQueryResolver->getAlias($subQueryResolver->resolveRelation($sourcePath)->getTarget()); + + $subQueryConditions = []; + foreach ((array) $this->getModel()->getKeyName() as $column) { + $fk = $subQueryResolver->qualifyColumn($column, $sourceAlias); + + if (isset($from->$column)) { + $subQueryConditions["$fk = ?"] = $resolver + ->getBehaviors($from) + ->persistProperty($from->$column, $column); + } else { + $subQueryConditions[] = "$fk = " . $resolver->qualifyColumn($column, $baseAlias); + } + } + + $subQuery->getSelectBase()->where($subQueryConditions); + + return $subQuery; + } + + /** + * Dump the query + * + * @return array + */ + public function dump() + { + return $this->getDb()->getQueryBuilder()->assembleSelect($this->assembleSelect()); + } + + /** + * Execute the query + * + * @return ResultSet + */ + public function execute() + { + $class = $this->getResultSetClass(); + /** @var ResultSet $class Just for type hinting. $class is of course a string */ + + return $class::fromQuery($this); + } + + /** + * Fetch and return the first result + * + * @return Model|null Null in case there's no result + */ + public function first() + { + return $this->execute()->current(); + } + + /** + * Set whether to peek ahead for more results + * + * Enabling this causes the current query limit to be increased by one. The potential extra row being yielded will + * be removed from the result set. Note that this only applies when fetching multiple results of limited queries. + * + * @param bool $peekAhead + * + * @return $this + */ + public function peekAhead($peekAhead = true) + { + $this->peekAhead = (bool) $peekAhead; + + return $this; + } + + /** + * Yield the query's results + * + * @return \Generator + */ + public function yieldResults() + { + $select = $this->assembleSelect(); + $stmt = $this->getDb()->select($select); + $stmt->setFetchMode(\PDO::FETCH_ASSOC); + + $hydrator = $this->createHydrator(); + $modelClass = get_class($this->getModel()); + + foreach ($stmt as $row) { + yield $hydrator->hydrate($row, new $modelClass()); + } + } + + public function count(): int + { + if ($this->count === null) { + $this->count = $this->getDb()->select($this->assembleSelect()->getCountQuery())->fetchColumn(0); + } + + return $this->count; + } + + public function getIterator(): Traversable + { + return $this->execute(); + } + + /** + * Group columns from {@link Resolver::requireAndResolveColumns()} by target models + * + * @param Generator $columns + * + * @return SplObjectStorage + */ + protected function groupColumnsByTarget(Generator $columns) + { + $columnStorage = new SplObjectStorage(); + + foreach ($columns as list($target, $alias, $column)) { + if (! $columnStorage->contains($target)) { + $resolved = new ArrayObject(); + $columnStorage->attach($target, $resolved); + } else { + $resolved = $columnStorage[$target]; + } + + if (is_int($alias)) { + $resolved[] = $column; + } else { + $resolved[$alias] = $column; + } + } + + return $columnStorage; + } + + /** + * Resolve, require and apply ORDER BY columns + * + * @param Select $select + * + * @return $this + */ + protected function order(Select $select) + { + $orderBy = $this->getOrderBy(); + $defaultSort = []; + if (! $this->defaultSortDisabled()) { + $defaultSort = (array) $this->getModel()->getDefaultSort(); + } + + if (empty($orderBy)) { + if (empty($defaultSort)) { + return $this; + } + + $orderBy = SortUtil::createOrderBy($defaultSort); + } + + $columnsAndDirections = []; + $orderByResolved = []; + $resolver = $this->getResolver(); + $selectedColumns = $select->getColumns(); + + foreach ($orderBy as $part) { + list($column, $direction) = $part; + + if (isset($selectedColumns[$column])) { + // If it's a custom alias, we have no other way of knowing it, + // unless the caller explicitly uses it in the sort rule. + $orderByResolved[] = $part; + } else { + // Prepare flat ORDER BY column(s) and direction(s) for requireAndResolveColumns() + $columnsAndDirections[$column] = $direction; + } + } + + foreach ( + $resolver->requireAndResolveColumns(array_keys($columnsAndDirections)) as list($model, $alias, $column) + ) { + $direction = reset($columnsAndDirections); + $selectColumns = $resolver->getSelectColumns($model); + $tableName = $resolver->getAlias($model); + + if ($column instanceof ExpressionInterface) { + if (is_int($alias) && $column instanceof AliasedExpression) { + $alias = $column->getAlias(); + } elseif (is_string($alias) && $model !== $this->getModel()) { + $alias = $resolver->qualifyColumnAlias($alias, $tableName); + } + + if (is_string($alias) && isset($selectedColumns[$alias])) { + // An expression's alias can only be used if the expression is also selected + $column = $alias; + } + } else { + if (isset($selectColumns[$column])) { + $column = $selectColumns[$column]; + } + + if (is_string($column)) { + $column = $resolver->qualifyColumn($column, $tableName); + } + } + + $orderByResolved[] = [$column, $direction]; + + array_shift($columnsAndDirections); + } + + $select->orderBy($orderByResolved); + + return $this; + } + + public function __clone() + { + $this->resolver = clone $this->resolver; + + if ($this->filter !== null) { + $this->filter = clone $this->filter; + } + + if ($this->selectBase !== null) { + $this->selectBase = clone $this->selectBase; + } + } +} diff --git a/vendor/ipl/orm/src/Relation.php b/vendor/ipl/orm/src/Relation.php new file mode 100644 index 0000000..1959363 --- /dev/null +++ b/vendor/ipl/orm/src/Relation.php @@ -0,0 +1,336 @@ +<?php + +namespace ipl\Orm; + +use function ipl\Stdlib\get_php_type; + +/** + * Relations represent the connection between models, i.e. the association between rows in one or more tables + * on the basis of matching key columns. The relationships are defined using candidate key-foreign key constructs. + */ +class Relation +{ + /** @var string Name of the relation */ + protected $name; + + /** @var Model Source model */ + protected $source; + + /** @var string|array Column name(s) of the foreign key found in the target table */ + protected $foreignKey; + + /** @var string|array Column name(s) of the candidate key in the source table which references the foreign key */ + protected $candidateKey; + + /** @var string Target model class */ + protected $targetClass; + + /** @var Model Target model */ + protected $target; + + /** @var string Type of the JOIN used in the query */ + protected $joinType = 'INNER'; + + /** @var bool Whether this is the inverse of a relationship */ + protected $inverse; + + /** @var bool Whether this is a to-one relationship */ + protected $isOne = true; + + /** + * Get the default column name(s) in the source table used to match the foreign key + * + * The default candidate key is the primary key column name(s) of the given model. + * + * @param Model $source + * + * @return array + */ + public static function getDefaultCandidateKey(Model $source) + { + return (array) $source->getKeyName(); + } + + /** + * Get the default column name(s) of the foreign key found in the target table + * + * The default foreign key is the given model's primary key column name(s) prefixed with its table name. + * + * @param Model $source + * + * @return array + */ + public static function getDefaultForeignKey(Model $source) + { + $tableName = $source->getTableName(); + + return array_map( + function ($key) use ($tableName) { + return "{$tableName}_{$key}"; + }, + (array) $source->getKeyName() + ); + } + + /** + * Get whether this is a to-one relationship + * + * @return bool + */ + public function isOne() + { + return $this->isOne; + } + + /** + * Get the name of the relation + * + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Set the name of the relation + * + * @param string $name + * + * @return $this + */ + public function setName($name) + { + $this->name = $name; + + return $this; + } + + /** + * Get the source model of the relation + * + * @return Model + */ + public function getSource() + { + return $this->source; + } + + /** + * Set the source model of the relation + * + * @param Model $source + * + * @return $this + */ + public function setSource(Model $source) + { + $this->source = $source; + + return $this; + } + + /** + * Get the column name(s) of the foreign key found in the target table + * + * @return string|array Array if the foreign key is compound, string otherwise + */ + public function getForeignKey() + { + return $this->foreignKey; + } + + /** + * Set the column name(s) of the foreign key found in the target table + * + * @param string|array $foreignKey Array if the foreign key is compound, string otherwise + * + * @return $this + */ + public function setForeignKey($foreignKey) + { + $this->foreignKey = $foreignKey; + + return $this; + } + + /** + * Get the column name(s) of the candidate key in the source table which references the foreign key + * + * @return string|array Array if the candidate key is compound, string otherwise + */ + public function getCandidateKey() + { + return $this->candidateKey; + } + + /** + * Set the column name(s) of the candidate key in the source table which references the foreign key + * + * @param string|array $candidateKey Array if the candidate key is compound, string otherwise + * + * @return $this + */ + public function setCandidateKey($candidateKey) + { + $this->candidateKey = $candidateKey; + + return $this; + } + + /** + * Get the target model class + * + * @return string + */ + public function getTargetClass() + { + return $this->targetClass; + } + + /** + * Set the target model class + * + * @param string $targetClass + * + * @return $this + * + * @throws \InvalidArgumentException If the target model class is not of type string + */ + public function setTargetClass($targetClass) + { + if (! is_string($targetClass)) { + // Require a class name here instead of a concrete model in oder to prevent circular references when + // constructing relations + throw new \InvalidArgumentException(sprintf( + '%s() expects parameter 1 to be string, %s given', + __METHOD__, + get_php_type($targetClass) + )); + } + + $this->targetClass = $targetClass; + + return $this; + } + + /** + * Get the target model + * + * Returns the model from {@link setTarget()} or an instance of {@link getTargetClass()}. + * Note that multiple calls to this method always returns the very same model instance. + * + * @return Model + */ + public function getTarget() + { + if ($this->target === null) { + $targetClass = $this->getTargetClass(); + $this->target = new $targetClass(); + } + + return $this->target; + } + + /** + * Set the the target model + * + * @param Model $target + * + * @return $this + */ + public function setTarget(Model $target) + { + $this->target = $target; + + return $this; + } + + /** + * Get the type of the JOIN used in the query + * + * @return string + */ + public function getJoinType() + { + return $this->joinType; + } + + /** + * Set the type of the JOIN used in the query + * + * @param string $joinType + * + * @return Relation + */ + public function setJoinType($joinType) + { + $this->joinType = $joinType; + + return $this; + } + + /** + * Determine the candidate key-foreign key construct of the relation + * + * @param Model $source + * + * @return array Candidate key-foreign key column name pairs + * + * @throws \UnexpectedValueException If there's no candidate key to be found + * or the foreign key count does not match the candidate key count + */ + public function determineKeys(Model $source) + { + $candidateKey = (array) $this->getCandidateKey(); + + if (empty($candidateKey)) { + $candidateKey = $this->inverse + ? static::getDefaultForeignKey($this->getTarget()) + : static::getDefaultCandidateKey($source); + } + + if (empty($candidateKey)) { + throw new \UnexpectedValueException(sprintf( + "Can't join relation '%s' in model '%s'. No candidate key found.", + $this->getName(), + get_class($source) + )); + } + + $foreignKey = (array) $this->getForeignKey(); + + if (empty($foreignKey)) { + $foreignKey = $this->inverse + ? static::getDefaultCandidateKey($this->getTarget()) + : static::getDefaultForeignKey($source); + } + + if (count($foreignKey) !== count($candidateKey)) { + throw new \UnexpectedValueException(sprintf( + "Can't join relation '%s' in model '%s'." + . " Foreign key count (%s) does not match candidate key count (%s).", + $this->getName(), + get_class($source), + implode(', ', $foreignKey), + implode(', ', $candidateKey) + )); + } + + return array_combine($foreignKey, $candidateKey); + } + + /** + * Resolve the relation + * + * Yields a three-element array consisting of the source model, target model and the join keys. + * + * @return \Generator + */ + public function resolve() + { + $source = $this->getSource(); + + yield [$source, $this->getTarget(), $this->determineKeys($source)]; + } +} diff --git a/vendor/ipl/orm/src/Relation/BelongsTo.php b/vendor/ipl/orm/src/Relation/BelongsTo.php new file mode 100644 index 0000000..1edcff3 --- /dev/null +++ b/vendor/ipl/orm/src/Relation/BelongsTo.php @@ -0,0 +1,13 @@ +<?php + +namespace ipl\Orm\Relation; + +use ipl\Orm\Relation; + +/** + * Inverse of a one-to-one or one-to-many relationship + */ +class BelongsTo extends Relation +{ + protected $inverse = true; +} diff --git a/vendor/ipl/orm/src/Relation/BelongsToMany.php b/vendor/ipl/orm/src/Relation/BelongsToMany.php new file mode 100644 index 0000000..132fe55 --- /dev/null +++ b/vendor/ipl/orm/src/Relation/BelongsToMany.php @@ -0,0 +1,199 @@ +<?php + +namespace ipl\Orm\Relation; + +use ipl\Orm\Model; +use ipl\Orm\Relation; +use ipl\Orm\Relations; + +use function ipl\Stdlib\get_php_type; + +/** + * Many-to-many relationship + */ +class BelongsToMany extends Relation +{ + protected $isOne = false; + + /** @var string Name of the join table or junction model class */ + protected $throughClass; + + /** @var Model The junction model */ + protected $through; + + /** @var string|array Column name(s) of the target model's foreign key found in the join table */ + protected $targetForeignKey; + + /** @var string|array Candidate key column name(s) in the target table which references the target foreign key */ + protected $targetCandidateKey; + + /** + * Get the name of the join table or junction model class + * + * @return string + */ + public function getThroughClass() + { + return $this->throughClass; + } + + /** + * Set the join table name or junction model class + * + * @param string $through + * + * @return $this + */ + public function through($through) + { + $this->throughClass = $through; + + return $this; + } + + /** + * Get the junction model + * + * @return Model|Junction + */ + public function getThrough() + { + if ($this->through === null) { + $throughClass = $this->getThroughClass(); + + if (class_exists($throughClass)) { + $this->through = new $throughClass(); + } else { + $this->through = (new Junction()) + ->setTableName($throughClass); + } + } + + return $this->through; + } + + /** + * Set the junction model + * + * @param Model $through + * + * @return $this + */ + public function setThrough($through) + { + $this->through = $through; + + return $this; + } + + /** + * Get the column name(s) of the target model's foreign key found in the join table + * + * @return string|array Array if the foreign key is compound, string otherwise + */ + public function getTargetForeignKey() + { + return $this->targetForeignKey; + } + + /** + * Set the column name(s) of the target model's foreign key found in the join table + * + * @param string|array $targetForeignKey Array if the foreign key is compound, string otherwise + * + * @return $this + */ + public function setTargetForeignKey($targetForeignKey) + { + $this->targetForeignKey = $targetForeignKey; + + return $this; + } + + /** + * Get the candidate key column name(s) in the target table which references the target foreign key + * + * @return string|array Array if the foreign key is compound, string otherwise + */ + public function getTargetCandidateKey() + { + return $this->targetCandidateKey; + } + + /** + * Set the candidate key column name(s) in the target table which references the target foreign key + * + * @param string|array $targetCandidateKey Array if the foreign key is compound, string otherwise + * + * @return $this + */ + public function setTargetCandidateKey($targetCandidateKey) + { + $this->targetCandidateKey = $targetCandidateKey; + + return $this; + } + + public function resolve() + { + $source = $this->getSource(); + + $possibleCandidateKey = [$this->getCandidateKey()]; + $possibleForeignKey = [$this->getForeignKey()]; + + $target = $this->getTarget(); + + $possibleTargetCandidateKey = [$this->getTargetForeignKey() ?: static::getDefaultForeignKey($target)]; + $possibleTargetForeignKey = [$this->getTargetCandidateKey() ?: static::getDefaultCandidateKey($target)]; + + $junction = $this->getThrough(); + + if (! $junction instanceof Junction) { + $relations = new Relations(); + $junction->createRelations($relations); + + if ($relations->has($source->getTableName())) { + $sourceRelation = $relations->get($source->getTableName()); + + $possibleCandidateKey[] = $sourceRelation->getForeignKey(); + $possibleForeignKey[] = $sourceRelation->getCandidateKey(); + } + + if ($relations->has($target->getTableName())) { + $targetRelation = $relations->get($target->getTableName()); + + $possibleTargetCandidateKey[] = $targetRelation->getCandidateKey(); + $possibleTargetForeignKey[] = $targetRelation->getForeignKey(); + } + } + + $toJunction = (new HasMany()) + ->setName($junction->getTableName()) + ->setSource($source) + ->setTarget($junction) + ->setCandidateKey($this->extractKey($possibleCandidateKey)) + ->setForeignKey($this->extractKey($possibleForeignKey)); + + $toTarget = (new HasMany()) + ->setName($this->getName()) + ->setSource($junction) + ->setTarget($target) + ->setCandidateKey($this->extractKey($possibleTargetCandidateKey)) + ->setForeignKey($this->extractKey($possibleTargetForeignKey)); + + foreach ($toJunction->resolve() as $k => $v) { + yield $k => $v; + } + + foreach ($toTarget->resolve() as $k => $v) { + yield $k => $v; + } + } + + protected function extractKey(array $possibleKey) + { + $filtered = array_filter($possibleKey); + + return array_pop($filtered); + } +} diff --git a/vendor/ipl/orm/src/Relation/HasMany.php b/vendor/ipl/orm/src/Relation/HasMany.php new file mode 100644 index 0000000..3e71e25 --- /dev/null +++ b/vendor/ipl/orm/src/Relation/HasMany.php @@ -0,0 +1,13 @@ +<?php + +namespace ipl\Orm\Relation; + +use ipl\Orm\Relation; + +/** + * One-to-many relationship + */ +class HasMany extends Relation +{ + protected $isOne = false; +} diff --git a/vendor/ipl/orm/src/Relation/HasOne.php b/vendor/ipl/orm/src/Relation/HasOne.php new file mode 100644 index 0000000..8f7a802 --- /dev/null +++ b/vendor/ipl/orm/src/Relation/HasOne.php @@ -0,0 +1,12 @@ +<?php + +namespace ipl\Orm\Relation; + +use ipl\Orm\Relation; + +/** + * One-to-one relationship + */ +class HasOne extends Relation +{ +} diff --git a/vendor/ipl/orm/src/Relation/Junction.php b/vendor/ipl/orm/src/Relation/Junction.php new file mode 100644 index 0000000..9e23bb2 --- /dev/null +++ b/vendor/ipl/orm/src/Relation/Junction.php @@ -0,0 +1,43 @@ +<?php + +namespace ipl\Orm\Relation; + +use ipl\Orm\Model; + +/** + * Junction model for many-to-many relations + */ +class Junction extends Model +{ + /** @var string */ + protected $tableName; + + public function getTableName() + { + return $this->tableName; + } + + /** + * Set the table name + * + * @param string $tableName + * + * @return $this + */ + public function setTableName($tableName) + { + $this->tableName = $tableName; + + return $this; + } + + public function getKeyName() + { + return null; + } + + public function getColumns() + { + return null; + } +} diff --git a/vendor/ipl/orm/src/Relations.php b/vendor/ipl/orm/src/Relations.php new file mode 100644 index 0000000..340435f --- /dev/null +++ b/vendor/ipl/orm/src/Relations.php @@ -0,0 +1,212 @@ +<?php + +namespace ipl\Orm; + +use ArrayIterator; +use ipl\Orm\Exception\InvalidRelationException; +use ipl\Orm\Relation\BelongsTo; +use ipl\Orm\Relation\BelongsToMany; +use ipl\Orm\Relation\HasMany; +use ipl\Orm\Relation\HasOne; +use IteratorAggregate; +use Traversable; + +use function ipl\Stdlib\get_php_type; + +/** + * Collection of a model's relations. + */ +class Relations implements IteratorAggregate +{ + /** @var Relation[] */ + protected $relations = []; + + /** + * Get whether a relation with the given name exists + * + * @param string $name + * + * @return bool + */ + public function has($name) + { + return isset($this->relations[$name]); + } + + /** + * Get the relation with the given name + * + * @param string $name + * + * @return Relation + * + * @throws \InvalidArgumentException If the relation with the given name does not exist + */ + public function get($name) + { + $this->assertRelationExists($name); + + return $this->relations[$name]; + } + + /** + * Add the given relation to the collection + * + * @param Relation $relation + * + * @return $this + * + * @throws \InvalidArgumentException If a relation with the given name already exists + */ + public function add(Relation $relation) + { + $name = $relation->getName(); + + $this->assertRelationDoesNotYetExist($name); + + $this->relations[$name] = $relation; + + return $this; + } + + /** + * Create a new relation from the given class, name and target model class + * + * @param string $class Class of the relation to create + * @param string $name Name of the relation + * @param string $targetClass Target model class + * + * @return BelongsTo|BelongsToMany|HasMany|HasOne|Relation + * + * @throws \InvalidArgumentException If the target model class is not of type string + */ + public function create($class, $name, $targetClass) + { + $relation = new $class(); + + if (! $relation instanceof Relation) { + throw new \InvalidArgumentException(sprintf( + '%s() expects parameter 1 to be a subclass of %s, %s given', + __METHOD__, + Relation::class, + get_php_type($relation) + )); + } + + // Test target model + $target = new $targetClass(); + if (! $target instanceof Model) { + throw new \InvalidArgumentException(sprintf( + '%s() expects parameter 3 to be a subclass of %s, %s given', + __METHOD__, + Model::class, + get_php_type($target) + )); + } + + /** @var Relation $relation */ + $relation + ->setName($name) + ->setTarget($target) + ->setTargetClass($targetClass); + + return $relation; + } + + /** + * Define a one-to-one relationship + * + * @param string $name Name of the relation + * @param string $targetClass Target model class + * + * @return HasOne + */ + public function hasOne($name, $targetClass) + { + $relation = $this->create(HasOne::class, $name, $targetClass); + + $this->add($relation); + + return $relation; + } + + /** + * Define a one-to-many relationship + * + * @param string $name Name of the relation + * @param string $targetClass Target model class + * + * @return HasMany + */ + public function hasMany($name, $targetClass) + { + $relation = $this->create(HasMany::class, $name, $targetClass); + + $this->add($relation); + + return $relation; + } + + /** + * Define the inverse of a one-to-one or one-to-many relationship + * + * @param string $name Name of the relation + * @param string $targetClass Target model class + * + * @return BelongsTo + */ + public function belongsTo($name, $targetClass) + { + $relation = $this->create(BelongsTo::class, $name, $targetClass); + + $this->add($relation); + + return $relation; + } + + /** + * Define a many-to-many relationship + * + * @param string $name Name of the relation + * @param string $targetClass Target model class + * + * @return BelongsToMany + */ + public function belongsToMany($name, $targetClass) + { + $relation = $this->create(BelongsToMany::class, $name, $targetClass); + + $this->add($relation); + + return $relation; + } + + public function getIterator(): Traversable + { + return new ArrayIterator($this->relations); + } + + /** + * Throw exception if a relation with the given name already exists + * + * @param string $name + */ + protected function assertRelationDoesNotYetExist($name) + { + if ($this->has($name)) { + throw new \InvalidArgumentException("Relation '$name' already exists"); + } + } + + /** + * Throw exception if a relation with the given name does not exist + * + * @param string $name + */ + protected function assertRelationExists($name) + { + if (! $this->has($name)) { + throw new InvalidRelationException($name); + } + } +} diff --git a/vendor/ipl/orm/src/ResolvedExpression.php b/vendor/ipl/orm/src/ResolvedExpression.php new file mode 100644 index 0000000..86883da --- /dev/null +++ b/vendor/ipl/orm/src/ResolvedExpression.php @@ -0,0 +1,49 @@ +<?php + +namespace ipl\Orm; + +use Generator; +use ipl\Sql\Expression; +use ipl\Sql\ExpressionInterface; +use RuntimeException; + +class ResolvedExpression extends Expression +{ + /** @var Generator */ + protected $resolvedColumns; + + /** + * Create a resolved database expression + * + * @param ExpressionInterface $expr The original expression + * @param Generator $resolvedColumns The generator as returned by {@see Resolver::requireAndResolveColumns()} + */ + public function __construct(ExpressionInterface $expr, Generator $resolvedColumns) + { + parent::__construct($expr->getStatement(), $expr->getColumns(), ...$expr->getValues()); + + $this->resolvedColumns = $resolvedColumns; + } + + /** + * @throws RuntimeException In case the columns are not qualified yet + */ + public function getColumns() + { + if ($this->resolvedColumns->valid()) { + throw new RuntimeException('Columns are not yet qualified'); + } + + return parent::getColumns(); + } + + /** + * Get the resolved column generator + * + * @return Generator + */ + public function getResolvedColumns() + { + return $this->resolvedColumns; + } +} diff --git a/vendor/ipl/orm/src/Resolver.php b/vendor/ipl/orm/src/Resolver.php new file mode 100644 index 0000000..a910716 --- /dev/null +++ b/vendor/ipl/orm/src/Resolver.php @@ -0,0 +1,783 @@ +<?php + +namespace ipl\Orm; + +use AppendIterator; +use ArrayIterator; +use Generator; +use InvalidArgumentException; +use ipl\Orm\Contract\QueryAwareBehavior; +use ipl\Orm\Exception\InvalidColumnException; +use ipl\Orm\Exception\InvalidRelationException; +use ipl\Orm\Relation\BelongsToMany; +use ipl\Sql\ExpressionInterface; +use LogicException; +use OutOfBoundsException; +use SplObjectStorage; + +use function ipl\Stdlib\get_php_type; + +/** + * Column and relation resolver. Acts as glue between queries and models + */ +class Resolver +{ + /** @var Query The query to resolve */ + protected $query; + + /** @var SplObjectStorage Model relations */ + protected $relations; + + /** @var SplObjectStorage Model behaviors */ + protected $behaviors; + + /** @var SplObjectStorage Model defaults */ + protected $defaults; + + /** @var SplObjectStorage Model aliases */ + protected $aliases; + + /** @var string The alias prefix to use */ + protected $aliasPrefix; + + /** @var SplObjectStorage Selectable columns from resolved models */ + protected $selectableColumns; + + /** @var SplObjectStorage Select columns from resolved models */ + protected $selectColumns; + + /** @var SplObjectStorage Meta data from models and their direct relations */ + protected $metaData; + + /** @var SplObjectStorage Resolved relations */ + protected $resolvedRelations; + + /** + * Create a new resolver + * + * @param Query $query The query to resolve + */ + public function __construct(Query $query) + { + $this->query = $query; + + $this->relations = new SplObjectStorage(); + $this->behaviors = new SplObjectStorage(); + $this->defaults = new SplObjectStorage(); + $this->aliases = new SplObjectStorage(); + $this->selectableColumns = new SplObjectStorage(); + $this->selectColumns = new SplObjectStorage(); + $this->metaData = new SplObjectStorage(); + $this->resolvedRelations = new SplObjectStorage(); + } + + /** + * Get a model's relations + * + * @param Model $model + * + * @return Relations + */ + public function getRelations(Model $model) + { + if (! $this->relations->contains($model)) { + $relations = new Relations(); + $model->createRelations($relations); + $this->relations->attach($model, $relations); + } + + return $this->relations[$model]; + } + + /** + * Get a model's behaviors + * + * @param Model $model + * + * @return Behaviors + */ + public function getBehaviors(Model $model) + { + if (! $this->behaviors->contains($model)) { + $behaviors = new Behaviors(); + $model->createBehaviors($behaviors); + $this->behaviors->attach($model, $behaviors); + + foreach ($behaviors as $behavior) { + if ($behavior instanceof QueryAwareBehavior) { + $behavior->setQuery($this->query); + } + } + } + + return $this->behaviors[$model]; + } + + /** + * Get a model's defaults + * + * @param Model $model + * + * @return Defaults + */ + public function getDefaults(Model $model): Defaults + { + if (! $this->defaults->contains($model)) { + $defaults = new Defaults($this->query); + $model->createDefaults($defaults); + $this->defaults->attach($model, $defaults); + } + + return $this->defaults[$model]; + } + + /** + * Get a model alias + * + * @param Model $model + * + * @return string + * + * @throws OutOfBoundsException If no alias exists for the given model + */ + public function getAlias(Model $model) + { + if (! $this->aliases->contains($model)) { + throw new OutOfBoundsException(sprintf( + "Can't get alias for model '%s'. Alias does not exist", + get_class($model) + )); + } + + return $this->aliasPrefix . $this->aliases[$model]; + } + + /** + * Set a model alias + * + * @param Model $model + * @param string $alias + * + * @return $this + */ + public function setAlias(Model $model, $alias) + { + $this->aliases[$model] = $alias; + + return $this; + } + + /** + * Get the alias prefix + * + * @return string + */ + public function getAliasPrefix() + { + return $this->aliasPrefix; + } + + /** + * Set the alias prefix + * + * @param string $alias + * + * @return $this + */ + public function setAliasPrefix($alias) + { + $this->aliasPrefix = $alias; + + return $this; + } + + /** + * Get whether the specified model provides the given selectable column + * + * @param Model $subject + * @param string $column + * + * @return bool + */ + public function hasSelectableColumn(Model $subject, $column) + { + if (! $this->selectableColumns->contains($subject)) { + $this->collectColumns($subject); + } + + $columns = $this->selectableColumns[$subject]; + if (! isset($columns[$column])) { + $columns[$column] = $this->getBehaviors($subject)->isSelectableColumn($column); + } + + return $columns[$column]; + } + + /** + * Get all selectable columns from the given model + * + * @param Model $subject + * + * @return array + */ + public function getSelectableColumns(Model $subject) + { + if (! $this->selectableColumns->contains($subject)) { + $this->collectColumns($subject); + } + + return array_keys($this->selectableColumns[$subject]); + } + + /** + * Get all select columns from the given model + * + * @param Model $subject + * + * @return array Select columns suitable for {@link \ipl\Sql\Select::columns()} + */ + public function getSelectColumns(Model $subject) + { + if (! $this->selectColumns->contains($subject)) { + $this->collectColumns($subject); + } + + return $this->selectColumns[$subject]; + } + + /** + * Get all meta data from the given model and its direct relations + * + * @param Model $subject + * + * @return array Column paths as keys (relative to $subject) and their meta data as values + */ + public function getColumnDefinitions(Model $subject) + { + if (! $this->metaData->contains($subject)) { + $this->metaData->attach($subject, $this->collectMetaData($subject)); + } + + return $this->metaData[$subject]; + } + + /** + * Get definition of the given column + * + * @param string $columnPath + * + * @return ColumnDefinition + */ + public function getColumnDefinition(string $columnPath): ColumnDefinition + { + $parts = explode('.', $columnPath); + $model = $this->query->getModel(); + + if ($parts[0] !== $model->getTableName()) { + array_unshift($parts, $model->getTableName()); + } + + do { + $relationPath[] = array_shift($parts); + $column = implode('.', $parts); + + if (count($relationPath) === 1) { + $subject = $model; + } else { + $subject = $this->resolveRelation(implode('.', $relationPath))->getTarget(); + } + + if ($this->hasSelectableColumn($subject, $column)) { + break; + } + } while ($parts); + + $definition = $this->getColumnDefinitions($subject)[$column] ?? new ColumnDefinition($column); + $this->getBehaviors($subject)->rewriteColumnDefinition($definition, implode('.', $relationPath)); + + return $definition; + } + + /** + * Qualify the given alias by the specified table name + * + * @param string $alias + * @param string $tableName + * + * @return string + */ + public function qualifyColumnAlias($alias, $tableName) + { + return $tableName . '_' . $alias; + } + + /** + * Qualify the given column by the specified table name + * + * @param string $column + * @param string $tableName + * + * @return string + */ + public function qualifyColumn($column, $tableName) + { + return $tableName . '.' . $column; + } + + /** + * Qualify the given columns by the specified model + * + * @param iterable $columns + * @param Model $model Leave null in case $columns is {@see Resolver::requireAndResolveColumns()} + * + * @return array + * + * @throws InvalidArgumentException If $columns is not iterable + * @throws InvalidArgumentException If $model is not passed and $columns is not a generator + */ + public function qualifyColumns($columns, Model $model = null) + { + $target = $model ?: $this->query->getModel(); + $targetAlias = $this->getAlias($target); + + if (! is_iterable($columns)) { + throw new InvalidArgumentException( + sprintf('$columns is not iterable, got %s instead', get_php_type($columns)) + ); + } + + $qualified = []; + foreach ($columns as $alias => $column) { + if (is_int($alias) && is_array($column)) { + // $columns is $this->requireAndResolveColumns() + list($target, $alias, $columnName) = $column; + $targetAlias = $this->getAlias($target); + + // Thanks to PHP 5.6 where `list` is evaluated from right to left. It will extract + // the values for `$target` and `$alias` then from the third argument (`$column`). + $column = $columnName; + } elseif ($target === null) { + throw new InvalidArgumentException( + 'Passing no model is only possible if $columns is a generator' + ); + } + + if ($column instanceof ResolvedExpression) { + $column->setColumns($this->qualifyColumns($column->getResolvedColumns())); + } elseif ($column instanceof ExpressionInterface) { + $column = clone $column; // The expression may be part of a model and those shouldn't change implicitly + $column->setColumns($this->qualifyColumns($column->getColumns(), $target)); + } else { + $column = $this->qualifyColumn($column, $targetAlias); + } + + $qualified[$alias] = $column; + } + + return $qualified; + } + + /** + * Qualify the given columns and aliases by the specified model + * + * @param iterable $columns + * @param Model $model Leave null in case $columns is {@see Resolver::requireAndResolveColumns()} + * @param bool $autoAlias Set an alias for columns which have none + * + * @return array + * + * @throws InvalidArgumentException If $columns is not iterable + * @throws InvalidArgumentException If $model is not passed and $columns is not a generator + */ + public function qualifyColumnsAndAliases($columns, Model $model = null, $autoAlias = true) + { + $target = $model ?: $this->query->getModel(); + $targetAlias = $this->getAlias($target); + + if (! is_iterable($columns)) { + throw new InvalidArgumentException( + sprintf('$columns is not iterable, got %s instead', get_php_type($columns)) + ); + } + + $qualified = []; + foreach ($columns as $alias => $column) { + if (is_int($alias) && is_array($column)) { + // $columns is $this->requireAndResolveColumns() + list($target, $alias, $columnName) = $column; + $targetAlias = $this->getAlias($target); + + // Thanks to PHP 5.6 where `list` is evaluated from right to left. It will extract + // the values for `$target` and `$alias` then from the third argument (`$column`). + $column = $columnName; + } elseif ($target === null) { + throw new InvalidArgumentException( + 'Passing no model is only possible if $columns is a generator' + ); + } + + if (is_int($alias)) { + if ($column instanceof AliasedExpression) { + $alias = $column->getAlias(); + } elseif ($autoAlias && ! $column instanceof ExpressionInterface) { + $alias = $this->qualifyColumnAlias($column, $targetAlias); + } + } elseif ($target !== $this->query->getModel()) { + if (strpos($alias, '.') !== false) { + // This is safe, because custom aliases won't be qualified + $alias = str_replace('.', '_', $alias); + } else { + $alias = $this->qualifyColumnAlias($alias, $targetAlias); + } + } + + if ($column instanceof ResolvedExpression) { + $column->setColumns($this->qualifyColumns($column->getResolvedColumns())); + } elseif ($column instanceof ExpressionInterface) { + $column = clone $column; // The expression may be part of a model and those shouldn't change implicitly + $column->setColumns($this->qualifyColumns($column->getColumns(), $target)); + } else { + $column = $this->qualifyColumn($column, $targetAlias); + } + + $qualified[$alias] = $column; + } + + return $qualified; + } + + /** + * Qualify the given path by the specified table name + * + * @param string $path + * @param string $tableName + * + * @return string + */ + public function qualifyPath($path, $tableName) + { + $segments = explode('.', $path, 2); + + if ($segments[0] !== $tableName) { + array_unshift($segments, $tableName); + } + + $path = implode('.', $segments); + + return $path; + } + + /** + * Get whether the given relation path points to a distinct entity + * + * @param string $path + * + * @return bool + */ + public function isDistinctRelation($path) + { + foreach ($this->resolveRelations($path) as $relation) { + if (! $relation->isOne()) { + return false; + } + } + + return true; + } + + /** + * Resolve the rightmost relation of the given path + * + * Also resolves all other relations. + * + * @param string $path + * @param Model $subject + * + * @return Relation + */ + public function resolveRelation($path, Model $subject = null) + { + $subject = $subject ?: $this->query->getModel(); + if (! $this->resolvedRelations->contains($subject) || ! isset($this->resolvedRelations[$subject][$path])) { + foreach ($this->resolveRelations($path, $subject) as $_) { + // run and exhaust generator + } + } + + return $this->resolvedRelations[$subject][$path]; + } + + /** + * Resolve all relations of the given path + * + * Traverses the entire path and yields the path travelled so far as key and the relation as value. + * + * @param string $path + * @param Model $subject + * + * @return Generator + * @throws InvalidArgumentException In case $path is not fully qualified + * @throws InvalidRelationException In case a relation is unknown + */ + public function resolveRelations($path, Model $subject = null) + { + $relations = explode('.', $path); + $subject = $subject ?: $this->query->getModel(); + + if ($relations[0] !== $subject->getTableName()) { + throw new InvalidArgumentException(sprintf( + 'Cannot resolve relation path "%s". Base table name is missing.', + $path + )); + } + + $resolvedRelations = []; + if ($this->resolvedRelations->contains($subject)) { + $resolvedRelations = $this->resolvedRelations[$subject]; + } + + $target = $subject; + $pathBeingResolved = null; + $segments = [array_shift($relations)]; + while (! empty($relations)) { + $newPath = $this->getBehaviors($target) + ->rewritePath(join('.', $relations), join('.', $segments)); + if ($newPath !== null) { + $relations = explode('.', $newPath); + $pathBeingResolved = $path; + } + + $relationName = array_shift($relations); + $segments[] = $relationName; + $relationPath = join('.', $segments); + + if (isset($resolvedRelations[$relationPath])) { + $relation = $resolvedRelations[$relationPath]; + } else { + $targetRelations = $this->getRelations($target); + if (! $targetRelations->has($relationName)) { + throw new InvalidRelationException($relationName, $target); + } + + $relation = $targetRelations->get($relationName); + $relation->setSource($target); + + $resolvedRelations[$relationPath] = $relation; + + if ($relation instanceof BelongsToMany) { + $through = $relation->getThrough(); + $this->setAlias($through, join('_', array_merge( + array_slice($segments, 0, -1), + [$through->getTableName()] + ))); + } + + $this->setAlias($relation->getTarget(), join('_', $segments)); + } + + yield $relationPath => $relation; + + $target = $relation->getTarget(); + } + + if ($pathBeingResolved !== null) { + $resolvedRelations[$pathBeingResolved] = $relation; + } + + $this->resolvedRelations->attach($subject, $resolvedRelations); + } + + /** + * Require and resolve columns + * + * Related models will be automatically added for eager-loading. + * + * @param array $columns + * @param Model $model + * + * @return Generator + * + * @throws InvalidColumnException If a column does not exist + */ + public function requireAndResolveColumns(array $columns, Model $model = null) + { + $model = $model ?: $this->query->getModel(); + $tableName = $model->getTableName(); + + foreach ($columns as $alias => $column) { + $columnPath = &$column; + if ($column instanceof ExpressionInterface) { + $column = new ResolvedExpression( + $column, + $this->requireAndResolveColumns($column->getColumns(), $model) + ); + + if (is_int($alias)) { + // Scalar queries and such + yield [$model, $alias, $column]; + + continue; + } + + $columnPath = &$alias; + } elseif ($column === '*') { + yield [$model, $alias, $column]; + + continue; + } + + $dot = strrpos($columnPath, '.'); + + switch (true) { + /** @noinspection PhpMissingBreakStatementInspection */ + case $dot !== false: + $hydrationPath = substr($columnPath, 0, $dot); + $columnPath = substr($columnPath, $dot + 1); // Updates also $column or $alias + + if ($hydrationPath !== $tableName) { + $hydrationPath = $this->qualifyPath($hydrationPath, $tableName); + + $relations = new AppendIterator(); + $relations->append(new ArrayIterator([$tableName => null])); + $relations->append($this->resolveRelations($hydrationPath)); + foreach ($relations as $relationPath => $relation) { + if ($column instanceof ExpressionInterface) { + continue; + } + + if ($relationPath === $tableName) { + $subject = $model; + } else { + /** @var Relation $relation */ + $subject = $relation->getTarget(); + } + + $columnName = $columnPath; + if ($relationPath !== $hydrationPath) { + // It's still an intermediate relation, not the target + $columnName = substr($hydrationPath, strlen($relationPath) + 1) . ".$columnName"; + } + + $newColumn = $this->getBehaviors($subject)->rewriteColumn($columnName, $relationPath); + if ($newColumn !== null) { + if ($newColumn instanceof ExpressionInterface) { + $column = $newColumn; + $target = $subject; + break 2; // Expressions don't need to be *withed* and get no automatic alias either + } + + $column = $newColumn; + break; + } + } + + if (is_int($alias) && $relationPath !== $hydrationPath) { + // If the actual relation is resolved differently, + // ensure the hydration path is not an unexpected one + $alias = "$hydrationPath.$column"; + } + + $this->query->with($hydrationPath); + $target = $relation->getTarget(); + + break; + } + // Move to default + default: + $relationPath = null; + $target = $model; + + if (! $column instanceof ExpressionInterface) { + $column = $this->getBehaviors($target)->rewriteColumn($column) ?: $column; + } + } + + if (! $column instanceof ExpressionInterface) { + $targetColumns = $target->getColumns(); + if (isset($targetColumns[$column])) { + // $column is actually an alias + $alias = is_string($alias) ? $alias : ($relationPath ? "$hydrationPath.$column" : $column); + $column = $targetColumns[$column]; + + if ($column instanceof ExpressionInterface) { + $column = new ResolvedExpression( + $column, + $this->requireAndResolveColumns($column->getColumns(), $target) + ); + } + } + } + + if (! $column instanceof ExpressionInterface && ! $this->hasSelectableColumn($target, $columnPath)) { + throw new InvalidColumnException($columnPath, $target); + } + + yield [$target, $alias, $column]; + } + } + + /** + * Collect all selectable columns from the given model + * + * @param Model $subject + */ + protected function collectColumns(Model $subject) + { + // Don't fail if Model::getColumns() also contains the primary key columns + $columns = array_merge((array) $subject->getKeyName(), (array) $subject->getColumns()); + + $this->selectColumns->attach($subject, $columns); + + $selectable = []; + + foreach ($columns as $alias => $column) { + if (is_string($alias)) { + $selectable[$alias] = true; + } + + if (is_string($column)) { + $selectable[$column] = true; + } + } + + $this->selectableColumns->attach($subject, $selectable); + } + + /** + * Collect all meta data from the given model and its direct relations + * + * @param Model $subject + * + * @return array + */ + protected function collectMetaData(Model $subject) + { + $definitions = []; + foreach ($subject->getColumnDefinitions() as $name => $data) { + if ($data instanceof ColumnDefinition) { + $definition = $data; + } else { + if (is_string($data)) { + $data = ['name' => $name, 'label' => $data]; + } elseif (! isset($data[$name])) { + $data['name'] = $name; + } + + $definition = ColumnDefinition::fromArray($data); + } + + if (is_string($name) && $definition->getName() !== $name) { + throw new LogicException(sprintf( + 'Model %s provides a column definition with a different name (%s) than the index (%s)', + get_class($subject), + $definition->getName(), + $name + )); + } + + $definitions[$name] = $definition; + } + + return $definitions; + } +} diff --git a/vendor/ipl/orm/src/ResultSet.php b/vendor/ipl/orm/src/ResultSet.php new file mode 100644 index 0000000..05117a5 --- /dev/null +++ b/vendor/ipl/orm/src/ResultSet.php @@ -0,0 +1,146 @@ +<?php + +namespace ipl\Orm; + +use ArrayIterator; +use Iterator; +use Traversable; + +class ResultSet implements Iterator +{ + protected $cache; + + /** @var bool Whether cache is disabled */ + protected $isCacheDisabled = false; + + protected $generator; + + protected $limit; + + protected $position; + + public function __construct(Traversable $traversable, $limit = null) + { + $this->cache = new ArrayIterator(); + $this->generator = $this->yieldTraversable($traversable); + $this->limit = $limit; + } + + /** + * Create a new result set from the given query + * + * @param Query $query + * + * @return static + */ + public static function fromQuery(Query $query) + { + return new static($query->yieldResults(), $query->getLimit()); + } + + /** + * Do not cache query result + * + * ResultSet instance can only be iterated once + * + * @return $this + */ + public function disableCache() + { + $this->isCacheDisabled = true; + + return $this; + } + + public function hasMore() + { + return $this->generator->valid(); + } + + public function hasResult() + { + return $this->generator->valid(); + } + + #[\ReturnTypeWillChange] + public function current() + { + if ($this->position === null) { + $this->advance(); + } + + return $this->isCacheDisabled ? $this->generator->current() : $this->cache->current(); + } + + public function next(): void + { + if (! $this->isCacheDisabled) { + $this->cache->next(); + } + + if ($this->isCacheDisabled || ! $this->cache->valid()) { + $this->generator->next(); + $this->advance(); + } else { + $this->position += 1; + } + } + + public function key(): int + { + if ($this->position === null) { + $this->advance(); + } + + return $this->isCacheDisabled ? $this->generator->key() : $this->cache->key(); + } + + public function valid(): bool + { + if ($this->limit !== null && $this->position === $this->limit) { + return false; + } + + return $this->cache->valid() || $this->generator->valid(); + } + + public function rewind(): void + { + if (! $this->isCacheDisabled) { + $this->cache->rewind(); + } + + if ($this->position === null) { + $this->advance(); + } else { + $this->position = 0; + } + } + + protected function advance() + { + if (! $this->generator->valid()) { + return; + } + + if (! $this->isCacheDisabled) { + $this->cache[$this->generator->key()] = $this->generator->current(); + + // Only required on PHP 5.6, 7+ does it automatically + $this->cache->seek($this->generator->key()); + } + + if ($this->position === null) { + $this->position = 0; + } else { + $this->position += 1; + } + } + + protected function yieldTraversable(Traversable $traversable) + { + foreach ($traversable as $key => $value) { + yield $key => $value; + } + } +} diff --git a/vendor/ipl/orm/src/UnionModel.php b/vendor/ipl/orm/src/UnionModel.php new file mode 100644 index 0000000..5373bb9 --- /dev/null +++ b/vendor/ipl/orm/src/UnionModel.php @@ -0,0 +1,29 @@ +<?php + +namespace ipl\Orm; + +use ipl\Sql\Connection; + +abstract class UnionModel extends Model +{ + /** + * Get a UNION query which is tied to this model and the given database connection + * + * @param Connection $db + * + * @return UnionQuery + */ + public static function on(Connection $db) + { + return (new UnionQuery()) + ->setDb($db) + ->setModel(new static()); + } + + /** + * Get the UNION models and columns + * + * @return array + */ + abstract public function getUnions(); +} diff --git a/vendor/ipl/orm/src/UnionQuery.php b/vendor/ipl/orm/src/UnionQuery.php new file mode 100644 index 0000000..1229ff2 --- /dev/null +++ b/vendor/ipl/orm/src/UnionQuery.php @@ -0,0 +1,59 @@ +<?php + +namespace ipl\Orm; + +use ipl\Sql\Select; + +class UnionQuery extends Query +{ + /** @var Query[] Underlying queries */ + private $unions; + + /** + * Get the underlying queries + * + * @return Query[] + */ + public function getUnions() + { + if ($this->unions === null) { + $this->unions = []; + + foreach ($this->getModel()->getUnions() as list($target, $relations, $columns)) { + $query = (new Query()) + ->setDb($this->getDb()) + ->setModel(new $target()) + ->columns($columns) + ->disableDefaultSort() + ->with($relations); + + $this->unions[] = $query; + } + } + + return $this->unions; + } + + public function getSelectBase() + { + if ($this->selectBase === null) { + $this->selectBase = new Select(); + } + + $union = new Select(); + + foreach ($this->getUnions() as $query) { + $select = $query->assembleSelect(); + $columns = $select->getColumns(); + $select->resetColumns(); + ksort($columns); + $select->columns($columns); + + $union->unionAll($select); + } + + $this->selectBase->from([$this->getModel()->getTableName() => $union]); + + return $this->selectBase; + } +} |