]> BookStack Code Mirror - bookstack/commitdiff
Search: Tested changes to single-table search 5854/head
authorDan Brown <redacted>
Wed, 29 Oct 2025 12:59:34 +0000 (12:59 +0000)
committerDan Brown <redacted>
Wed, 29 Oct 2025 12:59:34 +0000 (12:59 +0000)
Updated filters to use single table where needed.

app/Entities/Models/EntityTable.php
app/Entities/Queries/EntityQueries.php
app/Entities/Tools/EntityHydrator.php
app/Search/SearchRunner.php
tests/Api/SearchApiTest.php
tests/Search/EntitySearchTest.php

index 50112a8e9a568628d375a3839dfeb2e5f70e79a9..5780162d1d2922a3459af43880938f3411592b9e 100644 (file)
@@ -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');
     }
 }
index c27cc61ccb1a34b7c0e1ca3b32ed4c2ca0491a80..91c6a43633d8480ead05df70e459c34a0d180de9 100644 (file)
@@ -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');
index 7cabc7ecbec8c62a16e1aeb438485198b658e61a..87e39d222ec1d9ddb0a5e29659f2b06cca6707ab 100644 (file)
@@ -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);
             }
         }
     }
index 0128e38040da47d0f376d53e5963c767005d7409..bfb65cf0f40592531699d665f530fd3e4cf13bc3 100644 (file)
@@ -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)
index 9da7900ca9a433b776952f79a2d4c9e22c80bccc..517c5d8e4efdc022511a3c9a1000c57b4d1134e3 100644 (file)
@@ -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');
     }
index d3d85998615f40e91219f37776ce01663cd18c75..ca900c2edc48f46c750389a9572a42ad51b97dd9 100644 (file)
@@ -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('<p>test</p>'));