Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Change /api/v1/items/aggregations filtering logic #879

Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ All notable changes to this project will be documented in this file[^1].
### Added
- images:generate-ratios command

### Changed
- behaviour for /api/v1/items/aggregations so that facet doesn't filter itself

## [2.78.0] - 2023-07-21
### Added
- images->deep_zoom_url to /api/v2/items/{id}
Expand Down
100 changes: 62 additions & 38 deletions app/Http/Controllers/Api/V1/ItemController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,10 @@
use App\Elasticsearch\Repositories\ItemRepository;
use App\Http\Controllers\Controller;
use App\Item;
use ElasticAdapter\Search\Aggregation;
use ElasticAdapter\Search\Bucket;
use ElasticScoutDriverPlus\Exceptions\QueryBuilderException;
use ElasticScoutDriverPlus\Support\Query;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Primal\Color\Parser as ColorParser;

class ItemController extends Controller
Expand Down Expand Up @@ -101,55 +100,76 @@ public function aggregations(Request $request)
$size = (int) $request->get('size', 1);
$q = (string) $request->get('q');

try {
$query = $this->createQueryBuilder($q, $filter)->buildQuery();
} catch (QueryBuilderException $e) {
$query = ['match_all' => new \stdClass()];
}

$searchRequest = Item::searchQuery($query);
$aggregationsQuery = [];

foreach ($terms as $agg => $field) {
$searchRequest->aggregate($agg, [
'terms' => [
'field' => $field,
'size' => $size,
],
]);
$aggregationsQuery[$agg]['aggregations']['filtered']['terms'] = [
'field' => $field,
'size' => $size,
];
}

foreach ($min as $agg => $field) {
$searchRequest->aggregate($agg, [
'min' => [
'field' => $field,
],
]);
$aggregationsQuery[$agg]['aggregations']['filtered']['min'] = [
'field' => $field,
];
}
eronisko marked this conversation as resolved.
Show resolved Hide resolved

