From d0ff79ea6010f71861ce687c1f768264db3978e1 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 22 May 2021 14:05:28 +0100 Subject: [PATCH] Revamped some complex queries, added favourites to home - Removed old view system and started use of new query classes instead. - Finished off RelationMultiModelQuery but found it was less efficient than x-many queries due to the amount of tables being scanned. Adding now for history but will delete as not used. - Updated recently viewed to use same query system as popular items rather than running and joining x-entities queries. - Added "Most Viewed Faviourites" listing to homepages. --- app/Actions/View.php | 2 +- app/Actions/ViewService.php | 81 ------- app/Config/app.php | 2 - app/Entities/Queries/EntityQuery.php | 17 ++ app/Entities/Queries/Popular.php | 29 +++ app/Entities/Queries/RecentlyViewed.php | 32 +++ app/Entities/Queries/TopFavourites.php | 36 +++ .../Tools/RelationMultiModelQuery.php | 218 +++++++++++++++--- app/Facades/Views.php | 16 -- app/Http/Controllers/HomeController.php | 9 +- app/Http/Controllers/SearchController.php | 3 +- resources/lang/en/entities.php | 1 + resources/views/common/home-sidebar.blade.php | 12 + resources/views/common/home.blade.php | 12 + resources/views/errors/404.blade.php | 6 +- 15 files changed, 337 insertions(+), 139 deletions(-) delete mode 100644 app/Actions/ViewService.php create mode 100644 app/Entities/Queries/EntityQuery.php create mode 100644 app/Entities/Queries/Popular.php create mode 100644 app/Entities/Queries/RecentlyViewed.php create mode 100644 app/Entities/Queries/TopFavourites.php delete mode 100644 app/Facades/Views.php diff --git a/app/Actions/View.php b/app/Actions/View.php index 62e03d9f4..de30900c7 100644 --- a/app/Actions/View.php +++ b/app/Actions/View.php @@ -42,7 +42,7 @@ class View extends Model 'user_id' => $user->id, ], ['views' => 0]); - $view->save(['views' => $view->views + 1]); + $view->forceFill(['views' => $view->views + 1])->save(); return $view->views; } diff --git a/app/Actions/ViewService.php b/app/Actions/ViewService.php deleted file mode 100644 index febc93af3..000000000 --- a/app/Actions/ViewService.php +++ /dev/null @@ -1,81 +0,0 @@ -view = $view; - $this->permissionService = $permissionService; - $this->entityProvider = $entityProvider; - } - - /** - * Get the entities with the most views. - * @param int $count - * @param int $page - * @param string|array $filterModels - * @param string $action - used for permission checking - * @return Collection - */ - public function getPopular(int $count = 10, int $page = 0, array $filterModels = null, string $action = 'view') - { - $skipCount = $count * $page; - $query = $this->permissionService - ->filterRestrictedEntityRelations($this->view->newQuery(), 'views', 'viewable_id', 'viewable_type', $action) - ->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count')) - ->groupBy('viewable_id', 'viewable_type') - ->orderBy('view_count', 'desc'); - - if ($filterModels) { - $query->whereIn('viewable_type', $this->entityProvider->getMorphClasses($filterModels)); - } - - return $query->with('viewable') - ->skip($skipCount) - ->take($count) - ->get() - ->pluck('viewable') - ->filter(); - } - - /** - * Get all recently viewed entities for the current user. - */ - public function getUserRecentlyViewed(int $count = 10, int $page = 1) - { - $user = user(); - if ($user === null || $user->isDefault()) { - return collect(); - } - - $all = collect(); - /** @var Entity $instance */ - foreach ($this->entityProvider->all() as $name => $instance) { - $items = $instance::visible()->withLastView() - ->having('last_viewed_at', '>', 0) - ->orderBy('last_viewed_at', 'desc') - ->skip($count * ($page - 1)) - ->take($count) - ->get(); - $all = $all->concat($items); - } - - return $all->sortByDesc('last_viewed_at')->slice(0, $count); - } -} diff --git a/app/Config/app.php b/app/Config/app.php index 065845f96..88f38423a 100755 --- a/app/Config/app.php +++ b/app/Config/app.php @@ -184,11 +184,9 @@ return [ // Custom BookStack 'Activity' => BookStack\Facades\Activity::class, - 'Views' => BookStack\Facades\Views::class, 'Images' => BookStack\Facades\Images::class, 'Permissions' => BookStack\Facades\Permissions::class, 'Theme' => BookStack\Facades\Theme::class, - ], // Proxy configuration diff --git a/app/Entities/Queries/EntityQuery.php b/app/Entities/Queries/EntityQuery.php new file mode 100644 index 000000000..bd920c3a5 --- /dev/null +++ b/app/Entities/Queries/EntityQuery.php @@ -0,0 +1,17 @@ +make(PermissionService::class); + } + + protected function entityProvider(): EntityProvider + { + return app()->make(EntityProvider::class); + } +} \ No newline at end of file diff --git a/app/Entities/Queries/Popular.php b/app/Entities/Queries/Popular.php new file mode 100644 index 000000000..98db2fe62 --- /dev/null +++ b/app/Entities/Queries/Popular.php @@ -0,0 +1,29 @@ +permissionService() + ->filterRestrictedEntityRelations(View::query(), 'views', 'viewable_id', 'viewable_type', $action) + ->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count')) + ->groupBy('viewable_id', 'viewable_type') + ->orderBy('view_count', 'desc'); + + if ($filterModels) { + $query->whereIn('viewable_type', $this->entityProvider()->getMorphClasses($filterModels)); + } + + return $query->with('viewable') + ->skip($count * ($page - 1)) + ->take($count) + ->get() + ->pluck('viewable') + ->filter(); + } + +} \ No newline at end of file diff --git a/app/Entities/Queries/RecentlyViewed.php b/app/Entities/Queries/RecentlyViewed.php new file mode 100644 index 000000000..d528fea44 --- /dev/null +++ b/app/Entities/Queries/RecentlyViewed.php @@ -0,0 +1,32 @@ +isDefault()) { + return collect(); + } + + $query = $this->permissionService()->filterRestrictedEntityRelations( + View::query(), + 'views', + 'viewable_id', + 'viewable_type', + 'view' + ) + ->orderBy('views.updated_at', 'desc') + ->where('user_id', '=', user()->id); + + return $query->with('viewable') + ->skip(($page - 1) * $count) + ->take($count) + ->get() + ->pluck('viewable') + ->filter(); + } +} diff --git a/app/Entities/Queries/TopFavourites.php b/app/Entities/Queries/TopFavourites.php new file mode 100644 index 000000000..aaacdc5ad --- /dev/null +++ b/app/Entities/Queries/TopFavourites.php @@ -0,0 +1,36 @@ +isDefault()) { + return collect(); + } + + $query = $this->permissionService() + ->filterRestrictedEntityRelations(View::query(), 'views', 'viewable_id', 'viewable_type', 'view') + ->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count')) + ->groupBy('viewable_id', 'viewable_type') + ->rightJoin('favourites', function (JoinClause $join) { + $join->on('views.viewable_id', '=', 'favourites.favouritable_id'); + $join->on('views.viewable_type', '=', 'favourites.favouritable_type'); + $join->where('favourites.user_id', '=', user()->id); + }) + ->orderBy('view_count', 'desc'); + + return $query->with('viewable') + ->skip($count * ($page - 1)) + ->take($count) + ->get() + ->pluck('viewable') + ->filter(); + } + +} \ No newline at end of file diff --git a/app/Entities/Tools/RelationMultiModelQuery.php b/app/Entities/Tools/RelationMultiModelQuery.php index 68a90acd0..c992fe454 100644 --- a/app/Entities/Tools/RelationMultiModelQuery.php +++ b/app/Entities/Tools/RelationMultiModelQuery.php @@ -18,28 +18,6 @@ use Illuminate\Support\Collection; */ class RelationMultiModelQuery { - - // TODO - Hydrate results to models - // TODO - Allow setting additional wheres and all-model columns (From the core relation - eg, last_viewed_at) - -//select views.updated_at as last_viewed_at, -//b.id as book_id, b.name as book_name, b.slug as book_slug, b.description as book_description, -//s.id as bookshelf_id, s.name as bookshelf_name, s.slug as bookshelf_slug, s.description as bookshelf_description, -//c.id as chapter_id, c.name as chapter_name, c.slug as chapter_slug, c.description as chapter_description, -//p.id as page_id, p.name as page_name, p.slug as page_slug, p.text as page_description -//from views -//left join bookshelves s on (s.id = views.viewable_id and views.viewable_type = 'BookStack\\Bookshelf' and s.deleted_at is null) -//left join books b on (b.id = views.viewable_id and views.viewable_type = 'BookStack\\Book' and b.deleted_at is null) -//left join chapters c on (c.id = views.viewable_id and views.viewable_type = 'BookStack\\Chapter' and c.deleted_at is null) -//left join pages p on (p.id = views.viewable_id and views.viewable_type = 'BookStack\\Page' and p.deleted_at is null) -//# Permissions -//where exists( -//select * from joint_permissions jp where jp.entity_id = views.viewable_id and jp.entity_type = views.viewable_type -//and jp.action = 'view' and jp.role_id in (1, 2, 3, 6, 12) and (jp.has_permission = 1 or (jp.has_permission_own = 1 and jp.owned_by = 1)) -//) -//and (s.id is not null or b.id is not null or c.id is not null or p.id is not null) -//and views.user_id = 1 - /** @var array */ protected $lookupModels = []; @@ -49,9 +27,52 @@ class RelationMultiModelQuery /** @var string */ protected $polymorphicFieldName; - public function __construct(Model $relation, string $polymorphicFieldName) + /** + * The keys are relation fields to fetch. + * The values are the name to use for the resulting model attribute. + * @var array + */ + protected $relationFields = []; + + /** + * An array of [string $col, string $operator, mixed $value] where conditions. + * @var array> + */ + protected $relationWheres = []; + + /** + * Field on the relation field to order by. + * @var ?array[string $column, string $direction] + */ + protected $orderByRelationField = null; + + /** + * Number of results to take + * @var ?int + */ + protected $take = null; + + /** + * Number of results to skip. + * @var ?int + */ + protected $skip = null; + + /** + * Callback that will receive the query for any advanced customization. + * @var ?callable + */ + protected $queryCustomizer = null; + + /** + * @throws \Exception + */ + public function __construct(string $relation, string $polymorphicFieldName) { - $this->relation = $relation; + $this->relation = (new $relation); + if (!$this->relation instanceof Model) { + throw new \Exception('Given relation must be a model instance class'); + } $this->polymorphicFieldName = $polymorphicFieldName; } @@ -76,6 +97,78 @@ class RelationMultiModelQuery return $this; } + /** + * Bring back a field from the relation object with the model results. + */ + public function withRelationField(string $fieldName, string $modelAttributeName): self + { + $this->relationFields[$fieldName] = $modelAttributeName; + return $this; + } + + /** + * Add a where condition to the query for the main relation table. + */ + public function whereRelation(string $column, string $operator, $value): self + { + $this->relationWheres[] = [$column, $operator, $value]; + return $this; + } + + /** + * Order by the given relation column. + */ + public function orderByRelation(string $column, string $direction = 'asc'): self + { + $this->orderByRelationField = [$column, $direction]; + return $this; + } + + /** + * Skip the given $count of results in the query. + */ + public function skip(?int $count): self + { + $this->skip = $count; + return $this; + } + + /** + * Take the given $count of results in the query. + */ + public function take(?int $count): self + { + $this->take = $count; + return $this; + } + + /** + * Pass a callable, which will receive the base query + * to perform additional custom operations on the query. + */ + public function customizeUsing(callable $customizer): self + { + $this->queryCustomizer = $customizer; + return $this; + } + + /** + * Get the SQL from the core query being ran. + */ + public function toSql(): string + { + return $this->build()->toSql(); + } + + /** + * Run the query and get the results. + */ + public function run(): Collection + { + $results = $this->build()->get(); + return $this->hydrateModelsFromResults($results); + } + /** * Build the core query to run. */ @@ -85,6 +178,14 @@ class RelationMultiModelQuery $relationTable = $this->relation->getTable(); $modelTables = []; + // Load relation fields + foreach ($this->relationFields as $relationField => $alias) { + $query->addSelect( + $relationTable . '.' . $relationField . ' as ' + . $relationTable . '@' . $relationField + ); + } + // Load model selects & joins foreach ($this->lookupModels as $lookupModel => $columns) { /** @var Entity $model */ @@ -107,11 +208,34 @@ class RelationMultiModelQuery } }); + // Add relation wheres + foreach ($this->relationWheres as [$column, $operator, $value]) { + $query->where($relationTable . '.' . $column, $operator, $value); + } + + // Skip and take + if (!is_null($this->skip)) { + $query->skip($this->skip); + } + if (!is_null($this->take)) { + $query->take($this->take); + } + if (!is_null($this->queryCustomizer)) { + $customizer = $this->queryCustomizer; + $customizer($query); + } + if (!is_null($this->orderByRelationField)) { + $query->orderBy($relationTable . '.' . $this->orderByRelationField[0], $this->orderByRelationField[1]); + } + $this->applyPermissionsToQuery($query, 'view'); return $query; } + /** + * Run the query through the permission system. + */ protected function applyPermissionsToQuery(Builder $query, string $action) { $permissions = app()->make(PermissionService::class); @@ -131,24 +255,54 @@ class RelationMultiModelQuery { $selectArray = []; foreach ($columns as $column) { - $selectArray[] = $table . '.' . $column . ' as '. $table . '_' . $column; + $selectArray[] = $table . '.' . $column . ' as ' . $table . '@' . $column; } return $selectArray; } /** - * Get the SQL from the core query being ran. + * Hydrate a collection of result data into models. */ - public function toSql(): string + protected function hydrateModelsFromResults(Collection $results): Collection { - return $this->build()->toSql(); + $modelByIdColumn = []; + foreach ($this->lookupModels as $lookupModel => $columns) { + /** @var Model $model */ + $model = new $lookupModel; + $modelByIdColumn[$model->getTable() . '@id'] = $model; + } + + return $results->map(function ($result) use ($modelByIdColumn) { + foreach ($modelByIdColumn as $idColumn => $modelInstance) { + if (isset($result->$idColumn)) { + return $this->hydrateModelFromResult($modelInstance, $result); + } + } + return null; + }); } /** - * Run the query and get the results. + * Hydrate the given model type with the database result. */ - public function run(): Collection + protected function hydrateModelFromResult(Model $model, \stdClass $result): Model { - return $this->build()->get(); + $modelPrefix = $model->getTable() . '@'; + $relationPrefix = $this->relation->getTable() . '@'; + $attrs = []; + + foreach ((array) $result as $col => $value) { + if (strpos($col, $modelPrefix) === 0) { + $attrName = substr($col, strlen($modelPrefix)); + $attrs[$attrName] = $value; + } + if (strpos($col, $relationPrefix) === 0) { + $col = substr($col, strlen($relationPrefix)); + $attrName = $this->relationFields[$col]; + $attrs[$attrName] = $value; + } + } + + return $model->newInstance()->forceFill($attrs); } -} \ No newline at end of file +} diff --git a/app/Facades/Views.php b/app/Facades/Views.php deleted file mode 100644 index f5357112e..000000000 --- a/app/Facades/Views.php +++ /dev/null @@ -1,16 +0,0 @@ - 0 ? 0.5 : 1; $recents = $this->isSignedIn() ? - Views::getUserRecentlyViewed(12*$recentFactor, 1) + (new RecentlyViewed)->run(12*$recentFactor, 1) : Book::visible()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get(); + $faviourites = (new TopFavourites)->run(6, 1); $recentlyUpdatedPages = Page::visible()->with('book') ->where('draft', false) ->orderBy('updated_at', 'desc') - ->take(12) + ->take($faviourites->count() > 0 ? 6 : 12) ->get(); $homepageOptions = ['default', 'books', 'bookshelves', 'page']; @@ -51,6 +53,7 @@ class HomeController extends Controller 'recents' => $recents, 'recentlyUpdatedPages' => $recentlyUpdatedPages, 'draftPages' => $draftPages, + 'favourites' => $faviourites, ]; // Add required list ordering & sorting for books & shelves views. diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index bb824fd9b..1f77e6377 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -1,6 +1,7 @@ searchRunner->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 20, $permission)['results']; } else { - $entities = $this->viewService->getPopular(20, 0, $entityTypes, $permission); + $entities = (new Popular)->run(20, 0, $entityTypes, $permission); } return view('search.entity-ajax-list', ['entities' => $entities]); diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php index 1661bae57..6c3341368 100644 --- a/resources/lang/en/entities.php +++ b/resources/lang/en/entities.php @@ -27,6 +27,7 @@ return [ 'images' => 'Images', 'my_recent_drafts' => 'My Recent Drafts', 'my_recently_viewed' => 'My Recently Viewed', + 'my_most_viewed_favourites' => 'My Most Viewed Favourites', 'no_pages_viewed' => 'You have not viewed any pages', 'no_pages_recently_created' => 'No pages have been recently created', 'no_pages_recently_updated' => 'No pages have been recently updated', diff --git a/resources/views/common/home-sidebar.blade.php b/resources/views/common/home-sidebar.blade.php index 4c36ce61a..b66c8495d 100644 --- a/resources/views/common/home-sidebar.blade.php +++ b/resources/views/common/home-sidebar.blade.php @@ -5,6 +5,18 @@ @endif +@if(count($favourites) > 0) +
+

