Issues (17)

src/Relations/BelongsToThrough.php (7 issues)

1
<?php
2
3
namespace Znck\Eloquent\Relations;
4
5
use Illuminate\Database\Eloquent\Builder;
6
use Illuminate\Database\Eloquent\Collection;
7
use Illuminate\Database\Eloquent\Model;
8
use Illuminate\Database\Eloquent\Relations\Concerns\SupportsDefaultModels;
9
use Illuminate\Database\Eloquent\Relations\Relation;
10
use Illuminate\Database\Eloquent\SoftDeletes;
11
use Illuminate\Database\Query\Expression;
12
use Illuminate\Support\Str;
13
use RuntimeException;
14
15
/**
16
 * @template TRelatedModel of \Illuminate\Database\Eloquent\Model
17
 * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model
18
 *
19
 * @extends \Illuminate\Database\Eloquent\Relations\Relation<TRelatedModel>
20
 */
21
class BelongsToThrough extends Relation
22
{
23
    use SupportsDefaultModels;
24
25
    /**
26
     * The column alias for the local key on the first "through" parent model.
27
     *
28
     * @var string
29
     */
30
    public const THROUGH_KEY = 'laravel_through_key';
31
32
    /**
33
     * The "through" parent model instances.
34
     *
35
     * @var list<\Illuminate\Database\Eloquent\Model>
0 ignored issues
show
The type Znck\Eloquent\Relations\list was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
36
     */
37
    protected $throughParents;
38
39
    /**
40
     * The foreign key prefix for the first "through" parent model.
41
     *
42
     * @var string
43
     */
44
    protected $prefix;
45
46
    /**
47
     * The custom foreign keys on the relationship.
48
     *
49
     * @var array<string, string>
50
     */
51
    protected $foreignKeyLookup;
52
53
    /**
54
     * The custom local keys on the relationship.
55
     *
56
     * @var array<string, string>
57
     */
58
    protected $localKeyLookup;
59
60
    /**
61
     * Create a new belongs to through relationship instance.
62
     *
63
     * @param \Illuminate\Database\Eloquent\Builder<TRelatedModel> $query
64
     * @param TDeclaringModel $parent
0 ignored issues
show
The type Znck\Eloquent\Relations\TDeclaringModel was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
65
     * @param list<\Illuminate\Database\Eloquent\Model> $throughParents
66
     * @param string|null $localKey
67
     * @param string $prefix
68
     * @param array<string, string> $foreignKeyLookup
69
     * @param array<string, string> $localKeyLookup
70
     * @return void
71
     *
72
     * @phpstan-ignore constructor.unusedParameter($localKey)
73
     */
74
    public function __construct(
75
        Builder $query,
76
        Model $parent,
77
        array $throughParents,
78
        $localKey = null,
0 ignored issues
show
The parameter $localKey is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

78
        /** @scrutinizer ignore-unused */ $localKey = null,

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
79
        $prefix = '',
80
        array $foreignKeyLookup = [],
81
        array $localKeyLookup = []
82
    ) {
83
        $this->throughParents = $throughParents;
0 ignored issues
show
Documentation Bug introduced by
It seems like $throughParents of type array is incompatible with the declared type Znck\Eloquent\Relations\list of property $throughParents.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
84
        $this->prefix = $prefix;
85
        $this->foreignKeyLookup = $foreignKeyLookup;
86
        $this->localKeyLookup = $localKeyLookup;
87
88
        parent::__construct($query, $parent);
89
    }
90
91
    /** @inheritDoc */
92
    public function addConstraints()
93
    {
94
        $this->performJoins();
95
96
        if (static::$constraints) {
97
            $localValue = $this->parent[$this->getFirstForeignKeyName()];
98
99
            $this->query->where($this->getQualifiedFirstLocalKeyName(), '=', $localValue);
100
        }
101
    }
102
103
    /**
104
     * Set the join clauses on the query.
105
     *
106
     * @param \Illuminate\Database\Eloquent\Builder<\Illuminate\Database\Eloquent\Model>|null $query
107
     * @return void
108
     */
109
    protected function performJoins(?Builder $query = null)
110
    {
111
        $query = $query ?: $this->query;
112
113
        foreach ($this->throughParents as $i => $model) {
114
            $predecessor = $i > 0 ? $this->throughParents[$i - 1] : $this->related;
115
116
            $first = $model->qualifyColumn($this->getForeignKeyName($predecessor));
117
118
            $second = $predecessor->qualifyColumn($this->getLocalKeyName($predecessor));
119
120
            $query->join($model->getTable(), $first, '=', $second);
121
122
            if ($this->hasSoftDeletes($model)) {
123
                /** @phpstan-ignore method.notFound */
124
                $column = $model->getQualifiedDeletedAtColumn();
125
126
                $query->withGlobalScope(__CLASS__ . ":{$column}", function (Builder $query) use ($column) {
127
                    $query->whereNull($column);
128
                });
129
            }
130
        }
131
    }
132
133
    /**
134
     * Get the foreign key for a model.
135
     *
136
     * @param \Illuminate\Database\Eloquent\Model|null $model
137
     * @return string
138
     */
139
    public function getForeignKeyName(?Model $model = null)
140
    {
141
        $table = explode(' as ', ($model ?? $this->parent)->getTable())[0];
142
143
        if (array_key_exists($table, $this->foreignKeyLookup)) {
144
            return $this->foreignKeyLookup[$table];
145
        }
146
147
        return Str::singular($table) . '_id';
148
    }
149
150
    /**
151
     * Get the local key for a model.
152
     *
153
     * @param \Illuminate\Database\Eloquent\Model $model
154
     * @return string
155
     */
156
    public function getLocalKeyName(Model $model): string
157
    {
158
        $table = explode(' as ', $model->getTable())[0];
159
160
        if (array_key_exists($table, $this->localKeyLookup)) {
161
            return $this->localKeyLookup[$table];
162
        }
163
164
        return $model->getKeyName();
165
    }
166
167
    /**
168
     * Determine whether a model uses SoftDeletes.
169
     *
170
     * @param \Illuminate\Database\Eloquent\Model $model
171
     * @return bool
172
     */
173
    public function hasSoftDeletes(Model $model)
174
    {
175
        return in_array(SoftDeletes::class, class_uses_recursive($model));
176
    }
177
178
    /** @inheritDoc */
179
    public function addEagerConstraints(array $models)
180
    {
181
        $keys = $this->getKeys($models, $this->getFirstForeignKeyName());
182
183
        $this->query->whereIn($this->getQualifiedFirstLocalKeyName(), $keys);
184
    }
185
186
    /** @inheritDoc */
187
    public function initRelation(array $models, $relation)
188
    {
189
        foreach ($models as $model) {
190
            $model->setRelation($relation, $this->getDefaultFor($model));
191
        }
192
193
        return $models;
194
    }
195
196
    /** @inheritDoc */
197
    public function match(array $models, Collection $results, $relation)
198
    {
199
        $dictionary = $this->buildDictionary($results);
200
201
        foreach ($models as $model) {
202
            $key = $model[$this->getFirstForeignKeyName()];
203
204
            if (isset($dictionary[$key])) {
205
                $model->setRelation($relation, $dictionary[$key]);
206
            }
207
        }
208
209
        return $models;
210
    }
211
212
    /**
213
     * Build model dictionary keyed by the relation's foreign key.
214
     *
215
     * @param \Illuminate\Database\Eloquent\Collection<int, TRelatedModel> $results
216
     * @return TRelatedModel[]
217
     */
218
    protected function buildDictionary(Collection $results)
219
    {
220
        $dictionary = [];
221
222
        foreach ($results as $result) {
223
            $dictionary[$result[static::THROUGH_KEY]] = $result;
224
225
            unset($result[static::THROUGH_KEY]);
226
        }
227
228
        return $dictionary;
229
    }
230
231
    /**
232
     * Get the results of the relationship.
233
     *
234
     * @return TRelatedModel|object|static|null
0 ignored issues
show
The type Znck\Eloquent\Relations\TRelatedModel was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
235
     */
236
    public function getResults()
237
    {
238
        return $this->first() ?: $this->getDefaultFor($this->parent);
239
    }
240
241
    /**
242
     * Execute the query and get the first result.
243
     *
244
     * @param string[] $columns
245
     * @return TRelatedModel|object|static|null
246
     */
247
    public function first($columns = ['*'])
248
    {
249
        if ($columns === ['*']) {
250
            $columns = [$this->related->getTable() . '.*'];
251
        }
252
253
        return $this->query->first($columns);
254
    }
255
256
    /** @inheritDoc */
257
    public function get($columns = ['*'])
258
    {
259
        $columns = $this->query->getQuery()->columns ? [] : $columns;
260
261
        if ($columns === ['*']) {
262
            $columns = [$this->related->getTable() . '.*'];
263
        }
264
265
        $columns[] = $this->getQualifiedFirstLocalKeyName() . ' as ' . static::THROUGH_KEY;
266
267
        $this->query->addSelect($columns);
268
269
        return $this->query->get();
270
    }
271
272
    /** @inheritDoc */
273
    public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*'])
274
    {
275
        $this->performJoins($query);
276
277
        $from = $parentQuery->getQuery()->from;
278
279
        if ($from instanceof Expression) {
280
            $from = $from->getValue(
281
                $parentQuery->getGrammar()
0 ignored issues
show
It seems like $parentQuery->getGrammar() can also be of type Illuminate\Database\Eloquent\Builder; however, parameter $grammar of Illuminate\Database\Query\Expression::getValue() does only seem to accept Illuminate\Database\Grammar, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

281
                /** @scrutinizer ignore-type */ $parentQuery->getGrammar()
Loading history...
282
            );
283
        }
284
285
        $foreignKey = $from . '.' . $this->getFirstForeignKeyName();
286
287
        /** @var \Illuminate\Database\Eloquent\Builder<TRelatedModel> $query */
288
        $query = $query->select($columns)->whereColumn(
289
            $this->getQualifiedFirstLocalKeyName(),
290
            '=',
291
            $foreignKey
292
        );
293
294
        return $query;
295
    }
296
297
    /**
298
     * Restore soft-deleted models.
299
     *
300
     * @param string[]|string ...$columns
301
     * @return $this
302
     */
303
    public function withTrashed(...$columns)
304
    {
305
        if (empty($columns)) {
306
            /** @phpstan-ignore method.notFound */
307
            $this->query->withTrashed();
308
309
            return $this;
310
        }
311
312
        if (is_array($columns[0])) {
313
            $columns = $columns[0];
314
        }
315
316
        /** @var string[] $columns */
317
        foreach ($columns as $column) {
318
            $this->query->withoutGlobalScope(__CLASS__ . ":$column");
319
        }
320
321
        return $this;
322
    }
323
324
    /**
325
     * Get the "through" parent model instances.
326
     *
327
     * @return list<\Illuminate\Database\Eloquent\Model>
328
     */
329
    public function getThroughParents()
330
    {
331
        return $this->throughParents;
332
    }
333
334
    /**
335
     * Get the foreign key for the first "through" parent model.
336
     *
337
     * @return string
338
     */
339
    public function getFirstForeignKeyName()
340
    {
341
        $firstThroughParent = end($this->throughParents);
342
343
        if ($firstThroughParent === false) {
344
            // @codeCoverageIgnore
345
            throw new RuntimeException('No "through" parent models were specified.');
346
        }
347
348
        return $this->prefix . $this->getForeignKeyName($firstThroughParent);
349
    }
350
351
    /**
352
     * Get the qualified local key for the first "through" parent model.
353
     *
354
     * @return string
355
     */
356
    public function getQualifiedFirstLocalKeyName()
357
    {
358
        $firstThroughParent = end($this->throughParents);
359
360
        if ($firstThroughParent === false) {
361
            // @codeCoverageIgnore
362
            throw new RuntimeException('No "through" parent models were specified.');
363
        }
364
365
        return $firstThroughParent->qualifyColumn($this->getLocalKeyName($firstThroughParent));
366
    }
367
368
    /**
369
     * Make a new related instance for the given model.
370
     *
371
     * @param \Illuminate\Database\Eloquent\Model $parent
372
     * @return \Illuminate\Database\Eloquent\Model
373
     */
374
    protected function newRelatedInstanceFor(Model $parent)
0 ignored issues
show
The parameter $parent is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

374
    protected function newRelatedInstanceFor(/** @scrutinizer ignore-unused */ Model $parent)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
375
    {
376
        return $this->related->newInstance();
377
    }
378
}
379