Skip to content

Commit

Permalink
FEATURE: Aggregation Support (a big one :-) )
Browse files Browse the repository at this point in the history
  • Loading branch information
skurfuerst committed Feb 2, 2021
1 parent 7105277 commit 396790b
Show file tree
Hide file tree
Showing 13 changed files with 674 additions and 93 deletions.
113 changes: 113 additions & 0 deletions Classes/Query/AbstractSearchRequestBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);

namespace Sandstorm\LightweightElasticsearch\Query;

use Neos\ContentRepository\Domain\Model\NodeInterface;
use Neos\ContentRepository\Utility;
use Neos\Flow\Annotations as Flow;
use Flowpack\ElasticSearch\ContentRepositoryAdaptor\ElasticSearchClient;
use Flowpack\ElasticSearch\Transfer\Exception\ApiException;
use Neos\Eel\ProtectedContextAwareInterface;
use Neos\Flow\Log\ThrowableStorageInterface;
use Neos\Flow\Log\Utility\LogEnvironment;
use Psr\Log\LoggerInterface;


abstract class AbstractSearchRequestBuilder implements ProtectedContextAwareInterface
{

/**
* @Flow\Inject
* @var ElasticSearchClient
*/
protected $elasticSearchClient;

/**
* @Flow\Inject
* @var ThrowableStorageInterface
*/
protected $throwableStorage;

/**
* @Flow\Inject
* @var LoggerInterface
*/
protected $logger;

private array $additionalIndices;

/**
* @var boolean
*/
protected $logThisQuery = false;

/**
* @var string
*/
protected $logMessage;

/**
* @var NodeInterface|null
*/
protected ?NodeInterface $contextNode;

public function __construct(NodeInterface $contextNode = null, array $additionalIndices = [])
{
$this->contextNode = $contextNode;
$this->additionalIndices = $additionalIndices;
}

/**
* Log the current request to the Elasticsearch log for debugging after it has been executed.
*
* @param string $message an optional message to identify the log entry
* @api
*/
public function log($message = null): self
{
$this->logThisQuery = true;
$this->logMessage = $message;

return $this;
}

/**
* Execute the query and return the SearchResult object as result.
*
* You can call this method multiple times; and the request is only executed at the first time; and cached
* for later use.
*
* @throws \Flowpack\ElasticSearch\Exception
* @throws \Neos\Flow\Http\Exception
*/
protected function executeInternal(array $request): array
{
try {
$timeBefore = microtime(true);

$indexNames = $this->additionalIndices;
if ($this->contextNode !== null) {
$dimensionValues = $this->contextNode->getContext()->getDimensions();
$dimensionHash = Utility::sortDimensionValueArrayAndReturnDimensionsHash($dimensionValues);
$indexNames[] = 'neoscr-' . $dimensionHash;
}

$response = $this->elasticSearchClient->request('GET', '/' . implode(',', $indexNames) . '/_search', [], $request);
$timeAfterwards = microtime(true);

$jsonResponse = $response->getTreatedContent();
$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__));
return $jsonResponse;
} catch (ApiException $exception) {
$message = $this->throwableStorage->logThrowable($exception);
$this->logger->error(sprintf('Request failed with %s', $message), LogEnvironment::fromMethodName(__METHOD__));
throw $exception;
}
}

public function allowsCallOfMethod($methodName)
{
return true;
}
}
30 changes: 30 additions & 0 deletions Classes/Query/Aggregation/AggregationBuilderInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php


namespace Sandstorm\LightweightElasticsearch\Query\Aggregation;

use Sandstorm\LightweightElasticsearch\Query\AggregationRequestBuilder;

interface AggregationBuilderInterface
{
/**
* Returns the Elasticsearch aggregation request part; so the part inside {"aggs": ...}.
*
* Is called by the framework (usually inside {@see AggregationRequestBuilder}, not by the end-user.
*
* @return array
*/
public function buildAggregationRequest(): array;

/**
* Binds the aggreation response to this aggregation; effectively creating an aggregation response object
* for this request.
*
* Is called by the framework (usually inside {@see AggregationRequestBuilder}, not by the end-user.
*
* @param array $aggregationResponse
* @return AggregationResultInterface
*/
public function bindResponse(array $aggregationResponse): AggregationResultInterface;

}
14 changes: 14 additions & 0 deletions Classes/Query/Aggregation/AggregationResultInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);

namespace Sandstorm\LightweightElasticsearch\Query\Aggregation;

