- 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.
'user_id' => $user->id,
], ['views' => 0]);
- $view->save(['views' => $view->views + 1]);
+ $view->forceFill(['views' => $view->views + 1])->save();
return $view->views;
}
+++ /dev/null
-<?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);
- }
-}
// 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
--- /dev/null
+<?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);
+ }
+}
\ No newline at end of file
--- /dev/null
+<?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();
+ }
+
+}
\ No newline at end of file
--- /dev/null
+<?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();
+ }
+}
--- /dev/null
+<?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();
+ }
+
+}
\ No newline at end of file
*/
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> */
protected $lookupModels = [];
/** @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<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;
}
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.
*/
$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 */
}
});
+ // 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);
{
$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
+}
+++ /dev/null
-<?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';
- }
-}
use Activity;
use BookStack\Entities\Models\Book;
+use BookStack\Entities\Queries\RecentlyViewed;
+use BookStack\Entities\Queries\TopFavourites;
use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Repos\BookshelfRepo;
-use Illuminate\Http\Response;
use Views;
class HomeController extends Controller
$recentFactor = count($draftPages) > 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'];
'recents' => $recents,
'recentlyUpdatedPages' => $recentlyUpdatedPages,
'draftPages' => $draftPages,
+ 'favourites' => $faviourites,
];
// Add required list ordering & sorting for books & shelves views.
<?php namespace BookStack\Http\Controllers;
use BookStack\Actions\ViewService;
+use BookStack\Entities\Queries\Popular;
use BookStack\Entities\Tools\SearchRunner;
use BookStack\Entities\Tools\ShelfContext;
use BookStack\Entities\Tools\SearchOptions;
$searchTerm .= ' {type:'. implode('|', $entityTypes) .'}';
$entities = $this->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]);
'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',
</div>
@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">
<h5>{{ trans('entities.' . (auth()->check() ? 'my_recently_viewed' : 'books_recent')) }}</h5>
@include('partials.entity-list', [
</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">
<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 class="card mb-xl">
<h3 class="card-title">{{ trans('entities.pages_popular') }}</h3>
<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 class="card mb-xl">
<h3 class="card-title">{{ trans('entities.books_popular') }}</h3>
<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 class="card mb-xl">
<h3 class="card-title">{{ trans('entities.chapters_popular') }}</h3>
<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>