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.
This commit is contained in:
Dan Brown 2021-05-22 14:05:28 +01:00
parent 3de02566bf
commit d0ff79ea60
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
15 changed files with 337 additions and 139 deletions

View File

@ -42,7 +42,7 @@ class View extends Model
'user_id' => $user->id, 'user_id' => $user->id,
], ['views' => 0]); ], ['views' => 0]);
$view->save(['views' => $view->views + 1]); $view->forceFill(['views' => $view->views + 1])->save();
return $view->views; return $view->views;
} }

View File

@ -1,81 +0,0 @@
<?php namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\EntityProvider;
use DB;
use Illuminate\Support\Collection;
class ViewService
{
protected $view;
protected $permissionService;
protected $entityProvider;
/**
* ViewService constructor.
* @param View $view
* @param PermissionService $permissionService
* @param EntityProvider $entityProvider
*/
public function __construct(View $view, PermissionService $permissionService, EntityProvider $entityProvider)
{
$this->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);
}
}

View File

@ -184,11 +184,9 @@ return [
// Custom BookStack // Custom BookStack
'Activity' => BookStack\Facades\Activity::class, 'Activity' => BookStack\Facades\Activity::class,
'Views' => BookStack\Facades\Views::class,
'Images' => BookStack\Facades\Images::class, 'Images' => BookStack\Facades\Images::class,
'Permissions' => BookStack\Facades\Permissions::class, 'Permissions' => BookStack\Facades\Permissions::class,
'Theme' => BookStack\Facades\Theme::class, 'Theme' => BookStack\Facades\Theme::class,
], ],
// Proxy configuration // Proxy configuration

View File

@ -0,0 +1,17 @@
<?php namespace BookStack\Entities\Queries;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\EntityProvider;
abstract class EntityQuery
{
protected function permissionService(): PermissionService
{
return app()->make(PermissionService::class);
}
protected function entityProvider(): EntityProvider
{
return app()->make(EntityProvider::class);
}
}

View File

@ -0,0 +1,29 @@
<?php namespace BookStack\Entities\Queries;
use BookStack\Actions\View;
use Illuminate\Support\Facades\DB;
class Popular extends EntityQuery
{
public function run(int $count, int $page, array $filterModels = null, string $action = 'view')
{
$query = $this->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();
}
}

View File