/**
* Marker interface for aggregation results.
*
* An aggregation result is always created by calling {@see AggregationBuilderInterface::bindResponse());
* and each AggregationBuilder implementation has a corresponding AggregationResult implementation.
*/
interface AggregationResultInterface
{
}
25 changes: 25 additions & 0 deletions Classes/Query/Aggregation/QueryErrorAggregationResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);

namespace Sandstorm\LightweightElasticsearch\Query\Aggregation;

use Neos\Eel\ProtectedContextAwareInterface;
use Neos\Flow\Annotations as Flow;

/**
* Placeholder for an aggregation result in case of a query error
*
* @Flow\Proxy(false)
*/
class QueryErrorAggregationResult implements AggregationResultInterface, ProtectedContextAwareInterface
{

public function isError() {
return true;
}

public function allowsCallOfMethod($methodName)
{
return true;
}
}
79 changes: 79 additions & 0 deletions Classes/Query/Aggregation/TermsAggregationBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php


namespace Sandstorm\LightweightElasticsearch\Query\Aggregation;

use Neos\Flow\Annotations as Flow;
use Sandstorm\LightweightElasticsearch\Query\SearchQueryBuilderInterface;

/**
* A Terms aggregation can be used to build faceted search.
*
* It needs to be configured using:
* - the Elasticsearch field name which should be faceted (should be of type "keyword" to have useful results)
* - The selected value from the request, if any.
*
* The Terms Aggregation can be additionally used as search filter.
*
* See https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html for the details of usage.
*
* @Flow\Proxy(false)
*/
class TermsAggregationBuilder implements AggregationBuilderInterface, SearchQueryBuilderInterface
{
private string $fieldName;
/**
* @var string|null the selected value, as taken from the URL parameters
*/
private ?string $selectedValue;

public static function create(string $fieldName, ?string $selectedValue = null): self
{
return new self($fieldName, $selectedValue);
}

private function __construct(string $fieldName, ?string $selectedValue = null)
{
$this->fieldName = $fieldName;
$this->selectedValue = $selectedValue;
}

public function buildAggregationRequest(): array
{
// This is a Terms aggregation, with the field name specified by the user.
return [
'terms' => [
'field' => $this->fieldName
]
];
}

public function bindResponse(array $aggregationResponse): AggregationResultInterface
{
return TermsAggregationResult::create($aggregationResponse, $this);
}

public function buildQuery(): array
{
// for implementing faceting, we build the restriction query here
if ($this->selectedValue) {
return [
'term' => [
$this->fieldName => $this->selectedValue
]
];
}

// json_encode([]) === "[]"
// json_encode(new \stdClass) === "{}" <-- we need this!
return ['match_all' => new \stdClass()];
}

/**
* @return string|null
*/
public function getSelectedValue(): ?string
{
return $this->selectedValue;
}
}
58 changes: 58 additions & 0 deletions Classes/Query/Aggregation/TermsAggregationResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);

namespace Sandstorm\LightweightElasticsearch\Query\Aggregation;

use Neos\Eel\ProtectedContextAwareInterface;
use Neos\Flow\Annotations as Flow;

/**
*
* Example usage:
*
* ```fusion
* nodeTypesFacet = Neos.Fusion:Component {
* termsAggregationResult = ${searchRequest.execute().aggregation("nodeTypes")}
* renderer = afx`
* <Neos.Fusion:Loop items={props.termsAggregationResult.buckets} itemName="bucket">
* <Neos.Neos:NodeLink node={documentNode} addQueryString={true} arguments={props.termsAggregationResult.buildUriArgumentForFacet(bucket.key)}>{bucket.key}</Neos.Neos:NodeLink> {bucket.doc_count}
* </Neos.Fusion:Loop>
* `
* }
* ```
*
* @Flow\Proxy(false)
*/
class TermsAggregationResult implements AggregationResultInterface, ProtectedContextAwareInterface
{
private array $aggregationResponse;
private TermsAggregationBuilder $termsAggregationBuilder;

private function __construct(array $aggregationResponse, TermsAggregationBuilder $aggregationRequestBuilder)
{
$this->aggregationResponse = $aggregationResponse;
$this->termsAggregationBuilder = $aggregationRequestBuilder;
}

public static function create(array $aggregationResponse, TermsAggregationBuilder $aggregationRequestBuilder): self
{
return new self($aggregationResponse, $aggregationRequestBuilder);
}

public function getBuckets() {
return $this->aggregationResponse['buckets'];
}

/**
* @return string|null
*/
public function getSelectedValue(): ?string
{
return $this->termsAggregationBuilder->getSelectedValue();
}

public function allowsCallOfMethod($methodName)
{
return true;
}
}
Loading

0 comments on commit 396790b

Please sign in to comment.