Skip to content

Commit 9b7f9d8

Browse files
authored
Adds support for BelongsToMany relation, avoid losing pivot values (#443)
* wip * wip
1 parent 1321c20 commit 9b7f9d8

File tree

9 files changed

+214
-55
lines changed

9 files changed

+214
-55
lines changed

src/AllowedFilter.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public function filter(QueryBuilder $query, $value)
4646
return;
4747
}
4848

49-
($this->filterClass)($query, $valueToFilter, $this->internalName);
49+
($this->filterClass)($query->getEloquentBuilder(), $valueToFilter, $this->internalName);
5050
}
5151

5252
public static function exact(

src/AllowedInclude.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,13 @@ public static function count(string $name, ?string $internalName = null): Collec
6161

6262
public function include(QueryBuilder $query): void
6363
{
64-
($this->includeClass)($query, $this->internalName);
64+
if (property_exists($this->includeClass, 'getRequestedFieldsForRelatedTable')) {
65+
$this->includeClass->getRequestedFieldsForRelatedTable = function (...$args) use ($query) {
66+
return $query->getRequestedFieldsForRelatedTable(...$args);
67+
};
68+
}
69+
70+
($this->includeClass)($query->getEloquentBuilder(), $this->internalName);
6571
}
6672

6773
public function getName(): string

src/AllowedSort.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public function sort(QueryBuilder $query, ?bool $descending = null): void
4141
{
4242
$descending = $descending ?? ($this->defaultDirection === SortDirection::DESCENDING);
4343

44-
($this->sortClass)($query, $descending, $this->internalName);
44+
($this->sortClass)($query->getEloquentBuilder(), $descending, $this->internalName);
4545
}
4646

4747
public static function field(string $name, ?string $internalName = null): self

src/Exceptions/InvalidSubject.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
namespace Spatie\QueryBuilder\Exceptions;
4+
5+
use InvalidArgumentException;
6+
7+
class InvalidSubject extends InvalidArgumentException
8+
{
9+
public static function make($subject)
10+
{
11+
return new static(
12+
sprintf(
13+
'Subject %s is invalid.',
14+
is_object($subject)
15+
? sprintf('class `%s`', get_class($subject))
16+
: sprintf('type `%s`', gettype($subject))
17+
)
18+
);
19+
}
20+
}

src/Includes/IncludedRelationship.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@
22

33
namespace Spatie\QueryBuilder\Includes;
44

5+
use Closure;
56
use Illuminate\Database\Eloquent\Builder;
67
use Illuminate\Support\Collection;
78

89
class IncludedRelationship implements IncludeInterface
910
{
11+
/** @var Closure|null */
12+
public $getRequestedFieldsForRelatedTable;
13+
1014
public function __invoke(Builder $query, string $relationship)
1115
{
1216
$relatedTables = collect(explode('.', $relationship));
@@ -15,7 +19,9 @@ public function __invoke(Builder $query, string $relationship)
1519
->mapWithKeys(function ($table, $key) use ($query, $relatedTables) {
1620
$fullRelationName = $relatedTables->slice(0, $key + 1)->implode('.');
1721

18-
$fields = $query->getRequestedFieldsForRelatedTable($fullRelationName);
22+
if ($this->getRequestedFieldsForRelatedTable) {
23+
$fields = ($this->getRequestedFieldsForRelatedTable)($fullRelationName);
24+
}
1925

2026
if (empty($fields)) {
2127
return [$fullRelationName];

src/QueryBuilder.php

Lines changed: 97 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -2,94 +2,143 @@
22

33
namespace Spatie\QueryBuilder;
44

5-
use Illuminate\Database\Eloquent\Builder;
5+
use ArrayAccess;
6+
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\Relation;
69
use Illuminate\Http\Request;
10+
use Illuminate\Support\Collection;
11+
use Illuminate\Support\Traits\ForwardsCalls;
712
use Spatie\QueryBuilder\Concerns\AddsFieldsToQuery;
813
use Spatie\QueryBuilder\Concerns\AddsIncludesToQuery;
914
use Spatie\QueryBuilder\Concerns\AppendsAttributesToResults;
1015
use Spatie\QueryBuilder\Concerns\FiltersQuery;
1116
use Spatie\QueryBuilder\Concerns\SortsQuery;
17+
use Spatie\QueryBuilder\Exceptions\InvalidSubject;
1218

13-
class QueryBuilder extends Builder
19+
/**
20+
* @mixin EloquentBuilder
21+
*/
22+
class QueryBuilder implements ArrayAccess
1423
{
1524
use FiltersQuery,
1625
SortsQuery,
1726
AddsIncludesToQuery,
1827
AddsFieldsToQuery,
19-
AppendsAttributesToResults;
28+
AppendsAttributesToResults,
29+
ForwardsCalls;
2030

21-
/** @var \Spatie\QueryBuilder\QueryBuilderRequest */
31+
/** @var QueryBuilderRequest */
2232
protected $request;
2333

24-
/**
25-
* QueryBuilder constructor.
26-
*
27-
* @param \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Eloquent\Relations\Relation $builder
28-
* @param null|\Illuminate\Http\Request $request
29-
*/
30-
public function __construct($builder, ?Request $request = null)
34+
/** @var EloquentBuilder|Relation */
35+
protected $subject;
36+
37+
public function __construct($subject, ?Request $request = null)
38+
{
39+
$this->initializeSubject($subject)
40+
->initializeRequest($request ?? app(Request::class));
41+
}
42+
43+
protected function initializeSubject($subject): self
3144
{
32-
parent::__construct(clone $builder->getQuery());
45+
throw_unless(
46+
$subject instanceof EloquentBuilder || $subject instanceof Relation,
47+
InvalidSubject::make($subject)
48+
);
49+
50+
$this->subject = $subject;
3351

34-
$this->initializeFromBuilder($builder);
52+
return $this;
53+
}
3554

55+
protected function initializeRequest(?Request $request = null): self
56+
{
3657
$this->request = $request
3758
? QueryBuilderRequest::fromRequest($request)
3859
: app(QueryBuilderRequest::class);
60+
61+
return $this;
3962
}
4063

41-
/**
42-
* Create a new QueryBuilder for a request and model.
43-
*
44-
* @param string|\Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Eloquent\Relations\Relation $baseQuery Model class or base query builder
45-
* @param \Illuminate\Http\Request $request
46-
*
47-
* @return \Spatie\QueryBuilder\QueryBuilder
48-
*/
49-
public static function for($baseQuery, ?Request $request = null): self
64+
public function getEloquentBuilder(): EloquentBuilder
5065
{
51-
if (is_string($baseQuery)) {
52-
/** @var Builder $baseQuery */
53-
$baseQuery = $baseQuery::query();
66+
if ($this->subject instanceof EloquentBuilder) {
67+
return $this->subject;
5468
}
5569

56-
return new static($baseQuery, $request ?? app(Request::class));
70+
if ($this->subject instanceof Relation) {
71+
return $this->subject->getQuery();
72+
}
73+
74+
throw InvalidSubject::make($this->subject);
75+
}
76+
77+
public function getSubject()
78+
{
79+
return $this->subject;
5780
}
5881

5982
/**
60-
* {@inheritdoc}
83+
* @param EloquentBuilder|Relation|string $subject
84+
* @param Request|null $request
85+
* @return static
6186
*/
62-
public function get($columns = ['*'])
87+
public static function for($subject, ?Request $request = null): self
6388
{
64-
$results = parent::get($columns);
89+
if (is_subclass_of($subject, Model::class)) {
90+
$subject = $subject::query();
91+
}
92+
93+
return new static($subject, $request);
94+
}
6595

66-
if ($this->request->appends()->isNotEmpty()) {
67-
$results = $this->addAppendsToResults($results);
96+
public function __call($name, $arguments)
97+
{
98+
$result = $this->forwardCallTo($this->subject, $name, $arguments);
99+
100+
if ($result === $this->subject) {
101+
return $this;
102+
}
103+
104+
if ($result instanceof Model) {
105+
$this->addAppendsToResults(collect([$result]));
106+
}
107+
108+
if ($result instanceof Collection) {
109+
$this->addAppendsToResults($result);
68110
}
69111

70-
return $results;
112+
return $result;
71113
}
72114

73-
/**
74-
* Add the model, scopes, eager loaded relationships, local macro's and onDelete callback
75-
* from the $builder to this query builder.
76-
*
77-
* @param \Illuminate\Database\Eloquent\Builder $builder
78-
*/
79-
protected function initializeFromBuilder(Builder $builder)
115+
public function __get($name)
80116
{
81-
$this
82-
->setModel($builder->getModel())
83-
->setEagerLoads($builder->getEagerLoads());
117+
return $this->subject->{$name};
118+
}
84119

85-
$builder->macro('getProtected', function (Builder $builder, string $property) {
86-
return $builder->{$property};
87-
});
120+
public function __set($name, $value)
121+
{
122+
$this->subject->{$name} = $value;
123+
}
88124

89-
$this->scopes = $builder->getProtected('scopes');
125+
public function offsetExists($offset)
126+
{
127+
return isset($this->subject[$offset]);
128+
}
90129

91-
$this->localMacros = $builder->getProtected('localMacros');
130+
public function offsetGet($offset)
131+
{
132+
return $this->subject[$offset];
133+
}
92134

93-
$this->onDelete = $builder->getProtected('onDelete');
135+
public function offsetSet($offset, $value)
136+
{
137+
$this->subject[$offset] = $value;
138+
}
139+
140+
public function offsetUnset($offset)
141+
{
142+
unset($this->subject[$offset]);
94143
}
95144
}

tests/QueryBuilderTest.php

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Illuminate\Http\Request;
77
use PHPUnit\Util\Test;
88
use ReflectionClass;
9+
use Spatie\QueryBuilder\Exceptions\InvalidSubject;
910
use Spatie\QueryBuilder\QueryBuilder;
1011
use Spatie\QueryBuilder\QueryBuilderRequest;
1112
use Spatie\QueryBuilder\Sorts\Sort;
@@ -17,7 +18,7 @@
1718
class QueryBuilderTest extends TestCase
1819
{
1920
/** @test */
20-
public function it_can_be_given_a_custom_base_query_using_where()
21+
public function it_can_be_given_an_eloquent_query_using_where()
2122
{
2223
$queryBuilder = QueryBuilder::for(TestModel::where('id', 1));
2324

@@ -30,7 +31,7 @@ public function it_can_be_given_a_custom_base_query_using_where()
3031
}
3132

3233
/** @test */
33-
public function it_can_be_given_a_custom_base_query_using_select()
34+
public function it_can_be_given_an_eloquent_query_using_select()
3435
{
3536
$queryBuilder = QueryBuilder::for(TestModel::select('id', 'name'));
3637

@@ -42,6 +43,53 @@ public function it_can_be_given_a_custom_base_query_using_select()
4243
);
4344
}
4445

46+
/** @test */
47+
public function it_can_be_given_a_belongs_to_many_relation_query_with_pivot()
48+
{
49+
/** @var TestModel $testModel */
50+
$testModel = TestModel::create(['id' => 329, 'name' => 'Illia']);
51+
52+
$queryBuilder = QueryBuilder::for($testModel->relatedThroughPivotModelsWithPivot());
53+
54+
$eloquentBuilder = $testModel->relatedThroughPivotModelsWithPivot();
55+
56+
$this->assertEquals(
57+
$eloquentBuilder->toSql(),
58+
$queryBuilder->toSql()
59+
);
60+
}
61+
62+
/** @test */
63+
public function it_can_be_given_a_model_class_name()
64+
{
65+
$queryBuilder = QueryBuilder::for(TestModel::class);
66+
67+
$this->assertEquals(
68+
TestModel::query()->toSql(),
69+
$queryBuilder->toSql()
70+
);
71+
}
72+
73+
/** @test */
74+
public function it_can_not_be_given_a_string_that_is_not_a_class_name()
75+
{
76+
$this->expectException(InvalidSubject::class);
77+
78+
$this->expectExceptionMessage('Subject type `string` is invalid.');
79+
80+
QueryBuilder::for('not a class name');
81+
}
82+
83+
/** @test */
84+
public function it_can_not_be_given_an_object_that_is_neither_relation_nor_eloquent_builder()
85+
{
86+
$this->expectException(InvalidSubject::class);
87+
88+
$this->expectExceptionMessage(sprintf('Subject class `%s` is invalid.', self::class));
89+
90+
QueryBuilder::for($this);
91+
}
92+
4593
/** @test */
4694
public function it_will_determine_the_request_when_its_not_given()
4795
{
@@ -109,7 +157,10 @@ public function it_keeps_local_macros_added_to_the_base_query()
109157

110158
$queryBuilder = QueryBuilder::for($baseQuery);
111159

112-
$this->assertEquals($baseQuery->customMacro()->toSql(), $queryBuilder->customMacro()->toSql());
160+
$this->assertEquals(
161+
'select * from `test_models` where `name` = ?',
162+
$queryBuilder->customMacro()->toSql()
163+
);
113164
}
114165

115166
/** @test */
@@ -209,4 +260,24 @@ public function it_queries_the_correct_data_for_a_relationship_query()
209260
$this->assertEquals(789, $queryBuilderResult->id);
210261
$this->assertEquals(123, $queryBuilderResult->testModel->id);
211262
}
263+
264+
/** @test */
265+
public function it_does_not_lose_pivot_values_with_belongs_to_many_relation()
266+
{
267+
/** @var TestModel $testModel */
268+
$testModel = TestModel::create(['id' => 324, 'name' => 'Illia']);
269+
270+
/** @var RelatedThroughPivotModel $relatedThroughPivotModel */
271+
$relatedThroughPivotModel = RelatedThroughPivotModel::create(['id' => 721, 'name' => 'Kate']);
272+
273+
$testModel->relatedThroughPivotModelsWithPivot()->attach($relatedThroughPivotModel, ['location' => 'Wood Cottage']);
274+
275+
$foundTestModel = QueryBuilder::for($testModel->relatedThroughPivotModelsWithPivot())
276+
->first();
277+
278+
$this->assertSame(
279+
'Wood Cottage',
280+
$foundTestModel->pivot->location
281+
);
282+
}
212283
}

tests/TestCase.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ protected function setUpDatabase(Application $app)
6565
$table->increments('id');
6666
$table->string('test_model_id');
6767
$table->integer('related_through_pivot_model_id');
68+
$table->string('location')->nullable();
6869
});
6970

7071
$app['db']->connection()->getSchemaBuilder()->create('related_through_pivot_models', function (Blueprint $table) {

0 commit comments

Comments
 (0)