@ -0,0 +1,32 @@
<?php namespace BookStack\Entities\Queries;
use BookStack\Actions\View;
use Illuminate\Support\Collection;
class RecentlyViewed extends EntityQuery
{
public function run(int $count, int $page): Collection
{
$user = user();
if ($user === null || $user->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();
}
}

View File

@ -0,0 +1,36 @@
<?php namespace BookStack\Entities\Queries;
use BookStack\Actions\View;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Facades\DB;
class TopFavourites extends EntityQuery
{
public function run(int $count, int $page)
{
$user = user();
if ($user === null || $user->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();
}
}

View File

@ -18,28 +18,6 @@ use Illuminate\Support\Collection;
*/ */
class RelationMultiModelQuery 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<string, array> */ /** @var array<string, array> */
protected $lookupModels = []; protected $lookupModels = [];
@ -49,9 +27,52 @@ class RelationMultiModelQuery
/** @var string */ /** @var string */
protected $polymorphicFieldName; 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<string, string>
*/
protected $relationFields = [];
/**
* An array of [string $col, string $operator, mixed $value] where conditions.
* @var array<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; $this->polymorphicFieldName = $polymorphicFieldName;
} }
@ -76,6 +97,78 @@ class RelationMultiModelQuery
return $this; 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. * Build the core query to run.
*/ */
@ -85,6 +178,14 @@ class RelationMultiModelQuery
$relationTable = $this->relation->getTable(); $relationTable = $this->relation->getTable();
$modelTables = []; $modelTables = [];
// Load relation fields
foreach ($this->relationFields as $relationField => $alias) {
$query->addSelect(
$relationTable . '.' . $relationField . ' as '
. $relationTable . '@' . $relationField
);
}
// Load model selects & joins // Load model selects & joins
foreach ($this->lookupModels as $lookupModel => $columns) { foreach ($this->lookupModels as $lookupModel => $columns) {
/** @var Entity $model */ /** @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'); $this->applyPermissionsToQuery($query, 'view');
return $query; return $query;
} }
/**
* Run the query through the permission system.
*/
protected function applyPermissionsToQuery(Builder $query, string $action) protected function applyPermissionsToQuery(Builder $query, string $action)
{ {
$permissions = app()->make(PermissionService::class); $permissions = app()->make(PermissionService::class);
@ -131,24 +255,54 @@ class RelationMultiModelQuery
{ {
$selectArray = []; $selectArray = [];
foreach ($columns as $column) { foreach ($columns as $column) {
$selectArray[] = $table . '.' . $column . ' as '. $table . '_' . $column; $selectArray[] = $table . '.' . $column . ' as ' . $table . '@' . $column;
} }
return $selectArray; 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);
} }
} }

View File

@ -1,16 +0,0 @@
<?php namespace BookStack\Facades;
use Illuminate\Support\Facades\Facade;
class Views extends Facade
{
/**
* Get the registered name of the component.
*
* @return string
*/
protected static function getFacadeAccessor()
{
return 'views';
}
}

View File

@ -2,11 +2,12 @@
use Activity; use Activity;
use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Book;
use BookStack\Entities\Queries\RecentlyViewed;
use BookStack\Entities\Queries\TopFavourites;
use BookStack\Entities\Tools\PageContent; use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\BookRepo; use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Repos\BookshelfRepo; use BookStack\Entities\Repos\BookshelfRepo;
use Illuminate\Http\Response;
use Views; use Views;
class HomeController extends Controller class HomeController extends Controller
@ -32,12 +33,13 @@ class HomeController extends Controller
$recentFactor = count($draftPages) > 0 ? 0.5 : 1; $recentFactor = count($draftPages) > 0 ? 0.5 : 1;
$recents = $this->isSignedIn() ? $recents = $this->isSignedIn() ?
Views::getUserRecentlyViewed(12*$recentFactor, 1) (new RecentlyViewed)->run(12*$recentFactor, 1)
: Book::visible()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get(); : Book::visible()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get();
$faviourites = (new TopFavourites)->run(6, 1);
$recentlyUpdatedPages = Page::visible()->with('book') $recentlyUpdatedPages = Page::visible()->with('book')
->where('draft', false) ->where('draft', false)
->orderBy('updated_at', 'desc') ->orderBy('updated_at', 'desc')
->take(12) ->take($faviourites->count() > 0 ? 6 : 12)
->get(); ->get();
$homepageOptions = ['default', 'books', 'bookshelves', 'page']; $homepageOptions = ['default', 'books', 'bookshelves', 'page'];
@ -51,6 +53,7 @@ class HomeController extends Controller
'recents' => $recents, 'recents' => $recents,
'recentlyUpdatedPages' => $recentlyUpdatedPages, 'recentlyUpdatedPages' => $recentlyUpdatedPages,
'draftPages' => $draftPages, 'draftPages' => $draftPages,
'favourites' => $faviourites,
]; ];
// Add required list ordering & sorting for books & shelves views. // Add required list ordering & sorting for books & shelves views.

View File

@ -1,6 +1,7 @@
<?php namespace BookStack\Http\Controllers; <?php namespace BookStack\Http\Controllers;
use BookStack\Actions\ViewService; use BookStack\Actions\ViewService;
use BookStack\Entities\Queries\Popular;
use BookStack\Entities\Tools\SearchRunner; use BookStack\Entities\Tools\SearchRunner;
use BookStack\Entities\Tools\ShelfContext; use BookStack\Entities\Tools\ShelfContext;
use BookStack\Entities\Tools\SearchOptions; use BookStack\Entities\Tools\SearchOptions;
@ -82,7 +83,7 @@ class SearchController extends Controller
$searchTerm .= ' {type:'. implode('|', $entityTypes) .'}'; $searchTerm .= ' {type:'. implode('|', $entityTypes) .'}';
$entities = $this->searchRunner->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 20, $permission)['results']; $entities = $this->searchRunner->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 20, $permission)['results'];
} else { } 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]); return view('search.entity-ajax-list', ['entities' => $entities]);

