Issues (3)

src/RelationPlus.php (2 issues)

1
<?php
2
3
namespace Blasttech\EloquentRelatedPlus;
4
5
use DB;
6
use Illuminate\Database\Eloquent\Builder;
7
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8
use Illuminate\Database\Eloquent\Relations\HasMany;
9
use Illuminate\Database\Eloquent\Relations\HasOneOrMany;
10
use Illuminate\Database\Query\Expression;
11
use Illuminate\Database\Query\JoinClause;
12
13
/**
14
 * Class RelationPlus
15
 *
16
 * @package Blasttech\EloquentRelatedPlus\Relations
17
 */
18
class RelationPlus
19
{
20
    use HelpersTrait;
21
22
    /**
23
     * @var string $tableName
24
     */
25
    public $tableName;
26
27
    /**
28
     * @var string $tableAlias
29
     */
30
    public $tableAlias;
31
32
    /**
33
     * Initialise $relation, $tableName and $tableAlias
34
     * If using a 'table' AS 'tableAlias' in a from statement, otherwise alias will be the table name
35
     *
36
     * @var BelongsTo|HasOneOrMany $relation
37
     */
38
    private $relation;
39
40
    public function __construct($relation)
41
    {
42
        $this->setRelation($relation);
43
        $this->tableName = $this->relation->getRelated()->getTable();
44
        $from = explode(' ', $this->relation->getQuery()->getQuery()->from);
45
        $this->tableAlias = array_pop($from);
46
    }
47
48
    /**
49
     * @return BelongsTo|HasOneOrMany
50
     */
51
    public function getRelation()
52
    {
53
        return $this->relation;
54
    }
55
56
    /**
57
     * @param BelongsTo|HasOneOrMany $relation
58
     */
59
    public function setRelation($relation)
60
    {
61
        $this->relation = $relation;
62
    }
63
64
    /**
65
     * Check relation type and get join
66
     *
67
     * @param JoinClause $join
68
     * @param string $operator
69
     * @param string|null $direction
70
     * @return Builder|JoinClause
71
     */
72
    public function getRelationJoin($join, $operator, $direction = null)
73
    {
74
        // If a HasOne relation and ordered - ie join to the latest/earliest
75
        if (class_basename($this->relation) === 'HasOne') {
76
            $this->relation = $this->removeGlobalScopes($this->relation->getRelated(), $this->relation, 'order');
77
78
            if (!empty($this->getOrders())) {
79
                return $this->hasOneJoin($join);
80
            }
81
        }
82
83
        return $this->hasManyJoin($join, $operator, $direction);
84
    }
85
86
    /**
87
     * Get the orders for the relation
88
     *
89
     * @return array
90
     */
91
    private function getOrders()
92
    {
93
        return $this->relation->toBase()->orders;
94
    }
95
96
    /**
97
     * Join a HasOne relation which is ordered
98
     *
99
     * @param JoinClause $join
100
     * @return JoinClause
101
     */
102
    private function hasOneJoin($join)
103
    {
104
        // Get first relation order (should only be one)
105
        $order = $this->getOrders()[0];
106
107
        return $join->on($order['column'], $this->hasOneJoinSql($order));
108
    }
109
110
    /**
111
     * Get join sql for a HasOne relation
112
     *
113
     * @param array $order
114
     * @return Expression
115
     */
116
    private function hasOneJoinSql($order)
117
    {
118
        // Build subquery for getting first/last record in related table
119
        $subQuery = $this
120
            ->joinOne(
121
                $this->relation->getRelated()->newQuery(),
122
                $order['column'],
123
                $order['direction']
124
            )
125
            ->setBindings($this->relation->getBindings());
126
127
        return DB::raw('(' . $this->toSqlWithBindings($subQuery) . ')');
128
    }
129
130
    /**
131
     * Adds a where for a relation's join columns and and min/max for a given column
132
     *
133
     * @param Builder $query
134
     * @param string $column
135
     * @param string $direction
136
     * @return Builder
137
     */
138
    private function joinOne($query, $column, $direction)
139
    {
140
        // Get join fields
141
        $joinColumns = $this->getJoinColumns();
142
143
        return $this->selectMinMax(
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->selectMinM...), $column, $direction) also could return the type Illuminate\Database\Query\Builder which is incompatible with the documented return type Illuminate\Database\Eloquent\Builder.
Loading history...
144
            $query->whereColumn($joinColumns->first, '=', $joinColumns->second),
145
            $column,
146
            $direction
147
        );
