diff --git a/app/Entities/Models/EntityTable.php b/app/Entities/Models/EntityTable.php index 50112a8e9..5780162d1 100644 --- a/app/Entities/Models/EntityTable.php +++ b/app/Entities/Models/EntityTable.php @@ -2,11 +2,15 @@ namespace BookStack\Entities\Models; +use BookStack\Activity\Models\Tag; +use BookStack\Activity\Models\View; use BookStack\App\Model; +use BookStack\Permissions\Models\EntityPermission; use BookStack\Permissions\Models\JointPermission; use BookStack\Permissions\PermissionApplicator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\SoftDeletes; /** @@ -32,6 +36,34 @@ class EntityTable extends Model */ public function jointPermissions(): HasMany { - return $this->hasMany(JointPermission::class, 'entity_id')->whereColumn('entity_type', '=', 'entities.type'); + return $this->hasMany(JointPermission::class, 'entity_id') + ->whereColumn('entity_type', '=', 'entities.type'); + } + + /** + * Get the Tags that have been assigned to entities. + */ + public function tags(): HasMany + { + return $this->hasMany(Tag::class, 'entity_id') + ->whereColumn('entity_type', '=', 'entities.type'); + } + + /** + * Get the assigned permissions. + */ + public function permissions(): HasMany + { + return $this->hasMany(EntityPermission::class, 'entity_id') + ->whereColumn('entity_type', '=', 'entities.type'); + } + + /** + * Get View objects for this entity. + */ + public function views(): HasMany + { + return $this->hasMany(View::class, 'viewable_id') + ->whereColumn('viewable_type', '=', 'entities.type'); } } diff --git a/app/Entities/Queries/EntityQueries.php b/app/Entities/Queries/EntityQueries.php index c27cc61cc..91c6a4363 100644 --- a/app/Entities/Queries/EntityQueries.php +++ b/app/Entities/Queries/EntityQueries.php @@ -5,6 +5,7 @@ namespace BookStack\Entities\Queries; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\EntityTable; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Database\Query\JoinClause; use Illuminate\Support\Facades\DB; use InvalidArgumentException; @@ -43,8 +44,14 @@ class EntityQueries public function visibleForList(): Builder { $rawDescriptionField = DB::raw('COALESCE(description, text) as description'); + $bookSlugSelect = function (QueryBuilder $query) { + return $query->select('slug')->from('entities as books') + ->whereColumn('books.id', '=', 'entities.book_id') + ->where('type', '=', 'book'); + }; + return EntityTable::query()->scopes('visible') - ->select(['id', 'type', 'name', 'slug', 'book_id', 'chapter_id', 'created_at', 'updated_at', 'draft', $rawDescriptionField]) + ->select(['id', 'type', 'name', 'slug', 'book_id', 'chapter_id', 'created_at', 'updated_at', 'draft', 'book_slug' => $bookSlugSelect, $rawDescriptionField]) ->leftJoin('entity_container_data', function (JoinClause $join) { $join->on('entity_container_data.entity_id', '=', 'entities.id') ->on('entity_container_data.entity_type', '=', 'entities.type'); diff --git a/app/Entities/Tools/EntityHydrator.php b/app/Entities/Tools/EntityHydrator.php index 7cabc7ecb..87e39d222 100644 --- a/app/Entities/Tools/EntityHydrator.php +++ b/app/Entities/Tools/EntityHydrator.php @@ -129,11 +129,11 @@ class EntityHydrator foreach ($entities as $entity) { if ($entity instanceof Page || $entity instanceof Chapter) { $key = 'book:' . $entity->getRawAttribute('book_id'); - $entity->setAttribute('book', $parentMap[$key] ?? null); + $entity->setRelation('book', $parentMap[$key] ?? null); } if ($entity instanceof Page) { $key = 'chapter:' . $entity->getRawAttribute('chapter_id'); - $entity->setAttribute('chapter', $parentMap[$key] ?? null); + $entity->setRelation('chapter', $parentMap[$key] ?? null); } } } diff --git a/app/Search/SearchRunner.php b/app/Search/SearchRunner.php index 0128e3804..bfb65cf0f 100644 --- a/app/Search/SearchRunner.php +++ b/app/Search/SearchRunner.php @@ -72,9 +72,9 @@ class SearchRunner $entityTypesToSearch = isset($filterMap['type']) ? explode('|', $filterMap['type']) : $entityTypes; $filteredTypes = array_intersect($entityTypesToSearch, $entityTypes); - $results = $this->buildQuery($opts, $filteredTypes)->where('book_id', '=', $bookId)->take(20)->get(); + $query = $this->buildQuery($opts, $filteredTypes)->where('book_id', '=', $bookId); - return $results->sortByDesc('score')->take(20); + return $this->getPageOfDataFromQuery($query, 1, 20)->sortByDesc('score'); } /** @@ -83,9 +83,9 @@ class SearchRunner public function searchChapter(int $chapterId, string $searchString): Collection { $opts = SearchOptions::fromString($searchString); - $pages = $this->buildQuery($opts, ['page'])->where('chapter_id', '=', $chapterId)->take(20)->get(); + $query = $this->buildQuery($opts, ['page'])->where('chapter_id', '=', $chapterId); - return $pages->sortByDesc('score'); + return $this->getPageOfDataFromQuery($query, 1, 20)->sortByDesc('score'); } /** @@ -120,7 +120,8 @@ class SearchRunner $filter = function (EloquentBuilder $query) use ($exact) { $inputTerm = str_replace('\\', '\\\\', $exact->value); $query->where('name', 'like', '%' . $inputTerm . '%') - ->orWhere('description', 'like', '%' . $inputTerm . '%'); + ->orWhere('description', 'like', '%' . $inputTerm . '%') + ->orWhere('text', 'like', '%' . $inputTerm . '%'); }; $exact->negated ? $entityQuery->whereNot($filter) : $entityQuery->where($filter); @@ -301,7 +302,7 @@ class SearchRunner $option->negated ? $query->whereDoesntHave('tags', $filter) : $query->whereHas('tags', $filter); } - protected function applyNegatableWhere(EloquentBuilder $query, bool $negated, string $column, string $operator, mixed $value): void + protected function applyNegatableWhere(EloquentBuilder $query, bool $negated, string|callable $column, string|null $operator, mixed $value): void { if ($negated) { $query->whereNot($column, $operator, $value); @@ -376,7 +377,10 @@ class SearchRunner protected function filterInBody(EloquentBuilder $query, string $input, bool $negated) { - $this->applyNegatableWhere($query, $negated, 'description', 'like', '%' . $input . '%'); + $this->applyNegatableWhere($query, $negated, function (EloquentBuilder $query) use ($input) { + $query->where('description', 'like', '%' . $input . '%') + ->orWhere('text', 'like', '%' . $input . '%'); + }, null, null); } protected function filterIsRestricted(EloquentBuilder $query, string $input, bool $negated) diff --git a/tests/Api/SearchApiTest.php b/tests/Api/SearchApiTest.php index 9da7900ca..517c5d8e4 100644 --- a/tests/Api/SearchApiTest.php +++ b/tests/Api/SearchApiTest.php @@ -113,6 +113,7 @@ class SearchApiTest extends TestCase $this->permissions->disableEntityInheritedPermissions($book); $resp = $this->getJson($this->baseEndpoint . '?query=superextrauniquevalue'); + $resp->assertOk(); $resp->assertJsonPath('data.0.id', $page->id); $resp->assertJsonMissingPath('data.0.book.name'); } diff --git a/tests/Search/EntitySearchTest.php b/tests/Search/EntitySearchTest.php index d3d859986..ca900c2ed 100644 --- a/tests/Search/EntitySearchTest.php +++ b/tests/Search/EntitySearchTest.php @@ -27,6 +27,12 @@ class EntitySearchTest extends TestCase $search->assertSeeText($shelf->name, true); } + public function test_search_shows_pagination() + { + $search = $this->asEditor()->get('/search?term=a'); + $this->withHtml($search)->assertLinkExists('/search?term=a&page=2', '2'); + } + public function test_invalid_page_search() { $resp = $this->asEditor()->get('/search?term=' . urlencode('

test

'));