foreach ($max as $agg => $field) {
$searchRequest->aggregate($agg, [
'max' => [
'field' => $field,
$aggregationsQuery[$agg]['aggregations']['filtered']['max'] = [
'field' => $field,
];
}

// Add filter terms to each aggregation
// Based on https://madewithlove.com/blog/faceted-search-using-elasticsearch/
foreach (array_keys($aggregationsQuery) as $term) {
$aggregationsQuery[$term]['filter'] = $this->createQueryBuilder(
$q,
Arr::except($filter, $term)
)->buildQuery();
}

$searchRequest = Item::searchQuery()
->size(0)
->aggregateRaw([
'all_items' => [
'global' => (object) [],
'aggregations' => (object) $aggregationsQuery,
],
eronisko marked this conversation as resolved.
Show resolved Hide resolved
]);
}

$searchResult = $searchRequest->execute();
return response()->json(
$searchResult->aggregations()->map(function (Aggregation $aggregation) {
$raw = $aggregation->raw();
if (array_key_exists('value', $raw)) {
return $raw['value'];
$searchResponse = collect(
Arr::get($searchRequest->execute()->raw(), 'aggregations.all_items')
)
->only(array_keys($aggregationsQuery))
->map(function (array $aggregation) {
if (Arr::has($aggregation, 'filtered.value')) {
return Arr::get($aggregation, 'filtered.value');
}

return $aggregation->buckets()->map(function (Bucket $bucket) {
return [
'value' => $bucket->key(),
'count' => $bucket->docCount(),
];
});
})
);
return collect(Arr::get($aggregation, 'filtered.buckets'))->map(
fn(array $bucket) => [
'value' => $bucket['key'],
'count' => $bucket['doc_count'],
]
);
});

// Ensure filtered (i.e. selected) terms are included in the response
foreach ($filter as $term => $value) {
if (!Arr::has($terms, $term)) {
continue;
}

foreach (array_reverse(Arr::wrap($value)) as $value) {
eronisko marked this conversation as resolved.
Show resolved Hide resolved
if (!$searchResponse[$term]->contains('value', $value)) {
$searchResponse[$term]->prepend(['value' => $value, 'count' => 0])->pop();
}
}
}

return $searchResponse;
}

public function detail(Request $request, $id)
Expand All @@ -176,6 +196,10 @@ public function detail(Request $request, $id)

protected function createQueryBuilder($q, $filter)
{
if (empty($q) && empty($filter)) {
return Query::matchAll();
}

$builder = Query::bool();

if ($q) {
Expand Down
4 changes: 3 additions & 1 deletion routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
->name('api.v1.')
->group(function () {
Route::get('items', [V1ItemController::class, 'index'])->name('items.index');
Route::get('items/aggregations', [V1ItemController::class, 'aggregations']);
Route::get('items/aggregations', [V1ItemController::class, 'aggregations'])->name(
'items.aggregations'
);
Route::get('items/{id}', [V1ItemController::class, 'detail'])->name('items.show');
});

Expand Down
123 changes: 123 additions & 0 deletions tests/Feature/Api/V1/ItemsAggregationsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<?php

namespace Tests\Feature\Api\V1;

use App\Elasticsearch\Repositories\ItemRepository;
use App\Item;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\RecreateSearchIndex;
use Tests\TestCase;

class ItemsAggregationsTest extends TestCase
{
use RefreshDatabase;
use RecreateSearchIndex;

private $initialized = false;

public function setUp(): void
{
parent::setUp();

if ($this->initialized) {
return;
}

Item::factory()->createMany([
[
'author' => 'Galanda, Mikuláš',
'topic' => 'spring',
'date_earliest' => 1000,
],
[
'author' => 'Wouwerman, Philips',
'topic' => 'summer',
'date_earliest' => 2000,
],
]);

app(ItemRepository::class)->refreshIndex();
$this->initialized = true;
}

private function getAggregations(array $params = [])
{
$defaultParams = ['size' => 10];
$url = route('api.v1.items.aggregations', array_merge($defaultParams, $params));
return $this->getJson($url);
}

public function test_shows_nothing_by_default()
{
$this->getAggregations()->assertExactJson([]);
}

public function test_shows_terms()
{
$this->getAggregations(['terms' => ['my_author' => 'author']])->assertExactJson([
'my_author' => [
['value' => 'Galanda, Mikuláš', 'count' => 1],
['value' => 'Wouwerman, Philips', 'count' => 1],
],
]);
}

public function test_filters_results()
{
$this->getAggregations([
'filter' => ['topic' => ['summer']],
'terms' => ['author' => 'author'],
])->assertExactJson([
'author' => [['value' => 'Wouwerman, Philips', 'count' => 1]],
]);
}
public function test_filtered_faced_does_not_affect_itself()
{
$this->getAggregations([
'filter' => ['topic' => ['summer']],
'terms' => ['topic' => 'topic'],
])->assertExactJson([
'topic' => [['value' => 'spring', 'count' => 1], ['value' => 'summer', 'count' => 1]],
]);
}

public function test_gets_min_and_max()
{
$this->getAggregations([
'min' => ['date_earliest_min' => 'date_earliest'],
'max' => ['date_earliest_max' => 'date_earliest'],
])->assertExactJson([
'date_earliest_min' => 1000,
'date_earliest_max' => 2000,
]);
}

public function test_size_limits_number_of_buckets()
{
$this->assertCount(
1,
$this->getAggregations([
'size' => null, // reset to default
'terms' => ['topic' => 'topic'],
])['topic']
);

$this->assertCount(
2,
$this->getAggregations([
'terms' => ['topic' => 'topic'],
'size' => 2,
])['topic']
);
}

public function test_selected_facet_value_is_always_present()
{
$this->getAggregations([
'filter' => ['topic' => ['spring'], 'author' => ['Wouwerman, Philips']],
'terms' => ['topic' => 'topic', 'author' => 'author'],
])
->assertJsonFragment(['value' => 'spring', 'count' => 0])
->assertJsonFragment(['value' => 'Wouwerman, Philips', 'count' => 0]);
}
}