├── Configuration ├── Development │ └── Settings.yaml ├── NodeTypes.yaml ├── Settings.yaml └── Objects.yaml ├── Classes ├── Query │ ├── Query │ │ ├── SearchQueryBuilderInterface.php │ │ ├── TermQueryBuilder.php │ │ ├── TermsQueryBuilder.php │ │ ├── SimpleQueryStringBuilder.php │ │ ├── BooleanQueryBuilder.php │ │ └── NeosFulltextQueryBuilder.php │ ├── Highlight │ │ ├── HighlightBuilderInterface.php │ │ └── NeosFulltextHighlightBuilder.php │ ├── Aggregation │ │ ├── AggregationResultInterface.php │ │ ├── QueryErrorAggregationResult.php │ │ ├── AggregationBuilderInterface.php │ │ ├── TermsAggregationResult.php │ │ └── TermsAggregationBuilder.php │ ├── Result │ │ ├── SearchResultDocument.php │ │ └── SearchResult.php │ ├── ElasticsearchHelper.php │ ├── AbstractSearchRequestBuilder.php │ ├── SearchRequestBuilder.php │ └── AggregationRequestBuilder.php ├── CustomIndexing │ ├── IndexAliasManager.php │ └── CustomIndexer.php └── DocumentIndexing │ ├── DocumentIndexerDriver.php │ └── DocumentNodeIndexer.php ├── composer.json ├── Documentation ├── 03_FulltextEelHelper.php ├── 03_CommandController.php ├── 02a_FacetedHighlightedSearchTemplate.fusion.diff ├── build_readme.php ├── 01_BasicSearchTemplate.fusion ├── 02_FacetedSearchTemplate.fusion.diff ├── 03_ExternalDataTemplate.fusion.diff ├── 03_ExternalDataTemplate.fusion ├── 02_FacetedSearchTemplate.fusion ├── 02a_FacetedHighlightedSearchTemplate.fusion └── README_template.md ├── Resources └── Private │ └── Fusion │ └── Root.fusion └── README.md /Configuration/Development/Settings.yaml: -------------------------------------------------------------------------------- 1 | Sandstorm: 2 | LightweightElasticsearch: 3 | handleElasticsearchExceptions: throw 4 | -------------------------------------------------------------------------------- /Classes/Query/Query/SearchQueryBuilderInterface.php: -------------------------------------------------------------------------------- 1 | query = [ 25 | 'term' => [ 26 | $key => $value 27 | ] 28 | ]; 29 | } 30 | 31 | public function buildQuery(): array 32 | { 33 | return $this->query; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Classes/Query/Query/TermsQueryBuilder.php: -------------------------------------------------------------------------------- 1 | query = [ 25 | 'terms' => [ 26 | $key => $value 27 | ] 28 | ]; 29 | } 30 | 31 | public function buildQuery(): array 32 | { 33 | return $this->query; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Configuration/Settings.yaml: -------------------------------------------------------------------------------- 1 | 2 | Neos: 3 | ContentRepository: 4 | Search: 5 | # we want to keep complexity lower; so we only index the live workspace 6 | indexAllWorkspaces: false 7 | 8 | # to keep the number of issues low when using the Neos backend and Elasticsearch is not properly set up, 9 | # we disable realtime indexing and resort only to batch indexing. 10 | realtimeIndexing: 11 | enabled: false 12 | 13 | Neos: 14 | fusion: 15 | autoInclude: 16 | 'Sandstorm.LightweightElasticsearch': true 17 | 18 | Fusion: 19 | defaultContext: 20 | 'Elasticsearch': Sandstorm\LightweightElasticsearch\Query\ElasticsearchHelper 21 | 22 | 23 | Sandstorm: 24 | LightweightElasticsearch: 25 | # either "log" or "throw". For dev, we want to throw elasticsearch exceptions; 26 | # for prod, we want to log them. 27 | handleElasticsearchExceptions: log 28 | -------------------------------------------------------------------------------- /Classes/Query/Query/SimpleQueryStringBuilder.php: -------------------------------------------------------------------------------- 1 | [] 18 | ]; 19 | 20 | public static function create(string $queryString): self 21 | { 22 | return new self($queryString); 23 | } 24 | 25 | private function __construct(string $queryString) 26 | { 27 | $this->query['simple_query_string']['query'] = $queryString; 28 | } 29 | 30 | public function fields(array $fields): self 31 | { 32 | $this->query['simple_query_string']['fields'] = $fields; 33 | return $this; 34 | } 35 | 36 | public function buildQuery(): array 37 | { 38 | return $this->query; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Documentation/03_FulltextEelHelper.php: -------------------------------------------------------------------------------- 1 | filter(TermQueryBuilder::create('index_discriminator', 'faq')) 19 | ->must( 20 | SimpleQueryStringBuilder::create($query ?? '')->fields([ 21 | 'faqEntryTitle^5', 22 | ]) 23 | ); 24 | } 25 | 26 | public function allowsCallOfMethod($methodName) 27 | { 28 | return true; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/Root.fusion: -------------------------------------------------------------------------------- 1 | prototype(Flowpack.Listable:PaginatedCollection) { 2 | renderer.@context.data.collection.lightweightElasticsearch { 3 | @position = "start" 4 | 5 | condition = ${Type.instance(props.collection, 'Sandstorm\LightweightElasticsearch\Query\SearchRequestBuilder')} 6 | renderer = ${props.collection} 7 | renderer.@process.limit = ${value.size(props.itemsPerPage)} 8 | renderer.@process.offset = ${value.from(offset)} 9 | renderer.@process.execute = ${value.execute()} 10 | } 11 | } 12 | 13 | prototype(Flowpack.Listable:ContentCaseShort) { 14 | lightweightElasticsearchDocument { 15 | @position = "start" 16 | condition = ${Type.instance(node, 'Sandstorm\LightweightElasticsearch\Query\Result\SearchResultDocument')} 17 | condition = true 18 | type = 'Sandstorm.LightweightElasticsearch:SearchResultCase' 19 | element.@context.searchResultDocument = ${node} 20 | } 21 | } 22 | 23 | prototype(Sandstorm.LightweightElasticsearch:SearchResultCase) < prototype(Neos.Fusion:Case) { 24 | } 25 | -------------------------------------------------------------------------------- /Classes/Query/Aggregation/AggregationBuilderInterface.php: -------------------------------------------------------------------------------- 1 | createIndexWithMapping(['properties' => [ 15 | 'faqEntryTitle' => [ 16 | 'type' => 'text' 17 | ] 18 | ]]); 19 | $indexer->index([ 20 | 'faqEntryTitle' => 'FAQ Dresden' 21 | ]); 22 | $indexer->index([ 23 | 'faqEntryTitle' => 'FAQ Berlin' 24 | ]); 25 | $indexer->finalizeAndSwitchAlias(); 26 | } 27 | 28 | public function cleanupCommand() 29 | { 30 | $indexer = CustomIndexer::create('faq'); 31 | $removedIndices = $indexer->removeObsoleteIndices(); 32 | foreach ($removedIndices as $index) { 33 | $this->outputLine('Removed ' . $index); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Configuration/Objects.yaml: -------------------------------------------------------------------------------- 1 | Neos\ContentRepository\Search\Indexer\NodeIndexerInterface: 2 | className: 'Sandstorm\LightweightElasticsearch\DocumentIndexing\DocumentNodeIndexer' 3 | 4 | Flowpack\ElasticSearch\ContentRepositoryAdaptor\Driver\IndexerDriverInterface: 5 | className: 'Sandstorm\LightweightElasticsearch\DocumentIndexing\DocumentIndexerDriver' 6 | 7 | # WORKAROUND: the NodeIndexCommandController::nodeIndexer is types as "NodeIndexer" and not 8 | # as the interface; so we need to ensure our overridden DocumentNodeIndexer is injected here. 9 | Flowpack\ElasticSearch\ContentRepositoryAdaptor\Command\NodeIndexCommandController: 10 | properties: 11 | nodeIndexer: 12 | object: 'Neos\ContentRepository\Search\Indexer\NodeIndexerInterface' 13 | 14 | # WORKAROUND: the NodeIndexMappingCommandController::nodeIndexer is types as "NodeIndexer" and not 15 | # as the interface; so we need to ensure our overridden DocumentNodeIndexer is injected here. 16 | Flowpack\ElasticSearch\ContentRepositoryAdaptor\Command\NodeIndexMappingCommandController: 17 | properties: 18 | nodeIndexer: 19 | object: 'Neos\ContentRepository\Search\Indexer\NodeIndexerInterface' 20 | -------------------------------------------------------------------------------- /Documentation/02a_FacetedHighlightedSearchTemplate.fusion.diff: -------------------------------------------------------------------------------- 1 | @@ -9,7 +9,7 @@ 2 | // - this._elasticsearchBaseQuery is applied 3 | // - this._nodeTypesAggregation is applied as well (if the user chose a facet value) 4 | // <-- if you add additional aggregations, you need to add them here to this list. 5 | - @context.mainSearchRequest = ${Elasticsearch.createRequest(site).query(Elasticsearch.createBooleanQuery().must(this._elasticsearchBaseQuery).filter(this._nodeTypesAggregation))} 6 | + @context.mainSearchRequest = ${Elasticsearch.createRequest(site).query(Elasticsearch.createBooleanQuery().must(this._elasticsearchBaseQuery).filter(this._nodeTypesAggregation)).highlight(Elasticsearch.createNeosFulltextHighlight())} 7 | 8 | // The Request is for displaying the Node Types aggregation (faceted search). 9 | // 10 | @@ -102,7 +102,9 @@ 11 | condition = ${searchResultDocument.property('index_discriminator') == 'neos_nodes'} 12 | renderer.@context.node = ${searchResultDocument.loadNode()} 13 | renderer = afx` 14 | - 15 | + 16 | + {Array.join(searchResultDocument.processedHighlights, '…')} 17 | + 18 | ` 19 | // If you want to see the full Search Response hit, you can include the following 20 | // snippet in the renderer above: 21 | -------------------------------------------------------------------------------- /Documentation/build_readme.php: -------------------------------------------------------------------------------- 1 | ./02_FacetedSearchTemplate.fusion.diff'); 5 | exec('diff -u ./02_FacetedSearchTemplate.fusion ./03_ExternalDataTemplate.fusion | tail -n +3 > ./03_ExternalDataTemplate.fusion.diff'); 6 | exec('diff -u ./02_FacetedSearchTemplate.fusion ./02a_FacetedHighlightedSearchTemplate.fusion | tail -n +3 > ./02a_FacetedHighlightedSearchTemplate.fusion.diff'); 7 | 8 | $readme = file_get_contents(__DIR__ .'/README_template.md'); 9 | 10 | $readme = str_replace('###01_BasicSearchTemplate.fusion###', file_get_contents(__DIR__ . '/01_BasicSearchTemplate.fusion'), $readme); 11 | $readme = str_replace('###02_FacetedSearchTemplate.fusion###', file_get_contents(__DIR__ . '/02_FacetedSearchTemplate.fusion'), $readme); 12 | $readme = str_replace('###02_FacetedSearchTemplate.fusion.diff###', file_get_contents(__DIR__ . '/02_FacetedSearchTemplate.fusion.diff'), $readme); 13 | $readme = str_replace('###02a_FacetedHighlightedSearchTemplate.fusion###', file_get_contents(__DIR__ . '/02a_FacetedHighlightedSearchTemplate.fusion'), $readme); 14 | $readme = str_replace('###02a_FacetedHighlightedSearchTemplate.fusion.diff###', file_get_contents(__DIR__ . '/02a_FacetedHighlightedSearchTemplate.fusion.diff'), $readme); 15 | $readme = str_replace('###03_CommandController.php###', file_get_contents(__DIR__ . '/03_CommandController.php'), $readme); 16 | $readme = str_replace('###03_ExternalDataTemplate.fusion###', file_get_contents(__DIR__ . '/03_ExternalDataTemplate.fusion'), $readme); 17 | $readme = str_replace('###03_ExternalDataTemplate.fusion.diff###', file_get_contents(__DIR__ . '/03_ExternalDataTemplate.fusion.diff'), $readme); 18 | $readme = str_replace('###03_FulltextEelHelper.php###', file_get_contents(__DIR__ . '/03_FulltextEelHelper.php'), $readme); 19 | 20 | 21 | file_put_contents(__DIR__ . '/../README.md', $readme); 22 | -------------------------------------------------------------------------------- /Classes/Query/Highlight/NeosFulltextHighlightBuilder.php: -------------------------------------------------------------------------------- 1 | fragmentSize = $fragmentSize; 26 | $this->fragmentCount = $fragmentCount; 27 | } 28 | 29 | /** 30 | * add an extra field to the fulltext 31 | * 32 | * @param string $fieldName 33 | * @return $this 34 | */ 35 | public function extraField(string $fieldName): self 36 | { 37 | $this->extraHighlightFields[] = $fieldName; 38 | return $this; 39 | } 40 | 41 | public function buildHighlightRequestPart(array $extraFields = []): array 42 | { 43 | $highlightRequestPart = [ 44 | 'fields' => [ 45 | 'neos_fulltext*' => [ 46 | 'fragment_size' => $this->fragmentSize, 47 | 'no_match_size' => $this->fragmentSize, 48 | 'number_of_fragments' => $this->fragmentCount 49 | ] 50 | ] 51 | ]; 52 | 53 | foreach ($this->extraHighlightFields as $fieldName) { 54 | $highlightRequestPart['fields'][$fieldName] = $highlightRequestPart['fields']['neos_fulltext*']; 55 | } 56 | 57 | return $highlightRequestPart; 58 | } 59 | 60 | public function allowsCallOfMethod($methodName) 61 | { 62 | return true; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Classes/Query/Aggregation/TermsAggregationResult.php: -------------------------------------------------------------------------------- 1 | 18 | * {bucket.key} {bucket.doc_count} 19 | * 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 | } 59 | -------------------------------------------------------------------------------- /Classes/Query/Result/SearchResultDocument.php: -------------------------------------------------------------------------------- 1 | hit = $hit; 18 | $this->contextNode = $contextNode; 19 | } 20 | 21 | public static function fromElasticsearchJsonResponse(array $hit, NodeInterface $contextNode = null): self 22 | { 23 | return new static($hit, $contextNode); 24 | } 25 | 26 | public function loadNode(): ?NodeInterface 27 | { 28 | $nodePath = $this->hit['_source']['neos_path']; 29 | 30 | if (is_array($nodePath)) { 31 | $nodePath = current($nodePath); 32 | } 33 | 34 | return $this->contextNode->getNode($nodePath); 35 | } 36 | 37 | public function getFullSearchHit() 38 | { 39 | return $this->hit; 40 | } 41 | 42 | public function property(string $key) 43 | { 44 | return $this->hit['_source'][$key] ?? null; 45 | } 46 | 47 | public function getProperties(): array 48 | { 49 | return $this->hit['_source'] ?? []; 50 | } 51 | 52 | /** 53 | * all highlights as a flat array of strings, no matter which field they were found in 54 | * @return string[] 55 | */ 56 | public function getProcessedHighlights(): array 57 | { 58 | $highlights = $this->hit['highlight'] ?? []; 59 | $processedHighlights = []; 60 | foreach ($highlights as $field => $highlightArray) { 61 | if (is_string($highlightArray)) { 62 | $highlightArray = [$highlightArray]; 63 | } 64 | foreach ($highlightArray as $highlight) { 65 | $processedHighlights[] = $highlight; 66 | } 67 | } 68 | 69 | return $processedHighlights; 70 | } 71 | 72 | public function allowsCallOfMethod($methodName) 73 | { 74 | return true; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Classes/Query/Result/SearchResult.php: -------------------------------------------------------------------------------- 1 | response = $response; 50 | $this->isError = $isError; 51 | $this->contextNode = $contextNode; 52 | } 53 | 54 | public function isError(): bool 55 | { 56 | return $this->isError; 57 | } 58 | 59 | public function getIterator(): \Generator 60 | { 61 | if (isset($this->response['hits']['hits'])) { 62 | foreach ($this->response['hits']['hits'] as $hit) { 63 | yield SearchResultDocument::fromElasticsearchJsonResponse($hit, $this->contextNode); 64 | } 65 | } 66 | } 67 | 68 | public function total(): int 69 | { 70 | if (!isset($this->response['hits']['total']['value'])) { 71 | return 0; 72 | } 73 | return $this->response['hits']['total']['value']; 74 | 75 | } 76 | 77 | public function count() 78 | { 79 | if (isset($this->response['hits']['hits'])) { 80 | return count($this->response['hits']['hits']); 81 | } 82 | return 0; 83 | } 84 | 85 | public function allowsCallOfMethod($methodName) 86 | { 87 | return true; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Classes/Query/Aggregation/TermsAggregationBuilder.php: -------------------------------------------------------------------------------- 1 | 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 | } 80 | -------------------------------------------------------------------------------- /Classes/CustomIndexing/IndexAliasManager.php: -------------------------------------------------------------------------------- 1 | searchClient->findIndex($indexName); 48 | if (!$index->exists()) { 49 | throw new Exception(sprintf('The target index "%s" does not exist.', $index->getName()), 1611586520); 50 | } 51 | 52 | $aliasActions = []; 53 | try { 54 | $indexNames = $this->indexDriver->getIndexNamesByAlias($aliasName); 55 | if ($indexNames === []) { 56 | // if there is an actual index with the name we want to use as alias, remove it now 57 | $this->indexDriver->deleteIndex($aliasName); 58 | } else { 59 | // Remove all existing aliasses 60 | foreach ($indexNames as $indexNameToRemove) { 61 | $aliasActions[] = [ 62 | 'remove' => [ 63 | 'index' => $indexNameToRemove, 64 | 'alias' => $aliasName 65 | ] 66 | ]; 67 | } 68 | } 69 | } catch (ApiException $exception) { 70 | // in case of 404, do not throw an error... 71 | if ($exception->getResponse()->getStatusCode() !== 404) { 72 | throw $exception; 73 | } 74 | } 75 | 76 | $aliasActions[] = [ 77 | 'add' => [ 78 | 'index' => $indexName, 79 | 'alias' => $aliasName 80 | ] 81 | ]; 82 | 83 | $this->indexDriver->aliasActions($aliasActions); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Classes/Query/Query/BooleanQueryBuilder.php: -------------------------------------------------------------------------------- 1 | [] 26 | ]; 27 | 28 | /** 29 | * Add a query to the "must" part of the Bool query. This query must ALWAYS match for a document to be included in the results. 30 | * 31 | * @param SearchQueryBuilderInterface $query 32 | * @return $this 33 | */ 34 | public function must(SearchQueryBuilderInterface $query): self 35 | { 36 | $this->query['bool']['must'][] = $query->buildQuery(); 37 | return $this; 38 | } 39 | 40 | /** 41 | * Add a query to the "should" part of the Bool query. 42 | * 43 | * The "minimum_should_match" property defines the number or percentage of should clauses returned documents must match. 44 | * If the bool query includes at least one should clause and no must or filter clauses, the default value is 1. Otherwise, the default value is 0. 45 | * 46 | * @param SearchQueryBuilderInterface $query 47 | * @return $this 48 | */ 49 | public function should(SearchQueryBuilderInterface $query): self 50 | { 51 | $this->query['bool']['should'][] = $query->buildQuery(); 52 | return $this; 53 | } 54 | 55 | /** 56 | * Add a query to the "must_not" part of the Bool query. This query must NEVER match for a document to be included in the results. 57 | * 58 | * @param SearchQueryBuilderInterface $query 59 | * @return $this 60 | */ 61 | public function mustNot(SearchQueryBuilderInterface $query): self 62 | { 63 | $this->query['bool']['must_not'][] = $query->buildQuery(); 64 | return $this; 65 | } 66 | 67 | /** 68 | * Add a query to the "filter" part of the Bool query. This query must ALWAYS match for a document to be included in the results; and ranking information is discarded. 69 | * 70 | * @param SearchQueryBuilderInterface $query 71 | * @return $this 72 | */ 73 | public function filter(SearchQueryBuilderInterface $query): self 74 | { 75 | $this->query['bool']['filter'][] = $query->buildQuery(); 76 | return $this; 77 | } 78 | 79 | public function allowsCallOfMethod($methodName) 80 | { 81 | return true; 82 | } 83 | 84 | public function buildQuery(): array 85 | { 86 | return $this->query; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Documentation/01_BasicSearchTemplate.fusion: -------------------------------------------------------------------------------- 1 | prototype(My.Package:Search) < prototype(Neos.Fusion:Component) { 2 | // for possibilities on how to build the query, see the next section in the documentation 3 | _elasticsearchBaseQuery = ${Elasticsearch.createNeosFulltextQuery(site).fulltext(request.arguments.q)} 4 | @context.mainSearchRequest = ${Elasticsearch.createRequest(site).query(this._elasticsearchBaseQuery))} 5 | 6 | // Search Result Display is controlled through Flowpack.Listable 7 | searchResults = Flowpack.Listable:PaginatedCollection { 8 | collection = ${mainSearchRequest} 9 | itemsPerPage = 12 10 | 11 | // we use cache mode "dynamic" for the full Search component; so we do not need an additional cache entry 12 | // for the PaginatedCollection. 13 | @cache.mode = "embed" 14 | } 15 | renderer = afx` 16 |
17 | 18 | 19 | 20 |
21 | There was an error executing the search request. Please try again in a few minutes. 22 |
23 |

Showing {mainSearchRequest.execute().count()} of {mainSearchRequest.execute().total()} results

24 | 25 | {props.searchResults} 26 |
27 | ` 28 | // If you want to see the full request going to Elasticsearch, you can include 29 | // the following snippet in the renderer above: 30 | // 31 | 32 | // The parameter "q" should be included in this pagination 33 | prototype(Flowpack.Listable:PaginationParameters) { 34 | q = ${request.arguments.q} 35 | } 36 | 37 | // We configure the cache mode "dynamic" here. 38 | @cache { 39 | mode = 'dynamic' 40 | entryIdentifier { 41 | node = ${node} 42 | type = 'searchForm' 43 | } 44 | entryDiscriminator = ${request.arguments.q + '-' + request.arguments.currentPage} 45 | context { 46 | 1 = 'node' 47 | 2 = 'documentNode' 48 | 3 = 'site' 49 | } 50 | entryTags { 51 | 1 = ${Neos.Caching.nodeTag(node)} 52 | } 53 | } 54 | } 55 | 56 | // The result display is done here. 57 | // In the context, you'll find an object `searchResultDocument` which is of type 58 | // Sandstorm\LightweightElasticsearch\Query\Result\SearchResultDocument. 59 | prototype(Sandstorm.LightweightElasticsearch:SearchResultCase) { 60 | neosNodes { 61 | // all Documents in the index which are Nodes have a property "index_discriminator" set to "neos_nodes"; 62 | // This is in preparation for displaying other kinds of data. 63 | condition = ${searchResultDocument.property('index_discriminator') == 'neos_nodes'} 64 | renderer.@context.node = ${searchResultDocument.loadNode()} 65 | renderer = afx` 66 | 67 | ` 68 | // If you want to see the full Search Response hit, you can include the following 69 | // snippet in the renderer above: 70 | // 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Classes/Query/Query/NeosFulltextQueryBuilder.php: -------------------------------------------------------------------------------- 1 | boolQuery = BooleanQueryBuilder::create() 29 | // on indexing, the neos_parent_path is tokenized to contain ALL parent path parts, 30 | // e.g. /foo, /foo/bar/, /foo/bar/baz; to speed up matching.. That's why we use a simple "term" filter here. 31 | // http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/query-dsl-term-filter.html 32 | // another term filter against the path allows the context node itself to be found 33 | ->filter( 34 | BooleanQueryBuilder::create() 35 | ->should(TermQueryBuilder::create('neos_parent_path', $contextNode->getPath())) 36 | ->should(TermQueryBuilder::create('neos_path', $contextNode->getPath())) 37 | ) 38 | ->filter( 39 | // http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/query-dsl-terms-filter.html 40 | TermsQueryBuilder::create('neos_workspace', array_unique(['live', $contextNode->getContext()->getWorkspace()->getName()])) 41 | ); 42 | } 43 | 44 | /** 45 | * Specify the fulltext query string to be used for searching. 46 | * 47 | * NOTE: this method can be called multiple times; this corresponds to an "AND" of the queries (i.e. BOTH must match to include the result). 48 | * I am not yet sure if this is a good idea or not :-) 49 | * 50 | * @param string|null $query 51 | * @return $this 52 | */ 53 | public function fulltext(string $query = null): self 54 | { 55 | $this->boolQuery->must(SimpleQueryStringBuilder::create($query ?? '')->fields([ 56 | 'neos_fulltext.h1^5', 57 | 'neos_fulltext.h2^4', 58 | 'neos_fulltext.h3^3', 59 | 'neos_fulltext.h4^2', 60 | 'neos_fulltext.h5^1', 61 | 'neos_fulltext.h6', 62 | 'neos_fulltext.text', 63 | ])); 64 | return $this; 65 | } 66 | 67 | /** 68 | * Add a query to the "filter" part of the query. This query must ALWAYS match for a document to be included in the results; and ranking information is discarded. 69 | * 70 | * @param SearchQueryBuilderInterface $query 71 | * @return $this 72 | */ 73 | public function filter(SearchQueryBuilderInterface $query): self 74 | { 75 | $this->boolQuery->filter($query); 76 | return $this; 77 | } 78 | 79 | public function buildQuery(): array 80 | { 81 | return $this->boolQuery->buildQuery(); 82 | } 83 | 84 | public function allowsCallOfMethod($methodName) 85 | { 86 | return true; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Classes/Query/ElasticsearchHelper.php: -------------------------------------------------------------------------------- 1 | tempStorage; 58 | $document->setData($documentData); 59 | 60 | $this->tempStorage->attach($node, ['doc' => $document, 'index' => $indexName]); 61 | 62 | return []; 63 | } 64 | 65 | /** 66 | * {@inheritdoc} 67 | * @throws \Neos\Flow\Persistence\Exception\IllegalObjectTypeException 68 | */ 69 | public function fulltext(NodeInterface $node, array $fulltextIndexOfNode, string $targetWorkspaceName = null): array 70 | { 71 | assert($this->tempStorage->offsetExists($node)); 72 | $documentAndIndex = $this->tempStorage->offsetGet($node); 73 | $document = $documentAndIndex['doc']; 74 | $indexName = $documentAndIndex['index']; 75 | assert($document instanceof ElasticSearchDocument); 76 | $this->tempStorage->offsetUnset($node); 77 | 78 | return [ 79 | [ 80 | 'index' => [ 81 | '_type' => '_doc', 82 | '_id' => $document->getId(), 83 | '_index' => $indexName 84 | ] 85 | ], 86 | $document->getData() 87 | ]; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Classes/DocumentIndexing/DocumentNodeIndexer.php: -------------------------------------------------------------------------------- 1 | getChildNodes('Neos.Neos:Content,Neos.Neos:ContentCollection') as $childNode) { 52 | $this->enrichWithFulltextForContentNodes($childNode, $fulltextData); 53 | } 54 | 55 | return $result; 56 | } 57 | 58 | protected function enrichWithFulltextForContentNodes(NodeInterface $node, array &$fulltextData): void 59 | { 60 | if (self::isFulltextRoot($node)) { 61 | // fulltext roots are indexed on their own 62 | return; 63 | } 64 | $nodeType = $node->getNodeType(); 65 | $fulltextIndexingEnabledForNode = $this->isFulltextEnabled($node); 66 | 67 | foreach ($nodeType->getProperties() as $propertyName => $propertyConfiguration) { 68 | if ($fulltextIndexingEnabledForNode === true && isset($propertyConfiguration['search']['fulltextExtractor'])) { 69 | $this->extractFulltext($node, $propertyName, $propertyConfiguration['search']['fulltextExtractor'], $fulltextData); 70 | } 71 | } 72 | 73 | foreach ($node->getChildNodes('Neos.Neos:Content,Neos.Neos:ContentCollection') as $childNode) { 74 | $this->enrichWithFulltextForContentNodes($childNode, $fulltextData); 75 | } 76 | } 77 | 78 | /** 79 | * Whether the node is configured as fulltext root. Copied from AbstractIndexerDriver::isFulltextRoot(). 80 | * 81 | * @param NodeInterface $node 82 | * @return bool 83 | */ 84 | protected static function isFulltextRoot(NodeInterface $node): bool 85 | { 86 | if ($node->getNodeType()->hasConfiguration('search')) { 87 | $elasticSearchSettingsForNode = $node->getNodeType()->getConfiguration('search'); 88 | if (isset($elasticSearchSettingsForNode['fulltext']['isRoot']) && $elasticSearchSettingsForNode['fulltext']['isRoot'] === true) { 89 | return true; 90 | } 91 | } 92 | 93 | return false; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Classes/Query/AbstractSearchRequestBuilder.php: -------------------------------------------------------------------------------- 1 | contextNode = $contextNode; 57 | $this->additionalIndices = $additionalIndices; 58 | } 59 | 60 | /** 61 | * Log the current request to the Elasticsearch log for debugging after it has been executed. 62 | * 63 | * @param string $message an optional message to identify the log entry 64 | * @api 65 | */ 66 | public function log($message = null): self 67 | { 68 | $this->logThisQuery = true; 69 | $this->logMessage = $message; 70 | 71 | return $this; 72 | } 73 | 74 | /** 75 | * Execute the query and return the SearchResult object as result. 76 | * 77 | * You can call this method multiple times; and the request is only executed at the first time; and cached 78 | * for later use. 79 | * 80 | * @throws \Flowpack\ElasticSearch\Exception 81 | * @throws \Neos\Flow\Http\Exception 82 | */ 83 | protected function executeInternal(array $request): array 84 | { 85 | try { 86 | $timeBefore = microtime(true); 87 | 88 | $indexNames = $this->additionalIndices; 89 | if ($this->contextNode !== null) { 90 | $this->elasticSearchClient->setContextNode($this->contextNode); 91 | $indexNames[] = $this->elasticSearchClient->getIndexName(); 92 | } 93 | 94 | $response = $this->elasticSearchClient->request('GET', '/' . implode(',', $indexNames) . '/_search', [], $request); 95 | $timeAfterwards = microtime(true); 96 | 97 | $jsonResponse = $response->getTreatedContent(); 98 | $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), json_encode($request), (($timeAfterwards - $timeBefore) * 1000), count($jsonResponse['hits']['hits']), $jsonResponse['hits']['total']['value']), LogEnvironment::fromMethodName(__METHOD__)); 99 | return $jsonResponse; 100 | } catch (ApiException $exception) { 101 | $message = $this->throwableStorage->logThrowable($exception); 102 | $this->logger->error(sprintf('Request failed with %s', $message), LogEnvironment::fromMethodName(__METHOD__)); 103 | throw $exception; 104 | } 105 | } 106 | 107 | public function allowsCallOfMethod($methodName) 108 | { 109 | return true; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Classes/Query/SearchRequestBuilder.php: -------------------------------------------------------------------------------- 1 | searchResult !== null) { 33 | // we need to reset the search result cache when the builder is mutated 34 | $this->searchResult = null; 35 | } 36 | 37 | $this->request['query'] = $query->buildQuery(); 38 | return $this; 39 | } 40 | 41 | public function from(int $offset): self 42 | { 43 | if ($this->searchResult !== null) { 44 | // we need to reset the search result cache when the builder is mutated 45 | $this->searchResult = null; 46 | } 47 | 48 | $this->request['from'] = $offset; 49 | return $this; 50 | } 51 | 52 | public function size(int $size): self 53 | { 54 | if ($this->searchResult !== null) { 55 | // we need to reset the search result cache when the builder is mutated 56 | $this->searchResult = null; 57 | } 58 | 59 | $this->request['size'] = $size; 60 | return $this; 61 | } 62 | 63 | public function minScore(float $minScore): self 64 | { 65 | if ($this->searchResult !== null) { 66 | // we need to reset the search result cache when the builder is mutated 67 | $this->searchResult = null; 68 | } 69 | 70 | $this->request['min_score'] = $minScore; 71 | return $this; 72 | } 73 | 74 | public function highlight(HighlightBuilderInterface $highlightBuilder): self 75 | { 76 | if ($this->searchResult !== null) { 77 | // we need to reset the search result cache when the builder is mutated 78 | $this->searchResult = null; 79 | } 80 | 81 | $this->request['highlight'] = $highlightBuilder->buildHighlightRequestPart(); 82 | return $this; 83 | } 84 | 85 | /** 86 | * Execute the query and return the SearchResult object as result. 87 | * 88 | * You can call this method multiple times; and the request is only executed at the first time; and cached 89 | * for later use. 90 | * 91 | * @throws \Flowpack\ElasticSearch\Exception 92 | * @throws \Neos\Flow\Http\Exception 93 | */ 94 | public function execute(): SearchResult 95 | { 96 | if ($this->searchResult === null) { 97 | try { 98 | $jsonResponse = $this->executeInternal($this->request); 99 | $this->searchResult = SearchResult::fromElasticsearchJsonResponse($jsonResponse, $this->contextNode); 100 | } catch (ApiException $exception) { 101 | if ($this->handleElasticsearchExceptions === 'throw') { 102 | throw $exception; 103 | } 104 | 105 | $this->searchResult = SearchResult::error(); 106 | } 107 | } 108 | return $this->searchResult; 109 | } 110 | 111 | /** 112 | * DO NOT USE THIS METHOD DIRECTLY; it is implemented to ensure Flowpack.Listable plays well with these objects here. 113 | * 114 | * @return int 115 | * @throws \Flowpack\ElasticSearch\Exception 116 | * @throws \Neos\Flow\Http\Exception 117 | * @internal 118 | */ 119 | public function count(): int 120 | { 121 | return $this->execute()->total(); 122 | } 123 | 124 | /** 125 | * Returns the full request as it is sent to Elasticsearch; useful for debugging purposes. 126 | * 127 | * @return array 128 | */ 129 | public function requestForDebugging(): array 130 | { 131 | return $this->request; 132 | } 133 | 134 | public function allowsCallOfMethod($methodName) 135 | { 136 | return true; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /Documentation/02_FacetedSearchTemplate.fusion.diff: -------------------------------------------------------------------------------- 1 | @@ -1,7 +1,24 @@ 2 | prototype(My.Package:Search) < prototype(Neos.Fusion:Component) { 3 | - // for possibilities on how to build the query, see the next section in the documentation 4 | + // this is the base query from the user which should *always* be applied. 5 | _elasticsearchBaseQuery = ${Elasticsearch.createNeosFulltextQuery(site).fulltext(request.arguments.q)} 6 | - @context.mainSearchRequest = ${Elasticsearch.createRequest(site).query(this._elasticsearchBaseQuery))} 7 | + 8 | + // register a Terms aggregation with the URL parameter "nodeTypesFilter" 9 | + _nodeTypesAggregation = ${Elasticsearch.createTermsAggregation("neos_type", request.arguments.nodeTypesFilter)} 10 | + 11 | + // This is the main elasticsearch query which determines the search results: 12 | + // - this._elasticsearchBaseQuery is applied 13 | + // - this._nodeTypesAggregation is applied as well (if the user chose a facet value) 14 | + // <-- if you add additional aggregations, you need to add them here to this list. 15 | + @context.mainSearchRequest = ${Elasticsearch.createRequest(site).query(Elasticsearch.createBooleanQuery().must(this._elasticsearchBaseQuery).filter(this._nodeTypesAggregation))} 16 | + 17 | + // The Request is for displaying the Node Types aggregation (faceted search). 18 | + // 19 | + // For faceted search to work properly, we need to add all OTHER query parts as filter; so NOT ourselves. 20 | + // This means, for the `.aggregation()` part, we take the aggregation itself. 21 | + // For the `.filter()` part, we add: 22 | + // - this._elasticsearchBaseQuery to ensure the entered query string by the user is taken into account 23 | + // <-- if you add additional aggregations, you need to add them here to the list. 24 | + @context.nodeTypesFacet = ${Elasticsearch.createAggregationRequest(site).aggregation(this._nodeTypesAggregation).filter(this._elasticsearchBaseQuery).execute()} 25 | 26 | // Search Result Display is controlled through Flowpack.Listable 27 | searchResults = Flowpack.Listable:PaginatedCollection { 28 | @@ -12,6 +29,23 @@ 29 | // for the PaginatedCollection. 30 | @cache.mode = "embed" 31 | } 32 | + 33 | + nodeTypesFacet = Neos.Fusion:Component { 34 | + // the nodeTypesFacet is a "Terms" aggregation... 35 | + // ...so we can access nodeTypesFacet.buckets. 36 | + // To build a link to the facet, we use Neos.Neos:NodeLink with two additions: 37 | + // - addQueryString must be set to TRUE, to keep the search query and potentially other facets. 38 | + // - to build the arguments, we need to set `nodeTypesFilter` to the current bucket key (or to null in case we want to clear the facet) 39 | + renderer = afx` 40 | +
    41 | + 42 | +
  • {bucket.key} {bucket.doc_count} (selected)
  • 43 | +
    44 | +
45 | + CLEAR FACET 46 | + ` 47 | + } 48 | + 49 | renderer = afx` 50 |
51 | 52 | @@ -22,6 +56,8 @@ 53 | 54 |

Showing {mainSearchRequest.execute().count()} of {mainSearchRequest.execute().total()} results

55 | 56 | + {props.nodeTypesFacet} 57 | + 58 | {props.searchResults} 59 |
60 | ` 61 | @@ -32,6 +68,8 @@ 62 | // The parameter "q" should be included in this pagination 63 | prototype(Flowpack.Listable:PaginationParameters) { 64 | q = ${request.arguments.q} 65 | + // <-- if you add additional aggregations, you need to add the parameter names here 66 | + nodeTypesFilter = ${request.arguments.nodeTypesFilter} 67 | } 68 | 69 | // We configure the cache mode "dynamic" here. 70 | @@ -41,7 +79,8 @@ 71 | node = ${node} 72 | type = 'searchForm' 73 | } 74 | - entryDiscriminator = ${request.arguments.q + '-' + request.arguments.currentPage} 75 | + // <-- if you add additional aggregations, you need to add the parameter names to the entryDiscriminator 76 | + entryDiscriminator = ${request.arguments.q + '-' + request.arguments.currentPage + '-' + request.arguments.nodeTypesFilter} 77 | context { 78 | 1 = 'node' 79 | 2 = 'documentNode' 80 | -------------------------------------------------------------------------------- /Classes/Query/AggregationRequestBuilder.php: -------------------------------------------------------------------------------- 1 | 0, 36 | 'query' => [ 37 | // the aggregations need to run on a subset of all documents; 38 | // so we prepare a boolean filter query for this. 39 | 'bool' => [ 40 | 'filter' => [] 41 | ] 42 | ], 43 | 'aggs' => [ 44 | // we prepare the aggregation; we do not know its type yet. 45 | self::AGGREGATION_NAME => [] 46 | ] 47 | ]; 48 | 49 | /** 50 | * Cached aggregation result 51 | * 52 | * @var AggregationResultInterface|null 53 | */ 54 | private ?AggregationResultInterface $aggregationResult = null; 55 | 56 | 57 | /** 58 | * Add a filter to the aggregation. 59 | * 60 | * @param SearchQueryBuilderInterface $query 61 | * @return $this 62 | */ 63 | public function filter(SearchQueryBuilderInterface $query): self 64 | { 65 | 66 | if ($this->aggregationResult !== null) { 67 | // we need to reset the aggregation result cache when the builder is mutated 68 | $this->aggregationResult = null; 69 | } 70 | 71 | $this->request['query']['bool']['filter'][] = $query->buildQuery(); 72 | return $this; 73 | } 74 | 75 | /** 76 | * Set the actual Aggregation to run 77 | * 78 | * @param AggregationBuilderInterface $aggregationBuilder 79 | */ 80 | public function aggregation(AggregationBuilderInterface $aggregationBuilder): self 81 | { 82 | if ($this->aggregationResult !== null) { 83 | // we need to reset the aggregation result cache when the builder is mutated 84 | $this->aggregationResult = null; 85 | } 86 | 87 | $this->aggregationBuilder = $aggregationBuilder; 88 | return $this; 89 | } 90 | 91 | /** 92 | * Execute the query and return the Aggregation Result object as result. 93 | * 94 | * You can call this method multiple times; and the request is only executed at the first time; and cached 95 | * for later use. 96 | * 97 | * @throws \Flowpack\ElasticSearch\Exception 98 | * @throws \Neos\Flow\Http\Exception 99 | */ 100 | public function execute(): AggregationResultInterface 101 | { 102 | 103 | if ($this->aggregationResult === null) { 104 | try { 105 | 106 | $request = $this->prepareRequest(); 107 | $jsonResponse = $this->executeInternal($request); 108 | $this->aggregationResult = $this->aggregationBuilder->bindResponse($jsonResponse['aggregations'][self::AGGREGATION_NAME]); 109 | } catch (ApiException $exception) { 110 | if ($this->handleElasticsearchExceptions === 'throw') { 111 | throw $exception; 112 | } 113 | 114 | $this->aggregationResult = new QueryErrorAggregationResult(); 115 | } 116 | 117 | 118 | } 119 | return $this->aggregationResult; 120 | } 121 | 122 | private function prepareRequest(): array 123 | { 124 | $request = $this->request; 125 | $request['aggs'][self::AGGREGATION_NAME] = $this->aggregationBuilder->buildAggregationRequest(); 126 | 127 | // add an empty match_all filter which handles the case that we do not have any filters and need all results 128 | // returned. 129 | $request['query']['bool']['filter'][] = [ 130 | 'match_all' => new \stdClass() 131 | ]; 132 | return $request; 133 | } 134 | 135 | 136 | /** 137 | * Returns the full request as it is sent to Elasticsearch; useful for debugging purposes. 138 | * 139 | * @return array 140 | */ 141 | public function requestForDebugging(): array 142 | { 143 | return $this->prepareRequest(); 144 | } 145 | 146 | public function allowsCallOfMethod($methodName) 147 | { 148 | return true; 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /Documentation/03_ExternalDataTemplate.fusion.diff: -------------------------------------------------------------------------------- 1 | @@ -1,26 +1,15 @@ 2 | prototype(My.Package:Search) < prototype(Neos.Fusion:Component) { 3 | - // this is the base query from the user which should *always* be applied. 4 | - _elasticsearchBaseQuery = ${Elasticsearch.createNeosFulltextQuery(site).fulltext(request.arguments.q)} 5 | + // for possibilities on how to build the query, see the next section in the documentation 6 | + _elasticsearchBaseQuery = ${Elasticsearch.createBooleanQuery().should(Elasticsearch.createNeosFulltextQuery(site).fulltext(request.arguments.q)).should(MyQueries.faqQuery(request.arguments.q))} 7 | 8 | - // register a Terms aggregation with the URL parameter "nodeTypesFilter" 9 | + // register a Terms aggregation with the URL parameter "nodeTypesFilter". 10 | + // we also need to pass in the request, so that the aggregation can extract the currently selected value. 11 | _nodeTypesAggregation = ${Elasticsearch.createTermsAggregation("neos_type", request.arguments.nodeTypesFilter)} 12 | 13 | - // This is the main elasticsearch query which determines the search results: 14 | - // - this._elasticsearchBaseQuery is applied 15 | - // - this._nodeTypesAggregation is applied as well (if the user chose a facet value) 16 | - // <-- if you add additional aggregations, you need to add them here to this list. 17 | - @context.mainSearchRequest = ${Elasticsearch.createRequest(site).query(Elasticsearch.createBooleanQuery().must(this._elasticsearchBaseQuery).filter(this._nodeTypesAggregation))} 18 | - 19 | - // The Request is for displaying the Node Types aggregation (faceted search). 20 | - // 21 | - // For faceted search to work properly, we need to add all OTHER query parts as filter; so NOT ourselves. 22 | - // This means, for the `.aggregation()` part, we take the aggregation itself. 23 | - // For the `.filter()` part, we add: 24 | - // - this._elasticsearchBaseQuery to ensure the entered query string by the user is taken into account 25 | - // <-- if you add additional aggregations, you need to add them here to the list. 26 | - @context.nodeTypesFacet = ${Elasticsearch.createAggregationRequest(site).aggregation(this._nodeTypesAggregation).filter(this._elasticsearchBaseQuery).execute()} 27 | - 28 | - // Search Result Display is controlled through Flowpack.Listable 29 | + // this is the main elasticsearch query which determines the search results - here, we also apply any restrictions imposed 30 | + // by the _nodeTypesAggregation 31 | + @context.mainSearchRequest = ${Elasticsearch.createRequest(site, ['faq']).query(Elasticsearch.createBooleanQuery().must(this._elasticsearchBaseQuery).filter(this._nodeTypesAggregation))} 32 | + @context.nodeTypesFacet = ${Elasticsearch.createAggregationRequest(site, ['faq']).aggregation(this._nodeTypesAggregation).filter(this._elasticsearchBaseQuery).execute()} 33 | searchResults = Flowpack.Listable:PaginatedCollection { 34 | collection = ${mainSearchRequest} 35 | itemsPerPage = 12 36 | @@ -35,7 +24,7 @@ 37 | // ...so we can access nodeTypesFacet.buckets. 38 | // To build a link to the facet, we use Neos.Neos:NodeLink with two additions: 39 | // - addQueryString must be set to TRUE, to keep the search query and potentially other facets. 40 | - // - to build the arguments, we need to set `nodeTypesFilter` to the current bucket key (or to null in case we want to clear the facet) 41 | + // - to build the arguments, each aggregation result type (e.g. TermsAggregationResult) has a specific method with the required arguments. 42 | renderer = afx` 43 |
    44 | 45 | @@ -50,7 +39,6 @@ 46 |
    47 | 48 | 49 | - 50 |
    51 | There was an error executing the search request. Please try again in a few minutes. 52 |
    53 | @@ -68,8 +56,7 @@ 54 | // The parameter "q" should be included in this pagination 55 | prototype(Flowpack.Listable:PaginationParameters) { 56 | q = ${request.arguments.q} 57 | - // <-- if you add additional aggregations, you need to add the parameter names here 58 | - nodeTypesFilter = ${request.arguments.nodeTypesFilter} 59 | + nodeTypes = ${request.arguments.nodeTypesFilter} 60 | } 61 | 62 | // We configure the cache mode "dynamic" here. 63 | @@ -79,7 +66,6 @@ 64 | node = ${node} 65 | type = 'searchForm' 66 | } 67 | - // <-- if you add additional aggregations, you need to add the parameter names to the entryDiscriminator 68 | entryDiscriminator = ${request.arguments.q + '-' + request.arguments.currentPage + '-' + request.arguments.nodeTypesFilter} 69 | context { 70 | 1 = 'node' 71 | @@ -96,6 +82,12 @@ 72 | // In the context, you'll find an object `searchResultDocument` which is of type 73 | // Sandstorm\LightweightElasticsearch\Query\Result\SearchResultDocument. 74 | prototype(Sandstorm.LightweightElasticsearch:SearchResultCase) { 75 | + faqEntries { 76 | + condition = ${searchResultDocument.property('index_discriminator') == 'faq'} 77 | + renderer = afx` 78 | + {searchResultDocument.properties.faqEntryTitle} 79 | + ` 80 | + } 81 | neosNodes { 82 | // all Documents in the index which are Nodes have a property "index_discriminator" set to "neos_nodes"; 83 | // This is in preparation for displaying other kinds of data. 84 | -------------------------------------------------------------------------------- /Documentation/03_ExternalDataTemplate.fusion: -------------------------------------------------------------------------------- 1 | prototype(My.Package:Search) < prototype(Neos.Fusion:Component) { 2 | // for possibilities on how to build the query, see the next section in the documentation 3 | _elasticsearchBaseQuery = ${Elasticsearch.createBooleanQuery().should(Elasticsearch.createNeosFulltextQuery(site).fulltext(request.arguments.q)).should(MyQueries.faqQuery(request.arguments.q))} 4 | 5 | // register a Terms aggregation with the URL parameter "nodeTypesFilter". 6 | // we also need to pass in the request, so that the aggregation can extract the currently selected value. 7 | _nodeTypesAggregation = ${Elasticsearch.createTermsAggregation("neos_type", request.arguments.nodeTypesFilter)} 8 | 9 | // this is the main elasticsearch query which determines the search results - here, we also apply any restrictions imposed 10 | // by the _nodeTypesAggregation 11 | @context.mainSearchRequest = ${Elasticsearch.createRequest(site, ['faq']).query(Elasticsearch.createBooleanQuery().must(this._elasticsearchBaseQuery).filter(this._nodeTypesAggregation))} 12 | @context.nodeTypesFacet = ${Elasticsearch.createAggregationRequest(site, ['faq']).aggregation(this._nodeTypesAggregation).filter(this._elasticsearchBaseQuery).execute()} 13 | searchResults = Flowpack.Listable:PaginatedCollection { 14 | collection = ${mainSearchRequest} 15 | itemsPerPage = 12 16 | 17 | // we use cache mode "dynamic" for the full Search component; so we do not need an additional cache entry 18 | // for the PaginatedCollection. 19 | @cache.mode = "embed" 20 | } 21 | 22 | nodeTypesFacet = Neos.Fusion:Component { 23 | // the nodeTypesFacet is a "Terms" aggregation... 24 | // ...so we can access nodeTypesFacet.buckets. 25 | // To build a link to the facet, we use Neos.Neos:NodeLink with two additions: 26 | // - addQueryString must be set to TRUE, to keep the search query and potentially other facets. 27 | // - to build the arguments, each aggregation result type (e.g. TermsAggregationResult) has a specific method with the required arguments. 28 | renderer = afx` 29 |
      30 | 31 |
    • {bucket.key} {bucket.doc_count} (selected)
    • 32 |
      33 |
    34 | CLEAR FACET 35 | ` 36 | } 37 | 38 | renderer = afx` 39 | 40 | 41 | 42 |
    43 | There was an error executing the search request. Please try again in a few minutes. 44 |
    45 |

    Showing {mainSearchRequest.execute().count()} of {mainSearchRequest.execute().total()} results

    46 | 47 | {props.nodeTypesFacet} 48 | 49 | {props.searchResults} 50 |
    51 | ` 52 | // If you want to see the full request going to Elasticsearch, you can include 53 | // the following snippet in the renderer above: 54 | // 55 | 56 | // The parameter "q" should be included in this pagination 57 | prototype(Flowpack.Listable:PaginationParameters) { 58 | q = ${request.arguments.q} 59 | nodeTypes = ${request.arguments.nodeTypesFilter} 60 | } 61 | 62 | // We configure the cache mode "dynamic" here. 63 | @cache { 64 | mode = 'dynamic' 65 | entryIdentifier { 66 | node = ${node} 67 | type = 'searchForm' 68 | } 69 | entryDiscriminator = ${request.arguments.q + '-' + request.arguments.currentPage + '-' + request.arguments.nodeTypesFilter} 70 | context { 71 | 1 = 'node' 72 | 2 = 'documentNode' 73 | 3 = 'site' 74 | } 75 | entryTags { 76 | 1 = ${Neos.Caching.nodeTag(node)} 77 | } 78 | } 79 | } 80 | 81 | // The result display is done here. 82 | // In the context, you'll find an object `searchResultDocument` which is of type 83 | // Sandstorm\LightweightElasticsearch\Query\Result\SearchResultDocument. 84 | prototype(Sandstorm.LightweightElasticsearch:SearchResultCase) { 85 | faqEntries { 86 | condition = ${searchResultDocument.property('index_discriminator') == 'faq'} 87 | renderer = afx` 88 | {searchResultDocument.properties.faqEntryTitle} 89 | ` 90 | } 91 | neosNodes { 92 | // all Documents in the index which are Nodes have a property "index_discriminator" set to "neos_nodes"; 93 | // This is in preparation for displaying other kinds of data. 94 | condition = ${searchResultDocument.property('index_discriminator') == 'neos_nodes'} 95 | renderer.@context.node = ${searchResultDocument.loadNode()} 96 | renderer = afx` 97 | 98 | ` 99 | // If you want to see the full Search Response hit, you can include the following 100 | // snippet in the renderer above: 101 | // 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Documentation/02_FacetedSearchTemplate.fusion: -------------------------------------------------------------------------------- 1 | prototype(My.Package:Search) < prototype(Neos.Fusion:Component) { 2 | // this is the base query from the user which should *always* be applied. 3 | _elasticsearchBaseQuery = ${Elasticsearch.createNeosFulltextQuery(site).fulltext(request.arguments.q)} 4 | 5 | // register a Terms aggregation with the URL parameter "nodeTypesFilter" 6 | _nodeTypesAggregation = ${Elasticsearch.createTermsAggregation("neos_type", request.arguments.nodeTypesFilter)} 7 | 8 | // This is the main elasticsearch query which determines the search results: 9 | // - this._elasticsearchBaseQuery is applied 10 | // - this._nodeTypesAggregation is applied as well (if the user chose a facet value) 11 | // <-- if you add additional aggregations, you need to add them here to this list. 12 | @context.mainSearchRequest = ${Elasticsearch.createRequest(site).query(Elasticsearch.createBooleanQuery().must(this._elasticsearchBaseQuery).filter(this._nodeTypesAggregation))} 13 | 14 | // The Request is for displaying the Node Types aggregation (faceted search). 15 | // 16 | // For faceted search to work properly, we need to add all OTHER query parts as filter; so NOT ourselves. 17 | // This means, for the `.aggregation()` part, we take the aggregation itself. 18 | // For the `.filter()` part, we add: 19 | // - this._elasticsearchBaseQuery to ensure the entered query string by the user is taken into account 20 | // <-- if you add additional aggregations, you need to add them here to the list. 21 | @context.nodeTypesFacet = ${Elasticsearch.createAggregationRequest(site).aggregation(this._nodeTypesAggregation).filter(this._elasticsearchBaseQuery).execute()} 22 | 23 | // Search Result Display is controlled through Flowpack.Listable 24 | searchResults = Flowpack.Listable:PaginatedCollection { 25 | collection = ${mainSearchRequest} 26 | itemsPerPage = 12 27 | 28 | // we use cache mode "dynamic" for the full Search component; so we do not need an additional cache entry 29 | // for the PaginatedCollection. 30 | @cache.mode = "embed" 31 | } 32 | 33 | nodeTypesFacet = Neos.Fusion:Component { 34 | // the nodeTypesFacet is a "Terms" aggregation... 35 | // ...so we can access nodeTypesFacet.buckets. 36 | // To build a link to the facet, we use Neos.Neos:NodeLink with two additions: 37 | // - addQueryString must be set to TRUE, to keep the search query and potentially other facets. 38 | // - to build the arguments, we need to set `nodeTypesFilter` to the current bucket key (or to null in case we want to clear the facet) 39 | renderer = afx` 40 |
      41 | 42 |
    • {bucket.key} {bucket.doc_count} (selected)
    • 43 |
      44 |
    45 | CLEAR FACET 46 | ` 47 | } 48 | 49 | renderer = afx` 50 |
    51 | 52 | 53 | 54 |
    55 | There was an error executing the search request. Please try again in a few minutes. 56 |
    57 |

    Showing {mainSearchRequest.execute().count()} of {mainSearchRequest.execute().total()} results

    58 | 59 | {props.nodeTypesFacet} 60 | 61 | {props.searchResults} 62 |
    63 | ` 64 | // If you want to see the full request going to Elasticsearch, you can include 65 | // the following snippet in the renderer above: 66 | // 67 | 68 | // The parameter "q" should be included in this pagination 69 | prototype(Flowpack.Listable:PaginationParameters) { 70 | q = ${request.arguments.q} 71 | // <-- if you add additional aggregations, you need to add the parameter names here 72 | nodeTypesFilter = ${request.arguments.nodeTypesFilter} 73 | } 74 | 75 | // We configure the cache mode "dynamic" here. 76 | @cache { 77 | mode = 'dynamic' 78 | entryIdentifier { 79 | node = ${node} 80 | type = 'searchForm' 81 | } 82 | // <-- if you add additional aggregations, you need to add the parameter names to the entryDiscriminator 83 | entryDiscriminator = ${request.arguments.q + '-' + request.arguments.currentPage + '-' + request.arguments.nodeTypesFilter} 84 | context { 85 | 1 = 'node' 86 | 2 = 'documentNode' 87 | 3 = 'site' 88 | } 89 | entryTags { 90 | 1 = ${Neos.Caching.nodeTag(node)} 91 | } 92 | } 93 | } 94 | 95 | // The result display is done here. 96 | // In the context, you'll find an object `searchResultDocument` which is of type 97 | // Sandstorm\LightweightElasticsearch\Query\Result\SearchResultDocument. 98 | prototype(Sandstorm.LightweightElasticsearch:SearchResultCase) { 99 | neosNodes { 100 | // all Documents in the index which are Nodes have a property "index_discriminator" set to "neos_nodes"; 101 | // This is in preparation for displaying other kinds of data. 102 | condition = ${searchResultDocument.property('index_discriminator') == 'neos_nodes'} 103 | renderer.@context.node = ${searchResultDocument.loadNode()} 104 | renderer = afx` 105 | 106 | ` 107 | // If you want to see the full Search Response hit, you can include the following 108 | // snippet in the renderer above: 109 | // 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Documentation/02a_FacetedHighlightedSearchTemplate.fusion: -------------------------------------------------------------------------------- 1 | prototype(My.Package:Search) < prototype(Neos.Fusion:Component) { 2 | // this is the base query from the user which should *always* be applied. 3 | _elasticsearchBaseQuery = ${Elasticsearch.createNeosFulltextQuery(site).fulltext(request.arguments.q)} 4 | 5 | // register a Terms aggregation with the URL parameter "nodeTypesFilter" 6 | _nodeTypesAggregation = ${Elasticsearch.createTermsAggregation("neos_type", request.arguments.nodeTypesFilter)} 7 | 8 | // This is the main elasticsearch query which determines the search results: 9 | // - this._elasticsearchBaseQuery is applied 10 | // - this._nodeTypesAggregation is applied as well (if the user chose a facet value) 11 | // <-- if you add additional aggregations, you need to add them here to this list. 12 | @context.mainSearchRequest = ${Elasticsearch.createRequest(site).query(Elasticsearch.createBooleanQuery().must(this._elasticsearchBaseQuery).filter(this._nodeTypesAggregation)).highlight(Elasticsearch.createNeosFulltextHighlight())} 13 | 14 | // The Request is for displaying the Node Types aggregation (faceted search). 15 | // 16 | // For faceted search to work properly, we need to add all OTHER query parts as filter; so NOT ourselves. 17 | // This means, for the `.aggregation()` part, we take the aggregation itself. 18 | // For the `.filter()` part, we add: 19 | // - this._elasticsearchBaseQuery to ensure the entered query string by the user is taken into account 20 | // <-- if you add additional aggregations, you need to add them here to the list. 21 | @context.nodeTypesFacet = ${Elasticsearch.createAggregationRequest(site).aggregation(this._nodeTypesAggregation).filter(this._elasticsearchBaseQuery).execute()} 22 | 23 | // Search Result Display is controlled through Flowpack.Listable 24 | searchResults = Flowpack.Listable:PaginatedCollection { 25 | collection = ${mainSearchRequest} 26 | itemsPerPage = 12 27 | 28 | // we use cache mode "dynamic" for the full Search component; so we do not need an additional cache entry 29 | // for the PaginatedCollection. 30 | @cache.mode = "embed" 31 | } 32 | 33 | nodeTypesFacet = Neos.Fusion:Component { 34 | // the nodeTypesFacet is a "Terms" aggregation... 35 | // ...so we can access nodeTypesFacet.buckets. 36 | // To build a link to the facet, we use Neos.Neos:NodeLink with two additions: 37 | // - addQueryString must be set to TRUE, to keep the search query and potentially other facets. 38 | // - to build the arguments, we need to set `nodeTypesFilter` to the current bucket key (or to null in case we want to clear the facet) 39 | renderer = afx` 40 |
      41 | 42 |
    • {bucket.key} {bucket.doc_count} (selected)
    • 43 |
      44 |
    45 | CLEAR FACET 46 | ` 47 | } 48 | 49 | renderer = afx` 50 |
    51 | 52 | 53 | 54 |
    55 | There was an error executing the search request. Please try again in a few minutes. 56 |
    57 |

    Showing {mainSearchRequest.execute().count()} of {mainSearchRequest.execute().total()} results

    58 | 59 | {props.nodeTypesFacet} 60 | 61 | {props.searchResults} 62 |
    63 | ` 64 | // If you want to see the full request going to Elasticsearch, you can include 65 | // the following snippet in the renderer above: 66 | // 67 | 68 | // The parameter "q" should be included in this pagination 69 | prototype(Flowpack.Listable:PaginationParameters) { 70 | q = ${request.arguments.q} 71 | // <-- if you add additional aggregations, you need to add the parameter names here 72 | nodeTypesFilter = ${request.arguments.nodeTypesFilter} 73 | } 74 | 75 | // We configure the cache mode "dynamic" here. 76 | @cache { 77 | mode = 'dynamic' 78 | entryIdentifier { 79 | node = ${node} 80 | type = 'searchForm' 81 | } 82 | // <-- if you add additional aggregations, you need to add the parameter names to the entryDiscriminator 83 | entryDiscriminator = ${request.arguments.q + '-' + request.arguments.currentPage + '-' + request.arguments.nodeTypesFilter} 84 | context { 85 | 1 = 'node' 86 | 2 = 'documentNode' 87 | 3 = 'site' 88 | } 89 | entryTags { 90 | 1 = ${Neos.Caching.nodeTag(node)} 91 | } 92 | } 93 | } 94 | 95 | // The result display is done here. 96 | // In the context, you'll find an object `searchResultDocument` which is of type 97 | // Sandstorm\LightweightElasticsearch\Query\Result\SearchResultDocument. 98 | prototype(Sandstorm.LightweightElasticsearch:SearchResultCase) { 99 | neosNodes { 100 | // all Documents in the index which are Nodes have a property "index_discriminator" set to "neos_nodes"; 101 | // This is in preparation for displaying other kinds of data. 102 | condition = ${searchResultDocument.property('index_discriminator') == 'neos_nodes'} 103 | renderer.@context.node = ${searchResultDocument.loadNode()} 104 | renderer = afx` 105 | 106 | {Array.join(searchResultDocument.processedHighlights, '…')} 107 | 108 | ` 109 | // If you want to see the full Search Response hit, you can include the following 110 | // snippet in the renderer above: 111 | // 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /Classes/CustomIndexing/CustomIndexer.php: -------------------------------------------------------------------------------- 1 | createIndexWithMapping([ 19 | * 'properties' => [ 20 | * 'faqEntry' => ['type' => 'text'] 21 | * ] 22 | * ]); 23 | * $indexer->index([ 24 | * 'faqEntryTitle' => 'FAQ Dresden' 25 | * ]); 26 | * $indexer->finalizeAndSwitchAlias(); 27 | * 28 | * 29 | * 30 | * USAGE - CLEANING OLD INDICES 31 | * 32 | * $indexer = CustomIndexer::create('faq'); 33 | * $indexer->removeObsoleteIndices(); 34 | */ 35 | class CustomIndexer 36 | { 37 | /** 38 | * @Flow\Inject 39 | * @var ClientFactory 40 | */ 41 | protected $clientFactory; 42 | 43 | /** 44 | * @Flow\Inject 45 | * @var IndexAliasManager 46 | */ 47 | protected $indexAliasManager; 48 | 49 | /** 50 | * @Flow\Inject 51 | * @var IndexDriverInterface 52 | */ 53 | protected $indexDriver; 54 | 55 | 56 | private \Flowpack\ElasticSearch\Domain\Model\Client $elasticsearchClient; 57 | 58 | protected string $aliasName; 59 | protected string $discriminatorValue; 60 | protected string $indexName; 61 | protected ?Index $index; 62 | 63 | protected int $bulkSize = 100; 64 | protected array $currentBulkRequest = []; 65 | 66 | 67 | /** 68 | * Create a custom indexer with the given $aliasName as index alias (i.e. what you specify in the 2nd argument 69 | * of `Elasticsearch.createRequest(site, ['myAlias'])` in Eel). 70 | * 71 | * The given $discriminatorValue is used as value of the `index_discriminator` key in every indexed document; 72 | * and can be used to distinguish different document types inside a query. 73 | * 74 | * If no $discriminatorValue is specified, the $aliasName is used by default. 75 | * 76 | * @param string $aliasName name of the Elasticsearch alias to create/update when indexing is completed 77 | * @param string|null $discriminatorValue value of the index_discriminator field of all documents indexed by this indexer. If null, $aliasName is used. 78 | * @return CustomIndexer 79 | */ 80 | public static function create(string $aliasName, string $discriminatorValue = null): CustomIndexer 81 | { 82 | if ($discriminatorValue === null) { 83 | $discriminatorValue = $aliasName; 84 | } 85 | 86 | return new CustomIndexer($aliasName, $discriminatorValue); 87 | } 88 | 89 | protected function __construct(string $aliasName, string $discriminatorValue) 90 | { 91 | $this->aliasName = $aliasName; 92 | $this->discriminatorValue = $discriminatorValue; 93 | $this->indexName = $aliasName . '-' . time(); 94 | } 95 | 96 | public function initializeObject() 97 | { 98 | $this->elasticsearchClient = $this->clientFactory->create(); 99 | $this->index = new Index($this->indexName, $this->elasticsearchClient); 100 | } 101 | 102 | /** 103 | * Create a new index with the given Elasticsearch mapping. 104 | * 105 | * @param array $fullMapping 106 | * @throws \Flowpack\ElasticSearch\Exception 107 | * @throws \Neos\Flow\Http\Exception 108 | */ 109 | public function createIndexWithMapping(array $fullMapping): void 110 | { 111 | $this->index->create(); 112 | $mapping = new Mapping(new GenericType($this->index, $this->discriminatorValue)); 113 | 114 | // enforce correct type of index_discriminator 115 | $fullMapping['properties']['index_discriminator'] = [ 116 | 'type' => 'keyword' 117 | ]; 118 | 119 | $mapping->setFullMapping($fullMapping); 120 | $mapping->apply(); 121 | } 122 | 123 | /** 124 | * Determines after how many calls to index() the request is sent to Elasticsearch 125 | * @param int $bulkSize 126 | */ 127 | public function setBulkSize(int $bulkSize): void 128 | { 129 | $this->bulkSize = $bulkSize; 130 | } 131 | 132 | /** 133 | * Index a document, optionally also specifying the document ID. 134 | * 135 | * @param array $documentProperties 136 | * @param string|null $documentId 137 | */ 138 | public function index(array $documentProperties, ?string $documentId = null): void 139 | { 140 | if ($documentId !== null) { 141 | $this->currentBulkRequest[] = [ 142 | 'index' => [ 143 | '_id' => $documentId, 144 | ] 145 | ]; 146 | } else { 147 | $this->currentBulkRequest[] = [ 148 | 'index' => new \stdClass() 149 | ]; 150 | } 151 | 152 | $documentProperties['index_discriminator'] = $this->discriminatorValue; 153 | $this->currentBulkRequest[] = $documentProperties; 154 | 155 | // for every request, we have two rows in $this->currentBulkRequest 156 | if (count($this->currentBulkRequest) / 2 >= $this->bulkSize) { 157 | $this->sendCurrentBulkRequest(); 158 | } 159 | } 160 | 161 | protected function sendCurrentBulkRequest(): void 162 | { 163 | if (count($this->currentBulkRequest) > 0) { 164 | // Bulk request MUST end with line return 165 | $request = implode("\n", array_map(fn($requestPart) => json_encode($requestPart), $this->currentBulkRequest)) . "\n"; 166 | 167 | $this->index->request('POST', '/_bulk', [], $request); 168 | } 169 | 170 | $this->currentBulkRequest = []; 171 | } 172 | 173 | /** 174 | * Send the last bulk request to ensure indexing is completed; and then switch the index alias, so that 175 | * documents can be found. 176 | * 177 | * @throws \Flowpack\ElasticSearch\ContentRepositoryAdaptor\Exception 178 | * @throws \Flowpack\ElasticSearch\Exception 179 | * @throws \Flowpack\ElasticSearch\Transfer\Exception\ApiException 180 | */ 181 | public function finalizeAndSwitchAlias(): void 182 | { 183 | $this->sendCurrentBulkRequest(); 184 | $this->indexAliasManager->updateIndexAlias($this->aliasName, $this->indexName); 185 | } 186 | 187 | /** 188 | * Remove old indices which are not active anymore (remember, each bulk index creates a new index from scratch, 189 | * making the "old" index a stale one). 190 | * 191 | * @return array a list of index names which were removed 192 | * @throws \Flowpack\ElasticSearch\Transfer\Exception 193 | * @throws \Flowpack\ElasticSearch\Transfer\Exception\ApiException 194 | * @throws \Neos\Flow\Http\Exception 195 | */ 196 | public function removeObsoleteIndices(): array 197 | { 198 | $currentlyLiveIndices = $this->indexDriver->getIndexNamesByAlias($this->aliasName); 199 | 200 | $indexStatus = $this->elasticsearchClient->request('GET', '/_stats')->getTreatedContent(); 201 | $allIndices = array_keys($indexStatus['indices']); 202 | 203 | $indicesToBeRemoved = []; 204 | 205 | foreach ($allIndices as $indexName) { 206 | if (strpos($indexName, $this->aliasName . '-') !== 0) { 207 | // filter out all indices not starting with the alias-name, as they are unrelated to our application 208 | continue; 209 | } 210 | 211 | if (in_array($indexName, $currentlyLiveIndices, true)) { 212 | // skip the currently live index names from deletion 213 | continue; 214 | } 215 | 216 | $indicesToBeRemoved[] = $indexName; 217 | } 218 | 219 | array_map(function ($index) { 220 | $this->indexDriver->deleteIndex($index); 221 | }, $indicesToBeRemoved); 222 | 223 | return $indicesToBeRemoved; 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /Documentation/README_template.md: -------------------------------------------------------------------------------- 1 | # Sandstorm.LightweightElasticsearch 2 | 3 | ...this is an attempt for a more lightweight elasticsearch integration for Neos CMS. This is built because I wanted 4 | to try out some different design decisions in parts of the Neos <-> Elasticsearch integration. 5 | 6 | This is a wrapper around Flowpack.Elasticsearch.ContentRepositoryAdaptor, which replaces parts of its API. 7 | A huge thanks goes to everybody maintaining Flowpack.Elasticsearch.ContentRepositoryAdaptor and Flowpack.Elasticsearch, 8 | as we build upon this work and profit from it greatly. 9 | 10 | 11 | 12 | - [Sandstorm.LightweightElasticsearch](#sandstormlightweightelasticsearch) 13 | - [Goals and Limitations](#goals-and-limitations) 14 | - [Starting Elasticsearch for development](#starting-elasticsearch-for-development) 15 | - [Indexing](#indexing) 16 | - [Configurations per property index field](#configurations-per-property-index-field) 17 | - [Exclude NodeTypes from indexing](#exclude-nodetypes-from-indexing) 18 | - [Indexing configuration per data type](#indexing-configuration-per-data-type) 19 | - [Indexing configuration per property](#indexing-configuration-per-property) 20 | - [Skip indexing and mapping of a property](#skip-indexing-and-mapping-of-a-property) 21 | - [Fulltext Indexing](#fulltext-indexing) 22 | - [Working with Dates](#working-with-dates) 23 | - [Working with Assets / Attachments](#working-with-assets--attachments) 24 | - [Search Component](#search-component) 25 | - [Query API](#query-api) 26 | - [Aggregations and Faceting](#aggregations-and-faceting) 27 | - [Result Highlighting](#result-highlighting) 28 | - [Indexing other data](#indexing-other-data) 29 | - [Querying other data](#querying-other-data) 30 | - [Debugging Elasticsearch queries](#debugging-elasticsearch-queries) 31 | - [Developing](#developing) 32 | - [Changing this readme](#changing-this-readme) 33 | - [License](#license) 34 | 35 | 36 | 37 | ## Goals and Limitations 38 | 39 | The project has the following goals and limitations: 40 | 41 | - **Only for fulltext search** 42 | 43 | This means only document nodes or anything which can potentially appear in fulltext search results is put into 44 | the Elasticsearch index (everything marked in the NodeTypes as `search.fulltext.isRoot = TRUE`). 45 | That means (by default) no content nodes or ContentCollections are stored inside the index. 46 | 47 | - **Easier Fulltext indexing implementation** 48 | 49 | Fulltext collection is done in PHP instead of inside Elasticsearch with Painless. 50 | 51 | - **Query Results not specific to Neos** 52 | 53 | You can easily write queries which target anything stored in Elasticsearch; and not just Neos Nodes. 54 | We provide examples and utilities how other data sources can be indexed in Elasticsearch. 55 | 56 | - **More flexible and simple Query API** 57 | 58 | The Query API is aligned to the Elasticsearch API; and it is possible to write arbitrary Elasticsearch Search 59 | queries. We do not support the `Neos\Flow\Persistence\QueryResultInterface`, and thus no `` 60 | to keep things simple. 61 | 62 | - **Only supports Batch Indexing** 63 | 64 | We currently only support batch indexing, as this removes many errors in the Neos UI if there are problems 65 | with the Elasticsearch indexing. 66 | 67 | This is an "artificial limitation" which could be removed; but we do not provide support for this removal 68 | right now. 69 | 70 | - **Only support for a single Elasticsearch version** 71 | 72 | We only support Elasticsearch 7 right now. 73 | 74 | - **Only index live the workspace** 75 | 76 | We only index the live workspace, as this is the 99% case to be supported. 77 | 78 | - **Faceting using multiple Elasticsearch requests / One Aggregation per Request** 79 | 80 | Building a huge Elasticsearch request for all facets and queries at the same time is possible, but 81 | hard to debug and understand. 82 | 83 | That's why we keep it simple here; and if you use aggregations, there will be one query per aggregation 84 | which is done. 85 | 86 | ## Starting Elasticsearch for development 87 | 88 | ```bash 89 | docker run --rm --name neos7-es -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" docker.elastic.co/elasticsearch/elasticsearch:7.10.2 90 | ``` 91 | 92 | ## Indexing 93 | 94 | > **Tip: Indexing behaves to the user in the same way as defined in [Flowpack.ElasticSearch.ContentRepositoryAdaptor](https://github.com/Flowpack/Flowpack.ElasticSearch.ContentRepositoryAdaptor). 95 | > The only difference is the internal implementation:** Instead of indexing every node (content and document) individually and letting Elasticsearch 96 | > do the merging, we merge the content to the parent document in PHP, as this is easier to handle. 97 | > 98 | > **The full configuration for indexing is exactly the same as in [Flowpack.ElasticSearch.ContentRepositoryAdaptor](https://github.com/Flowpack/Flowpack.ElasticSearch.ContentRepositoryAdaptor)**. 99 | > It is included below for your convenience. 100 | 101 | The following commands are needed for indexing: 102 | 103 | ```bash 104 | ./flow nodeindex:build 105 | ./flow nodeindex:cleanup 106 | ``` 107 | 108 | **NOTE:** Only nodes which are marked as `search.fulltext.isRoot` in the corresponding `NodeTypes.yaml` 109 | will become part of the search index, and all their children Content nodes' texts will be indexed as part of this. 110 | 111 | **Under the Covers** 112 | 113 | - The different indexing strategy is implemented using a custom `DocumentNodeIndexer`, which then calls a custom 114 | `DocumentIndexerDriver`. 115 | 116 | As an example, you can then query the Elasticsearch index using: 117 | 118 | ```bash 119 | curl -X GET "localhost:9200/neoscr/_search?pretty" -H 'Content-Type: application/json' -d' 120 | { 121 | "query": { 122 | "match_all": {} 123 | } 124 | } 125 | ' 126 | ``` 127 | 128 | ### Configurations per property (index field) 129 | 130 | You can change the analyzers on a per-field level; or e.g. reconfigure the _all field with the following snippet 131 | in the NodeTypes.yaml. Generally this works by defining the global mapping at `[nodeType].search.elasticSearchMapping`: 132 | 133 | ```yaml 134 | 'Neos.Neos:Node': 135 | search: 136 | elasticSearchMapping: 137 | myProperty: 138 | analyzer: custom_french_analyzer 139 | ``` 140 | 141 | ### Exclude NodeTypes from indexing 142 | 143 | By default the indexing processes all NodeTypes, but you can change this in your *Settings.yaml*: 144 | 145 | ```yaml 146 | Neos: 147 | ContentRepository: 148 | Search: 149 | defaultConfigurationPerNodeType: 150 | '*': 151 | indexed: true 152 | 'Neos.Neos:FallbackNode': 153 | indexed: false 154 | 'Neos.Neos:Shortcut': 155 | indexed: false 156 | 'Neos.Neos:ContentCollection': 157 | indexed: false 158 | ``` 159 | 160 | You need to explicitly configure the individual NodeTypes (this feature does not check the Super Type configuration). 161 | But you can use a special notation to configure a full namespace, `Acme.AcmeCom:*` will be applied for all node 162 | types in the `Acme.AcmeCom` namespace. The most specific configuration is used in this order: 163 | 164 | - NodeType name (`Neos.Neos:Shortcut`) 165 | - Full namespace notation (`Neos.Neos:*`) 166 | - Catch all (`*`) 167 | 168 | ### Indexing configuration per data type 169 | 170 | **The default configuration supports most use cases and often may not need to be touched, as this package comes 171 | with sane defaults for all Neos data types.** 172 | 173 | Indexing of properties is configured at two places. The defaults per-data-type are configured 174 | inside `Neos.ContentRepository.Search.defaultConfigurationPerType` of `Settings.yaml`. 175 | Furthermore, this can be overridden using the `properties.[....].search` path inside 176 | `NodeTypes.yaml`. 177 | 178 | This configuration contains two parts: 179 | 180 | * Underneath `elasticSearchMapping`, the Elasticsearch property mapping can be defined. 181 | * Underneath `indexing`, an Eel expression which processes the value before indexing has to be 182 | specified. It has access to the current `value` and the current `node`. 183 | 184 | Example (from the default configuration): 185 | 186 | ```yaml 187 | # Settings.yaml 188 | Neos: 189 | ContentRepository: 190 | Search: 191 | defaultConfigurationPerType: 192 | 193 | # strings should just be indexed with their simple value. 194 | string: 195 | elasticSearchMapping: 196 | type: string 197 | indexing: '${value}' 198 | ``` 199 | 200 | ### Indexing configuration per property 201 | 202 | ```yaml 203 | # NodeTypes.yaml 204 | 'Neos.Neos:Timable': 205 | properties: 206 | '_hiddenBeforeDateTime': 207 | search: 208 | 209 | # A date should be mapped differently, and in this case we want to use a date format which 210 | # Elasticsearch understands 211 | elasticSearchMapping: 212 | type: DateTime 213 | format: 'date_time_no_millis' 214 | indexing: '${(node.hiddenBeforeDateTime ? Date.format(node.hiddenBeforeDateTime, "Y-m-d\TH:i:sP") : null)}' 215 | ``` 216 | 217 | If your nodetypes schema defines custom properties of type DateTime, you have got to provide similar configuration for 218 | them as well in your `NodeTypes.yaml`, or else they will not be indexed correctly. 219 | 220 | There are a few indexing helpers inside the `Indexing` namespace which are usable inside the 221 | `indexing` expression. In most cases, you don't need to touch this, but they were needed to build up 222 | the standard indexing configuration: 223 | 224 | * `Indexing.buildAllPathPrefixes`: for a path such as `foo/bar/baz`, builds up a list of path 225 | prefixes, e.g. `['foo', 'foo/bar', 'foo/bar/baz']`. 226 | * `Indexing.extractNodeTypeNamesAndSupertypes(NodeType)`: extracts a list of node type names for 227 | the passed node type and all of its supertypes 228 | * `Indexing.convertArrayOfNodesToArrayOfNodeIdentifiers(array $nodes)`: convert the given nodes to 229 | their node identifiers. 230 | 231 | ### Skip indexing and mapping of a property 232 | 233 | If you don't want a property to be indexed, set `indexing: false`. In this case no mapping is configured for this field. 234 | This can be used to also solve a type conflict of two node properties with same name and different type. 235 | 236 | ### Fulltext Indexing 237 | 238 | In order to enable fulltext indexing, every `Document` node must be configured as *fulltext root*. Thus, 239 | the following is configured in the default configuration: 240 | 241 | ```yaml 242 | 'Neos.Neos:Document': 243 | search: 244 | fulltext: 245 | isRoot: true 246 | ``` 247 | 248 | A *fulltext root* contains all the *content* of its non-document children, such that when one searches 249 | inside these texts, the document itself is returned as result. 250 | 251 | In order to specify how the fulltext of a property in a node should be extracted, this is configured 252 | in `NodeTypes.yaml` at `properties.[propertyName].search.fulltextExtractor`. 253 | 254 | An example: 255 | 256 | ```yaml 257 | 'Neos.Neos.NodeTypes:Text': 258 | properties: 259 | 'text': 260 | search: 261 | fulltextExtractor: '${Indexing.extractHtmlTags(value)}' 262 | 263 | 'My.Blog:Post': 264 | properties: 265 | title: 266 | search: 267 | fulltextExtractor: '${Indexing.extractInto("h1", value)}' 268 | ``` 269 | 270 | 271 | ### Working with Dates 272 | 273 | As a default, Elasticsearch indexes dates in the UTC Timezone. In order to have it index using the timezone 274 | currently configured in PHP, the configuration for any property in a node which represents a date should look like this: 275 | 276 | ```yaml 277 | 'My.Blog:Post': 278 | properties: 279 | date: 280 | search: 281 | elasticSearchMapping: 282 | type: 'date' 283 | format: 'date_time_no_millis' 284 | indexing: '${(value ? Date.format(value, "Y-m-d\TH:i:sP") : null)}' 285 | ``` 286 | 287 | This is important so that Date- and Time-based searches work as expected, both when using formatted DateTime strings and 288 | when using relative DateTime calculations (eg.: `now`, `now+1d`). 289 | 290 | If you want to filter items by date, e.g. to show items with date later than today, you can create a query like this: 291 | 292 | ``` 293 | ${...greaterThan('date', Date.format(Date.Now(), "Y-m-d\TH:i:sP"))...} 294 | ``` 295 | 296 | For more information on Elasticsearch's Date Formats, 297 | [click here](http://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-date-format.html). 298 | 299 | 300 | ### Working with Assets / Attachments 301 | 302 | If you want to index attachments, you need to install the [Elasticsearch Ingest-Attachment Plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/master/ingest-attachment.html). 303 | Then, you can add the following to your `Settings.yaml`: 304 | 305 | ```yaml 306 | Neos: 307 | ContentRepository: 308 | Search: 309 | defaultConfigurationPerType: 310 | 'Neos\Media\Domain\Model\Asset': 311 | elasticSearchMapping: 312 | type: text 313 | indexing: ${Indexing.Indexing.extractAssetContent(value)} 314 | ``` 315 | 316 | or add the attachments content to a fulletxt field in your NodeType configuration: 317 | 318 | ```yaml 319 | properties: 320 | file: 321 | type: 'Neos\Media\Domain\Model\Asset' 322 | ui: 323 | search: 324 | fulltextExtractor: ${Indexing.extractInto('text', Indexing.extractAssetContent(value))} 325 | ``` 326 | 327 | By default `Indexing.extractAssetContent(value)` returns the asset content. You can use the second parameter to return asset meta data. The field parameter can be set to one of the following: `content, title, name, author, keywords, date, content_type, content_length, language`. 328 | 329 | With that, you can for example add the keywords of a file to a higher boosted field: 330 | 331 | ```yaml 332 | properties: 333 | file: 334 | type: 'Neos\Media\Domain\Model\Asset' 335 | ui: 336 | search: 337 | fulltextExtractor: ${Indexing.extractInto('h2', Indexing.extractAssetContent(value, 'keywords'))} 338 | ``` 339 | 340 | 341 | ## Search Component 342 | 343 | As the search component usually needs to be heavily adjusted, we only include a snippet which can be copy/pasted 344 | and adjusted into your project: 345 | 346 | ```neosfusion 347 | ###01_BasicSearchTemplate.fusion### 348 | ``` 349 | 350 | ## Query API 351 | 352 | Simple Example as Eel expression: 353 | 354 | ``` 355 | Elasticsearch.createRequest(site) 356 | .query( 357 | Elasticsearch.createNeosFulltextQuery(site) 358 | .fulltext(request.arguments.q) 359 | ) 360 | ``` 361 | 362 | - If you want to search Neos nodes, we need to pass in a *context node* as first argument to `Elasticsearch.createRequest()` 363 | - This way, the correct index (in the current language) is automatically searched 364 | - You are able to call `searchResultDocument.loadNode()` on the individual document 365 | - `Elasticsearch.createNeosFulltextQuery` *also needs a context node*, which specifies the part of the node tree which 366 | we want to search. 367 | 368 | There exists a query API for more complex cases, i.e. you can do the following: 369 | 370 | ``` 371 | Elasticsearch.createRequest(site) 372 | .query( 373 | Elasticsearch.createNeosFulltextQuery(site) 374 | .fulltext(request.arguments.q) 375 | // only the results to documents where myKey = myValue 376 | .filter(Elasticsearch.createTermQuery("myKey", "myValue")) 377 | ) 378 | ``` 379 | 380 | More complex queries for searching through multiple indices can look like this: 381 | 382 | ``` 383 | Elasticsearch.createRequest(site, ['index2', 'index3') 384 | .query( 385 | Elasticsearch.createBooleanQuery() 386 | .should( 387 | Elasticsearch.createNeosFulltextQuery(site) 388 | .fulltext(request.arguments.q) 389 | .filter(Elasticsearch.createTermQuery("index_discriminator", "neos_nodes")) 390 | ) 391 | .should( 392 | // add query for index2 here 393 | ) 394 | ) 395 | ``` 396 | 397 | **We recommend to build more complex queries through Custom Eel helpers; directly calling the Query Builders of this package** 398 | 399 | ## Aggregations and Faceting 400 | 401 | Implementing Faceted Search is more difficult than it looks at first sight - so let's first build a mental model 402 | of how the queries need to work. 403 | 404 | Faceting usually looks like: 405 | 406 | ``` 407 | [ Global Search Input Field ] <-- global info 408 | 409 | Categories 410 | - News 411 | - FAQ Entries 412 | - ... 413 | 414 | Products 415 | - Product 1 416 | - Product 2 (chosen) 417 | 418 | [Result Listing] 419 | ``` 420 | 421 | The global search input field is the easiest - as it influences both the facets (Categories and Products) above, 422 | and the Result Listing. 423 | 424 | For a facet, things are a bit more difficult. To *calculate* the facet values (i.e. what is shown underneath the "Categories" 425 | headline), an *aggregation query* needs to be executed. In this query, we need to take into account the Global Search field, 426 | and the choices of all other facets (but not our own one). 427 | 428 | For the result listing, we need to take into account the global search, and the choices of all facets. 429 | 430 | To model that in Elasticsearch, we recommend to use multiple queries: One for each facet; and one for rendering 431 | the result listing. 432 | 433 | Here follows the list of modifications done to the template above: 434 | 435 | ```diff 436 | ###02_FacetedSearchTemplate.fusion.diff### 437 | ``` 438 | 439 | You can also copy/paste the full file: 440 | 441 |
    442 | See the faceted search example 443 | 444 | ``` 445 | ###02_FacetedSearchTemplate.fusion### 446 | ``` 447 | 448 |
    449 | 450 | ## Result Highlighting 451 | 452 | Result highlighting is implemented using the [highlight API](https://www.elastic.co/guide/en/elasticsearch/reference/current/highlighting.html) 453 | of Elasticsearch. 454 | 455 | To enable it, you need to change thef following parts: 456 | 457 | - To use a default highlighting, add the `.highlight(Elasticsearch.createNeosFulltextHighlight())` 458 | part to your main Elasticsearch query. 459 | - Additionally, you can call the getter `searchResultDocument.processedHighlights` for each 460 | result, which contains the highlighted extracts, which you can simply join together like this: 461 | 462 | `Array.join(searchResultDocument.processedHighlights, '…')` 463 | 464 | A full example can be found below: 465 | 466 | ```diff 467 | ###02a_FacetedHighlightedSearchTemplate.fusion.diff### 468 | ``` 469 | 470 | You can also copy/paste the full file: 471 | 472 |
    473 | See the faceted + highlighted search example 474 | 475 | ``` 476 | ###02a_FacetedHighlightedSearchTemplate.fusion### 477 | ``` 478 | 479 |
    480 | 481 | 482 | ## Indexing other data 483 | 484 | We suggest to set `index_discriminator` to different values for different data sources, to be able to 485 | identify different sources properly. 486 | 487 | You can use the `CustomIndexer` as a basis for indexing as follows: 488 | 489 | ```php 490 | $indexer = CustomIndexer::create('faq'); 491 | $indexer->createIndexWithMapping(['properties' => [ 492 | 'faqEntryTitle' => [ 493 | 'type' => 'text' 494 | ] 495 | ]]); 496 | $indexer->index([ 497 | 'faqEntryTitle' => 'FAQ Dresden' 498 | ]); 499 | // index other documents here 500 | $indexer->finalizeAndSwitchAlias(); 501 | 502 | // Optionally for cleanup 503 | $indexer->removeObsoleteIndices(); 504 | ``` 505 | 506 | For your convenience, a full CommandController can be copied/pasted below: 507 | 508 |
    509 | Command Controller for custom indexing 510 | 511 | ``` 512 | ###03_CommandController.php### 513 | ``` 514 | 515 |
    516 | 517 | See the next section for querying other data sources 518 | 519 | ## Querying other data 520 | 521 | Three parts need to be adjusted for querying other data sources: 522 | 523 | - adjust `Elasticsearch.createRequest()` and `Elasticsearch.createAggregationRequest()` calls 524 | - build up and use the fulltext query for your custom data 525 | - customize result rendering. 526 | 527 | We'll now go through these steps one by one. 528 | 529 | **Adjust `Elasticsearch.createRequest()` and `Elasticsearch.createAggregationRequest()`** 530 | 531 | Here, you need to include the other index as second parameter; so for example `Elasticsearch.createRequest(site, ['faq'])` 532 | is a valid invocation. 533 | 534 | 535 | **Build up the fulltext query** 536 | 537 | We suggest that you build custom Eel helpers for doing the fulltext search in your custom data, 538 | e.g. by filtering on `index_discriminator` and using a `simple_query_string` query as follows: 539 | 540 | ```php 541 | return BooleanQueryBuilder::create() 542 | ->filter(TermQueryBuilder::create('index_discriminator', 'faq')) 543 | ->must( 544 | SimpleQueryStringBuilder::create($query ?? '')->fields([ 545 | 'faqEntryTitle^5', 546 | ]) 547 | ); 548 | ``` 549 | 550 | As an example, you can also check out the full Eel helper: 551 | 552 |
    553 | Eel helper for fulltext querying custom data 554 | 555 | ``` 556 | ###03_FulltextEelHelper.php### 557 | ``` 558 | 559 |
    560 | 561 | **Remember to register the Eel helper in `Settings.yaml` as usual: 562 | 563 | ```yaml 564 | Neos: 565 | Fusion: 566 | defaultContext: 567 | MyQueries: My\Package\Eel\MyQueries 568 | ``` 569 | 570 | 571 | **Use the fulltext query** 572 | 573 | To use both the Neos and your custom fulltext query, these two queries should be combined using a `should` clause 574 | in the `Terms` query; so this is like an "or" query combination: 575 | 576 | ``` 577 | Elasticsearch.createBooleanQuery() 578 | .should(Elasticsearch.createNeosFulltextQuery(site).fulltext(request.arguments.q)) 579 | .should(MyQueries.faqQuery(request.arguments.q))} 580 | ``` 581 | 582 | 583 | **Adjust Result Rendering** 584 | 585 | By adding a conditional branch to `prototype(Sandstorm.LightweightElasticsearch:SearchResultCase)`, you can have a custom 586 | result rendering: 587 | 588 | ```neosfusion 589 | prototype(Sandstorm.LightweightElasticsearch:SearchResultCase) { 590 | faqEntries { 591 | condition = ${searchResultDocument.property('index_discriminator') == 'faq'} 592 | renderer = afx` 593 | {searchResultDocument.properties.faqEntryTitle} 594 | ` 595 | } 596 | } 597 | ``` 598 | 599 | 600 | **Putting it all together** 601 | 602 | See the following diff, or the full source code below: 603 | 604 | 605 | ```diff 606 | ###03_ExternalDataTemplate.fusion.diff### 607 | ``` 608 | 609 | You can also copy/paste the full file: 610 | 611 |
    612 | See the faceted search example 613 | 614 | ``` 615 | ###03_ExternalDataTemplate.fusion### 616 | ``` 617 | 618 |
    619 | 620 | 621 | ## Debugging Elasticsearch queries 622 | 623 | 1. add `.log("!!!FOO")` to your Eel ElasticSearch query 624 | 2. Check the System_Development log file for the full query and save it to a file (req.json) 625 | 3. We suggest to use https://httpie.io/ (which can be installed using `brew install httpie`) to 626 | do a request: 627 | 628 | ``` 629 | http 127.0.0.1:9200/_cat/aliases 630 | cat req.json | http 127.0.0.1:9200/neoscr,foo/_search 631 | ``` 632 | 633 | ## Developing 634 | 635 | We gladly accept pull requests or contributors :-) 636 | 637 | ### Changing this readme 638 | 639 | First change Documentation/README_template.php; then run `php Documentation/build_readme.php`. 640 | 641 | ## License 642 | 643 | MIT 644 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sandstorm.LightweightElasticsearch 2 | 3 | ...this is an attempt for a more lightweight elasticsearch integration for Neos CMS. This is built because I wanted 4 | to try out some different design decisions in parts of the Neos <-> Elasticsearch integration. 5 | 6 | This is a wrapper around Flowpack.Elasticsearch.ContentRepositoryAdaptor, which replaces parts of its API. 7 | A huge thanks goes to everybody maintaining Flowpack.Elasticsearch.ContentRepositoryAdaptor and Flowpack.Elasticsearch, 8 | as we build upon this work and profit from it greatly. 9 | 10 | 11 | 12 | - [Sandstorm.LightweightElasticsearch](#sandstormlightweightelasticsearch) 13 | - [Goals and Limitations](#goals-and-limitations) 14 | - [Starting Elasticsearch for development](#starting-elasticsearch-for-development) 15 | - [Indexing](#indexing) 16 | - [Configurations per property index field](#configurations-per-property-index-field) 17 | - [Exclude NodeTypes from indexing](#exclude-nodetypes-from-indexing) 18 | - [Indexing configuration per data type](#indexing-configuration-per-data-type) 19 | - [Indexing configuration per property](#indexing-configuration-per-property) 20 | - [Skip indexing and mapping of a property](#skip-indexing-and-mapping-of-a-property) 21 | - [Fulltext Indexing](#fulltext-indexing) 22 | - [Working with Dates](#working-with-dates) 23 | - [Working with Assets / Attachments](#working-with-assets--attachments) 24 | - [Search Component](#search-component) 25 | - [Query API](#query-api) 26 | - [Aggregations and Faceting](#aggregations-and-faceting) 27 | - [Result Highlighting](#result-highlighting) 28 | - [Indexing other data](#indexing-other-data) 29 | - [Querying other data](#querying-other-data) 30 | - [Debugging Elasticsearch queries](#debugging-elasticsearch-queries) 31 | - [Developing](#developing) 32 | - [Changing this readme](#changing-this-readme) 33 | - [License](#license) 34 | 35 | 36 | 37 | ## Goals and Limitations 38 | 39 | The project has the following goals and limitations: 40 | 41 | - **Only for fulltext search** 42 | 43 | This means only document nodes or anything which can potentially appear in fulltext search results is put into 44 | the Elasticsearch index (everything marked in the NodeTypes as `search.fulltext.isRoot = TRUE`). 45 | That means (by default) no content nodes or ContentCollections are stored inside the index. 46 | 47 | - **Easier Fulltext indexing implementation** 48 | 49 | Fulltext collection is done in PHP instead of inside Elasticsearch with Painless. 50 | 51 | - **Query Results not specific to Neos** 52 | 53 | You can easily write queries which target anything stored in Elasticsearch; and not just Neos Nodes. 54 | We provide examples and utilities how other data sources can be indexed in Elasticsearch. 55 | 56 | - **More flexible and simple Query API** 57 | 58 | The Query API is aligned to the Elasticsearch API; and it is possible to write arbitrary Elasticsearch Search 59 | queries. We do not support the `Neos\Flow\Persistence\QueryResultInterface`, and thus no `` 60 | to keep things simple. 61 | 62 | - **Only supports Batch Indexing** 63 | 64 | We currently only support batch indexing, as this removes many errors in the Neos UI if there are problems 65 | with the Elasticsearch indexing. 66 | 67 | This is an "artificial limitation" which could be removed; but we do not provide support for this removal 68 | right now. 69 | 70 | - **Only support for a single Elasticsearch version** 71 | 72 | We only support Elasticsearch 7 right now. 73 | 74 | - **Only index live the workspace** 75 | 76 | We only index the live workspace, as this is the 99% case to be supported. 77 | 78 | - **Faceting using multiple Elasticsearch requests / One Aggregation per Request** 79 | 80 | Building a huge Elasticsearch request for all facets and queries at the same time is possible, but 81 | hard to debug and understand. 82 | 83 | That's why we keep it simple here; and if you use aggregations, there will be one query per aggregation 84 | which is done. 85 | 86 | ## Starting Elasticsearch for development 87 | 88 | ```bash 89 | docker run --rm --name neos7-es -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" docker.elastic.co/elasticsearch/elasticsearch:7.10.2 90 | ``` 91 | 92 | ## Indexing 93 | 94 | > **Tip: Indexing behaves to the user in the same way as defined in [Flowpack.ElasticSearch.ContentRepositoryAdaptor](https://github.com/Flowpack/Flowpack.ElasticSearch.ContentRepositoryAdaptor). 95 | > The only difference is the internal implementation:** Instead of indexing every node (content and document) individually and letting Elasticsearch 96 | > do the merging, we merge the content to the parent document in PHP, as this is easier to handle. 97 | > 98 | > **The full configuration for indexing is exactly the same as in [Flowpack.ElasticSearch.ContentRepositoryAdaptor](https://github.com/Flowpack/Flowpack.ElasticSearch.ContentRepositoryAdaptor)**. 99 | > It is included below for your convenience. 100 | 101 | The following commands are needed for indexing: 102 | 103 | ```bash 104 | ./flow nodeindex:build 105 | ./flow nodeindex:cleanup 106 | ``` 107 | 108 | **NOTE:** Only nodes which are marked as `search.fulltext.isRoot` in the corresponding `NodeTypes.yaml` 109 | will become part of the search index, and all their children Content nodes' texts will be indexed as part of this. 110 | 111 | **Under the Covers** 112 | 113 | - The different indexing strategy is implemented using a custom `DocumentNodeIndexer`, which then calls a custom 114 | `DocumentIndexerDriver`. 115 | 116 | As an example, you can then query the Elasticsearch index using: 117 | 118 | ```bash 119 | curl -X GET "localhost:9200/neoscr/_search?pretty" -H 'Content-Type: application/json' -d' 120 | { 121 | "query": { 122 | "match_all": {} 123 | } 124 | } 125 | ' 126 | ``` 127 | 128 | ### Configurations per property (index field) 129 | 130 | You can change the analyzers on a per-field level; or e.g. reconfigure the _all field with the following snippet 131 | in the NodeTypes.yaml. Generally this works by defining the global mapping at `[nodeType].search.elasticSearchMapping`: 132 | 133 | ```yaml 134 | 'Neos.Neos:Node': 135 | search: 136 | elasticSearchMapping: 137 | myProperty: 138 | analyzer: custom_french_analyzer 139 | ``` 140 | 141 | ### Exclude NodeTypes from indexing 142 | 143 | By default the indexing processes all NodeTypes, but you can change this in your *Settings.yaml*: 144 | 145 | ```yaml 146 | Neos: 147 | ContentRepository: 148 | Search: 149 | defaultConfigurationPerNodeType: 150 | '*': 151 | indexed: true 152 | 'Neos.Neos:FallbackNode': 153 | indexed: false 154 | 'Neos.Neos:Shortcut': 155 | indexed: false 156 | 'Neos.Neos:ContentCollection': 157 | indexed: false 158 | ``` 159 | 160 | You need to explicitly configure the individual NodeTypes (this feature does not check the Super Type configuration). 161 | But you can use a special notation to configure a full namespace, `Acme.AcmeCom:*` will be applied for all node 162 | types in the `Acme.AcmeCom` namespace. The most specific configuration is used in this order: 163 | 164 | - NodeType name (`Neos.Neos:Shortcut`) 165 | - Full namespace notation (`Neos.Neos:*`) 166 | - Catch all (`*`) 167 | 168 | ### Indexing configuration per data type 169 | 170 | **The default configuration supports most use cases and often may not need to be touched, as this package comes 171 | with sane defaults for all Neos data types.** 172 | 173 | Indexing of properties is configured at two places. The defaults per-data-type are configured 174 | inside `Neos.ContentRepository.Search.defaultConfigurationPerType` of `Settings.yaml`. 175 | Furthermore, this can be overridden using the `properties.[....].search` path inside 176 | `NodeTypes.yaml`. 177 | 178 | This configuration contains two parts: 179 | 180 | * Underneath `elasticSearchMapping`, the Elasticsearch property mapping can be defined. 181 | * Underneath `indexing`, an Eel expression which processes the value before indexing has to be 182 | specified. It has access to the current `value` and the current `node`. 183 | 184 | Example (from the default configuration): 185 | 186 | ```yaml 187 | # Settings.yaml 188 | Neos: 189 | ContentRepository: 190 | Search: 191 | defaultConfigurationPerType: 192 | 193 | # strings should just be indexed with their simple value. 194 | string: 195 | elasticSearchMapping: 196 | type: string 197 | indexing: '${value}' 198 | ``` 199 | 200 | ### Indexing configuration per property 201 | 202 | ```yaml 203 | # NodeTypes.yaml 204 | 'Neos.Neos:Timable': 205 | properties: 206 | '_hiddenBeforeDateTime': 207 | search: 208 | 209 | # A date should be mapped differently, and in this case we want to use a date format which 210 | # Elasticsearch understands 211 | elasticSearchMapping: 212 | type: DateTime 213 | format: 'date_time_no_millis' 214 | indexing: '${(node.hiddenBeforeDateTime ? Date.format(node.hiddenBeforeDateTime, "Y-m-d\TH:i:sP") : null)}' 215 | ``` 216 | 217 | If your nodetypes schema defines custom properties of type DateTime, you have got to provide similar configuration for 218 | them as well in your `NodeTypes.yaml`, or else they will not be indexed correctly. 219 | 220 | There are a few indexing helpers inside the `Indexing` namespace which are usable inside the 221 | `indexing` expression. In most cases, you don't need to touch this, but they were needed to build up 222 | the standard indexing configuration: 223 | 224 | * `Indexing.buildAllPathPrefixes`: for a path such as `foo/bar/baz`, builds up a list of path 225 | prefixes, e.g. `['foo', 'foo/bar', 'foo/bar/baz']`. 226 | * `Indexing.extractNodeTypeNamesAndSupertypes(NodeType)`: extracts a list of node type names for 227 | the passed node type and all of its supertypes 228 | * `Indexing.convertArrayOfNodesToArrayOfNodeIdentifiers(array $nodes)`: convert the given nodes to 229 | their node identifiers. 230 | 231 | ### Skip indexing and mapping of a property 232 | 233 | If you don't want a property to be indexed, set `indexing: false`. In this case no mapping is configured for this field. 234 | This can be used to also solve a type conflict of two node properties with same name and different type. 235 | 236 | ### Fulltext Indexing 237 | 238 | In order to enable fulltext indexing, every `Document` node must be configured as *fulltext root*. Thus, 239 | the following is configured in the default configuration: 240 | 241 | ```yaml 242 | 'Neos.Neos:Document': 243 | search: 244 | fulltext: 245 | isRoot: true 246 | ``` 247 | 248 | A *fulltext root* contains all the *content* of its non-document children, such that when one searches 249 | inside these texts, the document itself is returned as result. 250 | 251 | In order to specify how the fulltext of a property in a node should be extracted, this is configured 252 | in `NodeTypes.yaml` at `properties.[propertyName].search.fulltextExtractor`. 253 | 254 | An example: 255 | 256 | ```yaml 257 | 'Neos.Neos.NodeTypes:Text': 258 | properties: 259 | 'text': 260 | search: 261 | fulltextExtractor: '${Indexing.extractHtmlTags(value)}' 262 | 263 | 'My.Blog:Post': 264 | properties: 265 | title: 266 | search: 267 | fulltextExtractor: '${Indexing.extractInto("h1", value)}' 268 | ``` 269 | 270 | 271 | ### Working with Dates 272 | 273 | As a default, Elasticsearch indexes dates in the UTC Timezone. In order to have it index using the timezone 274 | currently configured in PHP, the configuration for any property in a node which represents a date should look like this: 275 | 276 | ```yaml 277 | 'My.Blog:Post': 278 | properties: 279 | date: 280 | search: 281 | elasticSearchMapping: 282 | type: 'date' 283 | format: 'date_time_no_millis' 284 | indexing: '${(value ? Date.format(value, "Y-m-d\TH:i:sP") : null)}' 285 | ``` 286 | 287 | This is important so that Date- and Time-based searches work as expected, both when using formatted DateTime strings and 288 | when using relative DateTime calculations (eg.: `now`, `now+1d`). 289 | 290 | If you want to filter items by date, e.g. to show items with date later than today, you can create a query like this: 291 | 292 | ``` 293 | ${...greaterThan('date', Date.format(Date.Now(), "Y-m-d\TH:i:sP"))...} 294 | ``` 295 | 296 | For more information on Elasticsearch's Date Formats, 297 | [click here](http://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-date-format.html). 298 | 299 | 300 | ### Working with Assets / Attachments 301 | 302 | If you want to index attachments, you need to install the [Elasticsearch Ingest-Attachment Plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/master/ingest-attachment.html). 303 | Then, you can add the following to your `Settings.yaml`: 304 | 305 | ```yaml 306 | Neos: 307 | ContentRepository: 308 | Search: 309 | defaultConfigurationPerType: 310 | 'Neos\Media\Domain\Model\Asset': 311 | elasticSearchMapping: 312 | type: text 313 | indexing: ${Indexing.Indexing.extractAssetContent(value)} 314 | ``` 315 | 316 | or add the attachments content to a fulletxt field in your NodeType configuration: 317 | 318 | ```yaml 319 | properties: 320 | file: 321 | type: 'Neos\Media\Domain\Model\Asset' 322 | ui: 323 | search: 324 | fulltextExtractor: ${Indexing.extractInto('text', Indexing.extractAssetContent(value))} 325 | ``` 326 | 327 | By default `Indexing.extractAssetContent(value)` returns the asset content. You can use the second parameter to return asset meta data. The field parameter can be set to one of the following: `content, title, name, author, keywords, date, content_type, content_length, language`. 328 | 329 | With that, you can for example add the keywords of a file to a higher boosted field: 330 | 331 | ```yaml 332 | properties: 333 | file: 334 | type: 'Neos\Media\Domain\Model\Asset' 335 | ui: 336 | search: 337 | fulltextExtractor: ${Indexing.extractInto('h2', Indexing.extractAssetContent(value, 'keywords'))} 338 | ``` 339 | 340 | 341 | ## Search Component 342 | 343 | As the search component usually needs to be heavily adjusted, we only include a snippet which can be copy/pasted 344 | and adjusted into your project: 345 | 346 | ```neosfusion 347 | prototype(My.Package:Search) < prototype(Neos.Fusion:Component) { 348 | // for possibilities on how to build the query, see the next section in the documentation 349 | _elasticsearchBaseQuery = ${Elasticsearch.createNeosFulltextQuery(site).fulltext(request.arguments.q)} 350 | @context.mainSearchRequest = ${Elasticsearch.createRequest(site).query(this._elasticsearchBaseQuery))} 351 | 352 | // Search Result Display is controlled through Flowpack.Listable 353 | searchResults = Flowpack.Listable:PaginatedCollection { 354 | collection = ${mainSearchRequest} 355 | itemsPerPage = 12 356 | 357 | // we use cache mode "dynamic" for the full Search component; so we do not need an additional cache entry 358 | // for the PaginatedCollection. 359 | @cache.mode = "embed" 360 | } 361 | renderer = afx` 362 |
    363 | 364 | 365 | 366 |
    367 | There was an error executing the search request. Please try again in a few minutes. 368 |
    369 |

    Showing {mainSearchRequest.execute().count()} of {mainSearchRequest.execute().total()} results

    370 | 371 | {props.searchResults} 372 |
    373 | ` 374 | // If you want to see the full request going to Elasticsearch, you can include 375 | // the following snippet in the renderer above: 376 | // 377 | 378 | // The parameter "q" should be included in this pagination 379 | prototype(Flowpack.Listable:PaginationParameters) { 380 | q = ${request.arguments.q} 381 | } 382 | 383 | // We configure the cache mode "dynamic" here. 384 | @cache { 385 | mode = 'dynamic' 386 | entryIdentifier { 387 | node = ${node} 388 | type = 'searchForm' 389 | } 390 | entryDiscriminator = ${request.arguments.q + '-' + request.arguments.currentPage} 391 | context { 392 | 1 = 'node' 393 | 2 = 'documentNode' 394 | 3 = 'site' 395 | } 396 | entryTags { 397 | 1 = ${Neos.Caching.nodeTag(node)} 398 | } 399 | } 400 | } 401 | 402 | // The result display is done here. 403 | // In the context, you'll find an object `searchResultDocument` which is of type 404 | // Sandstorm\LightweightElasticsearch\Query\Result\SearchResultDocument. 405 | prototype(Sandstorm.LightweightElasticsearch:SearchResultCase) { 406 | neosNodes { 407 | // all Documents in the index which are Nodes have a property "index_discriminator" set to "neos_nodes"; 408 | // This is in preparation for displaying other kinds of data. 409 | condition = ${searchResultDocument.property('index_discriminator') == 'neos_nodes'} 410 | renderer.@context.node = ${searchResultDocument.loadNode()} 411 | renderer = afx` 412 | 413 | ` 414 | // If you want to see the full Search Response hit, you can include the following 415 | // snippet in the renderer above: 416 | // 417 | } 418 | } 419 | 420 | ``` 421 | 422 | ## Query API 423 | 424 | Simple Example as Eel expression: 425 | 426 | ``` 427 | Elasticsearch.createRequest(site) 428 | .query( 429 | Elasticsearch.createNeosFulltextQuery(site) 430 | .fulltext(request.arguments.q) 431 | ) 432 | ``` 433 | 434 | - If you want to search Neos nodes, we need to pass in a *context node* as first argument to `Elasticsearch.createRequest()` 435 | - This way, the correct index (in the current language) is automatically searched 436 | - You are able to call `searchResultDocument.loadNode()` on the individual document 437 | - `Elasticsearch.createNeosFulltextQuery` *also needs a context node*, which specifies the part of the node tree which 438 | we want to search. 439 | 440 | There exists a query API for more complex cases, i.e. you can do the following: 441 | 442 | ``` 443 | Elasticsearch.createRequest(site) 444 | .query( 445 | Elasticsearch.createNeosFulltextQuery(site) 446 | .fulltext(request.arguments.q) 447 | // only the results to documents where myKey = myValue 448 | .filter(Elasticsearch.createTermQuery("myKey", "myValue")) 449 | ) 450 | ``` 451 | 452 | More complex queries for searching through multiple indices can look like this: 453 | 454 | ``` 455 | Elasticsearch.createRequest(site, ['index2', 'index3') 456 | .query( 457 | Elasticsearch.createBooleanQuery() 458 | .should( 459 | Elasticsearch.createNeosFulltextQuery(site) 460 | .fulltext(request.arguments.q) 461 | .filter(Elasticsearch.createTermQuery("index_discriminator", "neos_nodes")) 462 | ) 463 | .should( 464 | // add query for index2 here 465 | ) 466 | ) 467 | ``` 468 | 469 | **We recommend to build more complex queries through Custom Eel helpers; directly calling the Query Builders of this package** 470 | 471 | ## Aggregations and Faceting 472 | 473 | Implementing Faceted Search is more difficult than it looks at first sight - so let's first build a mental model 474 | of how the queries need to work. 475 | 476 | Faceting usually looks like: 477 | 478 | ``` 479 | [ Global Search Input Field ] <-- global info 480 | 481 | Categories 482 | - News 483 | - FAQ Entries 484 | - ... 485 | 486 | Products 487 | - Product 1 488 | - Product 2 (chosen) 489 | 490 | [Result Listing] 491 | ``` 492 | 493 | The global search input field is the easiest - as it influences both the facets (Categories and Products) above, 494 | and the Result Listing. 495 | 496 | For a facet, things are a bit more difficult. To *calculate* the facet values (i.e. what is shown underneath the "Categories" 497 | headline), an *aggregation query* needs to be executed. In this query, we need to take into account the Global Search field, 498 | and the choices of all other facets (but not our own one). 499 | 500 | For the result listing, we need to take into account the global search, and the choices of all facets. 501 | 502 | To model that in Elasticsearch, we recommend to use multiple queries: One for each facet; and one for rendering 503 | the result listing. 504 | 505 | Here follows the list of modifications done to the template above: 506 | 507 | ```diff 508 | @@ -1,7 +1,24 @@ 509 | prototype(My.Package:Search) < prototype(Neos.Fusion:Component) { 510 | - // for possibilities on how to build the query, see the next section in the documentation 511 | + // this is the base query from the user which should *always* be applied. 512 | _elasticsearchBaseQuery = ${Elasticsearch.createNeosFulltextQuery(site).fulltext(request.arguments.q)} 513 | - @context.mainSearchRequest = ${Elasticsearch.createRequest(site).query(this._elasticsearchBaseQuery))} 514 | + 515 | + // register a Terms aggregation with the URL parameter "nodeTypesFilter" 516 | + _nodeTypesAggregation = ${Elasticsearch.createTermsAggregation("neos_type", request.arguments.nodeTypesFilter)} 517 | + 518 | + // This is the main elasticsearch query which determines the search results: 519 | + // - this._elasticsearchBaseQuery is applied 520 | + // - this._nodeTypesAggregation is applied as well (if the user chose a facet value) 521 | + // <-- if you add additional aggregations, you need to add them here to this list. 522 | + @context.mainSearchRequest = ${Elasticsearch.createRequest(site).query(Elasticsearch.createBooleanQuery().must(this._elasticsearchBaseQuery).filter(this._nodeTypesAggregation))} 523 | + 524 | + // The Request is for displaying the Node Types aggregation (faceted search). 525 | + // 526 | + // For faceted search to work properly, we need to add all OTHER query parts as filter; so NOT ourselves. 527 | + // This means, for the `.aggregation()` part, we take the aggregation itself. 528 | + // For the `.filter()` part, we add: 529 | + // - this._elasticsearchBaseQuery to ensure the entered query string by the user is taken into account 530 | + // <-- if you add additional aggregations, you need to add them here to the list. 531 | + @context.nodeTypesFacet = ${Elasticsearch.createAggregationRequest(site).aggregation(this._nodeTypesAggregation).filter(this._elasticsearchBaseQuery).execute()} 532 | 533 | // Search Result Display is controlled through Flowpack.Listable 534 | searchResults = Flowpack.Listable:PaginatedCollection { 535 | @@ -12,6 +29,23 @@ 536 | // for the PaginatedCollection. 537 | @cache.mode = "embed" 538 | } 539 | + 540 | + nodeTypesFacet = Neos.Fusion:Component { 541 | + // the nodeTypesFacet is a "Terms" aggregation... 542 | + // ...so we can access nodeTypesFacet.buckets. 543 | + // To build a link to the facet, we use Neos.Neos:NodeLink with two additions: 544 | + // - addQueryString must be set to TRUE, to keep the search query and potentially other facets. 545 | + // - to build the arguments, we need to set `nodeTypesFilter` to the current bucket key (or to null in case we want to clear the facet) 546 | + renderer = afx` 547 | +
      548 | + 549 | +
    • {bucket.key} {bucket.doc_count} (selected)
    • 550 | +
      551 | +
    552 | + CLEAR FACET 553 | + ` 554 | + } 555 | + 556 | renderer = afx` 557 |
    558 | 559 | @@ -22,6 +56,8 @@ 560 | 561 |

    Showing {mainSearchRequest.execute().count()} of {mainSearchRequest.execute().total()} results

    562 | 563 | + {props.nodeTypesFacet} 564 | + 565 | {props.searchResults} 566 |
    567 | ` 568 | @@ -32,6 +68,8 @@ 569 | // The parameter "q" should be included in this pagination 570 | prototype(Flowpack.Listable:PaginationParameters) { 571 | q = ${request.arguments.q} 572 | + // <-- if you add additional aggregations, you need to add the parameter names here 573 | + nodeTypesFilter = ${request.arguments.nodeTypesFilter} 574 | } 575 | 576 | // We configure the cache mode "dynamic" here. 577 | @@ -41,7 +79,8 @@ 578 | node = ${node} 579 | type = 'searchForm' 580 | } 581 | - entryDiscriminator = ${request.arguments.q + '-' + request.arguments.currentPage} 582 | + // <-- if you add additional aggregations, you need to add the parameter names to the entryDiscriminator 583 | + entryDiscriminator = ${request.arguments.q + '-' + request.arguments.currentPage + '-' + request.arguments.nodeTypesFilter} 584 | context { 585 | 1 = 'node' 586 | 2 = 'documentNode' 587 | 588 | ``` 589 | 590 | You can also copy/paste the full file: 591 | 592 |
    593 | See the faceted search example 594 | 595 | ``` 596 | prototype(My.Package:Search) < prototype(Neos.Fusion:Component) { 597 | // this is the base query from the user which should *always* be applied. 598 | _elasticsearchBaseQuery = ${Elasticsearch.createNeosFulltextQuery(site).fulltext(request.arguments.q)} 599 | 600 | // register a Terms aggregation with the URL parameter "nodeTypesFilter" 601 | _nodeTypesAggregation = ${Elasticsearch.createTermsAggregation("neos_type", request.arguments.nodeTypesFilter)} 602 | 603 | // This is the main elasticsearch query which determines the search results: 604 | // - this._elasticsearchBaseQuery is applied 605 | // - this._nodeTypesAggregation is applied as well (if the user chose a facet value) 606 | // <-- if you add additional aggregations, you need to add them here to this list. 607 | @context.mainSearchRequest = ${Elasticsearch.createRequest(site).query(Elasticsearch.createBooleanQuery().must(this._elasticsearchBaseQuery).filter(this._nodeTypesAggregation))} 608 | 609 | // The Request is for displaying the Node Types aggregation (faceted search). 610 | // 611 | // For faceted search to work properly, we need to add all OTHER query parts as filter; so NOT ourselves. 612 | // This means, for the `.aggregation()` part, we take the aggregation itself. 613 | // For the `.filter()` part, we add: 614 | // - this._elasticsearchBaseQuery to ensure the entered query string by the user is taken into account 615 | // <-- if you add additional aggregations, you need to add them here to the list. 616 | @context.nodeTypesFacet = ${Elasticsearch.createAggregationRequest(site).aggregation(this._nodeTypesAggregation).filter(this._elasticsearchBaseQuery).execute()} 617 | 618 | // Search Result Display is controlled through Flowpack.Listable 619 | searchResults = Flowpack.Listable:PaginatedCollection { 620 | collection = ${mainSearchRequest} 621 | itemsPerPage = 12 622 | 623 | // we use cache mode "dynamic" for the full Search component; so we do not need an additional cache entry 624 | // for the PaginatedCollection. 625 | @cache.mode = "embed" 626 | } 627 | 628 | nodeTypesFacet = Neos.Fusion:Component { 629 | // the nodeTypesFacet is a "Terms" aggregation... 630 | // ...so we can access nodeTypesFacet.buckets. 631 | // To build a link to the facet, we use Neos.Neos:NodeLink with two additions: 632 | // - addQueryString must be set to TRUE, to keep the search query and potentially other facets. 633 | // - to build the arguments, we need to set `nodeTypesFilter` to the current bucket key (or to null in case we want to clear the facet) 634 | renderer = afx` 635 |
      636 | 637 |
    • {bucket.key} {bucket.doc_count} (selected)
    • 638 |
      639 |
    640 | CLEAR FACET 641 | ` 642 | } 643 | 644 | renderer = afx` 645 |
    646 | 647 | 648 | 649 |
    650 | There was an error executing the search request. Please try again in a few minutes. 651 |
    652 |

    Showing {mainSearchRequest.execute().count()} of {mainSearchRequest.execute().total()} results

    653 | 654 | {props.nodeTypesFacet} 655 | 656 | {props.searchResults} 657 |
    658 | ` 659 | // If you want to see the full request going to Elasticsearch, you can include 660 | // the following snippet in the renderer above: 661 | // 662 | 663 | // The parameter "q" should be included in this pagination 664 | prototype(Flowpack.Listable:PaginationParameters) { 665 | q = ${request.arguments.q} 666 | // <-- if you add additional aggregations, you need to add the parameter names here 667 | nodeTypesFilter = ${request.arguments.nodeTypesFilter} 668 | } 669 | 670 | // We configure the cache mode "dynamic" here. 671 | @cache { 672 | mode = 'dynamic' 673 | entryIdentifier { 674 | node = ${node} 675 | type = 'searchForm' 676 | } 677 | // <-- if you add additional aggregations, you need to add the parameter names to the entryDiscriminator 678 | entryDiscriminator = ${request.arguments.q + '-' + request.arguments.currentPage + '-' + request.arguments.nodeTypesFilter} 679 | context { 680 | 1 = 'node' 681 | 2 = 'documentNode' 682 | 3 = 'site' 683 | } 684 | entryTags { 685 | 1 = ${Neos.Caching.nodeTag(node)} 686 | } 687 | } 688 | } 689 | 690 | // The result display is done here. 691 | // In the context, you'll find an object `searchResultDocument` which is of type 692 | // Sandstorm\LightweightElasticsearch\Query\Result\SearchResultDocument. 693 | prototype(Sandstorm.LightweightElasticsearch:SearchResultCase) { 694 | neosNodes { 695 | // all Documents in the index which are Nodes have a property "index_discriminator" set to "neos_nodes"; 696 | // This is in preparation for displaying other kinds of data. 697 | condition = ${searchResultDocument.property('index_discriminator') == 'neos_nodes'} 698 | renderer.@context.node = ${searchResultDocument.loadNode()} 699 | renderer = afx` 700 | 701 | ` 702 | // If you want to see the full Search Response hit, you can include the following 703 | // snippet in the renderer above: 704 | // 705 | } 706 | } 707 | 708 | ``` 709 | 710 |
    711 | 712 | ## Result Highlighting 713 | 714 | Result highlighting is implemented using the [highlight API](https://www.elastic.co/guide/en/elasticsearch/reference/current/highlighting.html) 715 | of Elasticsearch. 716 | 717 | To enable it, you need to change thef following parts: 718 | 719 | - To use a default highlighting, add the `.highlight(Elasticsearch.createNeosFulltextHighlight())` 720 | part to your main Elasticsearch query. 721 | - Additionally, you can call the getter `searchResultDocument.processedHighlights` for each 722 | result, which contains the highlighted extracts, which you can simply join together like this: 723 | 724 | `Array.join(searchResultDocument.processedHighlights, '…')` 725 | 726 | A full example can be found below: 727 | 728 | ```diff 729 | @@ -9,7 +9,7 @@ 730 | // - this._elasticsearchBaseQuery is applied 731 | // - this._nodeTypesAggregation is applied as well (if the user chose a facet value) 732 | // <-- if you add additional aggregations, you need to add them here to this list. 733 | - @context.mainSearchRequest = ${Elasticsearch.createRequest(site).query(Elasticsearch.createBooleanQuery().must(this._elasticsearchBaseQuery).filter(this._nodeTypesAggregation))} 734 | + @context.mainSearchRequest = ${Elasticsearch.createRequest(site).query(Elasticsearch.createBooleanQuery().must(this._elasticsearchBaseQuery).filter(this._nodeTypesAggregation)).highlight(Elasticsearch.createNeosFulltextHighlight())} 735 | 736 | // The Request is for displaying the Node Types aggregation (faceted search). 737 | // 738 | @@ -102,7 +102,9 @@ 739 | condition = ${searchResultDocument.property('index_discriminator') == 'neos_nodes'} 740 | renderer.@context.node = ${searchResultDocument.loadNode()} 741 | renderer = afx` 742 | - 743 | + 744 | + {Array.join(searchResultDocument.processedHighlights, '…')} 745 | + 746 | ` 747 | // If you want to see the full Search Response hit, you can include the following 748 | // snippet in the renderer above: 749 | 750 | ``` 751 | 752 | You can also copy/paste the full file: 753 | 754 |
    755 | See the faceted + highlighted search example 756 | 757 | ``` 758 | prototype(My.Package:Search) < prototype(Neos.Fusion:Component) { 759 | // this is the base query from the user which should *always* be applied. 760 | _elasticsearchBaseQuery = ${Elasticsearch.createNeosFulltextQuery(site).fulltext(request.arguments.q)} 761 | 762 | // register a Terms aggregation with the URL parameter "nodeTypesFilter" 763 | _nodeTypesAggregation = ${Elasticsearch.createTermsAggregation("neos_type", request.arguments.nodeTypesFilter)} 764 | 765 | // This is the main elasticsearch query which determines the search results: 766 | // - this._elasticsearchBaseQuery is applied 767 | // - this._nodeTypesAggregation is applied as well (if the user chose a facet value) 768 | // <-- if you add additional aggregations, you need to add them here to this list. 769 | @context.mainSearchRequest = ${Elasticsearch.createRequest(site).query(Elasticsearch.createBooleanQuery().must(this._elasticsearchBaseQuery).filter(this._nodeTypesAggregation)).highlight(Elasticsearch.createNeosFulltextHighlight())} 770 | 771 | // The Request is for displaying the Node Types aggregation (faceted search). 772 | // 773 | // For faceted search to work properly, we need to add all OTHER query parts as filter; so NOT ourselves. 774 | // This means, for the `.aggregation()` part, we take the aggregation itself. 775 | // For the `.filter()` part, we add: 776 | // - this._elasticsearchBaseQuery to ensure the entered query string by the user is taken into account 777 | // <-- if you add additional aggregations, you need to add them here to the list. 778 | @context.nodeTypesFacet = ${Elasticsearch.createAggregationRequest(site).aggregation(this._nodeTypesAggregation).filter(this._elasticsearchBaseQuery).execute()} 779 | 780 | // Search Result Display is controlled through Flowpack.Listable 781 | searchResults = Flowpack.Listable:PaginatedCollection { 782 | collection = ${mainSearchRequest} 783 | itemsPerPage = 12 784 | 785 | // we use cache mode "dynamic" for the full Search component; so we do not need an additional cache entry 786 | // for the PaginatedCollection. 787 | @cache.mode = "embed" 788 | } 789 | 790 | nodeTypesFacet = Neos.Fusion:Component { 791 | // the nodeTypesFacet is a "Terms" aggregation... 792 | // ...so we can access nodeTypesFacet.buckets. 793 | // To build a link to the facet, we use Neos.Neos:NodeLink with two additions: 794 | // - addQueryString must be set to TRUE, to keep the search query and potentially other facets. 795 | // - to build the arguments, we need to set `nodeTypesFilter` to the current bucket key (or to null in case we want to clear the facet) 796 | renderer = afx` 797 |
      798 | 799 |
    • {bucket.key} {bucket.doc_count} (selected)
    • 800 |
      801 |
    802 | CLEAR FACET 803 | ` 804 | } 805 | 806 | renderer = afx` 807 |
    808 | 809 | 810 | 811 |
    812 | There was an error executing the search request. Please try again in a few minutes. 813 |
    814 |

    Showing {mainSearchRequest.execute().count()} of {mainSearchRequest.execute().total()} results

    815 | 816 | {props.nodeTypesFacet} 817 | 818 | {props.searchResults} 819 |
    820 | ` 821 | // If you want to see the full request going to Elasticsearch, you can include 822 | // the following snippet in the renderer above: 823 | // 824 | 825 | // The parameter "q" should be included in this pagination 826 | prototype(Flowpack.Listable:PaginationParameters) { 827 | q = ${request.arguments.q} 828 | // <-- if you add additional aggregations, you need to add the parameter names here 829 | nodeTypesFilter = ${request.arguments.nodeTypesFilter} 830 | } 831 | 832 | // We configure the cache mode "dynamic" here. 833 | @cache { 834 | mode = 'dynamic' 835 | entryIdentifier { 836 | node = ${node} 837 | type = 'searchForm' 838 | } 839 | // <-- if you add additional aggregations, you need to add the parameter names to the entryDiscriminator 840 | entryDiscriminator = ${request.arguments.q + '-' + request.arguments.currentPage + '-' + request.arguments.nodeTypesFilter} 841 | context { 842 | 1 = 'node' 843 | 2 = 'documentNode' 844 | 3 = 'site' 845 | } 846 | entryTags { 847 | 1 = ${Neos.Caching.nodeTag(node)} 848 | } 849 | } 850 | } 851 | 852 | // The result display is done here. 853 | // In the context, you'll find an object `searchResultDocument` which is of type 854 | // Sandstorm\LightweightElasticsearch\Query\Result\SearchResultDocument. 855 | prototype(Sandstorm.LightweightElasticsearch:SearchResultCase) { 856 | neosNodes { 857 | // all Documents in the index which are Nodes have a property "index_discriminator" set to "neos_nodes"; 858 | // This is in preparation for displaying other kinds of data. 859 | condition = ${searchResultDocument.property('index_discriminator') == 'neos_nodes'} 860 | renderer.@context.node = ${searchResultDocument.loadNode()} 861 | renderer = afx` 862 | 863 | {Array.join(searchResultDocument.processedHighlights, '…')} 864 | 865 | ` 866 | // If you want to see the full Search Response hit, you can include the following 867 | // snippet in the renderer above: 868 | // 869 | } 870 | } 871 | 872 | ``` 873 | 874 |
    875 | 876 | 877 | ## Indexing other data 878 | 879 | We suggest to set `index_discriminator` to different values for different data sources, to be able to 880 | identify different sources properly. 881 | 882 | You can use the `CustomIndexer` as a basis for indexing as follows: 883 | 884 | ```php 885 | $indexer = CustomIndexer::create('faq'); 886 | $indexer->createIndexWithMapping(['properties' => [ 887 | 'faqEntryTitle' => [ 888 | 'type' => 'text' 889 | ] 890 | ]]); 891 | $indexer->index([ 892 | 'faqEntryTitle' => 'FAQ Dresden' 893 | ]); 894 | // index other documents here 895 | $indexer->finalizeAndSwitchAlias(); 896 | 897 | // Optionally for cleanup 898 | $indexer->removeObsoleteIndices(); 899 | ``` 900 | 901 | For your convenience, a full CommandController can be copied/pasted below: 902 | 903 |
    904 | Command Controller for custom indexing 905 | 906 | ``` 907 | createIndexWithMapping(['properties' => [ 921 | 'faqEntryTitle' => [ 922 | 'type' => 'text' 923 | ] 924 | ]]); 925 | $indexer->index([ 926 | 'faqEntryTitle' => 'FAQ Dresden' 927 | ]); 928 | $indexer->index([ 929 | 'faqEntryTitle' => 'FAQ Berlin' 930 | ]); 931 | $indexer->finalizeAndSwitchAlias(); 932 | } 933 | 934 | public function cleanupCommand() 935 | { 936 | $indexer = CustomIndexer::create('faq'); 937 | $removedIndices = $indexer->removeObsoleteIndices(); 938 | foreach ($removedIndices as $index) { 939 | $this->outputLine('Removed ' . $index); 940 | } 941 | } 942 | } 943 | 944 | ``` 945 | 946 |
    947 | 948 | See the next section for querying other data sources 949 | 950 | ## Querying other data 951 | 952 | Three parts need to be adjusted for querying other data sources: 953 | 954 | - adjust `Elasticsearch.createRequest()` and `Elasticsearch.createAggregationRequest()` calls 955 | - build up and use the fulltext query for your custom data 956 | - customize result rendering. 957 | 958 | We'll now go through these steps one by one. 959 | 960 | **Adjust `Elasticsearch.createRequest()` and `Elasticsearch.createAggregationRequest()`** 961 | 962 | Here, you need to include the other index as second parameter; so for example `Elasticsearch.createRequest(site, ['faq'])` 963 | is a valid invocation. 964 | 965 | 966 | **Build up the fulltext query** 967 | 968 | We suggest that you build custom Eel helpers for doing the fulltext search in your custom data, 969 | e.g. by filtering on `index_discriminator` and using a `simple_query_string` query as follows: 970 | 971 | ```php 972 | return BooleanQueryBuilder::create() 973 | ->filter(TermQueryBuilder::create('index_discriminator', 'faq')) 974 | ->must( 975 | SimpleQueryStringBuilder::create($query ?? '')->fields([ 976 | 'faqEntryTitle^5', 977 | ]) 978 | ); 979 | ``` 980 | 981 | As an example, you can also check out the full Eel helper: 982 | 983 |
    984 | Eel helper for fulltext querying custom data 985 | 986 | ``` 987 | filter(TermQueryBuilder::create('index_discriminator', 'faq')) 1005 | ->must( 1006 | SimpleQueryStringBuilder::create($query ?? '')->fields([ 1007 | 'faqEntryTitle^5', 1008 | ]) 1009 | ); 1010 | } 1011 | 1012 | public function allowsCallOfMethod($methodName) 1013 | { 1014 | return true; 1015 | } 1016 | } 1017 | 1018 | ``` 1019 | 1020 |
    1021 | 1022 | **Remember to register the Eel helper in `Settings.yaml` as usual: 1023 | 1024 | ```yaml 1025 | Neos: 1026 | Fusion: 1027 | defaultContext: 1028 | MyQueries: My\Package\Eel\MyQueries 1029 | ``` 1030 | 1031 | 1032 | **Use the fulltext query** 1033 | 1034 | To use both the Neos and your custom fulltext query, these two queries should be combined using a `should` clause 1035 | in the `Terms` query; so this is like an "or" query combination: 1036 | 1037 | ``` 1038 | Elasticsearch.createBooleanQuery() 1039 | .should(Elasticsearch.createNeosFulltextQuery(site).fulltext(request.arguments.q)) 1040 | .should(MyQueries.faqQuery(request.arguments.q))} 1041 | ``` 1042 | 1043 | 1044 | **Adjust Result Rendering** 1045 | 1046 | By adding a conditional branch to `prototype(Sandstorm.LightweightElasticsearch:SearchResultCase)`, you can have a custom 1047 | result rendering: 1048 | 1049 | ```neosfusion 1050 | prototype(Sandstorm.LightweightElasticsearch:SearchResultCase) { 1051 | faqEntries { 1052 | condition = ${searchResultDocument.property('index_discriminator') == 'faq'} 1053 | renderer = afx` 1054 | {searchResultDocument.properties.faqEntryTitle} 1055 | ` 1056 | } 1057 | } 1058 | ``` 1059 | 1060 | 1061 | **Putting it all together** 1062 | 1063 | See the following diff, or the full source code below: 1064 | 1065 | 1066 | ```diff 1067 | @@ -1,26 +1,15 @@ 1068 | prototype(My.Package:Search) < prototype(Neos.Fusion:Component) { 1069 | - // this is the base query from the user which should *always* be applied. 1070 | - _elasticsearchBaseQuery = ${Elasticsearch.createNeosFulltextQuery(site).fulltext(request.arguments.q)} 1071 | + // for possibilities on how to build the query, see the next section in the documentation 1072 | + _elasticsearchBaseQuery = ${Elasticsearch.createBooleanQuery().should(Elasticsearch.createNeosFulltextQuery(site).fulltext(request.arguments.q)).should(MyQueries.faqQuery(request.arguments.q))} 1073 | 1074 | - // register a Terms aggregation with the URL parameter "nodeTypesFilter" 1075 | + // register a Terms aggregation with the URL parameter "nodeTypesFilter". 1076 | + // we also need to pass in the request, so that the aggregation can extract the currently selected value. 1077 | _nodeTypesAggregation = ${Elasticsearch.createTermsAggregation("neos_type", request.arguments.nodeTypesFilter)} 1078 | 1079 | - // This is the main elasticsearch query which determines the search results: 1080 | - // - this._elasticsearchBaseQuery is applied 1081 | - // - this._nodeTypesAggregation is applied as well (if the user chose a facet value) 1082 | - // <-- if you add additional aggregations, you need to add them here to this list. 1083 | - @context.mainSearchRequest = ${Elasticsearch.createRequest(site).query(Elasticsearch.createBooleanQuery().must(this._elasticsearchBaseQuery).filter(this._nodeTypesAggregation))} 1084 | - 1085 | - // The Request is for displaying the Node Types aggregation (faceted search). 1086 | - // 1087 | - // For faceted search to work properly, we need to add all OTHER query parts as filter; so NOT ourselves. 1088 | - // This means, for the `.aggregation()` part, we take the aggregation itself. 1089 | - // For the `.filter()` part, we add: 1090 | - // - this._elasticsearchBaseQuery to ensure the entered query string by the user is taken into account 1091 | - // <-- if you add additional aggregations, you need to add them here to the list. 1092 | - @context.nodeTypesFacet = ${Elasticsearch.createAggregationRequest(site).aggregation(this._nodeTypesAggregation).filter(this._elasticsearchBaseQuery).execute()} 1093 | - 1094 | - // Search Result Display is controlled through Flowpack.Listable 1095 | + // this is the main elasticsearch query which determines the search results - here, we also apply any restrictions imposed 1096 | + // by the _nodeTypesAggregation 1097 | + @context.mainSearchRequest = ${Elasticsearch.createRequest(site, ['faq']).query(Elasticsearch.createBooleanQuery().must(this._elasticsearchBaseQuery).filter(this._nodeTypesAggregation))} 1098 | + @context.nodeTypesFacet = ${Elasticsearch.createAggregationRequest(site, ['faq']).aggregation(this._nodeTypesAggregation).filter(this._elasticsearchBaseQuery).execute()} 1099 | searchResults = Flowpack.Listable:PaginatedCollection { 1100 | collection = ${mainSearchRequest} 1101 | itemsPerPage = 12 1102 | @@ -35,7 +24,7 @@ 1103 | // ...so we can access nodeTypesFacet.buckets. 1104 | // To build a link to the facet, we use Neos.Neos:NodeLink with two additions: 1105 | // - addQueryString must be set to TRUE, to keep the search query and potentially other facets. 1106 | - // - to build the arguments, we need to set `nodeTypesFilter` to the current bucket key (or to null in case we want to clear the facet) 1107 | + // - to build the arguments, each aggregation result type (e.g. TermsAggregationResult) has a specific method with the required arguments. 1108 | renderer = afx` 1109 |
      1110 | 1111 | @@ -50,7 +39,6 @@ 1112 |
      1113 | 1114 | 1115 | - 1116 |
      1117 | There was an error executing the search request. Please try again in a few minutes. 1118 |
      1119 | @@ -68,8 +56,7 @@ 1120 | // The parameter "q" should be included in this pagination 1121 | prototype(Flowpack.Listable:PaginationParameters) { 1122 | q = ${request.arguments.q} 1123 | - // <-- if you add additional aggregations, you need to add the parameter names here 1124 | - nodeTypesFilter = ${request.arguments.nodeTypesFilter} 1125 | + nodeTypes = ${request.arguments.nodeTypesFilter} 1126 | } 1127 | 1128 | // We configure the cache mode "dynamic" here. 1129 | @@ -79,7 +66,6 @@ 1130 | node = ${node} 1131 | type = 'searchForm' 1132 | } 1133 | - // <-- if you add additional aggregations, you need to add the parameter names to the entryDiscriminator 1134 | entryDiscriminator = ${request.arguments.q + '-' + request.arguments.currentPage + '-' + request.arguments.nodeTypesFilter} 1135 | context { 1136 | 1 = 'node' 1137 | @@ -96,6 +82,12 @@ 1138 | // In the context, you'll find an object `searchResultDocument` which is of type 1139 | // Sandstorm\LightweightElasticsearch\Query\Result\SearchResultDocument. 1140 | prototype(Sandstorm.LightweightElasticsearch:SearchResultCase) { 1141 | + faqEntries { 1142 | + condition = ${searchResultDocument.property('index_discriminator') == 'faq'} 1143 | + renderer = afx` 1144 | + {searchResultDocument.properties.faqEntryTitle} 1145 | + ` 1146 | + } 1147 | neosNodes { 1148 | // all Documents in the index which are Nodes have a property "index_discriminator" set to "neos_nodes"; 1149 | // This is in preparation for displaying other kinds of data. 1150 | 1151 | ``` 1152 | 1153 | You can also copy/paste the full file: 1154 | 1155 |
      1156 | See the faceted search example 1157 | 1158 | ``` 1159 | prototype(My.Package:Search) < prototype(Neos.Fusion:Component) { 1160 | // for possibilities on how to build the query, see the next section in the documentation 1161 | _elasticsearchBaseQuery = ${Elasticsearch.createBooleanQuery().should(Elasticsearch.createNeosFulltextQuery(site).fulltext(request.arguments.q)).should(MyQueries.faqQuery(request.arguments.q))} 1162 | 1163 | // register a Terms aggregation with the URL parameter "nodeTypesFilter". 1164 | // we also need to pass in the request, so that the aggregation can extract the currently selected value. 1165 | _nodeTypesAggregation = ${Elasticsearch.createTermsAggregation("neos_type", request.arguments.nodeTypesFilter)} 1166 | 1167 | // this is the main elasticsearch query which determines the search results - here, we also apply any restrictions imposed 1168 | // by the _nodeTypesAggregation 1169 | @context.mainSearchRequest = ${Elasticsearch.createRequest(site, ['faq']).query(Elasticsearch.createBooleanQuery().must(this._elasticsearchBaseQuery).filter(this._nodeTypesAggregation))} 1170 | @context.nodeTypesFacet = ${Elasticsearch.createAggregationRequest(site, ['faq']).aggregation(this._nodeTypesAggregation).filter(this._elasticsearchBaseQuery).execute()} 1171 | searchResults = Flowpack.Listable:PaginatedCollection { 1172 | collection = ${mainSearchRequest} 1173 | itemsPerPage = 12 1174 | 1175 | // we use cache mode "dynamic" for the full Search component; so we do not need an additional cache entry 1176 | // for the PaginatedCollection. 1177 | @cache.mode = "embed" 1178 | } 1179 | 1180 | nodeTypesFacet = Neos.Fusion:Component { 1181 | // the nodeTypesFacet is a "Terms" aggregation... 1182 | // ...so we can access nodeTypesFacet.buckets. 1183 | // To build a link to the facet, we use Neos.Neos:NodeLink with two additions: 1184 | // - addQueryString must be set to TRUE, to keep the search query and potentially other facets. 1185 | // - to build the arguments, each aggregation result type (e.g. TermsAggregationResult) has a specific method with the required arguments. 1186 | renderer = afx` 1187 |
        1188 | 1189 |
      • {bucket.key} {bucket.doc_count} (selected)
      • 1190 |
        1191 |
      1192 | CLEAR FACET 1193 | ` 1194 | } 1195 | 1196 | renderer = afx` 1197 | 1198 | 1199 | 1200 |
      1201 | There was an error executing the search request. Please try again in a few minutes. 1202 |
      1203 |

      Showing {mainSearchRequest.execute().count()} of {mainSearchRequest.execute().total()} results

      1204 | 1205 | {props.nodeTypesFacet} 1206 | 1207 | {props.searchResults} 1208 | 1209 | ` 1210 | // If you want to see the full request going to Elasticsearch, you can include 1211 | // the following snippet in the renderer above: 1212 | // 1213 | 1214 | // The parameter "q" should be included in this pagination 1215 | prototype(Flowpack.Listable:PaginationParameters) { 1216 | q = ${request.arguments.q} 1217 | nodeTypes = ${request.arguments.nodeTypesFilter} 1218 | } 1219 | 1220 | // We configure the cache mode "dynamic" here. 1221 | @cache { 1222 | mode = 'dynamic' 1223 | entryIdentifier { 1224 | node = ${node} 1225 | type = 'searchForm' 1226 | } 1227 | entryDiscriminator = ${request.arguments.q + '-' + request.arguments.currentPage + '-' + request.arguments.nodeTypesFilter} 1228 | context { 1229 | 1 = 'node' 1230 | 2 = 'documentNode' 1231 | 3 = 'site' 1232 | } 1233 | entryTags { 1234 | 1 = ${Neos.Caching.nodeTag(node)} 1235 | } 1236 | } 1237 | } 1238 | 1239 | // The result display is done here. 1240 | // In the context, you'll find an object `searchResultDocument` which is of type 1241 | // Sandstorm\LightweightElasticsearch\Query\Result\SearchResultDocument. 1242 | prototype(Sandstorm.LightweightElasticsearch:SearchResultCase) { 1243 | faqEntries { 1244 | condition = ${searchResultDocument.property('index_discriminator') == 'faq'} 1245 | renderer = afx` 1246 | {searchResultDocument.properties.faqEntryTitle} 1247 | ` 1248 | } 1249 | neosNodes { 1250 | // all Documents in the index which are Nodes have a property "index_discriminator" set to "neos_nodes"; 1251 | // This is in preparation for displaying other kinds of data. 1252 | condition = ${searchResultDocument.property('index_discriminator') == 'neos_nodes'} 1253 | renderer.@context.node = ${searchResultDocument.loadNode()} 1254 | renderer = afx` 1255 | 1256 | ` 1257 | // If you want to see the full Search Response hit, you can include the following 1258 | // snippet in the renderer above: 1259 | // 1260 | } 1261 | } 1262 | 1263 | ``` 1264 | 1265 |
      1266 | 1267 | 1268 | ## Debugging Elasticsearch queries 1269 | 1270 | 1. add `.log("!!!FOO")` to your Eel ElasticSearch query 1271 | 2. Check the System_Development log file for the full query and save it to a file (req.json) 1272 | 3. We suggest to use https://httpie.io/ (which can be installed using `brew install httpie`) to 1273 | do a request: 1274 | 1275 | ``` 1276 | http 127.0.0.1:9200/_cat/aliases 1277 | cat req.json | http 127.0.0.1:9200/neoscr,foo/_search 1278 | ``` 1279 | 1280 | ## Developing 1281 | 1282 | We gladly accept pull requests or contributors :-) 1283 | 1284 | ### Changing this readme 1285 | 1286 | First change Documentation/README_template.php; then run `php Documentation/build_readme.php`. 1287 | 1288 | ## License 1289 | 1290 | MIT 1291 | --------------------------------------------------------------------------------