View File

@ -27,6 +27,7 @@ return [
'images' => 'Images', 'images' => 'Images',
'my_recent_drafts' => 'My Recent Drafts', 'my_recent_drafts' => 'My Recent Drafts',
'my_recently_viewed' => 'My Recently Viewed', '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_viewed' => 'You have not viewed any pages',
'no_pages_recently_created' => 'No pages have been recently created', 'no_pages_recently_created' => 'No pages have been recently created',
'no_pages_recently_updated' => 'No pages have been recently updated', 'no_pages_recently_updated' => 'No pages have been recently updated',

View File

@ -5,6 +5,18 @@
</div> </div>
@endif @endif
@if(count($favourites) > 0)
<div id="top-favourites" class="card mb-xl">
<h3 class="card-title">{{ trans('entities.my_most_viewed_favourites') }}</h3>
<div class="px-m">
@include('partials.entity-list', [
'entities' => $favourites,
'style' => 'compact',
])
</div>
</div>
@endif
<div class="mb-xl"> <div class="mb-xl">
<h5>{{ trans('entities.' . (auth()->check() ? 'my_recently_viewed' : 'books_recent')) }}</h5> <h5>{{ trans('entities.' . (auth()->check() ? 'my_recently_viewed' : 'books_recent')) }}</h5>
@include('partials.entity-list', [ @include('partials.entity-list', [

View File

@ -42,6 +42,18 @@
</div> </div>
<div> <div>
@if(count($favourites) > 0)
<div id="top-favourites" class="card mb-xl">
<h3 class="card-title">{{ trans('entities.my_most_viewed_favourites') }}</h3>
<div class="px-m">
@include('partials.entity-list', [
'entities' => $favourites,
'style' => 'compact',
])
</div>
</div>
@endif
<div id="recent-pages" class="card mb-xl"> <div id="recent-pages" class="card mb-xl">
<h3 class="card-title"><a class="no-color" href="{{ url("/pages/recently-updated") }}">{{ trans('entities.recently_updated_pages') }}</a></h3> <h3 class="card-title"><a class="no-color" href="{{ url("/pages/recently-updated") }}">{{ trans('entities.recently_updated_pages') }}</a></h3>
<div id="recently-updated-pages" class="px-m"> <div id="recently-updated-pages" class="px-m">

View File

@ -26,7 +26,7 @@
<div class="card mb-xl"> <div class="card mb-xl">
<h3 class="card-title">{{ trans('entities.pages_popular') }}</h3> <h3 class="card-title">{{ trans('entities.pages_popular') }}</h3>
<div class="px-m"> <div class="px-m">
@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'])
</div> </div>
</div> </div>
</div> </div>
@ -34,7 +34,7 @@
<div class="card mb-xl"> <div class="card mb-xl">
<h3 class="card-title">{{ trans('entities.books_popular') }}</h3> <h3 class="card-title">{{ trans('entities.books_popular') }}</h3>
<div class="px-m"> <div class="px-m">
@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'])
</div> </div>
</div> </div>
</div> </div>
@ -42,7 +42,7 @@
<div class="card mb-xl"> <div class="card mb-xl">
<h3 class="card-title">{{ trans('entities.chapters_popular') }}</h3> <h3 class="card-title">{{ trans('entities.chapters_popular') }}</h3>
<div class="px-m"> <div class="px-m">
@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'])
</div> </div>
</div> </div>
</div> </div>