Skip to content

Commit

Permalink
[8.x] Add one-of-many relationship (inner join) (laravel#37362)
Browse files Browse the repository at this point in the history
* added one-of-many to has-one

* Apply fixes from StyleCI

* fixed getResults

* added query methods to forwardToOneOfManyQuery

* Apply fixes from StyleCI

* improvements & tests

* Apply fixes from StyleCI

* use where or having

* Apply fixes from StyleCI

* join

* wip

* wip

* fixes style

* updated contract

* multiple aggregastes

* Apply fixes from StyleCI

* formatting

* formatting

* Apply fixes from StyleCI

* formatting

* rename class

* add file

* rename array key

* add of-many to morph-one

* Apply fixes from StyleCI

* fixed pivot test

* Apply fixes from StyleCI

* fixed return type

* formatting

* add shortcut methods

* move test

* multiple columns in shortcut

* Apply fixes from StyleCI

* add key when missing

* Apply fixes from StyleCI

* use collections

* fail for invalid aggregates

* Apply fixes from StyleCI

* formatting

Co-authored-by: Taylor Otwell <taylorotwell@gmail.com>
  • Loading branch information
2 people authored and chu121su12 committed May 18, 2021
1 parent 2020bc3 commit b7a54de
Show file tree
Hide file tree
Showing 9 changed files with 1,050 additions and 5 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace Illuminate\Contracts\Database\Eloquent;

use Closure;

interface SupportsPartialRelations
{
/**
* Indicate that the relation is a single result of a larger one-to-many relationship.
*
* @param Closure|string|null $column
* @param string|null $relation
* @param string $relation
* @return $this
*/
public function ofMany($column = 'id', $aggregate = 'MAX', $relation = null);

/**
* Determine whether the relationship is a one-of-many relationship.
*
* @return bool
*/
public function isOneOfMany();
}
237 changes: 237 additions & 0 deletions src/Illuminate/Database/Eloquent/Relations/Concerns/CanBeOneOfMany.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
<?php

namespace Illuminate\Database\Eloquent\Relations\Concerns;

use Closure;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use InvalidArgumentException;

trait CanBeOneOfMany
{
/**
* Determines whether the relationship is one-of-many.
*
* @var bool
*/
protected $isOneOfMany = false;

/**
* The name of the relationship.
*
* @var string
*/
protected $relationName;

/**
* Add constraints for inner join subselect for one of many relationships.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string|null $column
* @param string|null $aggregate
* @return void
*/
abstract public function addOneOfManySubQueryConstraints(Builder $query, $column = null, $aggregate = null);

/**
* Get the columns the determine the relationship groups.
*
* @return array|string
*/
abstract public function getOneOfManySubQuerySelectColumns();

/**
* Add join query constraints for one of many relationships.
*
* @param \Illuminate\Database\Eloquent\JoinClause $join
* @return void
*/
abstract public function addOneOfManyJoinSubQueryConstraints(JoinClause $join);

/**
* Indicate that the relation is a single result of a larger one-to-many relationship.
*
* @param string|array|null $column
* @param string|Closure|null $aggregate
* @param string|null $relation
* @return $this
*
* @throws \InvalidArgumentException
*/
public function ofMany($column = 'id', $aggregate = 'MAX', $relation = null)
{
$this->isOneOfMany = true;

$this->relationName = $relation ?: $this->guessRelationship();

$keyName = $this->query->getModel()->getKeyName();

$columns = is_string($columns = $column) ? [
$column => $aggregate,
$keyName => $aggregate,
] : $column;

if (! array_key_exists($keyName, $columns)) {
$columns[$keyName] = 'MAX';
}

if ($aggregate instanceof Closure) {
$closure = $aggregate;
}

foreach ($columns as $column => $aggregate) {
if (! in_array(strtolower($aggregate), ['min', 'max'])) {
throw new InvalidArgumentException("Invalid aggregate [{$aggregate}] used within ofMany relation. Available aggregates: MIN, MAX");
}

$subQuery = $this->newSubQuery(
isset($previous) ? $previous['column'] : $this->getOneOfManySubQuerySelectColumns(),
$column, $aggregate
);

if (isset($previous)) {
$this->addJoinSub($subQuery, $previous['subQuery'], $previous['column']);
} elseif (isset($closure)) {
$closure($subQuery);
}

if (array_key_last($columns) == $column) {
$this->addJoinSub($this->query, $subQuery, $column);
}

$previous = [
'subQuery' => $subQuery,
'column' => $column,
];
}

return $this;
}

/**
* Indicate that the relation is the latest single result of a larger one-to-many relationship.
*
* @param string|array|null $column
* @param string|Closure|null $aggregate
* @param string|null $relation
* @return $this
*/
public function latestOfMany($column = 'id', $relation = null)
{
return $this->ofMany(collect(Arr::wrap($column))->mapWithKeys(function ($column) {
return [$column => 'MAX'];
})->all(), 'MAX', $relation ?: $this->guessRelationship());
}

/**
* Indicate that the relation is the oldest single result of a larger one-to-many relationship.
*
* @param string|array|null $column
* @param string|Closure|null $aggregate
* @param string|null $relation
* @return $this
*/
public function oldestOfMany($column = 'id', $relation = null)
{
return $this->ofMany(collect(Arr::wrap($column))->mapWithKeys(function ($column) {
return [$column => 'MIN'];
})->all(), 'MIN', $relation ?: $this->guessRelationship());
}

/**
* Get a new query for the related model, grouping the query by the given column, often the foreign key of the relationship.
*
* @param string|array $groupBy
* @param string|null $column
* @param string|null $aggregate
* @return \Illuminate\Database\Eloquent\Builder
*/
protected function newSubQuery($groupBy, $column = null, $aggregate = null)
{
$subQuery = $this->query->getModel()
->newQuery();

foreach (Arr::wrap($groupBy) as $group) {
$subQuery->groupBy($this->qualifyRelatedColumn($group));
}

if (! is_null($column)) {
$subQuery->selectRaw($aggregate.'('.$column.') as '.$column);
}

$this->addOneOfManySubQueryConstraints($subQuery, $groupBy, $column, $aggregate);

return $subQuery;
}

/**
* Add the join subquery to the given query on the given column and the relationship's foreign key.
*
* @param \Illuminate\Database\Eloquent\Builder $parent
* @param \Illuminate\Database\Eloquent\Builder $subQuery
* @param string $on
* @return void
*/
protected function addJoinSub(Builder $parent, Builder $subQuery, $on)
{
$parent->joinSub($subQuery, $this->relationName, function ($join) use ($on) {
$join->on($this->qualifySubSelectColumn($on), '=', $this->qualifyRelatedColumn($on));

$this->addOneOfManyJoinSubQueryConstraints($join, $on);
});
}

/**
* Get the qualified column name for the one-of-many relationship using the subselect join query's alias.
*
* @param string $column
* @return string
*/
public function qualifySubSelectColumn($column)
{
return $this->getRelationName().'.'.last(explode('.', $column));
}

/**
* Qualify related column using the related table name if it is not already qualified.
*
* @param string $column
* @return string
*/
protected function qualifyRelatedColumn($column)
{
return Str::contains($column, '.') ? $column : $this->query->getModel()->getTable().'.'.$column;
}

/**
* Guess the "hasOne" relationship's name via backtrace.
*
* @return string
*/
protected function guessRelationship()
{
return debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]['function'];
}

/**
* Determine whether the relationship is a one-of-many relationship.
*
* @return bool
*/
public function isOneOfMany()
{
return $this->isOneOfMany;
}

/**
* Get the name of the relationship.
*
* @return string
*/
public function getRelationName()
{
return $this->relationName;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Illuminate\Database\Eloquent\Relations\Concerns;

use Illuminate\Contracts\Database\Eloquent\SupportsPartialRelations;
use Illuminate\Database\Eloquent\Model;

trait ComparesRelatedModels
Expand All @@ -14,10 +15,18 @@ trait ComparesRelatedModels
*/
public function is($model)
{
return ! is_null($model) &&
$match = ! is_null($model) &&
$this->compareKeys($this->getParentKey(), $this->getRelatedKeyFrom($model)) &&
$this->related->getTable() === $model->getTable() &&
$this->related->getConnectionName() === $model->getConnectionName();

if ($match && $this instanceof SupportsPartialRelations && $this->isOneOfMany()) {
return $this->query
->whereKey($model->getKey())
->exists();
}

return $match;
}

/**
Expand Down
65 changes: 63 additions & 2 deletions src/Illuminate/Database/Eloquent/Relations/HasOne.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@

namespace Illuminate\Database\Eloquent\Relations;

use Illuminate\Contracts\Database\Eloquent\SupportsPartialRelations;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Concerns\CanBeOneOfMany;
use Illuminate\Database\Eloquent\Relations\Concerns\ComparesRelatedModels;
use Illuminate\Database\Eloquent\Relations\Concerns\SupportsDefaultModels;
use Illuminate\Database\Query\JoinClause;

class HasOne extends HasOneOrMany
class HasOne extends HasOneOrMany implements SupportsPartialRelations
{
use ComparesRelatedModels, SupportsDefaultModels;
use ComparesRelatedModels, CanBeOneOfMany, SupportsDefaultModels;

/**
* Get the results of the relationship.
Expand Down Expand Up @@ -54,6 +58,63 @@ public function match(array $models, Collection $results, $relation)
return $this->matchOne($models, $results, $relation);
}

/**
* Add the constraints for an internal relationship existence query.
*
* Essentially, these queries compare on column names like "whereColumn".
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param \Illuminate\Database\Eloquent\Builder $parentQuery
* @param array|mixed $columns
* @return \Illuminate\Database\Eloquent\Builder
*/
public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*'])
{
if (! $this->isOneOfMany()) {
return parent::getRelationExistenceQuery($query, $parentQuery, $columns);
}

$query->getQuery()->joins = $this->query->getQuery()->joins;

return $query->select($columns)->whereColumn(
$this->getQualifiedParentKeyName(), '=', $this->getExistenceCompareKey()
);
}

/**
* Add constraints for inner join subselect for one of many relationships.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string|null $column
* @param string|null $aggregate
* @return void
*/
public function addOneOfManySubQueryConstraints(Builder $query, $column = null, $aggregate = null)
{
$query->addSelect($this->foreignKey);
}

/**
* Get the columns that should be selected by the one of many subquery.
*
* @return array|string
*/
public function getOneOfManySubQuerySelectColumns()
{
return $this->foreignKey;
}

/**
* Add join query constraints for one of many relationships.
*
* @param \Illuminate\Database\Eloquent\JoinClause $join
* @return void
*/
public function addOneOfManyJoinSubQueryConstraints(JoinClause $join)
{
$join->on($this->qualifySubSelectColumn($this->foreignKey), '=', $this->qualifyRelatedColumn($this->foreignKey));
}

/**
* Make a new related instance for the given model.
*
Expand Down
Loading

0 comments on commit b7a54de

Please sign in to comment.