├── Classes ├── AssetExtraction │ └── NullAssetExtractor.php ├── Command │ └── NodeIndexCommandController.php ├── Indexer │ └── NodeIndexer.php ├── NotImplementedException.php └── Search │ ├── AbstractQueryBuilder.php │ ├── MysqlQueryBuilder.php │ └── SqLiteQueryBuilder.php ├── Configuration ├── NodeTypes.yaml ├── Objects.yaml └── Settings.yaml ├── LICENSE ├── README.md ├── Resources └── Private │ ├── Fusion │ └── Root.fusion │ └── Templates │ └── NodeTypes │ └── Search.html └── composer.json /Classes/AssetExtraction/NullAssetExtractor.php: -------------------------------------------------------------------------------- 1 | contentRepositoryRegistry->get($contentRepositoryId); 59 | 60 | if ($workspace === null) { 61 | foreach ($contentRepository->findWorkspaces() as $workspaceInstance) { 62 | $this->indexWorkspace($contentRepositoryId, $workspaceInstance->workspaceName); 63 | } 64 | } else { 65 | $workspaceName = WorkspaceName::fromString($workspace); 66 | $this->indexWorkspace($contentRepositoryId, $workspaceName); 67 | } 68 | $this->outputLine('Finished indexing.'); 69 | } 70 | 71 | /** 72 | * @param string $workspaceName 73 | * @throws Exception 74 | */ 75 | protected function indexWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): void 76 | { 77 | $dimensionSpacePoints = $this->nodeIndexer->calculateDimensionCombinations($contentRepositoryId); 78 | if ($dimensionSpacePoints->isEmpty()) { 79 | $dimensionSpacePoints = DimensionSpacePointSet::fromArray([DimensionSpacePoint::createWithoutDimensions()]); 80 | } 81 | 82 | foreach ($dimensionSpacePoints as $dimensionSpacePoint) { 83 | $indexedNodes = $this->indexWorkspaceInDimension($contentRepositoryId, $workspaceName, $dimensionSpacePoint); 84 | $this->outputLine('Workspace "' . $workspaceName . '" and dimensions "' . json_encode($dimensionSpacePoint) . '" done. (Indexed ' . $indexedNodes . ' nodes)'); 85 | } 86 | } 87 | 88 | /** 89 | * @param Node $currentNode 90 | * @throws Exception 91 | */ 92 | protected function indexWorkspaceInDimension(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, DimensionSpacePoint $dimensionSpacePoint): int 93 | { 94 | $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); 95 | $contentGraph = $contentRepository->getContentGraph($workspaceName); 96 | 97 | $rootNodeAggregate = $contentGraph->findRootNodeAggregateByType(NodeTypeNameFactory::forSites()); 98 | $subgraph = $contentGraph->getSubgraph($dimensionSpacePoint, NeosVisibilityConstraints::excludeRemoved()); 99 | 100 | $rootNode = $subgraph->findNodeById($rootNodeAggregate->nodeAggregateId); 101 | $indexedNodes = 0; 102 | 103 | $this->nodeIndexer->indexNode($rootNode, null, false); 104 | $indexedNodes++; 105 | 106 | foreach ($subgraph->findDescendantNodes($rootNode->aggregateId, FindDescendantNodesFilter::create()) as $descendantNode) { 107 | try { 108 | $this->nodeIndexer->indexNode($descendantNode, null, false); 109 | $indexedNodes++; 110 | } catch (IndexingException|EelException $exception) { 111 | throw new Exception(sprintf('Error during indexing of node %s', (string)$descendantNode->aggregateId), 1579170291, $exception); 112 | }; 113 | } 114 | 115 | return $indexedNodes; 116 | } 117 | 118 | /** 119 | * Clears the node index from all data. 120 | * 121 | * @param boolean $confirmation Should be set to true for something to actually happen. 122 | */ 123 | public function flushCommand(bool $confirmation = false): void 124 | { 125 | if ($confirmation) { 126 | $this->indexClient->flush(); 127 | $this->outputLine('The node index was flushed.'); 128 | } else { 129 | $this->outputLine('The node index was NOT flushed, confirmation option was missing.'); 130 | } 131 | } 132 | 133 | /** 134 | * Optimize the search index. Depends on the underlaying technology what will happen. 135 | * For sqlite the VACUUM command is sent to rebuild the full database file. 136 | */ 137 | public function optimizeCommand(): void 138 | { 139 | $this->outputLine('Starting optimization, do not interrupt or your index may be corrupted...'); 140 | $this->indexClient->optimize(); 141 | $this->outputLine('Optimization finished.'); 142 | } 143 | 144 | /** 145 | * Utility to check the content of the index. 146 | * 147 | * @param string $queryString raw SQL to send to the index 148 | */ 149 | public function findCommand(string $queryString): void 150 | { 151 | $result = $this->indexClient->executeStatement($queryString, []); 152 | $this->output->outputTable($result); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /Classes/Indexer/NodeIndexer.php: -------------------------------------------------------------------------------- 1 | indexClient; 75 | } 76 | 77 | /** 78 | * index this node, and add it to the current bulk request. 79 | * 80 | * @param Node $node 81 | * @param string $targetWorkspaceName 82 | * @param boolean $indexVariants 83 | * @return void 84 | * @throws IndexingException 85 | * @throws Exception 86 | */ 87 | public function indexNode(Node $node, $targetWorkspaceName = null, $indexVariants = true): void 88 | { 89 | if ($indexVariants === true) { 90 | $this->indexAllNodeVariants($node); 91 | return; 92 | } 93 | 94 | $identifier = $this->generateUniqueNodeIdentifier($node); 95 | 96 | $fulltextData = []; 97 | 98 | if (isset($this->indexedNodeData[$identifier]) && ($properties = $this->indexClient->findOneByIdentifier($identifier)) !== false) { 99 | unset($properties['__identifier__']); 100 | $properties['__workspace'] .= ', #' . ($targetWorkspaceName ?? $node->workspaceName) . '#'; 101 | if (array_key_exists('__dimensionshash', $properties)) { 102 | $properties['__dimensionshash'] .= ', #' . $node->dimensionSpacePoint->hash . '#'; 103 | } else { 104 | $properties['__dimensionshash'] = '#' . $node->dimensionSpacePoint->hash . '#'; 105 | } 106 | 107 | $this->indexClient->insertOrUpdatePropertiesToIndex($properties, $identifier); 108 | } else { 109 | $nodePropertiesToBeStoredInIndex = $this->extractPropertiesAndFulltext($node, $fulltextData); 110 | if (count($fulltextData) !== 0) { 111 | $this->addFulltextToRoot($node, $fulltextData); 112 | } 113 | 114 | $nodePropertiesToBeStoredInIndex = $this->postProcess($nodePropertiesToBeStoredInIndex); 115 | $this->indexClient->indexData($identifier, $nodePropertiesToBeStoredInIndex, $fulltextData); 116 | $this->indexedNodeData[$identifier] = $identifier; 117 | } 118 | } 119 | 120 | /** 121 | * @param Node $node 122 | * @param WorkspaceName|null $targetWorkspaceName 123 | * @return void 124 | */ 125 | public function removeNode(Node $node, ?WorkspaceName $targetWorkspaceName = null): void 126 | { 127 | $identifier = $this->generateUniqueNodeIdentifier($node); 128 | $this->indexClient->removeData($identifier); 129 | } 130 | 131 | /** 132 | * @return void 133 | */ 134 | public function flush(): void 135 | { 136 | $this->indexedNodeData = []; 137 | } 138 | 139 | /** 140 | * @param Node $node 141 | * @return void 142 | * @throws \Exception 143 | */ 144 | protected function indexAllNodeVariants(Node $node): void 145 | { 146 | $aggregateId = $node->aggregateId; 147 | 148 | $allIndexedVariants = $this->indexClient->executeStatement( 149 | $this->queryBuilder->getFindIdentifiersByNodeIdentifierQuery('identifier'), 150 | [':identifier' => $aggregateId->value] 151 | ); 152 | foreach ($allIndexedVariants as $nodeVariant) { 153 | $this->indexClient->removeData($nodeVariant['__identifier__']); 154 | } 155 | 156 | foreach ($this->contentRepositoryRegistry->get($node->contentRepositoryId)->findWorkspaces() as $workspace) { 157 | $this->indexNodeInWorkspace($node->contentRepositoryId, $aggregateId, $workspace->workspaceName); 158 | } 159 | } 160 | 161 | /** 162 | * @param string $aggregateId 163 | * @param string $workspaceName 164 | * @throws \Exception 165 | */ 166 | protected function indexNodeInWorkspace(ContentRepositoryId $contentRepositoryId, NodeAggregateId $aggregateId, WorkspaceName $workspaceName): void 167 | { 168 | $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); 169 | 170 | $indexer = $this; 171 | $this->securityContext->withoutAuthorizationChecks(static function () use ($indexer, $contentRepository, $aggregateId, $workspaceName) { 172 | $dimensionSpacePoints = $indexer->calculateDimensionCombinations($contentRepository->id); 173 | if ($dimensionSpacePoints->isEmpty()) { 174 | $dimensionSpacePoints = DimensionSpacePointSet::fromArray([DimensionSpacePoint::createWithoutDimensions()]); 175 | 176 | } 177 | foreach ($dimensionSpacePoints as $dimensionSpacePoint) { 178 | $subgraph = $contentRepository->getContentSubgraph($workspaceName, $dimensionSpacePoint); 179 | 180 | $node = $subgraph->findNodeById($aggregateId); 181 | if ($node !== null) { 182 | $indexer->indexNode($node, null, false); 183 | } 184 | } 185 | }); 186 | } 187 | 188 | /** 189 | * @param Node $node 190 | * @param array $fulltext 191 | */ 192 | protected function addFulltextToRoot(Node $node, array $fulltext): void 193 | { 194 | $fulltextRoot = $this->findFulltextRoot($node); 195 | if ($fulltextRoot !== null) { 196 | $identifier = $this->generateUniqueNodeIdentifier($fulltextRoot); 197 | $this->indexClient->addToFulltext($fulltext, $identifier); 198 | } 199 | } 200 | 201 | /** 202 | * @param Node $node 203 | * @return Node 204 | */ 205 | protected function findFulltextRoot(Node $node): ?Node 206 | { 207 | $fulltextRootNodeTypeNames = $this->getFulltextRootNodeTypeNames($node->contentRepositoryId); 208 | 209 | if (in_array($node->nodeTypeName, iterator_to_array($fulltextRootNodeTypeNames->getIterator()), true)) { 210 | return null; 211 | } 212 | 213 | try { 214 | $subgraph = $this->contentRepositoryRegistry->subgraphForNode($node); 215 | return $subgraph->findClosestNode($node->aggregateId, FindClosestNodeFilter::create( 216 | NodeTypeCriteria::createWithAllowedNodeTypeNames($fulltextRootNodeTypeNames) 217 | )); 218 | 219 | } catch (\Exception) { 220 | return null; 221 | } 222 | } 223 | 224 | /** 225 | * Generate identifier for index entry based on node identifier and context 226 | * 227 | * @param Node $node 228 | * @return string 229 | */ 230 | protected function generateUniqueNodeIdentifier(Node $node): string 231 | { 232 | return sha1(NodeAddress::fromNode($node)->toJson()); 233 | } 234 | 235 | /** 236 | * @param array $nodePropertiesToBeStoredInIndex 237 | * @return array 238 | */ 239 | protected function postProcess(array $nodePropertiesToBeStoredInIndex): array 240 | { 241 | foreach ($nodePropertiesToBeStoredInIndex as $propertyName => $propertyValue) { 242 | if (is_array($propertyValue)) { 243 | $nodePropertiesToBeStoredInIndex[$propertyName] = Yaml::dump($propertyValue); 244 | } 245 | } 246 | 247 | return $nodePropertiesToBeStoredInIndex; 248 | } 249 | 250 | /** 251 | * @return array 252 | */ 253 | public function calculateDimensionCombinations(ContentRepositoryId $contentRepositoryId): DimensionSpacePointSet 254 | { 255 | $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); 256 | return $contentRepository->getVariationGraph()->getDimensionSpacePoints(); 257 | } 258 | 259 | /** 260 | * @param Node $node 261 | * @return bool 262 | */ 263 | private function getFulltextRootNodeTypeNames(ContentRepositoryId $contentRepositoryId): NodeTypeNames 264 | { 265 | if (!isset($this->fulltextRootNodeTypes[$contentRepositoryId->value])) { 266 | $nodeTypeNames = []; 267 | $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); 268 | foreach ($contentRepository->getNodeTypeManager()->getNodeTypes() as $nodeType) { 269 | $searchSettingsForNodeType = $nodeType->getConfiguration('search'); 270 | if ( 271 | is_array($searchSettingsForNodeType) && isset($searchSettingsForNodeType['fulltext']['isRoot']) 272 | && $searchSettingsForNodeType['fulltext']['isRoot'] === true 273 | ) { 274 | $nodeTypeNames[] = $nodeType->name; 275 | } 276 | } 277 | $this->fulltextRootNodeTypes[$contentRepositoryId->value] = NodeTypeNames::fromArray($nodeTypeNames); 278 | } 279 | 280 | return $this->fulltextRootNodeTypes[$contentRepositoryId->value]; 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /Classes/NotImplementedException.php: -------------------------------------------------------------------------------- 1 | getSimpleSearchQueryBuilder()->sortDesc($propertyName); 56 | return $this; 57 | } 58 | 59 | public function sortAsc(string $propertyName): QueryBuilderInterface 60 | { 61 | $this->getSimpleSearchQueryBuilder()->sortAsc($propertyName); 62 | return $this; 63 | } 64 | 65 | public function limit($limit): QueryBuilderInterface 66 | { 67 | $this->getSimpleSearchQueryBuilder()->limit($limit); 68 | return $this; 69 | } 70 | 71 | public function from($from): QueryBuilderInterface 72 | { 73 | $this->getSimpleSearchQueryBuilder()->from($from); 74 | return $this; 75 | } 76 | 77 | public function fulltext(string $searchWord, array $options = []): QueryBuilderInterface 78 | { 79 | $this->getSimpleSearchQueryBuilder()->fulltext($searchWord); 80 | return $this; 81 | } 82 | 83 | /** 84 | * @param Node $contextNode 85 | * @return MysqlQueryBuilder 86 | * @throws IllegalObjectTypeException 87 | */ 88 | public function query(Node $contextNode): QueryBuilderInterface 89 | { 90 | $nodeAggregateIdPath = $this->getNodeAggregateIdPath($contextNode); 91 | 92 | $this->getSimpleSearchQueryBuilder()->customCondition("(__parentPath LIKE '%#" . $nodeAggregateIdPath->serializeToString() . "#%' OR __path LIKE '" . $nodeAggregateIdPath->serializeToString() . "')"); 93 | $this->getSimpleSearchQueryBuilder()->like('__path', $nodeAggregateIdPath->serializeToString()); 94 | $this->getSimpleSearchQueryBuilder()->like('__workspace', "#" . $contextNode->workspaceName->value . "#"); 95 | $this->getSimpleSearchQueryBuilder()->like('__dimensionshash', "#" . $contextNode->dimensionSpacePoint->hash . "#"); 96 | $this->contextNode = $contextNode; 97 | 98 | return $this; 99 | } 100 | 101 | /** 102 | * @param string $nodeIdentifierPlaceholder 103 | * @return string 104 | */ 105 | abstract public function getFindIdentifiersByNodeIdentifierQuery(string $nodeIdentifierPlaceholder); 106 | 107 | /** 108 | * HIGH-LEVEL API 109 | */ 110 | 111 | /** 112 | * Filter by node type, taking inheritance into account. 113 | * 114 | * @param string $nodeType the node type to filter for 115 | * @return QueryBuilderInterface 116 | */ 117 | public function nodeType(string $nodeType): QueryBuilderInterface 118 | { 119 | $this->getSimpleSearchQueryBuilder()->like('__typeAndSuperTypes', "#" . $nodeType . "#"); 120 | 121 | return $this; 122 | } 123 | 124 | /** 125 | * add an exact-match query for a given property 126 | * 127 | * @param string $propertyName 128 | * @param mixed $propertyValue 129 | * @return QueryBuilderInterface 130 | */ 131 | public function exactMatch(string $propertyName, $propertyValue): QueryBuilderInterface 132 | { 133 | if ($propertyValue instanceof Node) { 134 | $propertyValue = $propertyValue->aggregateId->value; 135 | } 136 | 137 | $this->getSimpleSearchQueryBuilder()->exactMatch($propertyName, $propertyValue); 138 | return $this; 139 | } 140 | 141 | /** 142 | * add an like query for a given property 143 | * 144 | * @param string $propertyName 145 | * @param mixed $propertyValue 146 | * @return QueryBuilderInterface 147 | */ 148 | public function like(string $propertyName, $propertyValue): QueryBuilderInterface 149 | { 150 | if ($propertyValue instanceof Node) { 151 | $propertyValue = $propertyValue->aggregateId->value; 152 | } 153 | 154 | $this->getSimpleSearchQueryBuilder()->like($propertyName, $propertyValue); 155 | return $this; 156 | } 157 | 158 | /** 159 | * add a greater than query for a given property 160 | * 161 | * @param string $propertyName 162 | * @param mixed $propertyValue 163 | * @return QueryBuilderInterface 164 | */ 165 | public function greaterThan($propertyName, $propertyValue) 166 | { 167 | if ($propertyValue instanceof Node) { 168 | $propertyValue = $propertyValue->aggregateId->value; 169 | } 170 | 171 | $this->getSimpleSearchQueryBuilder()->greaterThan($propertyName, $propertyValue); 172 | return $this; 173 | } 174 | 175 | /** 176 | * add a greater than or equal query for a given property 177 | * 178 | * @param string $propertyName 179 | * @param mixed $propertyValue 180 | * @return QueryBuilderInterface 181 | */ 182 | public function greaterThanOrEqual($propertyName, $propertyValue) 183 | { 184 | if ($propertyValue instanceof Node) { 185 | $propertyValue = $propertyValue->aggregateId->value; 186 | } 187 | 188 | $this->getSimpleSearchQueryBuilder()->greaterThanOrEqual($propertyName, $propertyValue); 189 | return $this; 190 | } 191 | 192 | /** 193 | * add a less than query for a given property 194 | * 195 | * @param string $propertyName 196 | * @param mixed $propertyValue 197 | * @return QueryBuilderInterface 198 | */ 199 | public function lessThan($propertyName, $propertyValue) 200 | { 201 | if ($propertyValue instanceof Node) { 202 | $propertyValue = $propertyValue->aggregateId; 203 | } 204 | 205 | $this->getSimpleSearchQueryBuilder()->lessThan($propertyName, $propertyValue); 206 | return $this; 207 | } 208 | 209 | /** 210 | * add a less than query for a given property 211 | * 212 | * @param string $propertyName 213 | * @param mixed $propertyValue 214 | * @return QueryBuilderInterface 215 | */ 216 | public function lessThanOrEqual($propertyName, $propertyValue) 217 | { 218 | if ($propertyValue instanceof Node) { 219 | $propertyValue = $propertyValue->aggregateId->value; 220 | } 221 | 222 | $this->getSimpleSearchQueryBuilder()->lessThanOrEqual($propertyName, $propertyValue); 223 | return $this; 224 | } 225 | 226 | /** 227 | * Execute the query and return the list of nodes as result 228 | * 229 | * @return array 230 | */ 231 | public function execute(): \Traversable 232 | { 233 | $timeBefore = microtime(true); 234 | $result = $this->getSimpleSearchQueryBuilder()->execute(); 235 | $timeAfterwards = microtime(true); 236 | 237 | if ($this->queryLogEnabled === true) { 238 | $this->logger->debug('Query Log (' . $this->logMessage . '): -- execution time: ' . (($timeAfterwards - $timeBefore) * 1000) . ' ms -- Total Results: ' . count($result)); 239 | } 240 | 241 | $nodes = []; 242 | foreach ($result as $hit) { 243 | $nodeAggregateId = NodeAggregateId::fromString($hit['__identifier']); 244 | $node = $this->contentRepositoryRegistry->subgraphForNode($this->contextNode)->findNodeById($nodeAggregateId); 245 | if ($node instanceof Node) { 246 | $nodes[$node->aggregateId->value] = $node; 247 | } 248 | } 249 | 250 | return (new \ArrayObject(array_values($nodes)))->getIterator(); 251 | } 252 | 253 | /** 254 | * Log the current request for debugging after it has been executed. 255 | * 256 | * @param string $message an optional message to identify the log entry 257 | * @return AbstractQueryBuilder 258 | */ 259 | public function log($message = null) 260 | { 261 | $this->queryLogEnabled = true; 262 | $this->logMessage = $message; 263 | 264 | return $this; 265 | } 266 | 267 | /** 268 | * Return the total number of hits for the query. 269 | * 270 | * @return integer 271 | */ 272 | public function count(): int 273 | { 274 | $timeBefore = microtime(true); 275 | $count = $this->getSimpleSearchQueryBuilder()->count(); 276 | $timeAfterwards = microtime(true); 277 | 278 | if ($this->queryLogEnabled === true) { 279 | $this->logger->debug('Query Log (' . $this->logMessage . '): -- execution time: ' . (($timeAfterwards - $timeBefore) * 1000) . ' ms -- Total Results: ' . $count); 280 | } 281 | 282 | return $count; 283 | } 284 | 285 | private function getNodeAggregateIdPath(Node $contextNode): NodeAggregateIdPath 286 | { 287 | $subgraph = $this->contentRepositoryRegistry->subgraphForNode($contextNode); 288 | $ancestors = $subgraph->findAncestorNodes( 289 | $contextNode->aggregateId, 290 | FindAncestorNodesFilter::create() 291 | )->reverse(); 292 | $nodeAggregateIdPath = NodeAggregateIdPath::fromNodes($ancestors); 293 | return $nodeAggregateIdPath; 294 | } 295 | 296 | /** 297 | * @param string $methodName 298 | * @return boolean 299 | */ 300 | public function allowsCallOfMethod($methodName) 301 | { 302 | if ($methodName !== 'getFindIdentifiersByNodeIdentifierQuery') { 303 | // query must be called first to establish a context and starting point. 304 | return !($this->contextNode === null && $methodName !== 'query'); 305 | } 306 | return false; 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /Classes/Search/MysqlQueryBuilder.php: -------------------------------------------------------------------------------- 1 | mysqlQueryBuilder; 24 | } 25 | 26 | /** 27 | * @param string $nodeIdentifierPlaceholder 28 | * @return string 29 | */ 30 | public function getFindIdentifiersByNodeIdentifierQuery(string $nodeIdentifierPlaceholder): string 31 | { 32 | return 'SELECT "__identifier__" FROM "fulltext_objects" WHERE "__identifier" = :' . $nodeIdentifierPlaceholder; 33 | } 34 | 35 | public function fulltextMatchResult($searchword, $resultTokens = 200, $ellipsis = '...', $beginModifier = '', $endModifier = ''): string 36 | { 37 | return $this->mysqlQueryBuilder->fulltextMatchResult($searchword, $resultTokens, $ellipsis, $beginModifier, $endModifier); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Classes/Search/SqLiteQueryBuilder.php: -------------------------------------------------------------------------------- 1 | sqLiteQueryBuilder; 25 | } 26 | 27 | /** 28 | * @param string $nodeIdentifierPlaceholder 29 | * @return string 30 | */ 31 | public function getFindIdentifiersByNodeIdentifierQuery(string $nodeIdentifierPlaceholder): string 32 | { 33 | return 'SELECT __identifier__ FROM objects WHERE __identifier = :' . $nodeIdentifierPlaceholder; 34 | } 35 | 36 | public function fulltextMatchResult(string $searchword, int $resultTokens = 60, string $ellipsis = '...', string $beginModifier = '', string $endModifier = ''): string 37 | { 38 | return $this->sqLiteQueryBuilder->fulltextMatchResult($searchword, $resultTokens, $ellipsis, $beginModifier, $endModifier); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Configuration/NodeTypes.yaml: -------------------------------------------------------------------------------- 1 | 2 | 'Neos.Neos:Node': &node 3 | properties: 4 | '__identifier': 5 | search: 6 | indexing: "${node.aggregateId}" 7 | 8 | '__workspace': 9 | search: 10 | indexing: "${'#' + node.workspaceName + '#'}" 11 | 12 | '__path': 13 | search: 14 | indexing: "${Indexing.aggregateIdPath(node)}" 15 | 16 | '__parentPath': 17 | search: 18 | indexing: "${'#' + Array.join(Array.pop(Indexing.buildAllPathPrefixes(Indexing.aggregateIdPath(node))), '#') + '#'}" 19 | 20 | '__type': 21 | search: 22 | indexing: "${node.nodeTypeName}" 23 | # we index the node type INCLUDING ALL SUPERTYPES 24 | '__typeAndSupertypes': 25 | search: 26 | indexing: "${'#' + Array.join(Indexing.extractNodeTypeNamesAndSupertypes(node), '#') + '#'}" 27 | '__dimensionshash': 28 | search: 29 | indexing: "${'#' + node.dimensionSpacePoint.hash + '#'}" 30 | 31 | 'unstructured': *node 32 | 33 | 'Neos.Neos:Hidable': 34 | properties: 35 | 'neos_hidden': 36 | search: 37 | indexing: '${Neos.Node.isDisabled(node)}' 38 | 39 | 'Neos.NodeTypes:Text': 40 | search: 41 | fulltext: 42 | enable: true 43 | properties: 44 | text: 45 | search: 46 | fulltextExtractor: "${Indexing.extractHtmlTags(value)}" 47 | 48 | 'Neos.NodeTypes:Headline': 49 | search: 50 | fulltext: 51 | enable: true 52 | properties: 53 | title: 54 | search: 55 | fulltextExtractor: "${Indexing.extractHtmlTags(value)}" 56 | 57 | 'Neos.Neos:Document': 58 | search: 59 | fulltext: 60 | isRoot: true 61 | enable: true 62 | properties: 63 | title: 64 | search: 65 | fulltextExtractor: "${Indexing.extractHtmlTags(value)}" 66 | 67 | 'Flowpack.SimpleSearch.ContentRepositoryAdaptor:Search': 68 | superTypes: 69 | 'Neos.Neos:Content': true 70 | ui: 71 | label: 'Search' 72 | icon: 'icon-search' 73 | -------------------------------------------------------------------------------- /Configuration/Objects.yaml: -------------------------------------------------------------------------------- 1 | Flowpack\SimpleSearch\ContentRepositoryAdaptor\Command\NodeIndexCommandController: 2 | properties: 3 | indexClient: 4 | object: 5 | factoryObjectName: Flowpack\SimpleSearch\Factory\IndexFactory 6 | factoryMethodName: create 7 | arguments: 8 | 1: 9 | value: 'Neos_CR' 10 | 11 | Neos\ContentRepository\Search\Search\QueryBuilderInterface: 12 | className: 'Flowpack\SimpleSearch\ContentRepositoryAdaptor\Search\SqLiteQueryBuilder' 13 | 14 | Neos\ContentRepository\Search\Indexer\NodeIndexerInterface: 15 | className: 'Flowpack\SimpleSearch\ContentRepositoryAdaptor\Indexer\NodeIndexer' 16 | 17 | Flowpack\SimpleSearch\Search\SqLiteQueryBuilder: 18 | properties: 19 | indexClient: 20 | object: 21 | factoryObjectName: Flowpack\SimpleSearch\Factory\IndexFactory 22 | factoryMethodName: create 23 | arguments: 24 | 1: 25 | value: 'Neos_CR' 26 | 27 | Flowpack\SimpleSearch\Search\MysqlQueryBuilder: 28 | properties: 29 | indexClient: 30 | object: 31 | factoryObjectName: Flowpack\SimpleSearch\Factory\IndexFactory 32 | factoryMethodName: create 33 | arguments: 34 | 1: 35 | value: 'Neos_CR' 36 | 37 | Flowpack\SimpleSearch\ContentRepositoryAdaptor\Indexer\NodeIndexer: 38 | properties: 39 | indexClient: 40 | object: 41 | factoryObjectName: Flowpack\SimpleSearch\Factory\IndexFactory 42 | factoryMethodName: create 43 | arguments: 44 | 1: 45 | value: 'Neos_CR' 46 | -------------------------------------------------------------------------------- /Configuration/Settings.yaml: -------------------------------------------------------------------------------- 1 | 2 | Neos: 3 | Neos: 4 | fusion: 5 | autoInclude: 6 | Flowpack.SimpleSearch.ContentRepositoryAdaptor: true 7 | ContentRepository: 8 | Search: 9 | defaultConfigurationPerType: 10 | references: 11 | indexing: "${'#' + Array.join(Indexing.convertArrayOfNodesToArrayOfNodeIdentifiers(value), '#') + '#'}" 12 | defaultContext: 13 | Json: Neos\Eel\Helper\JsonHelper 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Christian Müller 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SimpleSearch ContentRepositoryAdaptor 2 | 3 | [![Latest Stable Version](https://poser.pugx.org/flowpack/simplesearch-contentrepositoryadaptor/v/stable)](https://packagist.org/packages/flowpack/simplesearch-contentrepositoryadaptor) [![Total Downloads](https://poser.pugx.org/flowpack/simplesearch-contentrepositoryadaptor/downloads)](https://packagist.org/packages/flowpack/simplesearch-contentrepositoryadaptor) 4 | 5 | A search for the Neos Content Repository based on the SimpleSearch. This package 6 | is an implementation of the Neos.ContentRepository.Search API. 7 | 8 | 9 | Usage is pretty easy. Install this (and Flowpack.SimpleSearch will follow). 10 | 11 | Run the command: 12 | 13 | ./flow nodeindex:build 14 | 15 | After that use the "Search" helper in EEL or the QueryBuilder in PHP to query the 16 | index. 17 | 18 | With a few hundred nodes queries should be answered in a few milliseconds max. 19 | My biggest test so far was with around 23000 nodes which still got me reasonable 20 | query times of about 300ms. 21 | If you have more Nodes to index you should probably consider using a "real" search 22 | engine like ElasticSearch. 23 | 24 | ## Using MySQL 25 | 26 | 27 | To use MySQL, switch the implementation for the interfaces in your `Objects.yaml` 28 | and configure the DB connection as needed: 29 | 30 | Flowpack\SimpleSearch\Domain\Service\IndexInterface: 31 | className: 'Flowpack\SimpleSearch\Domain\Service\MysqlIndex' 32 | 33 | Neos\ContentRepository\Search\Search\QueryBuilderInterface: 34 | className: 'Flowpack\SimpleSearch\ContentRepositoryAdaptor\Search\MysqlQueryBuilder' 35 | 36 | Flowpack\SimpleSearch\Domain\Service\MysqlIndex: 37 | arguments: 38 | 1: 39 | value: 'Neos_CR' 40 | 2: 41 | value: 'mysql:host=%env:DATABASE_HOST%;dbname=%env:DATABASE_NAME%;charset=utf8mb4' 42 | properties: 43 | username: 44 | value: '%env:DATABASE_USERNAME%' 45 | password: 46 | value: '%env:DATABASE_PASSWORD%' 47 | 48 | The `arguments` are the index identifier (can be chosen freely) and the DSN. 49 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/Root.fusion: -------------------------------------------------------------------------------- 1 | prototype(Flowpack.SimpleSearch.ContentRepositoryAdaptor:Search) < prototype(Neos.Fusion:Template) { 2 | templatePath = 'resource://Flowpack.SimpleSearch.ContentRepositoryAdaptor/Private/Templates/NodeTypes/Search.html' 3 | 4 | searchResults = ${Search.query(site).nodeType('Neos.Neos:Document').log().fulltext(request.arguments.search.word).execute()} 5 | searchWord = ${request.arguments.search.word} 6 | searchQuery = ${this.searchWord ? Search.query(site).nodeType('Neos.Neos:Document').log().fulltext(request.arguments.search.word) : null} 7 | 8 | searchResultContent = ${Search.query(searchResult).nodeType('Neos.Neos:Content').fulltextMatchResult(request.arguments.search.word)} 9 | 10 | configuration = Neos.Fusion:DataStructure { 11 | itemsPerPage = 25 12 | insertAbove = false 13 | insertBelow = true 14 | maximumNumberOfLinks = 10 15 | } 16 | 17 | @cache { 18 | mode = 'uncached' 19 | 20 | context { 21 | 1 = 'site' 22 | 2 = 'node' 23 | } 24 | 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Resources/Private/Templates/NodeTypes/Search.html: -------------------------------------------------------------------------------- 1 | {namespace neos=Neos\Neos\ViewHelpers} 2 | {namespace fusion=Neos\Fusion\ViewHelpers} 3 | {namespace search=Neos\ContentRepository\Search\ViewHelpers} 4 | 32 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flowpack/simplesearch-contentrepositoryadaptor", 3 | "type": "neos-package", 4 | "description": "Implements a bridge to search in Neos CR via the flowpack/simplesearch package.", 5 | "license": "MIT", 6 | "require": { 7 | "neos/flow": "^9.0", 8 | "neos/neos": "^9.0", 9 | "neos/contentrepository-core": "^9.0", 10 | "neos/contentrepositoryregistry": "^9.0", 11 | "neos/content-repository-search": "^5.0 || dev-main", 12 | "flowpack/simplesearch": "^5.0 || dev-main", 13 | "symfony/yaml": "*" 14 | }, 15 | "autoload": { 16 | "psr-4": { 17 | "Flowpack\\SimpleSearch\\ContentRepositoryAdaptor\\": "Classes" 18 | } 19 | }, 20 | "extra": { 21 | "applied-flow-migrations": [ 22 | "TYPO3.FLOW3-201201261636", 23 | "TYPO3.Fluid-201205031303", 24 | "TYPO3.FLOW3-201205292145", 25 | "TYPO3.FLOW3-201206271128", 26 | "TYPO3.FLOW3-201209201112", 27 | "TYPO3.Flow-201209251426", 28 | "TYPO3.Flow-201211151101", 29 | "TYPO3.Flow-201212051340", 30 | "TYPO3.TypoScript-130516234520", 31 | "TYPO3.TypoScript-130516235550", 32 | "TYPO3.TYPO3CR-130523180140", 33 | "TYPO3.Neos.NodeTypes-201309111655", 34 | "TYPO3.Flow-201310031523", 35 | "TYPO3.Flow-201405111147", 36 | "TYPO3.Neos-201407061038", 37 | "TYPO3.Neos-201409071922", 38 | "TYPO3.TYPO3CR-140911160326", 39 | "TYPO3.Neos-201410010000", 40 | "TYPO3.TYPO3CR-141101082142", 41 | "TYPO3.Neos-20141113115300", 42 | "TYPO3.Fluid-20141113120800", 43 | "TYPO3.Flow-20141113121400", 44 | "TYPO3.Fluid-20141121091700", 45 | "TYPO3.Neos-20141218134700", 46 | "TYPO3.Fluid-20150214130800", 47 | "TYPO3.Neos-20150303231600", 48 | "TYPO3.TYPO3CR-20150510103823", 49 | "TYPO3.Flow-20151113161300", 50 | "TYPO3.Form-20160601101500", 51 | "TYPO3.Flow-20161115140400", 52 | "TYPO3.Flow-20161115140430", 53 | "Neos.Flow-20161124204700", 54 | "Neos.Flow-20161124204701", 55 | "Neos.Twitter.Bootstrap-20161124204912", 56 | "Neos.Form-20161124205254", 57 | "Neos.Flow-20161124224015", 58 | "Neos.Party-20161124225257", 59 | "Neos.Eel-20161124230101", 60 | "Neos.Kickstart-20161124230102", 61 | "Neos.Setup-20161124230842", 62 | "Neos.Imagine-20161124231742", 63 | "Neos.Media-20161124233100", 64 | "Neos.NodeTypes-20161125002300", 65 | "Neos.SiteKickstarter-20161125002311", 66 | "Neos.Neos-20161125002322", 67 | "Neos.ContentRepository-20161125012000", 68 | "Neos.Fusion-20161125013710", 69 | "Neos.Setup-20161125014759", 70 | "Neos.SiteKickstarter-20161125095901", 71 | "Neos.Fusion-20161125104701", 72 | "Neos.NodeTypes-20161125104800", 73 | "Neos.Neos-20161125104802", 74 | "Neos.Kickstarter-20161125110814", 75 | "Neos.Neos-20161125122412", 76 | "Neos.Flow-20161125124112", 77 | "TYPO3.FluidAdaptor-20161130112935", 78 | "Neos.Fusion-20161201202543", 79 | "Neos.Neos-20161201222211", 80 | "Neos.Fusion-20161202215034", 81 | "Neos.ContentRepository.Search-20161210231100", 82 | "Neos.SwiftMailer-20161130105617", 83 | "Neos.Fusion-20161219092345", 84 | "Neos.ContentRepository-20161219093512", 85 | "Neos.Media-20161219094126", 86 | "Neos.Neos-20161219094403", 87 | "Neos.Neos-20161219122512", 88 | "Neos.Fusion-20161219130100", 89 | "Neos.Neos-20161220163741", 90 | "Neos.Neos-20170115114620", 91 | "Neos.Fusion-20170120013047", 92 | "Neos.Flow-20170125103800", 93 | "Neos.Seo-20170127154600", 94 | "Neos.Flow-20170127183102", 95 | "Neos.Fusion-20180211175500", 96 | "Neos.Fusion-20180211184832", 97 | "Neos.Flow-20180415105700", 98 | "Neos.Neos-20180907103800", 99 | "Neos.Neos.Ui-20190319094900", 100 | "Neos.Flow-20190425144900", 101 | "Neos.Flow-20190515215000", 102 | "Neos.NodeTypes-20190917101945" 103 | ] 104 | } 105 | } 106 | --------------------------------------------------------------------------------