1 <?php namespace BookStack\Entities\Tools;
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;
10 use Illuminate\Database\Query\Builder;
11 use Illuminate\Database\Query\JoinClause;
12 use Illuminate\Support\Collection;
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.
19 class RelationMultiModelQuery
21 /** @var array<string, array> */
22 protected $lookupModels = [];
28 protected $polymorphicFieldName;
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>
35 protected $relationFields = [];
38 * An array of [string $col, string $operator, mixed $value] where conditions.
41 protected $relationWheres = [];
44 * Field on the relation field to order by.
45 * @var ?array[string $column, string $direction]
47 protected $orderByRelationField = null;
50 * Number of results to take
53 protected $take = null;
56 * Number of results to skip.
59 protected $skip = null;
62 * Callback that will receive the query for any advanced customization.
65 protected $queryCustomizer = null;
70 public function __construct(string $relation, string $polymorphicFieldName)
72 $this->relation = (new $relation);
73 if (!$this->relation instanceof Model) {
74 throw new \Exception('Given relation must be a model instance class');
76 $this->polymorphicFieldName = $polymorphicFieldName;
80 * Set the query to look up the given entity type.
82 public function forEntity(string $class, array $columns): self
84 $this->lookupModels[$class] = $columns;
89 * Set the query to look up all entity types.
91 public function forAllEntities(): self
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'];
101 * Bring back a field from the relation object with the model results.
103 public function withRelationField(string $fieldName, string $modelAttributeName): self
105 $this->relationFields[$fieldName] = $modelAttributeName;
110 * Add a where condition to the query for the main relation table.
112 public function whereRelation(string $column, string $operator, $value): self
114 $this->relationWheres[] = [$column, $operator, $value];
119 * Order by the given relation column.
121 public function orderByRelation(string $column, string $direction = 'asc'): self
123 $this->orderByRelationField = [$column, $direction];
128 * Skip the given $count of results in the query.
130 public function skip(?int $count): self
132 $this->skip = $count;
137 * Take the given $count of results in the query.
139 public function take(?int $count): self
141 $this->take = $count;
146 * Pass a callable, which will receive the base query
147 * to perform additional custom operations on the query.
149 public function customizeUsing(callable $customizer): self
151 $this->queryCustomizer = $customizer;
156 * Get the SQL from the core query being ran.
158 public function toSql(): string
160 return $this->build()->toSql();
164 * Run the query and get the results.
166 public function run(): Collection
168 $results = $this->build()->get();
169 return $this->hydrateModelsFromResults($results);
173 * Build the core query to run.
175 protected function build(): Builder
177 $query = $this->relation->newQuery()->toBase();
178 $relationTable = $this->relation->getTable();
181 // Load relation fields
182 foreach ($this->relationFields as $relationField => $alias) {
184 $relationTable . '.' . $relationField . ' as '
185 . $relationTable . '@' . $relationField
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');
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');
211 // Add relation wheres
212 foreach ($this->relationWheres as [$column, $operator, $value]) {
213 $query->where($relationTable . '.' . $column, $operator, $value);
217 if (!is_null($this->skip)) {
218 $query->skip($this->skip);
220 if (!is_null($this->take)) {
221 $query->take($this->take);
223 if (!is_null($this->queryCustomizer)) {
224 $customizer = $this->queryCustomizer;
227 if (!is_null($this->orderByRelationField)) {
228 $query->orderBy($relationTable . '.' . $this->orderByRelationField[0], $this->orderByRelationField[1]);
231 $this->applyPermissionsToQuery($query, 'view');
237 * Run the query through the permission system.
239 protected function applyPermissionsToQuery(Builder $query, string $action)
241 $permissions = app()->make(PermissionService::class);
242 $permissions->filterRestrictedEntityRelations(
244 $this->relation->getTable(),
245 $this->polymorphicFieldName . '_id',
246 $this->polymorphicFieldName . '_type',
252 * Create an array of select statements from the given table and column.
254 protected function tableColumnsToSelectArray(string $table, array $columns): array
257 foreach ($columns as $column) {
258 $selectArray[] = $table . '.' . $column . ' as ' . $table . '@' . $column;
264 * Hydrate a collection of result data into models.
266 protected function hydrateModelsFromResults(Collection $results): Collection
268 $modelByIdColumn = [];
269 foreach ($this->lookupModels as $lookupModel => $columns) {
270 /** @var Model $model */
271 $model = new $lookupModel;
272 $modelByIdColumn[$model->getTable() . '@id'] = $model;
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);
286 * Hydrate the given model type with the database result.
288 protected function hydrateModelFromResult(Model $model, \stdClass $result): Model
290 $modelPrefix = $model->getTable() . '@';
291 $relationPrefix = $this->relation->getTable() . '@';
294 foreach ((array) $result as $col => $value) {
295 if (strpos($col, $modelPrefix) === 0) {
296 $attrName = substr($col, strlen($modelPrefix));
297 $attrs[$attrName] = $value;
299 if (strpos($col, $relationPrefix) === 0) {
300 $col = substr($col, strlen($relationPrefix));
301 $attrName = $this->relationFields[$col];
302 $attrs[$attrName] = $value;
306 return $model->newInstance()->forceFill($attrs);