148
    }
149
150
    /**
151
     * Get the join columns for a relation
152
     *
153
     * @return \stdClass
154
     */
155
    private function getJoinColumns()
156
    {
157
        // Get keys with table names
158
        if ($this->relation instanceof BelongsTo) {
159
            return $this->getBelongsToColumns();
160
        }
161
162
        return $this->getHasOneOrManyColumns();
163
    }
164
165
    /**
166
     * Get the join columns for a BelongsTo relation
167
     *
168
     * @return object
169
     */
170
    private function getBelongsToColumns()
171
    {
172
        // Use relation ownerKey if it contains table name, otherwise use getQualifiedOwnerKeyName
173
        $first = $this->relation->getOwnerKeyName();
174
        if (!strpos($first, '.')) {
175
            $first = $this->relation->getQualifiedOwnerKeyName();
176
        }
177
178
        // Use relation foreignKey if it contains table name, otherwise use getQualifiedForeignKey
179
        $second = $this->relation->getForeignKeyName();
180
        if (!strpos($second, '.')) {
181
            $second = $this->relation->getQualifiedForeignKeyName();
182
        }
183
184
        return (object)['first' => $first, 'second' => $second];
185
    }
186
187
    /**
188
     * Get the join columns for a HasOneOrMany relation
189
     *
190
     * @return object
191
     */
192
    private function getHasOneOrManyColumns()
193
    {
194
        $first = $this->relation->getQualifiedParentKeyName();
195
        $second = $this->relation->getQualifiedForeignKeyName();
196
197
        return (object)['first' => $first, 'second' => $second];
198
    }
199
200
    /**
201
     * Adds a select for a min or max on the given column, depending on direction given
202
     *
203
     * @param Builder|\Illuminate\Database\Query\Builder $query
204
     * @param string $column
205
     * @param string $direction
206
     * @return Builder|\Illuminate\Database\Query\Builder
207
     */
208
    private function selectMinMax($query, $column, $direction)
209
    {
210
        $sqlDirection = ($direction == 'asc' ? 'MIN' : 'MAX');
211
212
        return $query->select(DB::raw($sqlDirection . '(' . $this->addBackticks($column) . ')'));
213
    }
214
215
    /**
216
     * Add backticks to a table/column
217
     *
218
     * @param string $column
219
     * @return string
220
     */
221
    private function addBackticks($column)
222
    {
223
        return preg_match('/^[0-9a-zA-Z\.]*$/', $column) ?
224
            '`' . str_replace(['`', '.'], ['', '`.`'], $column) . '`' : $column;
225
    }
226
227
    /**
228
     * Join a HasMany Relation
229
     *
230
     * @param JoinClause $join
231
     * @param string $operator
232
     * @param string $direction
233
     * @return Builder|JoinClause
234
     */
235
    private function hasManyJoin($join, $operator, $direction)
236
    {
237
        // Get relation join columns
238
        $joinColumns = $this->replaceColumnTables($this->getJoinColumns());
239
240
        $join->on($joinColumns->first, $operator, $joinColumns->second);
241
242
        // Add any where clauses from the relationship
243
        $join = $this->addRelatedWhereConstraints($join); // $table->alias
244
245
        if (!is_null($direction) && get_class($this->relation) === HasMany::class) {
0 ignored issues
show
The condition is_null($direction) is always false.
Loading history...
246
            $join = $this->hasManyJoinWhere($join, $joinColumns->first, $direction); // $table->alias,
247
        }
248
249
        return $join;
250
    }
