Skip to content

Commit 396790b

Browse files
committed
FEATURE: Aggregation Support (a big one :-) )
1 parent 7105277 commit 396790b

13 files changed

+674
-93
lines changed
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Sandstorm\LightweightElasticsearch\Query;
5+
6+
use Neos\ContentRepository\Domain\Model\NodeInterface;
7+
use Neos\ContentRepository\Utility;
8+
use Neos\Flow\Annotations as Flow;
9+
use Flowpack\ElasticSearch\ContentRepositoryAdaptor\ElasticSearchClient;
10+
use Flowpack\ElasticSearch\Transfer\Exception\ApiException;
11+
use Neos\Eel\ProtectedContextAwareInterface;
12+
use Neos\Flow\Log\ThrowableStorageInterface;
13+
use Neos\Flow\Log\Utility\LogEnvironment;
14+
use Psr\Log\LoggerInterface;
15+
16+
17+
abstract class AbstractSearchRequestBuilder implements ProtectedContextAwareInterface
18+
{
19+
20+
/**
21+
* @Flow\Inject
22+
* @var ElasticSearchClient
23+
*/
24+
protected $elasticSearchClient;
25+
26+
/**
27+
* @Flow\Inject
28+
* @var ThrowableStorageInterface
29+
*/
30+
protected $throwableStorage;
31+
32+
/**
33+
* @Flow\Inject
34+
* @var LoggerInterface
35+
*/
36+
protected $logger;
37+
38+
private array $additionalIndices;
39+
40+
/**
41+
* @var boolean
42+
*/
43+
protected $logThisQuery = false;
44+
45+
/**
46+
* @var string
47+
*/
48+
protected $logMessage;
49+
50+
/**
51+
* @var NodeInterface|null
52+
*/
53+
protected ?NodeInterface $contextNode;
54+
55+
public function __construct(NodeInterface $contextNode = null, array $additionalIndices = [])
56+
{
57+
$this->contextNode = $contextNode;
58+
$this->additionalIndices = $additionalIndices;
59+
}
60+
61+
/**
62+
* Log the current request to the Elasticsearch log for debugging after it has been executed.
63+
*
64+
* @param string $message an optional message to identify the log entry
65+
* @api
66+
*/
67+
public function log($message = null): self
68+
{
69+
$this->logThisQuery = true;
70+
$this->logMessage = $message;
71+
72+
return $this;
73+
}
74+
75+
/**
76+
* Execute the query and return the SearchResult object as result.
77+
*
78+
* You can call this method multiple times; and the request is only executed at the first time; and cached
79+
* for later use.
80+
*
81+
* @throws \Flowpack\ElasticSearch\Exception
82+
* @throws \Neos\Flow\Http\Exception
83+
*/
84+
protected function executeInternal(array $request): array
85+
{
86+
try {
87+
$timeBefore = microtime(true);
88+
89+
$indexNames = $this->additionalIndices;
90+
if ($this->contextNode !== null) {
91+
$dimensionValues = $this->contextNode->getContext()->getDimensions();
92+
$dimensionHash = Utility::sortDimensionValueArrayAndReturnDimensionsHash($dimensionValues);
93+
$indexNames[] = 'neoscr-' . $dimensionHash;
94+
}
95+
96+
$response = $this->elasticSearchClient->request('GET', '/' . implode(',', $indexNames) . '/_search', [], $request);
97+
$timeAfterwards = microtime(true);
98+
99+
$jsonResponse = $response->getTreatedContent();
100+
$this->logThisQuery && $this->logger->debug(sprintf('Query Log (%s): Indexname: %s %s -- execution time: %s ms -- Number of results returned: %s -- Total Results: %s', $this->logMessage, implode(',', $indexNames), $request, (($timeAfterwards - $timeBefore) * 1000), count($jsonResponse['hits']['hits']), $jsonResponse['hits']['total']['value']), LogEnvironment::fromMethodName(__METHOD__));
101+
return $jsonResponse;
102+
} catch (ApiException $exception) {
103+
$message = $this->throwableStorage->logThrowable($exception);
104+
$this->logger->error(sprintf('Request failed with %s', $message), LogEnvironment::fromMethodName(__METHOD__));
105+
throw $exception;
106+
}
107+
}
108+
109+
public function allowsCallOfMethod($methodName)
110+
{
111+
return true;
112+
}
113+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
4+
namespace Sandstorm\LightweightElasticsearch\Query\Aggregation;
5+
6+
use Sandstorm\LightweightElasticsearch\Query\AggregationRequestBuilder;
7+
8+
interface AggregationBuilderInterface
9+
{
10+
/**
11+
* Returns the Elasticsearch aggregation request part; so the part inside {"aggs": ...}.
12+
*
13+
* Is called by the framework (usually inside {@see AggregationRequestBuilder}, not by the end-user.
14+
*
15+
* @return array
16+
*/
17+
public function buildAggregationRequest(): array;
18+
19+
/**
20+
* Binds the aggreation response to this aggregation; effectively creating an aggregation response object
21+
* for this request.
22+
*
23+
* Is called by the framework (usually inside {@see AggregationRequestBuilder}, not by the end-user.
24+
*
25+
* @param array $aggregationResponse
26+
* @return AggregationResultInterface
27+
*/
28+
public function bindResponse(array $aggregationResponse): AggregationResultInterface;
29+
30+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Sandstorm\LightweightElasticsearch\Query\Aggregation;
5+
6+
/**
7+
* Marker interface for aggregation results.
8+
*
9+
* An aggregation result is always created by calling {@see AggregationBuilderInterface::bindResponse());
10+
* and each AggregationBuilder implementation has a corresponding AggregationResult implementation.
11+
*/
12+
interface AggregationResultInterface
13+
{
14+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Sandstorm\LightweightElasticsearch\Query\Aggregation;
5+
6+
use Neos\Eel\ProtectedContextAwareInterface;
7+
use Neos\Flow\Annotations as Flow;
8+
9+
/**
10+
* Placeholder for an aggregation result in case of a query error
11+
*
12+
* @Flow\Proxy(false)
13+
*/
14+
class QueryErrorAggregationResult implements AggregationResultInterface, ProtectedContextAwareInterface
15+
{
16+
17+
public function isError() {
18+
return true;
19+
}
20+
21+
public function allowsCallOfMethod($methodName)
22+
{
23+
return true;
24+
}
25+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
4+
namespace Sandstorm\LightweightElasticsearch\Query\Aggregation;
5+
6+
use Neos\Flow\Annotations as Flow;
7+
use Sandstorm\LightweightElasticsearch\Query\SearchQueryBuilderInterface;
8+
9+
/**
10+
* A Terms aggregation can be used to build faceted search.
11+
*
12+
* It needs to be configured using:
13+
* - the Elasticsearch field name which should be faceted (should be of type "keyword" to have useful results)
14+
* - The selected value from the request, if any.
15+
*
16+
* The Terms Aggregation can be additionally used as search filter.
17+
*
18+
* See https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html for the details of usage.
19+
*
20+
* @Flow\Proxy(false)
21+
*/
22+
class TermsAggregationBuilder implements AggregationBuilderInterface, SearchQueryBuilderInterface
23+
{
24+
private string $fieldName;
25+
/**
26+
* @var string|null the selected value, as taken from the URL parameters
27+
*/
28+
private ?string $selectedValue;
29+
30+
public static function create(string $fieldName, ?string $selectedValue = null): self
31+
{
32+
return new self($fieldName, $selectedValue);
33+
}
34+
35+
private function __construct(string $fieldName, ?string $selectedValue = null)
36+
{
37+
$this->fieldName = $fieldName;
38+
$this->selectedValue = $selectedValue;
39+
}
40+
41+
public function buildAggregationRequest(): array
42+
{
43+
// This is a Terms aggregation, with the field name specified by the user.
44+
return [
45+
'terms' => [
46+
'field' => $this->fieldName
47+
]
48+
];
49+
}
50+
51+
public function bindResponse(array $aggregationResponse): AggregationResultInterface
52+
{
53+
return TermsAggregationResult::create($aggregationResponse, $this);
54+
}
55+
56+
public function buildQuery(): array
57+
{
58+
// for implementing faceting, we build the restriction query here
59+
if ($this->selectedValue) {
60+
return [
61+
'term' => [
62+
$this->fieldName => $this->selectedValue
63+
]
64+
];
65+
}
66+
67+
// json_encode([]) === "[]"
68+
// json_encode(new \stdClass) === "{}" <-- we need this!
69+
return ['match_all' => new \stdClass()];
70+
}
71+
72+
/**
73+
* @return string|null
74+
*/
75+
public function getSelectedValue(): ?string
76+
{
77+
return $this->selectedValue;
78+
}
79+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Sandstorm\LightweightElasticsearch\Query\Aggregation;
5+
6+
use Neos\Eel\ProtectedContextAwareInterface;
7+
use Neos\Flow\Annotations as Flow;
8+
9+
/**
10+
*
11+
* Example usage:
12+
*
13+
* ```fusion
14+
* nodeTypesFacet = Neos.Fusion:Component {
15+
* termsAggregationResult = ${searchRequest.execute().aggregation("nodeTypes")}
16+
* renderer = afx`
17+
* <Neos.Fusion:Loop items={props.termsAggregationResult.buckets} itemName="bucket">
18+
* <Neos.Neos:NodeLink node={documentNode} addQueryString={true} arguments={props.termsAggregationResult.buildUriArgumentForFacet(bucket.key)}>{bucket.key}</Neos.Neos:NodeLink> {bucket.doc_count}
19+
* </Neos.Fusion:Loop>
20+
* `
21+
* }
22+
* ```
23+
*
24+
* @Flow\Proxy(false)
25+
*/
26+
class TermsAggregationResult implements AggregationResultInterface, ProtectedContextAwareInterface
27+
{
28+
private array $aggregationResponse;
29+
private TermsAggregationBuilder $termsAggregationBuilder;
30+
31+
private function __construct(array $aggregationResponse, TermsAggregationBuilder $aggregationRequestBuilder)
32+
{
33+
$this->aggregationResponse = $aggregationResponse;
34+
$this->termsAggregationBuilder = $aggregationRequestBuilder;
35+
}
36+
37+
public static function create(array $aggregationResponse, TermsAggregationBuilder $aggregationRequestBuilder): self
38+
{
39+
return new self($aggregationResponse, $aggregationRequestBuilder);
40+
}
41+
42+
public function getBuckets() {
43+
return $this->aggregationResponse['buckets'];
44+
}
45+
46+
/**
47+
* @return string|null
48+
*/
49+
public function getSelectedValue(): ?string
50+
{
51+
return $this->termsAggregationBuilder->getSelectedValue();
52+
}
53+
54+
public function allowsCallOfMethod($methodName)
55+
{
56+
return true;
57+
}
58+
}

0 commit comments

Comments
 (0)