From 4cb67f55bcaa1ad0cadcd00970d152e19f97dbc7 Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Wed, 12 Feb 2025 08:55:00 +0000 Subject: [PATCH 1/3] Support sorting in the CP --- README.md | 7 ++++--- src/Typesense/Index.php | 12 +++++------- src/Typesense/Query.php | 43 +++++++++++++++++++++++++++++------------ 3 files changed, 40 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 894eeac..d22e194 100644 --- a/README.md +++ b/README.md @@ -79,16 +79,17 @@ Any additional settings you want to define per index can be included in the `sta 'fields' => [ [ 'name' => 'company_name', - 'type' => 'string' + 'type' => 'string', ], [ 'name' => 'num_employees', - 'type' => 'int32' + 'type' => 'int32', + 'sort' => true, ], [ 'name' => 'country', 'type' => 'string', - 'facet' => true + 'facet' => true, ], ], ], diff --git a/src/Typesense/Index.php b/src/Typesense/Index.php index b54bc4c..2750796 100644 --- a/src/Typesense/Index.php +++ b/src/Typesense/Index.php @@ -4,6 +4,7 @@ use Illuminate\Support\Collection; use Statamic\Contracts\Search\Searchable; +use Statamic\Facades\Blink; use Statamic\Search\Documents; use Statamic\Search\Index as BaseIndex; use Statamic\Support\Arr; @@ -108,11 +109,7 @@ public function searchUsingApi($query, array $options = []): array ->join(',') ?: '*'; } - foreach (Arr::get($this->config, 'settings.search_options', []) as $handle => $value) { - $options[$handle] = $value; - } - - $searchResults = $this->getOrCreateIndex()->documents->search($options); + $searchResults = $this->getOrCreateIndex()->documents->search(array_merge(Arr::get($this->config, 'settings.search_options', []), $options)); $total = count($searchResults['hits']); @@ -162,8 +159,9 @@ public function getOrCreateIndex() public function getTypesenseSchemaFields(): Collection { - return collect(Arr::get($this->getOrCreateIndex()->retrieve(), 'fields', [])) - ->pluck('type', 'name'); + return Blink::once('statamic-typesense::schema::'.$this->name(), function () { + return collect(Arr::get($this->getOrCreateIndex()->retrieve(), 'fields', [])); + }); } private function getDefaultFields(Searchable $entry): array diff --git a/src/Typesense/Query.php b/src/Typesense/Query.php index 2cf75b3..3d925dd 100644 --- a/src/Typesense/Query.php +++ b/src/Typesense/Query.php @@ -28,15 +28,13 @@ private function wheresToFilter(array $wheres): string { $filterBy = ''; - $schemaFields = $this->index->getTypesenseSchemaFields(); + $schemaFields = $this->index->getTypesenseSchemaFields()->pluck('type', 'name'); - foreach ($this->wheres as $where) { - if ($filterBy != '') { - $filterBy .= $where['boolean'] == 'and' ? ' && ' : ' || '; - } + foreach ($wheres as $where) { + $operator = $filterBy != '' ? ($where['boolean'] == 'and' ? ' && ' : ' || ') : ''; if ($where['type'] == 'Nested') { - $filterBy .= ' ( '.$this->wheresToFilter($where->query['wheres']).' ) '; + $filterBy .= $operator.' ( '.$this->wheresToFilter($where->query['wheres']).' ) '; continue; } @@ -46,18 +44,18 @@ private function wheresToFilter(array $wheres): string continue; } - $filterBy .= ' ( '; + $filterBy .= $operator.' ( '; switch ($where['type']) { case 'JsonContains': case 'JsonOverlaps': - case 'WhereIn': + case 'In': $filterBy .= $where['column'].':'.$this->transformArrayOfValuesForTypeSense($schemaType, $where['values']); break; case 'JsonDoesnContain': case 'JsonDoesntOverlap': - case 'WhereNotIn': + case 'NotIn': $filterBy .= $where['column'].':!='.$this->transformArrayOfValuesForTypeSense($schemaType, $where['values']); break; @@ -97,16 +95,37 @@ private function transformValueForTypeSense(string $schemaType, mixed $value): m }; } + private function ordersToSortBy(array $orders): string + { + $schemaFields = $this->index->getTypesenseSchemaFields()->keyBy('name'); + + return collect($orders) + ->filter(function ($order) use ($schemaFields) { + if (! $field = $schemaFields->get($order->sort)) { + return false; + } + + return $field['sort'] ?? false; + }) + ->take(3) // typesense only allows up to 3 sort columns + ->map(function ($order) { + return $order->sort.':'.$order->direction; + }) + ->join(','); + } + private function getApiResults() { $options = ['per_page' => $this->perPage, 'page' => $this->page]; - $filterBy = $this->wheresToFilter($this->wheres); - - if ($filterBy) { + if ($filterBy = $this->wheresToFilter($this->wheres)) { $options['filter_by'] = $filterBy; } + if ($orderBy = $this->ordersToSortBy($this->orderBys)) { + $options['sort_by'] = $orderBy; + } + return $this->index->searchUsingApi($this->query ?? '', $options); } From ec4ab3e0ce8d3ddc738922cdc0912abe55d4b7af Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Wed, 12 Feb 2025 08:55:04 +0000 Subject: [PATCH 2/3] Add test coverage --- tests/Unit/QueryTest.php | 235 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 tests/Unit/QueryTest.php diff --git a/tests/Unit/QueryTest.php b/tests/Unit/QueryTest.php new file mode 100644 index 0000000..c30c128 --- /dev/null +++ b/tests/Unit/QueryTest.php @@ -0,0 +1,235 @@ +where('title', 'test'); + + Facades\Blink::put('statamic-typesense::schema::typesense_index', collect([ + [ + 'name' => 'title', + 'type' => 'string', + ], + [ + 'name' => 'other', + 'type' => 'string', + ], + [ + 'name' => 'final', + 'type' => 'int32', + ], + ])); + + $reflection = new \ReflectionObject($query); + $method = $reflection->getMethod('wheresToFilter'); + $method->setAccessible(true); + + $property = $reflection->getProperty('wheres'); + $property->setAccessible(true); + + $result = $method->invoke($query, $property->getValue($query)); + + $this->assertSame($result, ' ( title:=`test` ) '); + + $query->orWhere('other', 'value'); + + $result = $method->invoke($query, $property->getValue($query)); + + $this->assertSame($result, ' ( title:=`test` ) || ( other:=`value` ) '); + + $query->where('final', 'value'); + + $result = $method->invoke($query, $property->getValue($query)); + + $this->assertSame($result, ' ( title:=`test` ) || ( other:=`value` ) && ( final:=0 ) '); + } + + #[Test] + public function it_handles_where_ins() + { + $index = Facades\Search::index('typesense_index'); + $query = new Query($index); + $query->whereIn('title', ['test', 'two', 'three']); + + Facades\Blink::put('statamic-typesense::schema::typesense_index', collect([ + [ + 'name' => 'title', + 'type' => 'string[]', + ], + ])); + + $reflection = new \ReflectionObject($query); + $method = $reflection->getMethod('wheresToFilter'); + $method->setAccessible(true); + + $property = $reflection->getProperty('wheres'); + $property->setAccessible(true); + + $result = $method->invoke($query, $property->getValue($query)); + + $this->assertSame($result, ' ( title:["`test`","`two`","`three`"] ) '); + } + + #[Test] + public function it_handles_where_like() + { + $index = Facades\Search::index('typesense_index'); + $query = new Query($index); + $query->where('title', 'like', 'test'); + + Facades\Blink::put('statamic-typesense::schema::typesense_index', collect([ + [ + 'name' => 'title', + 'type' => 'string', + ], + ])); + + $reflection = new \ReflectionObject($query); + $method = $reflection->getMethod('wheresToFilter'); + $method->setAccessible(true); + + $property = $reflection->getProperty('wheres'); + $property->setAccessible(true); + + $result = $method->invoke($query, $property->getValue($query)); + + $this->assertSame($result, ' ( title:`test` ) '); + } + + #[Test] + public function it_ignores_wheres_not_found_in_the_typesense_schema() + { + $index = Facades\Search::index('typesense_index'); + $query = new Query($index); + $query->where('title', 'test'); + $query->orWhere('other', 'value'); + $query->where('final', 'value'); + + Facades\Blink::put('statamic-typesense::schema::typesense_index', collect([ + [ + 'name' => 'title', + 'type' => 'string', + ], + [ + 'name' => 'other', + 'type' => 'string', + ], + ])); + + $reflection = new \ReflectionObject($query); + $method = $reflection->getMethod('wheresToFilter'); + $method->setAccessible(true); + + $property = $reflection->getProperty('wheres'); + $property->setAccessible(true); + + $result = $method->invoke($query, $property->getValue($query)); + + $this->assertSame($result, ' ( title:=`test` ) || ( other:=`value` ) '); + } + + #[Test] + public function it_returns_sort_by_in_the_correct_format() + { + $index = Facades\Search::index('typesense_index'); + $query = new Query($index); + + Facades\Blink::put('statamic-typesense::schema::typesense_index', collect([ + [ + 'name' => 'title', + 'sort' => true, + ], + [ + 'name' => 'other', + 'sort' => true, + ], + ])); + + $reflection = new \ReflectionObject($query); + $method = $reflection->getMethod('ordersToSortBy'); + $method->setAccessible(true); + + $orderBys = [ + new OrderBy('title', 'desc'), + new OrderBy('other', 'asc'), + ]; + + $result = $method->invoke($query, $orderBys); + + $this->assertSame($result, 'title:desc,other:asc'); + } + + #[Test] + public function it_ignores_sorts_that_arent_found_in_the_typesense_schema() + { + $index = Facades\Search::index('typesense_index'); + $query = new Query($index); + + Facades\Blink::put('statamic-typesense::schema::typesense_index', collect([ + [ + 'name' => 'title', + 'sort' => true, + ], + [ + 'name' => 'other', + 'sort' => false, + ], + ])); + + $reflection = new \ReflectionObject($query); + $method = $reflection->getMethod('ordersToSortBy'); + $method->setAccessible(true); + + $orderBys = [ + new OrderBy('title', 'desc'), + new OrderBy('other', 'asc'), + ]; + + $result = $method->invoke($query, $orderBys); + + $this->assertSame($result, 'title:desc'); + } + + #[Test] + public function it_ignores_sorts_that_arent_sortable_in_the_typesense_schema() + { + $index = Facades\Search::index('typesense_index'); + $query = new Query($index); + + Facades\Blink::put('statamic-typesense::schema::typesense_index', collect([ + [ + 'name' => 'title', + 'sort' => true, + ], + [ + 'name' => 'other', + 'sort' => false, + ], + ])); + + $reflection = new \ReflectionObject($query); + $method = $reflection->getMethod('ordersToSortBy'); + $method->setAccessible(true); + + $orderBys = [ + new OrderBy('title', 'desc'), + new OrderBy('other', 'asc'), + ]; + + $result = $method->invoke($query, $orderBys); + + $this->assertSame($result, 'title:desc'); + } +} From a4a86c63251009a6ff5686108521522c66a92c0b Mon Sep 17 00:00:00 2001 From: ryanmitchell Date: Wed, 12 Feb 2025 11:46:27 +0000 Subject: [PATCH 3/3] Fix styling --- tests/Unit/QueryTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/QueryTest.php b/tests/Unit/QueryTest.php index c30c128..5222ad3 100644 --- a/tests/Unit/QueryTest.php +++ b/tests/Unit/QueryTest.php @@ -5,8 +5,8 @@ use PHPUnit\Framework\Attributes\Test; use Statamic\Facades; use Statamic\Query\OrderBy; -use StatamicRadPack\Typesense\Typesense\Query; use StatamicRadPack\Typesense\Tests\TestCase; +use StatamicRadPack\Typesense\Typesense\Query; class QueryTest extends TestCase {