{{ trans('entities.my_most_viewed_favourites') }}

+
+ @include('partials.entity-list', [ + 'entities' => $favourites, + 'style' => 'compact', + ]) +
+
+@endif +
{{ trans('entities.' . (auth()->check() ? 'my_recently_viewed' : 'books_recent')) }}
@include('partials.entity-list', [ diff --git a/resources/views/common/home.blade.php b/resources/views/common/home.blade.php index ad503463e..acfca97b2 100644 --- a/resources/views/common/home.blade.php +++ b/resources/views/common/home.blade.php @@ -42,6 +42,18 @@
+ @if(count($favourites) > 0) +
+

{{ trans('entities.my_most_viewed_favourites') }}

+
+ @include('partials.entity-list', [ + 'entities' => $favourites, + 'style' => 'compact', + ]) +
+
+ @endif +

{{ trans('entities.recently_updated_pages') }}

diff --git a/resources/views/errors/404.blade.php b/resources/views/errors/404.blade.php index b3325ba82..d4d8ed76b 100644 --- a/resources/views/errors/404.blade.php +++ b/resources/views/errors/404.blade.php @@ -26,7 +26,7 @@

{{ trans('entities.pages_popular') }}

- @include('partials.entity-list', ['entities' => Views::getPopular(10, 0, ['page']), 'style' => 'compact']) + @include('partials.entity-list', ['entities' => (new \BookStack\Entities\Queries\Popular)->run(10, 0, ['page']), 'style' => 'compact'])
@@ -34,7 +34,7 @@

{{ trans('entities.books_popular') }}

- @include('partials.entity-list', ['entities' => Views::getPopular(10, 0, ['book']), 'style' => 'compact']) + @include('partials.entity-list', ['entities' => (new \BookStack\Entities\Queries\Popular)->run(10, 0, ['book']), 'style' => 'compact'])
@@ -42,7 +42,7 @@

{{ trans('entities.chapters_popular') }}

- @include('partials.entity-list', ['entities' => Views::getPopular(10, 0, ['chapter']), 'style' => 'compact']) + @include('partials.entity-list', ['entities' => (new \BookStack\Entities\Queries\Popular)->run(10, 0, ['chapter']), 'style' => 'compact'])