251
252
    /**
253
     * Replace column table names with aliases
254
     *
255
     * @param \stdClass $joinColumns
256
     * @return \stdClass
257
     */
258
    private function replaceColumnTables($joinColumns)
259
    {
260
        if ($this->tableName !== $this->tableAlias) {
261
            $joinColumns->first = str_replace($this->tableName, $this->tableAlias, $joinColumns->first);
262
            $joinColumns->second = str_replace($this->tableName, $this->tableAlias, $joinColumns->second);
263
        }
264
265
        return $joinColumns;
266
    }
267
268
    /**
269
     * Add wheres if they exist for a relation
270
     *
271
     * @param Builder|JoinClause $builder
272
     * @return Builder|JoinClause $builder
273
     */
274
    private function addRelatedWhereConstraints($builder)
275
    {
276
        // Get where clauses from the relationship
277
        $wheres = collect($this->relation->toBase()->wheres)
278
            ->whereIn('type', ['Basic', 'Nested'])
279
            ->map(function ($where) {
280
                return collect($where['type'] == 'Basic' ? [$where] : $where['query']->wheres)
281
                    ->map(function ($where) {
282
                        // Add table name to column if it is absent
283
                        return [
284
                            $this->columnWithTableName($where['column']),
285
                            $where['operator'],
286
                            $where['value']
287
                        ];
288
                    });
289
            })
290
            ->flatten(1)
291
            ->toArray();
292
293
        if (!empty($wheres)) {
294
            $builder->where($wheres);
295
        }
296
297
        return $builder;
298
    }
299
300
    /**
301
     * Add table name to column name if table name not already included in column name
302
     *
303
     * @param string $column
304
     * @return string
305
     */
306
    private function columnWithTableName($column)
307
    {
308
        return (preg_match('/(' . $this->tableAlias . '\.|`' . $this->tableAlias . '`)/i', $column) > 0
309
                ? '' : $this->tableAlias . '.') . $column;
310
    }
311
312
    /**
313
     * If the relation is one-to-many, just get the first related record
314
     *
315
     * @param JoinClause $joinClause
316
     * @param string $column
317
     * @param string $direction
318
     *
319
     * @return JoinClause
320
     */
321
    private function hasManyJoinWhere(JoinClause $joinClause, $column, $direction)
322
    {
323
        return $joinClause->where(
324
            $column,
325
            function ($subQuery) use ($column, $direction) {
326
                $subQuery = $this->joinOne(
327
                    $subQuery->from($this->tableAlias),
328
                    $column,
329
                    $direction
330
                );
331
332
                // Add any where statements with the relationship
333
                $subQuery = $this->addRelatedWhereConstraints($subQuery); // $this->tableAlias
334
335
                // Add any order statements with the relationship
336
                return $this->addOrder($subQuery); // $this->tableAlias
337
            }
338
        );
339
    }
340
341
    /**
342
     * Add orderBy if orders exist for a relation
343
     *
344
     * @param Builder|JoinClause $builder
345
     * @return Builder|JoinClause $builder
346
     */
347
    private function addOrder($builder)
348
    {
349
        if (!empty($this->getOrders())) {
350
            // Get where clauses from the relationship
351
            foreach ($this->getOrders() as $order) {
352
                $builder->orderBy($this->columnWithTableName($order['column']), $order['direction']);
353
            }
354
        }
355
356
        return $builder;
357
    }
358
359
    /**
360
     * Get table name with alias if different to table name
361
     *
362
     * @return string
363
     */
364
    public function getTableWithAlias()
365
    {
366
        if ($this->tableAlias !== '' && $this->tableName !== $this->tableAlias) {
367
            return $this->tableName . ' AS ' . $this->tableAlias;
368
        }
369
370
        // it means the connection DB name is not on the table alias yet
371
        if (strpos('.', $this->tableName) === null) {
372
            return $this->relation->getConnection()->getDatabaseName() . '.' . $this->tableName;
373
        }
374
375
        return $this->tableName;
376
    }
377
}
378