]> BookStack Code Mirror - bookstack/blob - app/Entities/Tools/RelationMultiModelQuery.php
c992fe454e51504c01344a89e5b123ba8c086620
[bookstack] / app / Entities / Tools / RelationMultiModelQuery.php
1 <?php namespace BookStack\Entities\Tools;
2
3 use BookStack\Auth\Permissions\PermissionService;
4 use BookStack\Entities\Models\Book;
5 use BookStack\Entities\Models\Bookshelf;
6 use BookStack\Entities\Models\Chapter;
7 use BookStack\Entities\Models\Entity;
8 use BookStack\Entities\Models\Page;
9 use BookStack\Model;
10 use Illuminate\Database\Query\Builder;
11 use Illuminate\Database\Query\JoinClause;
12 use Illuminate\Support\Collection;
13
14 /**
15  * Create a query for a polymorphic relation
16  * that looks up all entity models in a single query
17  * returning a collection or various hydrated models.
18  */
19 class RelationMultiModelQuery
20 {
21     /** @var array<string, array> */
22     protected $lookupModels = [];
23
24     /** @var Model */
25     protected $relation;
26
27     /** @var string */
28     protected $polymorphicFieldName;
29
30     /**
31      * The keys are relation fields to fetch.
32      * The values are the name to use for the resulting model attribute.
33      * @var array<string, string>
34      */
35     protected $relationFields = [];
36
37     /**
38      * An array of [string $col, string $operator, mixed $value] where conditions.
39      * @var array<array>>
40      */
41     protected $relationWheres = [];
42
43     /**
44      * Field on the relation field to order by.
45      * @var ?array[string $column, string $direction]
46      */
47     protected $orderByRelationField = null;
48
49     /**
50      * Number of results to take
51      * @var ?int
52      */
53     protected $take = null;
54
55     /**
56      * Number of results to skip.
57      * @var ?int
58      */
59     protected $skip = null;
60
61     /**
62      * Callback that will receive the query for any advanced customization.
63      * @var ?callable
64      */
65     protected $queryCustomizer = null;
66
67     /**
68      * @throws \Exception
69      */
70     public function __construct(string $relation, string $polymorphicFieldName)
71     {
72         $this->relation = (new $relation);
73         if (!$this->relation instanceof Model) {
74             throw new \Exception('Given relation must be a model instance class');
75         }
76         $this->polymorphicFieldName = $polymorphicFieldName;
77     }
78
79     /**
80      * Set the query to look up the given entity type.
81      */
82     public function forEntity(string $class, array $columns): self
83     {
84         $this->lookupModels[$class] = $columns;
85         return $this;
86     }
87
88     /**
89      * Set the query to look up all entity types.
90      */
91     public function forAllEntities(): self
92     {
93         $this->lookupModels[Page::class] = ['id', 'name', 'slug', 'book_id', 'chapter_id', 'text'];
94         $this->lookupModels[Chapter::class] = ['id', 'name', 'slug', 'book_id', 'description'];
95         $this->lookupModels[Book::class] = ['id', 'name', 'slug', 'description', 'image_id'];
96         $this->lookupModels[Bookshelf::class] = ['id', 'name', 'slug', 'description', 'image_id'];
97         return $this;
98     }
99
100     /**
101      * Bring back a field from the relation object with the model results.
102      */
103     public function withRelationField(string $fieldName, string $modelAttributeName): self
104     {
105         $this->relationFields[$fieldName] = $modelAttributeName;
106         return $this;
107     }
108
109     /**
110      * Add a where condition to the query for the main relation table.
111      */
112     public function whereRelation(string $column, string $operator, $value): self
113     {
114         $this->relationWheres[] = [$column, $operator, $value];
115         return $this;
116     }
117
118     /**
119      * Order by the given relation column.
120      */
121     public function orderByRelation(string $column, string $direction = 'asc'): self
122     {
123         $this->orderByRelationField = [$column, $direction];
124         return $this;
125     }
126
127     /**
128      * Skip the given $count of results in the query.
129      */
130     public function skip(?int $count): self
131     {
132         $this->skip = $count;
133         return $this;
134     }
135
136     /**
137      * Take the given $count of results in the query.
138      */
139     public function take(?int $count): self
140     {
141         $this->take = $count;
142         return $this;
143     }
144
145     /**
146      * Pass a callable, which will receive the base query
147      * to perform additional custom operations on the query.
148      */
149     public function customizeUsing(callable $customizer): self
150     {
151         $this->queryCustomizer = $customizer;
152         return $this;
153     }
154
155     /**
156      * Get the SQL from the core query being ran.
157      */
158     public function toSql(): string
159     {
160         return $this->build()->toSql();
161     }
162
163     /**
164      * Run the query and get the results.
165      */
166     public function run(): Collection
167     {
168         $results = $this->build()->get();
169         return $this->hydrateModelsFromResults($results);
170     }
171
172     /**
173      * Build the core query to run.
174      */
175     protected function build(): Builder
176     {
177         $query = $this->relation->newQuery()->toBase();
178         $relationTable = $this->relation->getTable();
179         $modelTables = [];
180
181         // Load relation fields
182         foreach ($this->relationFields as $relationField => $alias) {
183             $query->addSelect(
184                 $relationTable . '.' . $relationField . ' as '
185                 . $relationTable . '@' . $relationField
186             );
187         }
188
189         // Load model selects & joins
190         foreach ($this->lookupModels as $lookupModel => $columns) {
191             /** @var Entity $model */
192             $model = (new $lookupModel);
193             $table = $model->getTable();
194             $modelTables[] = $table;
195             $query->addSelect($this->tableColumnsToSelectArray($table, $columns));
196             $query->leftJoin($table, function (JoinClause $join) use ($table, $relationTable, $model) {
197                 $polyPrefix = $relationTable . '.' . $this->polymorphicFieldName;
198                 $join->on($polyPrefix . '_id', '=', $table . '.id');
199                 $join->where($polyPrefix . '_type', '=', $model->getMorphClass());
200                 $join->whereNull($table . '.deleted_at');
201             });
202         }
203
204         // Where we have a model result
205         $query->where(function (Builder $query) use ($modelTables) {
206             foreach ($modelTables as $table) {
207                 $query->orWhereNotNull($table . '.id');
208             }
209         });
210
211         // Add relation wheres
212         foreach ($this->relationWheres as [$column, $operator, $value]) {
213             $query->where($relationTable . '.' . $column, $operator, $value);
214         }
215
216         // Skip and take
217         if (!is_null($this->skip)) {
218             $query->skip($this->skip);
219         }
220         if (!is_null($this->take)) {
221             $query->take($this->take);
222         }
223         if (!is_null($this->queryCustomizer)) {
224             $customizer = $this->queryCustomizer;
225             $customizer($query);
226         }
227         if (!is_null($this->orderByRelationField)) {
228             $query->orderBy($relationTable . '.' . $this->orderByRelationField[0], $this->orderByRelationField[1]);
229         }
230
231         $this->applyPermissionsToQuery($query, 'view');
232
233         return $query;
234     }
235
236     /**
237      * Run the query through the permission system.
238      */
239     protected function applyPermissionsToQuery(Builder $query, string $action)
240     {
241         $permissions = app()->make(PermissionService::class);
242         $permissions->filterRestrictedEntityRelations(
243             $query,
244             $this->relation->getTable(),
245             $this->polymorphicFieldName . '_id',
246             $this->polymorphicFieldName . '_type',
247             $action,
248         );
249     }
250
251     /**
252      * Create an array of select statements from the given table and column.
253      */
254     protected function tableColumnsToSelectArray(string $table, array $columns): array
255     {
256         $selectArray = [];
257         foreach ($columns as $column) {
258             $selectArray[] = $table . '.' . $column . ' as ' . $table . '@' . $column;
259         }
260         return $selectArray;
261     }
262
263     /**
264      * Hydrate a collection of result data into models.
265      */
266     protected function hydrateModelsFromResults(Collection $results): Collection
267     {
268         $modelByIdColumn = [];
269         foreach ($this->lookupModels as $lookupModel => $columns) {
270             /** @var Model $model */
271             $model = new $lookupModel;
272             $modelByIdColumn[$model->getTable() . '@id'] = $model;
273         }
274
275         return $results->map(function ($result) use ($modelByIdColumn) {
276             foreach ($modelByIdColumn as $idColumn => $modelInstance) {
277                 if (isset($result->$idColumn)) {
278                     return $this->hydrateModelFromResult($modelInstance, $result);
279                 }
280             }
281             return null;
282         });
283     }
284
285     /**
286      * Hydrate the given model type with the database result.
287      */
288     protected function hydrateModelFromResult(Model $model, \stdClass $result): Model
289     {
290         $modelPrefix = $model->getTable() . '@';
291         $relationPrefix = $this->relation->getTable() . '@';
292         $attrs = [];
293
294         foreach ((array) $result as $col => $value) {
295             if (strpos($col, $modelPrefix) === 0) {
296                 $attrName = substr($col, strlen($modelPrefix));
297                 $attrs[$attrName] = $value;
298             }
299             if (strpos($col, $relationPrefix) === 0) {
300                 $col = substr($col, strlen($relationPrefix));
301                 $attrName = $this->relationFields[$col];
302                 $attrs[$attrName] = $value;
303             }
304         }
305
306         return $model->newInstance()->forceFill($attrs);
307     }
308 }