Skip to content

Commit

Permalink
Merge branch '1.12'
Browse files Browse the repository at this point in the history
# Conflicts:
#	README.md
#	src/Eloquent/Relations/Graph/Ancestors.php
#	src/Eloquent/Relations/Graph/Descendants.php
#	tests/Graph/Models/Node.php
  • Loading branch information
staudenmeir committed Jan 30, 2024
2 parents 1543afd + a2670e7 commit f8a6778
Show file tree
Hide file tree
Showing 11 changed files with 716 additions and 2 deletions.
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,7 @@ Supports Laravel 9+.
- [Custom Paths](#graphs-custom-paths)
- [Nested Results](#graphs-nested-results)
- [Initial & Recursive Query Constraints](#graphs-initial--recursive-query-constraints)
- [Deep Relationship Concatenation](#graphs-deep-relationship-concatenation)
- [Known Issues](#graphs-known-issues)

#### <a name="graphs-getting-started">Getting Started</a>
Expand Down Expand Up @@ -1069,6 +1070,49 @@ $descendants = Node::withQueryConstraint(function (Builder $query) {
You can also add a custom constraint to only the initial or recursive query using `withInitialQueryConstraint()`/
`withRecursiveQueryConstraint()`.

#### <a name="graphs-deep-relationship-concatenation">Deep Relationship Concatenation</a>

You can include recursive relationships into deep relationships by concatenating them with other relationships
using [staudenmeir/eloquent-has-many-deep](https://github.com/staudenmeir/eloquent-has-many-deep) (Laravel 9+).

Consider a `HasMany` relationship between `Node` and `Post` and building a deep relationship to get all posts of a
node's descendants:

`Node` → descendants → `Node` → has many → `Post`

[Install](https://github.com/staudenmeir/eloquent-has-many-deep/#installation) the additional package, add the
`HasRelationships` trait to the recursive model
and [define](https://github.com/staudenmeir/eloquent-has-many-deep/#concatenating-existing-relationships) a
deep relationship:

```php
class Node extends Model
{
use \Staudenmeir\EloquentHasManyDeep\HasRelationships;
use \Staudenmeir\LaravelAdjacencyList\Eloquent\HasRecursiveRelationships;

public function descendantPosts()
{
return $this->hasManyDeepFromRelations(
$this->descendants(),
(new static)->posts()
);
}

public function posts()
{
return $this->hasMany(Post::class);
}
}

$descendantPosts = Node::find($id)->descendantPosts;
```

At the moment, recursive relationships can only be at the beginning of deep relationships:

- Supported: `Node` → descendants → `Node` → has many → `Post`
- Not supported: `Country` → has many → `Node` → descendants → `Node`

#### <a name="graphs-known-issues">Known Issues</a>

MariaDB [doesn't yet support](https://jira.mariadb.org/browse/MDEV-19077) correlated CTEs in subqueries. This affects
Expand Down
5 changes: 4 additions & 1 deletion src/Eloquent/Relations/Graph/Ancestors.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Query\Expression;
use Staudenmeir\EloquentHasManyDeepContracts\Interfaces\ConcatenableRelation;
use Staudenmeir\LaravelAdjacencyList\Eloquent\Relations\Graph\Traits\Concatenation\IsConcatenableAncestorsRelation;
use Staudenmeir\LaravelAdjacencyList\Eloquent\Relations\Graph\Traits\IsRecursiveRelation;

/**
* @template TRelatedModel of \Illuminate\Database\Eloquent\Model
* @extends BelongsToMany<TRelatedModel>
*/
class Ancestors extends BelongsToMany
class Ancestors extends BelongsToMany implements ConcatenableRelation
{
use IsConcatenableAncestorsRelation;
use IsRecursiveRelation {
buildDictionary as baseBuildDictionary;
}
Expand Down
5 changes: 4 additions & 1 deletion src/Eloquent/Relations/Graph/Descendants.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Query\Expression;
use Staudenmeir\EloquentHasManyDeepContracts\Interfaces\ConcatenableRelation;
use Staudenmeir\LaravelAdjacencyList\Eloquent\Relations\Graph\Traits\Concatenation\IsConcatenableDescendantsRelation;
use Staudenmeir\LaravelAdjacencyList\Eloquent\Relations\Graph\Traits\IsRecursiveRelation;

/**
* @template TRelatedModel of \Illuminate\Database\Eloquent\Model
* @extends BelongsToMany<TRelatedModel>
*/
class Descendants extends BelongsToMany
class Descendants extends BelongsToMany implements ConcatenableRelation
{
use IsConcatenableDescendantsRelation;
use IsRecursiveRelation {
buildDictionary as baseBuildDictionary;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace Staudenmeir\LaravelAdjacencyList\Eloquent\Relations\Graph\Traits\Concatenation;

trait IsConcatenableAncestorsRelation
{
use IsConcatenableRelation;

/**
* Get the custom through key for an eager load of the relation.
*
* @param string $alias
* @return array
*/
public function getThroughKeyForDeepRelationships(string $alias): array
{
$path = $this->related->qualifyColumn(
$this->related->getPathName()
);

$childKey = $this->related->qualifyColumn(
"pivot_" . $this->related->getChildKeyName()
);

return ["$path as {$alias}", "$childKey as {$alias}_pivot_id"];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace Staudenmeir\LaravelAdjacencyList\Eloquent\Relations\Graph\Traits\Concatenation;

trait IsConcatenableDescendantsRelation
{
use IsConcatenableRelation;

/**
* Get the custom through key for an eager load of the relation.
*
* @param string $alias
* @return array
*/
public function getThroughKeyForDeepRelationships(string $alias): array
{
$path = $this->related->qualifyColumn(
$this->related->getPathName()
);

$parentKey = $this->related->qualifyColumn(
"pivot_" . $this->related->getParentKeyName()
);

return ["$path as {$alias}", "$parentKey as {$alias}_pivot_id"];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
<?php

namespace Staudenmeir\LaravelAdjacencyList\Eloquent\Relations\Graph\Traits\Concatenation;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\PostgresConnection;
use RuntimeException;

trait IsConcatenableRelation
{
/**
* Append the relation's through parents, foreign and local keys to a deep relationship.
*
* @param \Illuminate\Database\Eloquent\Model[] $through
* @param array $foreignKeys
* @param array $localKeys
* @param int $position
* @return array
*/
public function appendToDeepRelationship(array $through, array $foreignKeys, array $localKeys, int $position): array
{
if ($position === 0) {
$foreignKeys[] = function (Builder $query, Builder $parentQuery = null) {
if ($parentQuery) {
$this->getRelationExistenceQuery($this->query, $parentQuery);
}

$this->mergeExpressions($query, $this->query);
};

$localKeys[] = null;
} else {
throw new RuntimeException(
sprintf(
'%s can only be at the beginning of deep relationships at the moment.',
class_basename($this)
)
);
}

return [$through, $foreignKeys, $localKeys];
}

/**
* Get the related table name for a deep relationship.
*
* @return string
*/
public function getTableForDeepRelationship(): string
{
return $this->related->getExpressionName();
}

/**
* The custom callback to run at the end of the get() method.
*
* @param \Illuminate\Database\Eloquent\Collection $models
* @return void
*/
public function postGetCallback(Collection $models): void
{
if (!$this->query->getConnection() instanceof PostgresConnection) {
return;
}

if (!isset($models[0]->laravel_through_key)) {
return;
}

$this->replacePathSeparator(
$models,
'laravel_through_key',
$this->related->getPathSeparator()
);
}

/**
* Replace the separator in a PostgreSQL path column.
*
* @param \Illuminate\Database\Eloquent\Collection $models
* @param string $column
* @param string $separator
* @return void
*/
protected function replacePathSeparator(Collection $models, string $column, string $separator): void
{
foreach ($models as $model) {
$model->$column = str_replace(
',',
$separator,
substr($model->$column, 1, -1)
);
}
}

/**
* Set the constraints for an eager load of the deep relation.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param array $models
* @return void
*/
public function addEagerConstraintsToDeepRelationship(Builder $query, array $models): void
{
$this->addEagerConstraints($models);

$this->mergeExpressions($query, $this->query);

$query->getQuery()->distinct = $this->query->getQuery()->distinct;
}

/**
* Merge the common table expressions from one query into another.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param \Illuminate\Database\Eloquent\Builder $from
* @return \Illuminate\Database\Eloquent\Builder
*/
protected function mergeExpressions(Builder $query, Builder $from): Builder
{
$query->getQuery()->expressions = array_merge(
$query->getQuery()->expressions,
$from->getQuery()->expressions
);

return $query->addBinding(
$from->getQuery()->getRawBindings()['expressions'],
'expressions'
);
}

/**
* Match the eagerly loaded results for a deep relationship to their parents.
*
* @param array $models
* @param \Illuminate\Database\Eloquent\Collection $results
* @param string $relation
* @param string $type
* @return array
*/
public function matchResultsForDeepRelationship(
array $models,
Collection $results,
string $relation,
string $type = 'many'
): array {
$dictionary = $this->buildDictionaryForDeepRelationship($results);

$attribute = $this->parentKey;

foreach ($models as $model) {
$key = $model->$attribute;

if (isset($dictionary[$key])) {
$value = $dictionary[$key];

$value = $type === 'one' ? reset($value) : $this->related->newCollection($value);

$model->setRelation($relation, $value);
}
}

return $models;
}

/**
* Build the model dictionary for a deep relation.
*
* @param \Illuminate\Database\Eloquent\Collection $results
* @return array
*/
protected function buildDictionaryForDeepRelationship(Collection $results): array
{
$pathSeparator = $this->related->getPathSeparator();

if ($this->andSelf) {
return $results->mapToDictionary(function (Model $result) use ($pathSeparator) {
return [strtok($result->laravel_through_key, $pathSeparator) => $result];
})->all();
}

$dictionary = [];

$firstLevelResults = $results->filter(
fn (Model $result) => !str_contains($result->laravel_through_key, $pathSeparator)
)->groupBy('laravel_through_key');

foreach ($results as $result) {
$keys = [];

if (str_contains($result->laravel_through_key, $pathSeparator)) {
$firstPathSegment = strtok($result->laravel_through_key, $pathSeparator);

foreach ($firstLevelResults[$firstPathSegment] as $model) {
$keys[] = $model->laravel_through_key_pivot_id;
}
} else {
$keys[] = $result->laravel_through_key_pivot_id;
}

foreach ($keys as $key) {
$dictionary[$key][] = $result;
}
}

return $dictionary;
}
}
Loading

0 comments on commit f8a6778

Please sign in to comment.