├── 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |