diff --git a/Classes/Command/NodeIndexCommandController.php b/Classes/Command/NodeIndexCommandController.php index 9548f642..b38391fd 100644 --- a/Classes/Command/NodeIndexCommandController.php +++ b/Classes/Command/NodeIndexCommandController.php @@ -64,11 +64,6 @@ class NodeIndexCommandController extends CommandController */ protected $nodeTypeMappingBuilder; - /** - * @var integer - */ - protected $limit; - /** * @Flow\Inject * @var \Flowpack\ElasticSearch\ContentRepositoryAdaptor\LoggerInterface @@ -226,7 +221,6 @@ public function buildCommand($limit = null, $update = false, $workspace = null, $this->logger->log(sprintf('Indexing %snodes ... ', ($limit !== null ? 'the first ' . $limit . ' ' : '')), LOG_INFO); $count = 0; - $this->limit = $limit; if ($workspace === null && $this->settings['indexAllWorkspaces'] === false) { $workspace = 'live'; @@ -241,10 +235,10 @@ public function buildCommand($limit = null, $update = false, $workspace = null, }; if ($workspace === null) { foreach ($this->workspaceRepository->findAll() as $workspace) { - $count += $this->indexWorkspace($workspace->getName()); + $count += $this->indexWorkspace($workspace->getName(), $limit, $callback); } } else { - $count = $this->indexWorkspace($workspace); + $count += $this->indexWorkspace($workspace, $limit, $callback); } $this->nodeIndexingManager->flushQueues(); @@ -266,81 +260,17 @@ public function buildCommand($limit = null, $update = false, $workspace = null, public function cleanupCommand() { try { - $removedIndices = $this->nodeIndexer->removeOldIndices(); - if (count($removedIndices) > 0) { - if (count($removedIndices) === 1) { - $this->logger->log('Removed old index ' . current($removedIndices) . '.'); - } else { - $this->logger->log('Removed old indices ' . implode(', ', $removedIndices) . '.'); + $indicesToBeRemoved = $this->nodeIndexer->removeOldIndices(); + if (count($indicesToBeRemoved) > 0) { + foreach ($indicesToBeRemoved as $indexToBeRemoved) { + $this->logger->log('Removing old index ' . $indexToBeRemoved); } } else { $this->logger->log('Nothing to remove.'); } } catch (\Flowpack\ElasticSearch\Transfer\Exception\ApiException $exception) { $response = json_decode($exception->getResponse()); - $this->logger->log(sprintf('Nothing removed. ElasticSearch responded with status %s, saying "%s: %s"', $response->status, $response->error->type, $response->error->reason)); - } - } - - /** - * @param string $workspaceName - * @return int - */ - protected function indexWorkspace($workspaceName) - { - $indexedNodes = 0; - $combinations = $this->contentDimensionCombinator->getAllAllowedCombinations(); - if ($combinations === []) { - $indexedNodes += $this->indexWorkspaceWithDimensions($workspaceName); - } else { - foreach ($combinations as $combination) { - $indexedNodes += $this->indexWorkspaceWithDimensions($workspaceName, $combination); - } - } - - return $indexedNodes; - } - - /** - * @param string $workspaceName - * @param array $dimensions - * @return int - */ - protected function indexWorkspaceWithDimensions($workspaceName, array $dimensions = []) - { - $indexedNodes = 0; - $context = $this->contextFactory->create(['workspaceName' => $workspaceName, 'dimensions' => $dimensions]); - $rootNode = $context->getRootNode(); - - $indexedNodes += $this->traverseNodes($rootNode); - - if ($dimensions === []) { - $this->outputLine('Workspace "' . $workspaceName . '" without dimensions done. (Indexed ' . $indexedNodes . ' nodes)'); - } else { - $this->outputLine('Workspace "' . $workspaceName . '" and dimensions "' . json_encode($dimensions) . '" done. (Indexed ' . $indexedNodes . ' nodes)'); - } - - return $indexedNodes; - } - - /** - * @param NodeInterface $currentNode - * @param integer $traversedUntilNow - * @return integer Indexed nodes in this traversal - */ - protected function traverseNodes(NodeInterface $currentNode, $traversedUntilNow = 0) - { - if ($this->limit !== null && $traversedUntilNow > $this->limit) { - return $traversedUntilNow; + $this->logger->log(sprintf('Nothing removed. ElasticSearch responded with status %s, saying "%s"', $response->status, $response->error)); } - - $this->nodeIndexingManager->indexNode($currentNode); - $traversedUntilNow++; - - foreach ($currentNode->getChildNodes() as $childNode) { - $traversedUntilNow = $this->traverseNodes($childNode, $traversedUntilNow); - } - - return $traversedUntilNow; } } diff --git a/Classes/Eel/ElasticSearchQueryBuilder.php b/Classes/Eel/ElasticSearchQueryBuilder.php index 05e94ae2..76b313cc 100644 --- a/Classes/Eel/ElasticSearchQueryBuilder.php +++ b/Classes/Eel/ElasticSearchQueryBuilder.php @@ -651,16 +651,7 @@ public function query(NodeInterface $contextNode) // on indexing, the __parentPath is tokenized to contain ALL parent path parts, // e.g. /foo, /foo/bar/, /foo/bar/baz; to speed up matching.. That's why we use a simple "term" filter here. // http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/query-dsl-term-filter.html - $this->queryFilter('bool', [ - 'should' => [ - [ - 'term' => ['__parentPath' => $contextNode->getPath()] - ], - [ - 'term' => ['__path' => $contextNode->getPath()] - ] - ] - ]); + $this->queryFilter('term', ['__parentPath' => $contextNode->getPath()]); // // http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/query-dsl-terms-filter.html diff --git a/Classes/Indexer/NodeIndexer.php b/Classes/Indexer/NodeIndexer.php new file mode 100644 index 00000000..c1bf743b --- /dev/null +++ b/Classes/Indexer/NodeIndexer.php @@ -0,0 +1,450 @@ +searchClient->getIndexName(); + if (strlen($this->indexNamePostfix) > 0) { + $indexName .= '-' . $this->indexNamePostfix; + } + + return $indexName; + } + + /** + * Set the postfix for the index name + * + * @param string $indexNamePostfix + * @return void + */ + public function setIndexNamePostfix($indexNamePostfix) + { + $this->indexNamePostfix = $indexNamePostfix; + } + + /** + * Return the currently active index to be used for indexing + * + * @return Index + */ + public function getIndex() + { + $index = $this->searchClient->findIndex($this->getIndexName()); + $index->setSettingsKey($this->searchClient->getIndexName()); + + return $index; + } + + /** + * Index this node, and add it to the current bulk request. + * + * @param NodeInterface $node + * @param string $targetWorkspaceName In case this is triggered during publishing, a workspace name will be passed in + * @return void + * @throws \Neos\ContentRepository\Search\Exception\IndexingException + */ + public function indexNode(NodeInterface $node, $targetWorkspaceName = null) + { + $indexer = function (NodeInterface $node, $targetWorkspaceName = null) { + $contextPath = $node->getContextPath(); + + if ($this->settings['indexAllWorkspaces'] === false) { + // we are only supposed to index the live workspace. + // We need to check the workspace at two occasions; checking the + // $targetWorkspaceName and the workspace name of the node's context as fallback + if ($targetWorkspaceName !== null && $targetWorkspaceName !== 'live') { + return; + } + + if ($targetWorkspaceName === null && $node->getContext()->getWorkspaceName() !== 'live') { + return; + } + } + + if ($targetWorkspaceName !== null) { + $contextPath = str_replace($node->getContext()->getWorkspace()->getName(), $targetWorkspaceName, $contextPath); + } + + $documentIdentifier = $this->calculateDocumentIdentifier($node, $targetWorkspaceName); + $nodeType = $node->getNodeType(); + + $mappingType = $this->getIndex()->findType(NodeTypeMappingBuilder::convertNodeTypeNameToMappingName($nodeType)); + + if ($this->bulkProcessing === false) { + // Remove document with the same contextPathHash but different NodeType, required after NodeType change + $this->logger->log(sprintf('NodeIndexer (%s): Search and remove duplicate document for node %s (%s) if needed.', $documentIdentifier, $contextPath, $node->getIdentifier()), LOG_DEBUG, null, 'ElasticSearch (CR)'); + $this->documentDriver->deleteDuplicateDocumentNotMatchingType($this->getIndex(), $documentIdentifier, $node->getNodeType()); + } + + $fulltextIndexOfNode = []; + $nodePropertiesToBeStoredInIndex = $this->extractPropertiesAndFulltext($node, $fulltextIndexOfNode, function ($propertyName) use ($documentIdentifier, $node) { + $this->logger->log(sprintf('NodeIndexer (%s) - Property "%s" not indexed because no configuration found, node type %s.', $documentIdentifier, $propertyName, $node->getNodeType()->getName()), LOG_DEBUG, null, 'ElasticSearch (CR)'); + }); + + $document = new ElasticSearchDocument($mappingType, + $nodePropertiesToBeStoredInIndex, + $documentIdentifier + ); + + $documentData = $document->getData(); + if ($targetWorkspaceName !== null) { + $documentData['__workspace'] = $targetWorkspaceName; + } + + $dimensionCombinations = $node->getContext()->getDimensions(); + if (is_array($dimensionCombinations)) { + $documentData['__dimensionCombinations'] = $dimensionCombinations; + $documentData['__dimensionCombinationHash'] = md5(json_encode($dimensionCombinations)); + } + + if ($this->isFulltextEnabled($node)) { + $this->currentBulkRequest[] = $this->indexerDriver->document($this->getIndexName(), $node, $document, $documentData, $fulltextIndexOfNode, $targetWorkspaceName); + $this->currentBulkRequest[] = $this->indexerDriver->fulltext($node, $fulltextIndexOfNode, $targetWorkspaceName); + } + + $this->logger->log(sprintf('NodeIndexer (%s): Indexed node %s.', $documentIdentifier, $contextPath), LOG_DEBUG, null, 'ElasticSearch (CR)'); + }; + + $handleNode = function (NodeInterface $node, Context $context) use ($targetWorkspaceName, $indexer) { + $nodeFromContext = $context->getNodeByIdentifier($node->getIdentifier()); + if ($nodeFromContext instanceof NodeInterface) { + $indexer($nodeFromContext, $targetWorkspaceName); + } else { + $documentIdentifier = $this->calculateDocumentIdentifier($node, $targetWorkspaceName); + if ($node->isRemoved()) { + $this->removeNode($node, $context->getWorkspaceName()); + $this->logger->log(sprintf('NodeIndexer (%s): Removed node with identifier %s, no longer in workspace %s', $documentIdentifier, $node->getIdentifier(), $context->getWorkspaceName()), LOG_DEBUG, null, 'ElasticSearch (CR)'); + } else { + $this->logger->log(sprintf('NodeIndexer (%s): Could not index node with identifier %s, not found in workspace %s', $documentIdentifier, $node->getIdentifier(), $context->getWorkspaceName()), LOG_DEBUG, null, 'ElasticSearch (CR)'); + } + } + }; + + $workspaceName = $targetWorkspaceName ?: $node->getContext()->getWorkspaceName(); + $dimensionCombinations = $this->contentDimensionCombinator->getAllAllowedCombinations(); + if ($dimensionCombinations !== []) { + foreach ($dimensionCombinations as $combination) { + $context = $this->contextFactory->create(['workspaceName' => $workspaceName, 'dimensions' => $combination, 'invisibleContentShown' => true]); + $handleNode($node, $context); + } + } else { + $context = $this->contextFactory->create(['workspaceName' => $workspaceName, 'invisibleContentShown' => true]); + $handleNode($node, $context); + } + } + + /** + * Returns a stable identifier for the Elasticsearch document representing the node + * + * @param NodeInterface $node + * @param string $targetWorkspaceName + * @return string + */ + protected function calculateDocumentIdentifier(NodeInterface $node, $targetWorkspaceName = null) + { + $contextPath = $node->getContextPath(); + + if ($targetWorkspaceName !== null) { + $contextPath = str_replace($node->getContext()->getWorkspace()->getName(), $targetWorkspaceName, $contextPath); + } + + return sha1($contextPath); + } + + /** + * Schedule node removal into the current bulk request. + * + * @param NodeInterface $node + * @param string $targetWorkspaceName + * @return void + */ + public function removeNode(NodeInterface $node, $targetWorkspaceName = null) + { + if ($this->settings['indexAllWorkspaces'] === false) { + // we are only supposed to index the live workspace. + // We need to check the workspace at two occasions; checking the + // $targetWorkspaceName and the workspace name of the node's context as fallback + if ($targetWorkspaceName !== null && $targetWorkspaceName !== 'live') { + return; + } + + if ($targetWorkspaceName === null && $node->getContext()->getWorkspaceName() !== 'live') { + return; + } + } + + $documentIdentifier = $this->calculateDocumentIdentifier($node, $targetWorkspaceName); + + $this->currentBulkRequest[] = $this->documentDriver->delete($node, $documentIdentifier); + $this->currentBulkRequest[] = $this->indexerDriver->fulltext($node, [], $targetWorkspaceName); + + $this->logger->log(sprintf('NodeIndexer (%s): Removed node %s (%s) from index.', $documentIdentifier, $node->getContextPath(), $node->getIdentifier()), LOG_DEBUG, null, 'ElasticSearch (CR)'); + } + + /** + * Perform the current bulk request + * + * @return void + */ + public function flush() + { + $bulkRequest = array_filter($this->currentBulkRequest); + if (count($bulkRequest) === 0) { + return; + } + + $content = ''; + foreach ($bulkRequest as $bulkRequestTuple) { + $tupleAsJson = ''; + foreach ($bulkRequestTuple as $bulkRequestItem) { + $itemAsJson = json_encode($bulkRequestItem); + if ($itemAsJson === false) { + $this->logger->log('NodeIndexer: Bulk request item could not be encoded as JSON - ' . json_last_error_msg(), LOG_ERR, $bulkRequestItem, 'ElasticSearch (CR)'); + continue 2; + } + $tupleAsJson .= $itemAsJson . chr(10); + } + $content .= $tupleAsJson; + } + + if ($content !== '') { + $response = $this->requestDriver->bulk($this->getIndex(), $content); + foreach ($response as $responseLine) { + if (isset($response['errors']) && $response['errors'] !== false) { + $this->logger->log('NodeIndexer: ' . json_encode($responseLine), LOG_ERR, null, 'ElasticSearch (CR)'); + } + } + } + + $this->currentBulkRequest = []; + } + + /** + * Update the index alias + * + * @return void + * @throws Exception + * @throws ApiException + * @throws \Exception + */ + public function updateIndexAlias() + { + $aliasName = $this->searchClient->getIndexName(); // The alias name is the unprefixed index name + if ($this->getIndexName() === $aliasName) { + throw new Exception('UpdateIndexAlias is only allowed to be called when $this->setIndexNamePostfix has been created.', 1383649061); + } + + if (!$this->getIndex()->exists()) { + throw new Exception('The target index for updateIndexAlias does not exist. This shall never happen.', 1383649125); + } + + $aliasActions = []; + try { + $indexNames = $this->indexDriver->indexesByAlias($aliasName); + + if ($indexNames === []) { + // if there is an actual index with the name we want to use as alias, remove it now + $this->indexDriver->deleteIndex($aliasName); + } else { + foreach ($indexNames as $indexName) { + $aliasActions[] = [ + 'remove' => [ + 'index' => $indexName, + 'alias' => $aliasName + ] + ]; + } + } + } catch (ApiException $exception) { + // in case of 404, do not throw an error... + if ($exception->getResponse()->getStatusCode() !== 404) { + throw $exception; + } + } + + $aliasActions[] = [ + 'add' => [ + 'index' => $this->getIndexName(), + 'alias' => $aliasName + ] + ]; + + $this->indexDriver->aliasActions($aliasActions); + } + + /** + * Remove old indices which are not active anymore (remember, each bulk index creates a new index from scratch, + * making the "old" index a stale one). + * + * @return array a list of index names which were removed + */ + public function removeOldIndices() + { + $aliasName = $this->searchClient->getIndexName(); // The alias name is the unprefixed index name + + $currentlyLiveIndices = $this->indexDriver->indexesByAlias($aliasName); + + $indexStatus = $this->systemDriver->status(); + $allIndices = array_keys($indexStatus['indices']); + + $indicesToBeRemoved = []; + + foreach ($allIndices as $indexName) { + if (strpos($indexName, $aliasName . '-') !== 0) { + // filter out all indices not starting with the alias-name, as they are unrelated to our application + continue; + } + + if (array_search($indexName, $currentlyLiveIndices) !== false) { + // skip the currently live index names from deletion + continue; + } + + $indicesToBeRemoved[] = $indexName; + } + + array_map(function ($index) { + $this->indexDriver->deleteIndex($index); + }, $indicesToBeRemoved); + + return $indicesToBeRemoved; + } + + /** + * Perform indexing without checking about duplication document + * + * This is used during bulk indexing to improve performance + * + * @param callable $callback + * @throws \Exception + */ + public function withBulkProcessing(callable $callback) + { + $bulkProcessing = $this->bulkProcessing; + $this->bulkProcessing = true; + try { + /** @noinspection PhpUndefinedMethodInspection */ + $callback->__invoke(); + } catch (\Exception $exception) { + $this->bulkProcessing = $bulkProcessing; + throw $exception; + } + $this->bulkProcessing = $bulkProcessing; + } +} diff --git a/Configuration/NodeTypes.yaml b/Configuration/NodeTypes.yaml index 5ea3d097..5f0abc96 100644 --- a/Configuration/NodeTypes.yaml +++ b/Configuration/NodeTypes.yaml @@ -171,19 +171,19 @@ 'Neos.NodeTypes:Text': properties: - text: + 'text': search: fulltextExtractor: '${Indexing.extractHtmlTags(value)}' 'Neos.NodeTypes:Headline': properties: - title: + 'title': search: fulltextExtractor: '${Indexing.extractHtmlTags(value)}' 'Neos.NodeTypes:TextWithImage': properties: - source: + 'text': search: fulltextExtractor: '${Indexing.extractHtmlTags(value)}' diff --git a/Documentation/ElasticConfiguration-1.2.md b/Documentation/ElasticConfiguration-1.2.md new file mode 100644 index 00000000..448ed0cb --- /dev/null +++ b/Documentation/ElasticConfiguration-1.2.md @@ -0,0 +1,15 @@ +# Needed Configuration for Elasticsearch 1.2.x + + +If you are using Elasticsearch version 1.2 you have also to install groovy as a plugin. To install the plugin just run +the following command in the root folder of your elastic: + +``` +bin/plugin -install elasticsearch/elasticsearch-lang-groovy/2.2.0. +``` + +``` +script.disable_dynamic: false +script.default_lang: groovy + +``` diff --git a/Documentation/ElasticConfiguration-1.3.md b/Documentation/ElasticConfiguration-1.3.md new file mode 100644 index 00000000..eafb86c2 --- /dev/null +++ b/Documentation/ElasticConfiguration-1.3.md @@ -0,0 +1,21 @@ +# Needed Configuration in configuration.yml for Elasticsearch 1.3.x + +``` +# The following settings are absolutely required for the CR adaptor to work +script.groovy.sandbox.class_whitelist: java.util.LinkedHashMap +script.groovy.sandbox.receiver_whitelist: java.util.Iterator, java.lang.Object, java.util.Map, java.util.Map$Entry + +# the following settings secure your cluster +cluster.name: [PUT_YOUR_CUSTOM_NAME_HERE] +network.host: 127.0.0.1 + +# the following settings are well-suited for smaller Elasticsearch instances (e.g. as long as you can stay on one host) +index.number_of_shards: 1 +index.number_of_replicas: 0 +``` + +You can get further information about this topic here: + +http://www.elasticsearch.org/blog/elasticsearch-1-3-0-released/ +http://www.elasticsearch.org/blog/scripting-security/ +http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/modules-scripting.html diff --git a/Documentation/ElasticConfiguration-1.4-1.5.md b/Documentation/ElasticConfiguration-1.4-1.5.md new file mode 100644 index 00000000..86042f1d --- /dev/null +++ b/Documentation/ElasticConfiguration-1.4-1.5.md @@ -0,0 +1,17 @@ +# Needed Configuration in configuration.yml for Elasticsearch 1.4.x and 1.5.x + +``` +# The following settings are absolutely required for the CR adaptor to work +script.disable_dynamic: sandbox +script.groovy.sandbox.class_whitelist: java.util.LinkedHashMap +script.groovy.sandbox.receiver_whitelist: java.util.Iterator, java.lang.Object, java.util.Map, java.util.Map$Entry +script.groovy.sandbox.enabled: true + +# the following settings secure your cluster +cluster.name: [PUT_YOUR_CUSTOM_NAME_HERE] +network.host: 127.0.0.1 + +# the following settings are well-suited for smaller Elasticsearch instances (e.g. as long as you can stay on one host) +index.number_of_shards: 1 +index.number_of_replicas: 0 +``` diff --git a/Documentation/ElasticConfiguration-2.0-2.4.md b/Documentation/ElasticConfiguration-2.0-2.4.md deleted file mode 100644 index b0fb0e2d..00000000 --- a/Documentation/ElasticConfiguration-2.0-2.4.md +++ /dev/null @@ -1,21 +0,0 @@ -# Needed Configuration in configuration.yml for Elasticsearch 2.0.x and 2.4.x - -``` -# The following settings are absolutely required for the CR adaptor to work -script.engine.groovy.inline.update: true - -# the following settings are well-suited for smaller Elasticsearch instances (i.e. as long as you can stay on one host) -index.number_of_shards: 1 -index.number_of_replicas: 0 -``` - -# Java Security Manager Policy file `.java.policy` - -We need to allow access to some classes in the groovy script, so we need to modify (or create) the `~/.java.policy` for -the user that is running Elasticsearch (`elasticsearch` by default). See [the Elasticsearch Reference](https://www.elastic.co/guide/en/elasticsearch/reference/2.3/modules-scripting-security.html#_customising_the_classloader_whitelist) for more info. - -``` -grant { - permission org.elasticsearch.script.ClassPermission "java.util.*"; -}; -``` diff --git a/Tests/Functional/Eel/ElasticSearchQueryTest.php b/Tests/Functional/Eel/ElasticSearchQueryTest.php index 00d083aa..8426c327 100644 --- a/Tests/Functional/Eel/ElasticSearchQueryTest.php +++ b/Tests/Functional/Eel/ElasticSearchQueryTest.php @@ -140,7 +140,7 @@ public function filterByNodeType() ->log($this->getLogMessagePrefix(__METHOD__)) ->nodeType('Neos.NodeTypes:Page') ->count(); - $this->assertEquals(4, $resultCount); + $this->assertEquals(3, $resultCount); } /** @@ -166,7 +166,7 @@ public function limitDoesNotImpactCount() ->limit(1); $resultCount = $query->count(); - $this->assertEquals(4, $resultCount, 'Asserting the count query returns the total count.'); + $this->assertEquals(3, $resultCount, 'Asserting the count query returns the total count.'); } /** @@ -198,7 +198,7 @@ public function fieldBasedAggregations() $this->assertArrayHasKey($aggregationTitle, $result); - $this->assertCount(3, $result[$aggregationTitle]['buckets']); + $this->assertCount(2, $result[$aggregationTitle]['buckets']); $expectedChickenBucket = [ 'key' => 'chicken', @@ -268,13 +268,13 @@ public function nodesWillBeSortedDesc() /** @var QueryResultInterface $result $node */ $this->assertInstanceOf(QueryResultInterface::class, $result); - $this->assertCount(4, $result, 'The result should have 4 items'); - $this->assertEquals(4, $result->count(), 'Count should be 4'); + $this->assertCount(3, $result, 'The result should have 3 items'); + $this->assertEquals(3, $result->count(), 'Count should be 3'); $node = $result->getFirst(); $this->assertInstanceOf(NodeInterface::class, $node); - $this->assertEquals('welcome', $node->getProperty('title'), 'Asserting a desc sort order by property title'); + $this->assertEquals('egg', $node->getProperty('title'), 'Asserting a desc sort order by property title'); } /** diff --git a/Tests/Unit/Eel/ElasticSearchQueryBuilderTest.php b/Tests/Unit/Eel/ElasticSearchQueryBuilderTest.php index 8d32ee1d..d61ea368 100644 --- a/Tests/Unit/Eel/ElasticSearchQueryBuilderTest.php +++ b/Tests/Unit/Eel/ElasticSearchQueryBuilderTest.php @@ -111,19 +111,8 @@ public function basicRequestStructureTakesContextNodeIntoAccount() 'bool' => [ 'must' => [ 0 => [ - 'bool' => [ - 'should' => [ - 0 => [ - 'term' => [ - '__parentPath' => '/foo/bar' - ] - ], - 1 => [ - 'term' => [ - '__path' => '/foo/bar' - ] - ] - ] + 'term' => [ + '__parentPath' => '/foo/bar' ] ], 1 => [