Skip to content

Commit

Permalink
Support sorting in the CP (#11)
Browse files Browse the repository at this point in the history
  • Loading branch information
ryanmitchell authored Feb 13, 2025
2 parents 00758ba + a4a86c6 commit b0fffad
Show file tree
Hide file tree
Showing 4 changed files with 275 additions and 22 deletions.
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
],
],
],
Expand Down
12 changes: 5 additions & 7 deletions src/Typesense/Index.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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']);

Expand Down Expand Up @@ -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
Expand Down
43 changes: 31 additions & 12 deletions src/Typesense/Query.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;

Expand Down Expand Up @@ -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);
}

Expand Down
235 changes: 235 additions & 0 deletions tests/Unit/QueryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
<?php

namespace StatamicRadPack\Typesense\Tests\Unit;

use PHPUnit\Framework\Attributes\Test;
use Statamic\Facades;
use Statamic\Query\OrderBy;
use StatamicRadPack\Typesense\Tests\TestCase;
use StatamicRadPack\Typesense\Typesense\Query;

class QueryTest extends TestCase
{
#[Test]
public function it_returns_simple_wheres_in_the_correct_format()
{
$index = Facades\Search::index('typesense_index');
$query = new Query($index);
$query->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');
}
}

0 comments on commit b0fffad

Please sign in to comment.