diff --git a/.phan/config.php b/.phan/config.php new file mode 100644 index 0000000..11a17af --- /dev/null +++ b/.phan/config.php @@ -0,0 +1,96 @@ + '7.0', + + // Override to hardcode existence and types of (non-builtin) globals. + // Class names should be prefixed with '\\'. + // (E.g. ['_FOO' => '\\FooClass', 'page' => '\\PageClass', 'userId' => 'int']) + 'globals_type_map' => [ + '_EXTKEY' => 'string', + 'EM_CONF' => 'array', + ], + + // A list of directories that should be parsed for class and + // method information. After excluding the directories + // defined in exclude_analysis_directory_list, the remaining + // files will be statically analyzed for errors. + // + // Thus, both first-party and third-party code being used by + // your application should be included in this list. + 'directory_list' => [ + 'Classes', + '.Build/vendor', + ], + + // A list of files to include in analysis + 'file_list' => [ + 'ext_emconf.php', + 'ext_tables.php', + 'ext_localconf.php', + ], + + // A directory list that defines files that will be excluded + // from static analysis, but whose class and method + // information should be included. + // + // Generally, you'll want to include the directories for + // third-party code (such as "vendor/") in this list. + // + // n.b.: If you'd like to parse but not analyze 3rd + // party code, directories containing that code + // should be added to the `directory_list` as + // to `exclude_analysis_directory_list`. + "exclude_analysis_directory_list" => [ + '.Build/vendor' + ], + + // A list of directories that should be parsed for class and + // method information. After excluding the directories + // defined in exclude_analysis_directory_list, the remaining + // files will be statically analyzed for errors. + // + // Thus, both first-party and third-party code being used by + // your application should be included in this list. + 'directory_list' => [ + 'Classes', + // 'Tests', + '.Build/vendor', + ], + + // The number of processes to fork off during the analysis phase. + 'processes' => 3, + + // Add any issue types (such as 'PhanUndeclaredMethod') + // here to inhibit them from being reported + 'suppress_issue_types' => [ + 'PhanDeprecatedFunction', // For now + 'PhanParamTooMany', // For now, due to ObjectManager->get() + ], + + // A list of plugin files to execute. + // See https://github.com/phan/phan/tree/master/.phan/plugins for even more. + // (Pass these in as relative paths. + // The 0.10.2 release will allow passing 'AlwaysReturnPlugin' if referring to a plugin that is bundled with Phan) + 'plugins' => [ + // checks if a function, closure or method unconditionally returns. + 'AlwaysReturnPlugin', // can also be written as 'vendor/phan/phan/.phan/plugins/AlwaysReturnPlugin.php' + // Checks for syntactically unreachable statements in + // the global scope or function bodies. + 'UnreachableCodePlugin', + 'DollarDollarPlugin', + 'DuplicateArrayKeyPlugin', + 'PregRegexCheckerPlugin', + 'PrintfCheckerPlugin', + ], +]; diff --git a/.scrutinizer.yml b/.scrutinizer.yml index ca92884..3122a2f 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -1,3 +1,11 @@ +build: + nodes: + analysis: + project_setup: + override: true + tests: + override: [php-scrutinizer-run] + filter: excluded_paths: - 'Configuration/*' @@ -19,7 +27,7 @@ tools: php_hhvm: enabled: true config: - use_undeclared_constant: false + use_undeclared_constant: false php_mess_detector: enabled: true @@ -34,5 +42,5 @@ tools: enabled: true # We generate code coverage during tests at travis and will send them here external_code_coverage: - runs: 2 - timeout: 1200 + runs: 2 + timeout: 1200 diff --git a/.travis.yml b/.travis.yml index 79bf8f9..56f42fa 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,19 @@ +sudo: true + +addons: + apt: + packages: + - oracle-java8-set-default +before_install: + - curl -O https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-5.2.0.deb && sudo dpkg -i --force-confnew elasticsearch-5.2.0.deb && sudo service elasticsearch start + - mysql -u root -e 'GRANT ALL ON `typo3_ci_ft%`.* TO travis@127.0.0.1;' + language: php php: - - 5.6 - 7.0 - 7.1 + - 7.2 env: global: @@ -17,34 +27,20 @@ env: - typo3DatabasePassword="" matrix: - TYPO3_VERSION="~7.6" - - TYPO3_VERSION="~8" - - TYPO3_VERSION="dev-master" + - TYPO3_VERSION="~8.7" matrix: fast_finish: true - exclude: - # TYPO3 no longer supports 5.6 - - env: TYPO3_VERSION="~8" - php: 5.6 - - env: TYPO3_VERSION="dev-master" - php: 5.6 - allow_failures: - - env: TYPO3_VERSION="~8" - php: 7.0 - - env: TYPO3_VERSION="~8" - php: 7.1 - - env: TYPO3_VERSION="dev-master" - php: 7.0 - - env: TYPO3_VERSION="dev-master" - php: 7.1 services: - mysql - - elasticsearch install: make install -script: make functionalTests +script: + - make cgl + - make unitTests + - make functionalTests after_script: - make uploadCodeCoverage diff --git a/Classes/Command/IndexCommandController.php b/Classes/Command/IndexCommandController.php index 2a89a88..e0bdeb9 100644 --- a/Classes/Command/IndexCommandController.php +++ b/Classes/Command/IndexCommandController.php @@ -21,7 +21,7 @@ namespace Codappix\SearchCore\Command; */ use Codappix\SearchCore\Domain\Index\IndexerFactory; -use TYPO3\CMS\Core\Utility\GeneralUtility; +use Codappix\SearchCore\Domain\Index\NoMatchingIndexerException; use TYPO3\CMS\Extbase\Mvc\Controller\CommandController; /** @@ -34,12 +34,6 @@ class IndexCommandController extends CommandController */ protected $indexerFactory; - /** - * @var \Codappix\SearchCore\Configuration\ConfigurationContainerInterface - * @inject - */ - protected $configuration; - /** * @param IndexerFactory $factory */ @@ -49,19 +43,32 @@ class IndexCommandController extends CommandController } /** - * Will index the given table or everything. + * Will index the given identifier. * - * @param string $table + * @param string $identifier */ - public function indexCommand($table) + public function indexCommand(string $identifier) { - // TODO: Allow to index multiple tables at once? - // TODO: Also allow to index everything? - if (! in_array($table, GeneralUtility::trimExplode(',', $this->configuration->get('indexer.tca.allowedTables')))) { - $this->outputLine('Table is not allowed for indexing.'); - $this->quit(1); + try { + $this->indexerFactory->getIndexer($identifier)->indexAllDocuments(); + $this->outputLine($identifier . ' was indexed.'); + } catch (NoMatchingIndexerException $e) { + $this->outputLine('No indexer found for: ' . $identifier); + } + } + + /** + * Will delete the given identifier. + * + * @param string $identifier + */ + public function deleteCommand(string $identifier) + { + try { + $this->indexerFactory->getIndexer($identifier)->delete(); + $this->outputLine($identifier . ' was deleted.'); + } catch (NoMatchingIndexerException $e) { + $this->outputLine('No indexer found for: ' . $identifier); } - $this->indexerFactory->getIndexer($table)->indexAllDocuments(); - $this->outputLine('Table was indexed.'); } } diff --git a/Classes/Compatibility/ImplementationRegistrationService.php b/Classes/Compatibility/ImplementationRegistrationService.php new file mode 100644 index 0000000..fa3faa8 --- /dev/null +++ b/Classes/Compatibility/ImplementationRegistrationService.php @@ -0,0 +1,56 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\VersionNumberUtility; +use TYPO3\CMS\Extbase\Object\Container\Container; + +/** + * Register different concrete implementations, depending on current TYPO3 version. + * This way we can provide working implementations for multiple TYPO3 versions. + */ +class ImplementationRegistrationService +{ + public static function registerImplementations() + { + $container = GeneralUtility::makeInstance(Container::class); + if (VersionNumberUtility::convertVersionNumberToInteger(TYPO3_version) >= 8000000) { + $container->registerImplementation( + \Codappix\SearchCore\Compatibility\TypoScriptServiceInterface::class, + \Codappix\SearchCore\Compatibility\TypoScriptService::class + ); + $container->registerImplementation( + \Codappix\SearchCore\Domain\Index\TcaIndexer\TcaTableServiceInterface::class, + \Codappix\SearchCore\Domain\Index\TcaIndexer\TcaTableService::class + ); + } else { + $container->registerImplementation( + \Codappix\SearchCore\Compatibility\TypoScriptServiceInterface::class, + \Codappix\SearchCore\Compatibility\TypoScriptService76::class + ); + $container->registerImplementation( + \Codappix\SearchCore\Domain\Index\TcaIndexer\TcaTableServiceInterface::class, + \Codappix\SearchCore\Domain\Index\TcaIndexer\TcaTableService76::class + ); + } + } +} diff --git a/Classes/Compatibility/TypoScriptService.php b/Classes/Compatibility/TypoScriptService.php new file mode 100644 index 0000000..e5aa788 --- /dev/null +++ b/Classes/Compatibility/TypoScriptService.php @@ -0,0 +1,31 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use TYPO3\CMS\Core\TypoScript\TypoScriptService as CoreTypoScriptService; + +/** + * Used since TYPO3 CMS 8.7. + */ +class TypoScriptService extends CoreTypoScriptService implements TypoScriptServiceInterface +{ + +} diff --git a/Classes/Compatibility/TypoScriptService76.php b/Classes/Compatibility/TypoScriptService76.php new file mode 100644 index 0000000..9df82ea --- /dev/null +++ b/Classes/Compatibility/TypoScriptService76.php @@ -0,0 +1,31 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use TYPO3\CMS\Extbase\Service\TypoScriptService as CoreTypoScriptService; + +/** + * Used before TYPO3 CMS 8.7. + */ +class TypoScriptService76 extends CoreTypoScriptService implements TypoScriptServiceInterface +{ + +} diff --git a/Classes/Compatibility/TypoScriptServiceInterface.php b/Classes/Compatibility/TypoScriptServiceInterface.php new file mode 100644 index 0000000..8e6a9e4 --- /dev/null +++ b/Classes/Compatibility/TypoScriptServiceInterface.php @@ -0,0 +1,30 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +/** + * Allows to use DI configuration to switch concrete implementation, depending + * on current TYPO3 Version. + */ +interface TypoScriptServiceInterface +{ + public function convertPlainArrayToTypoScriptArray(array $plainArray); +} diff --git a/Classes/Configuration/ConfigurationContainer.php b/Classes/Configuration/ConfigurationContainer.php index 7481692..eab76e0 100644 --- a/Classes/Configuration/ConfigurationContainer.php +++ b/Classes/Configuration/ConfigurationContainer.php @@ -39,7 +39,6 @@ class ConfigurationContainer implements ConfigurationContainerInterface /** * Inject settings via ConfigurationManager. * - * @param ConfigurationManagerInterface $configurationManager * @throws NoConfigurationException */ public function injectConfigurationManager(ConfigurationManagerInterface $configurationManager) @@ -59,7 +58,7 @@ class ConfigurationContainer implements ConfigurationContainerInterface * @return mixed * @throws InvalidArgumentException */ - public function get($path) + public function get(string $path) { $value = ArrayUtility::getValueByPath($this->settings, $path); @@ -77,7 +76,7 @@ class ConfigurationContainer implements ConfigurationContainerInterface * @param string $path In dot notation. * @return mixed */ - public function getIfExists($path) + public function getIfExists(string $path) { return ArrayUtility::getValueByPath($this->settings, $path); } diff --git a/Classes/Configuration/ConfigurationContainerInterface.php b/Classes/Configuration/ConfigurationContainerInterface.php index 87e273e..1978453 100644 --- a/Classes/Configuration/ConfigurationContainerInterface.php +++ b/Classes/Configuration/ConfigurationContainerInterface.php @@ -34,8 +34,10 @@ interface ConfigurationContainerInterface extends Singleton * * @param string $path In dot notation. E.g. indexer.tca.allowedTables * @return mixed + * + * @throws InvalidArgumentException */ - public function get($path); + public function get(string $path); /** * Same as get but will not throw an exception but return null. @@ -43,5 +45,5 @@ interface ConfigurationContainerInterface extends Singleton * @param string $path In dot notation. * @return mixed|null */ - public function getIfExists($path); + public function getIfExists(string $path); } diff --git a/Classes/Configuration/ConfigurationUtility.php b/Classes/Configuration/ConfigurationUtility.php new file mode 100644 index 0000000..29cb721 --- /dev/null +++ b/Classes/Configuration/ConfigurationUtility.php @@ -0,0 +1,69 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Connection\SearchRequestInterface; +use TYPO3\CMS\Fluid\View\StandaloneView; + +class ConfigurationUtility +{ + /** + * Will parse all entries, recursive as fluid template, with request variable set to $searchRequest. + */ + public function replaceArrayValuesWithRequestContent(SearchRequestInterface $searchRequest, array $array) : array + { + array_walk_recursive($array, function (&$value, $key, SearchRequestInterface $searchRequest) { + $template = new StandaloneView(); + $template->assign('request', $searchRequest); + $template->setTemplateSource($value); + $value = $template->render(); + + // As elasticsearch does need some doubles to be send as doubles. + if (is_numeric($value)) { + $value = (float) $value; + } + }, $searchRequest); + + return $array; + } + + /** + * Will check all entries, whether they have a condition and filter entries out, where condition is false. + * Also will remove condition in the end. + */ + public function filterByCondition(array $entries) : array + { + $entries = array_filter($entries, function ($entry) { + return !is_array($entry) + || !array_key_exists('condition', $entry) + || (bool) $entry['condition'] === true + ; + }); + + foreach ($entries as $key => $entry) { + if (is_array($entry) && array_key_exists('condition', $entry)) { + unset($entries[$key]['condition']); + } + } + + return $entries; + } +} diff --git a/Classes/Connection/ConnectionInterface.php b/Classes/Connection/ConnectionInterface.php index a130f9e..a336793 100644 --- a/Classes/Connection/ConnectionInterface.php +++ b/Classes/Connection/ConnectionInterface.php @@ -28,53 +28,44 @@ interface ConnectionInterface /** * Will add a new document. * - * @param string $documentType - * @param array $document - * * @return void */ - public function addDocument($documentType, array $document); + public function addDocument(string $documentType, array $document); /** * Add the given documents. * - * @param string $documentType - * @param array $documents - * * @return void */ - public function addDocuments($documentType, array $documents); + public function addDocuments(string $documentType, array $documents); /** * Will update an existing document. * * NOTE: Batch updating is not yet supported. * - * @param string $documentType - * @param array $document - * * @return void */ - public function updateDocument($documentType, array $document); + public function updateDocument(string $documentType, array $document); /** * Will remove an existing document. * * NOTE: Batch deleting is not yet supported. * - * @param string $documentType - * @param int $identifier - * * @return void */ - public function deleteDocument($documentType, $identifier); + public function deleteDocument(string $documentType, string $identifier); /** * Search by given request and return result. - * - * @param SearchRequestInterface $searchRequest - * - * @return SearchResultInterface */ - public function search(SearchRequestInterface $searchRequest); + public function search(SearchRequestInterface $searchRequest) : SearchResultInterface; + + /** + * Will delete the whole index / db. + * + * @return void + */ + public function deleteIndex(string $documentType); } diff --git a/Classes/Connection/Elasticsearch.php b/Classes/Connection/Elasticsearch.php index c40a5e2..555d4e7 100644 --- a/Classes/Connection/Elasticsearch.php +++ b/Classes/Connection/Elasticsearch.php @@ -20,7 +20,10 @@ namespace Codappix\SearchCore\Connection; * 02110-1301, USA. */ +use Codappix\SearchCore\Connection\Elasticsearch\SearchResult; +use Codappix\SearchCore\Domain\Search\QueryFactory; use TYPO3\CMS\Core\SingletonInterface as Singleton; +use TYPO3\CMS\Extbase\Object\ObjectManagerInterface; /** * Outer wrapper to elasticsearch. @@ -42,16 +45,31 @@ class Elasticsearch implements Singleton, ConnectionInterface */ protected $typeFactory; + /** + * @var Elasticsearch\MappingFactory + */ + protected $mappingFactory; + /** * @var Elasticsearch\DocumentFactory */ protected $documentFactory; + /** + * @var QueryFactory + */ + protected $queryFactory; + /** * @var \TYPO3\CMS\Core\Log\Logger */ protected $logger; + /** + * @var ObjectManagerInterface + */ + protected $objectManager; + /** * Inject log manager to get concrete logger from it. * @@ -62,25 +80,39 @@ class Elasticsearch implements Singleton, ConnectionInterface $this->logger = $logManager->getLogger(__CLASS__); } + /** + * @param ObjectManagerInterface $objectManager + */ + public function injectObjectManager(ObjectManagerInterface $objectManager) + { + $this->objectManager = $objectManager; + } + /** * @param Elasticsearch\Connection $connection * @param Elasticsearch\IndexFactory $indexFactory * @param Elasticsearch\TypeFactory $typeFactory + * @param Elasticsearch\MappingFactory $mappingFactory * @param Elasticsearch\DocumentFactory $documentFactory + * @param QueryFactory $queryFactory */ public function __construct( Elasticsearch\Connection $connection, Elasticsearch\IndexFactory $indexFactory, Elasticsearch\TypeFactory $typeFactory, - Elasticsearch\DocumentFactory $documentFactory + Elasticsearch\MappingFactory $mappingFactory, + Elasticsearch\DocumentFactory $documentFactory, + QueryFactory $queryFactory ) { $this->connection = $connection; $this->indexFactory = $indexFactory; $this->typeFactory = $typeFactory; + $this->mappingFactory = $mappingFactory; $this->documentFactory = $documentFactory; + $this->queryFactory = $queryFactory; } - public function addDocument($documentType, array $document) + public function addDocument(string $documentType, array $document) { $this->withType( $documentType, @@ -90,7 +122,7 @@ class Elasticsearch implements Singleton, ConnectionInterface ); } - public function deleteDocument($documentType, $identifier) + public function deleteDocument(string $documentType, string $identifier) { try { $this->withType( @@ -100,11 +132,14 @@ class Elasticsearch implements Singleton, ConnectionInterface } ); } catch (\Elastica\Exception\NotFoundException $exception) { - $this->logger->debug('Tried to delete document in index, which does not exist.', [$documentType, $identifier]); + $this->logger->debug( + 'Tried to delete document in index, which does not exist.', + [$documentType, $identifier] + ); } } - public function updateDocument($documentType, array $document) + public function updateDocument(string $documentType, array $document) { $this->withType( $documentType, @@ -114,7 +149,7 @@ class Elasticsearch implements Singleton, ConnectionInterface ); } - public function addDocuments($documentType, array $documents) + public function addDocuments(string $documentType, array $documents) { $this->withType( $documentType, @@ -124,42 +159,47 @@ class Elasticsearch implements Singleton, ConnectionInterface ); } + public function deleteIndex(string $documentType) + { + $index = $this->connection->getClient()->getIndex('typo3content'); + + if (! $index->exists()) { + $this->logger->notice('Index did not exist, therefore was not deleted.', [$documentType, 'typo3content']); + return; + } + + $index->delete(); + } + /** * Execute given callback with Elastica Type based on provided documentType - * - * @param string $documentType - * @param callable $callback */ - protected function withType($documentType, callable $callback) + protected function withType(string $documentType, callable $callback) { $type = $this->getType($documentType); + // TODO: Check whether it's to heavy to send it so often e.g. for every single document. + // Perhaps add command controller to submit mapping?! + // Also it's not possible to change mapping without deleting index first. + // Mattes told about a solution. + // So command looks like the best way so far, except we manage mattes solution. + // Still then this should be done once. So perhaps singleton which tracks state and does only once? + $this->mappingFactory->getMapping($type)->send(); $callback($type); $type->getIndex()->refresh(); } - /** - * @param SearchRequestInterface $searchRequest - * - * @return \Elastica\ResultSet - */ - public function search(SearchRequestInterface $searchRequest) + public function search(SearchRequestInterface $searchRequest) : SearchResultInterface { $this->logger->debug('Search for', [$searchRequest->getSearchTerm()]); $search = new \Elastica\Search($this->connection->getClient()); $search->addIndex('typo3content'); + $search->setQuery($this->queryFactory->create($searchRequest)); - // TODO: Return wrapped result to implement our interface. - // Also update php doc to reflect the change. - return $search->search('"' . $searchRequest->getSearchTerm() . '"'); + return $this->objectManager->get(SearchResult::class, $searchRequest, $search->search()); } - /** - * @param string $documentType - * - * @return \Elastica\Type - */ - protected function getType($documentType) + protected function getType(string $documentType) : \Elastica\Type { return $this->typeFactory->getType( $this->indexFactory->getIndex( diff --git a/Classes/Connection/Elasticsearch/Connection.php b/Classes/Connection/Elasticsearch/Connection.php index cd0a1fd..a5e7d0f 100644 --- a/Classes/Connection/Elasticsearch/Connection.php +++ b/Classes/Connection/Elasticsearch/Connection.php @@ -44,7 +44,7 @@ class Connection implements Singleton /** * @param ConfigurationContainerInterface $configuration - * @param \Elastica\Client $elasticaClient + * @param \Elastica\Client|null $elasticaClient */ public function __construct( ConfigurationContainerInterface $configuration, @@ -52,9 +52,8 @@ class Connection implements Singleton ) { $this->configuration = $configuration; - $this->elasticaClient = $elasticaClient; - if ($this->elasticaClient === null) { - $this->elasticaClient = new \Elastica\Client([ + if ($elasticaClient === null) { + $elasticaClient = new \Elastica\Client([ 'host' => $this->configuration->get('connections.elasticsearch.host'), 'port' => $this->configuration->get('connections.elasticsearch.port'), // TODO: Make configurable @@ -63,14 +62,13 @@ class Connection implements Singleton // TODO: Make configurable. // new \Elastica\Log($this->elasticaClient); } + $this->elasticaClient = $elasticaClient; } /** * Get the concrete client for internal usage! - * - * @return \Elastica\Client */ - public function getClient() + public function getClient() : \Elastica\Client { return $this->elasticaClient; } diff --git a/Classes/Connection/Elasticsearch/DocumentFactory.php b/Classes/Connection/Elasticsearch/DocumentFactory.php index 99d29e5..beb091a 100644 --- a/Classes/Connection/Elasticsearch/DocumentFactory.php +++ b/Classes/Connection/Elasticsearch/DocumentFactory.php @@ -44,13 +44,8 @@ class DocumentFactory implements Singleton /** * Creates document from document. - * - * @param string $documentType - * @param array $document - * - * @return \Elastica\Document */ - public function getDocument($documentType, array $document) + public function getDocument(string $documentType, array $document) : \Elastica\Document { // TODO: Use DocumentType for further configuration. @@ -61,19 +56,17 @@ class DocumentFactory implements Singleton $identifier = $document['search_identifier']; unset($document['search_identifier']); - $this->logger->debug('Convert document to document', [$identifier, $document]); + $this->logger->debug( + sprintf('Convert %s %u to document.', $documentType, $identifier), + [$identifier, $document] + ); return new \Elastica\Document($identifier, $document); } /** * Creates documents based on documents. - * - * @param string $documentType - * @param array $documents - * - * @return array */ - public function getDocuments($documentType, array $documents) + public function getDocuments(string $documentType, array $documents) : array { foreach ($documents as &$document) { $document = $this->getDocument($documentType, $document); diff --git a/Classes/Connection/Elasticsearch/Facet.php b/Classes/Connection/Elasticsearch/Facet.php new file mode 100644 index 0000000..27de076 --- /dev/null +++ b/Classes/Connection/Elasticsearch/Facet.php @@ -0,0 +1,95 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Configuration\ConfigurationContainerInterface; +use Codappix\SearchCore\Connection\FacetInterface; +use Codappix\SearchCore\Connection\FacetOptionInterface; + +class Facet implements FacetInterface +{ + /** + * @var string + */ + protected $name = ''; + + /** + * @var string + */ + protected $field = ''; + + /** + * @var array + */ + protected $buckets = []; + + /** + * @var array + */ + protected $options = []; + + public function __construct(string $name, array $aggregation, ConfigurationContainerInterface $configuration) + { + $this->name = $name; + $this->buckets = $aggregation['buckets']; + + $config = $configuration->getIfExists('searching.facets.' . $this->name) ?: []; + foreach ($config as $configEntry) { + if (isset($configEntry['field'])) { + $this->field = $configEntry['field']; + break; + } + } + } + + public function getName() : string + { + return $this->name; + } + + public function getField() : string + { + return $this->field; + } + + /** + * Returns all possible options for this facet. + * + * @return array + */ + public function getOptions() : array + { + $this->initOptions(); + + return $this->options; + } + + protected function initOptions() + { + if ($this->options !== []) { + return; + } + + foreach ($this->buckets as $bucket) { + $this->options[$bucket['key']] = new FacetOption($bucket); + } + } +} diff --git a/Classes/Connection/Elasticsearch/FacetOption.php b/Classes/Connection/Elasticsearch/FacetOption.php new file mode 100644 index 0000000..6b544bb --- /dev/null +++ b/Classes/Connection/Elasticsearch/FacetOption.php @@ -0,0 +1,66 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Connection\FacetOptionInterface; + +class FacetOption implements FacetOptionInterface +{ + /** + * @var string + */ + protected $name = ''; + + /** + * @var string + */ + protected $displayName = ''; + + /** + * @var int + */ + protected $count = 0; + + /** + * @param array $bucket + */ + public function __construct(array $bucket) + { + $this->name = $bucket['key']; + $this->displayName = isset($bucket['key_as_string']) ? $bucket['key_as_string'] : $this->getName(); + $this->count = $bucket['doc_count']; + } + + public function getName() : string + { + return $this->name; + } + + public function getDisplayName() : string + { + return $this->displayName; + } + + public function getCount() : int + { + return $this->count; + } +} diff --git a/Classes/Connection/Elasticsearch/IndexFactory.php b/Classes/Connection/Elasticsearch/IndexFactory.php index 019b091..2ef905f 100644 --- a/Classes/Connection/Elasticsearch/IndexFactory.php +++ b/Classes/Connection/Elasticsearch/IndexFactory.php @@ -20,9 +20,10 @@ namespace Codappix\SearchCore\Connection\Elasticsearch; * 02110-1301, USA. */ -use Elastica\Exception\ResponseException; +use Codappix\SearchCore\Configuration\ConfigurationContainerInterface; +use Codappix\SearchCore\Configuration\InvalidArgumentException; use TYPO3\CMS\Core\SingletonInterface as Singleton; -use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; /** * Factory to get indexes. @@ -32,28 +33,81 @@ use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface; class IndexFactory implements Singleton { /** - * Get an index bases on TYPO3 table name. - * - * @param Connection $connection - * @param string $documentType - * - * @return \Elastica\Index + * @var ConfigurationContainerInterface */ - public function getIndex(Connection $connection, $documentType) + protected $configuration; + + /** + * @var \TYPO3\CMS\Core\Log\Logger + */ + protected $logger; + + /** + * Inject log manager to get concrete logger from it. + * + * @param \TYPO3\CMS\Core\Log\LogManager $logManager + */ + public function injectLogger(\TYPO3\CMS\Core\Log\LogManager $logManager) + { + $this->logger = $logManager->getLogger(__CLASS__); + } + + /** + * @param ConfigurationContainerInterface $configuration + */ + public function __construct(ConfigurationContainerInterface $configuration) + { + $this->configuration = $configuration; + } + + /** + * Get an index bases on TYPO3 table name. + */ + public function getIndex(Connection $connection, string $documentType) : \Elastica\Index { - // TODO: Fetch index name from configuration, based on $documentType. $index = $connection->getClient()->getIndex('typo3content'); - try { - // TODO: Provide configuration?! - // http://elastica.io/getting-started/storing-and-indexing-documents.html#section-analysis - $index->create(); - } catch (ResponseException $exception) { - if (stripos($exception->getMessage(), 'already exists') === false) { - throw $exception; - } + if ($index->exists() === false) { + $config = $this->getConfigurationFor($documentType); + $this->logger->debug(sprintf('Create index %s.', $documentType), [$documentType, $config]); + $index->create($config); + $this->logger->debug(sprintf('Created index %s.', $documentType), [$documentType]); } return $index; } + + protected function getConfigurationFor(string $documentType) : array + { + try { + $configuration = $this->configuration->get('indexing.' . $documentType . '.index'); + + foreach (['analyzer', 'filter'] as $optionsToExpand) { + if (isset($configuration['analysis'][$optionsToExpand])) { + foreach ($configuration['analysis'][$optionsToExpand] as $key => $options) { + $configuration['analysis'][$optionsToExpand][$key] = $this->prepareAnalyzerConfiguration( + $options + ); + } + } + } + + return $configuration; + } catch (InvalidArgumentException $e) { + return []; + } + } + + protected function prepareAnalyzerConfiguration(array $analyzer) : array + { + $fieldsToExplode = ['char_filter', 'filter', 'word_list']; + + foreach ($fieldsToExplode as $fieldToExplode) { + if (isset($analyzer[$fieldToExplode])) { + $analyzer[$fieldToExplode] = GeneralUtility::trimExplode(',', $analyzer[$fieldToExplode], true); + } + } + + return $analyzer; + } } diff --git a/Classes/Connection/Elasticsearch/MappingFactory.php b/Classes/Connection/Elasticsearch/MappingFactory.php new file mode 100644 index 0000000..3882556 --- /dev/null +++ b/Classes/Connection/Elasticsearch/MappingFactory.php @@ -0,0 +1,71 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Configuration\ConfigurationContainerInterface; +use Codappix\SearchCore\Configuration\InvalidArgumentException; +use TYPO3\CMS\Core\SingletonInterface as Singleton; + +/** + * Factory to get mappings. + */ +class MappingFactory implements Singleton +{ + /** + * @var ConfigurationContainerInterface + */ + protected $configuration; + + /** + * @param ConfigurationContainerInterface $configuration + */ + public function __construct(ConfigurationContainerInterface $configuration) + { + $this->configuration = $configuration; + } + + /** + * Get an mapping based on type. + */ + public function getMapping(\Elastica\Type $type) : \Elastica\Type\Mapping + { + $mapping = new \Elastica\Type\Mapping(); + $mapping->setType($type); + + $configuration = $this->getConfiguration($type->getName()); + if (isset($configuration['_all'])) { + $mapping->setAllField($configuration['_all']); + unset($configuration['_all']); + } + $mapping->setProperties($configuration); + + return $mapping; + } + + protected function getConfiguration(string $identifier) : array + { + try { + return $this->configuration->get('indexing.' . $identifier . '.mapping'); + } catch (InvalidArgumentException $e) { + return []; + } + } +} diff --git a/Classes/Connection/Elasticsearch/SearchResult.php b/Classes/Connection/Elasticsearch/SearchResult.php index 48c26cb..5b3d381 100644 --- a/Classes/Connection/Elasticsearch/SearchResult.php +++ b/Classes/Connection/Elasticsearch/SearchResult.php @@ -20,12 +20,145 @@ namespace Codappix\SearchCore\Connection\Elasticsearch; * 02110-1301, USA. */ +use Codappix\SearchCore\Connection\FacetInterface; +use Codappix\SearchCore\Connection\ResultItemInterface; +use Codappix\SearchCore\Connection\SearchRequestInterface; use Codappix\SearchCore\Connection\SearchResultInterface; +use Codappix\SearchCore\Domain\Model\QueryResultInterfaceStub; +use Codappix\SearchCore\Domain\Model\ResultItem; +use TYPO3\CMS\Extbase\Object\ObjectManagerInterface; -/** - * - */ -class SearchResult extends \Elastica\SearchResult implements SearchResultInterface +class SearchResult implements SearchResultInterface { + use QueryResultInterfaceStub; + /** + * @var SearchRequestInterface + */ + protected $searchRequest; + + /** + * @var \Elastica\ResultSet + */ + protected $result; + + /** + * @var array + */ + protected $facets = []; + + /** + * @var array + */ + protected $results = []; + + /** + * For Iterator interface. + * + * @var int + */ + protected $position = 0; + + /** + * @var ObjectManagerInterface + */ + protected $objectManager; + + public function __construct( + SearchRequestInterface $searchRequest, + \Elastica\ResultSet $result, + ObjectManagerInterface $objectManager + ) { + $this->searchRequest = $searchRequest; + $this->result = $result; + $this->objectManager = $objectManager; + } + + /** + * @return array + */ + public function getResults() : array + { + $this->initResults(); + + return $this->results; + } + + /** + * Return all facets, if any. + * + * @return array + */ + public function getFacets() : array + { + $this->initFacets(); + + return $this->facets; + } + + public function getCurrentCount() : int + { + return $this->result->count(); + } + + protected function initResults() + { + if ($this->results !== []) { + return; + } + + foreach ($this->result->getResults() as $result) { + $this->results[] = new ResultItem($result->getData()); + } + } + + protected function initFacets() + { + if ($this->facets !== [] || !$this->result->hasAggregations()) { + return; + } + + foreach ($this->result->getAggregations() as $aggregationName => $aggregation) { + $this->facets[$aggregationName] = $this->objectManager->get(Facet::class, $aggregationName, $aggregation); + } + } + + // Countable - Interface + public function count() + { + return $this->result->getTotalHits(); + } + + // Iterator - Interface + public function current() + { + return $this->getResults()[$this->position]; + } + + public function next() + { + ++$this->position; + + return $this->current(); + } + + public function key() + { + return $this->position; + } + + public function valid() + { + return isset($this->getResults()[$this->position]); + } + + public function rewind() + { + $this->position = 0; + } + + public function getQuery() + { + return $this->searchRequest; + } } diff --git a/Classes/Connection/Elasticsearch/TypeFactory.php b/Classes/Connection/Elasticsearch/TypeFactory.php index d5283f8..e84cdd0 100644 --- a/Classes/Connection/Elasticsearch/TypeFactory.php +++ b/Classes/Connection/Elasticsearch/TypeFactory.php @@ -21,7 +21,6 @@ namespace Codappix\SearchCore\Connection\Elasticsearch; */ use TYPO3\CMS\Core\SingletonInterface as Singleton; -use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface; /** * Factory to get indexes. @@ -32,13 +31,8 @@ class TypeFactory implements Singleton { /** * Get an index bases on TYPO3 table name. - * - * @param \Elastica\Index $index - * @param string $documentType - * - * @return \Elastica\Type */ - public function getType(\Elastica\Index $index, $documentType) + public function getType(\Elastica\Index $index, string $documentType) : \Elastica\Type { return $index->getType($documentType); } diff --git a/Classes/Connection/FacetInterface.php b/Classes/Connection/FacetInterface.php new file mode 100644 index 0000000..3ec549d --- /dev/null +++ b/Classes/Connection/FacetInterface.php @@ -0,0 +1,36 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +/** + * A single facet, e.g. keyword based. + */ +interface FacetInterface +{ + public function getName() : string; + + /** + * Returns all possible options for this facet. + * + * @return array + */ + public function getOptions() : array; +} diff --git a/Classes/Connection/FacetOptionInterface.php b/Classes/Connection/FacetOptionInterface.php new file mode 100644 index 0000000..bf998db --- /dev/null +++ b/Classes/Connection/FacetOptionInterface.php @@ -0,0 +1,44 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +/** + * A single possible option of a facet. + */ +interface FacetOptionInterface +{ + /** + * Returns the name of this option. Equivalent + * to value used for filtering. + */ + public function getName() : string; + + /** + * If a pre-rendered name is provided, this will be returned. + * Otherwise it's the same as getName(). + */ + public function getDisplayName() : string; + + /** + * Returns the number of found results for this option. + */ + public function getCount() : int; +} diff --git a/Classes/Connection/FacetRequestInterface.php b/Classes/Connection/FacetRequestInterface.php new file mode 100644 index 0000000..cdc16b1 --- /dev/null +++ b/Classes/Connection/FacetRequestInterface.php @@ -0,0 +1,38 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +/** + * Used to request facets / aggregates from connection. + */ +interface FacetRequestInterface +{ + /** + * The identifier of the facet, used as key in arrays and to get the facet + * from search request, etc. + */ + public function getIdentifier() : string; + + /** + * The config to use for facet building. + */ + public function getConfig() : array; +} diff --git a/Classes/Connection/ResultItemInterface.php b/Classes/Connection/ResultItemInterface.php new file mode 100644 index 0000000..d7fe1a9 --- /dev/null +++ b/Classes/Connection/ResultItemInterface.php @@ -0,0 +1,36 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +/** + * Use ArrayAccess to enable retrieval of information in fluid. + */ +interface ResultItemInterface extends \ArrayAccess +{ + /** + * Returns every information as array. + * + * Provide key/column/field => data. + * + * Used e.g. for dataprocessing. + */ + public function getPlainData() : array; +} diff --git a/Classes/Connection/SearchRequestInterface.php b/Classes/Connection/SearchRequestInterface.php index fd98c1f..a400447 100644 --- a/Classes/Connection/SearchRequestInterface.php +++ b/Classes/Connection/SearchRequestInterface.php @@ -20,15 +20,47 @@ namespace Codappix\SearchCore\Connection; * 02110-1301, USA. */ -/** - * - */ -interface SearchRequestInterface +use Codappix\SearchCore\Connection\ConnectionInterface; +use Codappix\SearchCore\Connection\FacetRequestInterface; +use Codappix\SearchCore\Domain\Search\SearchService; +use TYPO3\CMS\Extbase\Persistence\QueryInterface; + +interface SearchRequestInterface extends QueryInterface { /** * Returns the actual string the user searched for. - * - * @return string */ - public function getSearchTerm(); + public function getSearchTerm() : string; + + public function hasFilter() : bool; + + public function getFilter() : array; + + public function setFilter(array $filter); + + /** + * @return void + */ + public function addFacet(FacetRequestInterface $facet); + + /** + * @return array + */ + public function getFacets() : array; + + /** + * Workaround for paginate widget support which will + * use the request to build another search. + * + * @return void + */ + public function setConnection(ConnectionInterface $connection); + + /** + * Workaround for paginate widget support which will + * use the request to build another search. + * + * @return void + */ + public function setSearchService(SearchService $searchService); } diff --git a/Classes/Connection/SearchResultInterface.php b/Classes/Connection/SearchResultInterface.php index 2dfdfc6..be698fa 100644 --- a/Classes/Connection/SearchResultInterface.php +++ b/Classes/Connection/SearchResultInterface.php @@ -20,10 +20,27 @@ namespace Codappix\SearchCore\Connection; * 02110-1301, USA. */ -/** - * - */ -interface SearchResultInterface extends \Iterator, \Countable, \ArrayAccess -{ +use TYPO3\CMS\Extbase\Persistence\QueryResultInterface; +/** + * A search result. + */ +interface SearchResultInterface extends \Iterator, \Countable, QueryResultInterface +{ + /** + * @return array + */ + public function getResults() : array; + + /** + * Return all facets, if any. + * + * @return array + */ + public function getFacets() : array; + + /** + * Returns the number of results in current result + */ + public function getCurrentCount() : int; } diff --git a/Classes/Controller/SearchController.php b/Classes/Controller/SearchController.php index 068f9a1..e484fcd 100644 --- a/Classes/Controller/SearchController.php +++ b/Classes/Controller/SearchController.php @@ -44,6 +44,26 @@ class SearchController extends ActionController parent::__construct(); } + public function initializeSearchAction() + { + if (isset($this->settings['searching']['mode']) && $this->settings['searching']['mode'] === 'filter' + && $this->request->hasArgument('searchRequest') === false + ) { + $this->request->setArguments(array_merge( + $this->request->getArguments(), + [ + 'searchRequest' => $this->objectManager->get(SearchRequest::class), + ] + )); + } + + if ($this->arguments->hasArgument('searchRequest')) { + $this->arguments->getArgument('searchRequest')->getPropertyMappingConfiguration() + ->allowAllProperties() + ; + } + } + /** * Process a search and deliver original request and result to view. * diff --git a/Classes/DataProcessing/ContentObjectDataProcessorAdapterProcessor.php b/Classes/DataProcessing/ContentObjectDataProcessorAdapterProcessor.php new file mode 100644 index 0000000..2b7f553 --- /dev/null +++ b/Classes/DataProcessing/ContentObjectDataProcessorAdapterProcessor.php @@ -0,0 +1,59 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Compatibility\TypoScriptServiceInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer; + +/** + * Executes an existing TYPO3 DataProcessor on the given data. + */ +class ContentObjectDataProcessorAdapterProcessor implements ProcessorInterface +{ + /** + * @var TypoScriptServiceInterface + */ + protected $typoScriptService; + + public function __construct(TypoScriptServiceInterface $typoScriptService) + { + $this->typoScriptService = $typoScriptService; + } + + public function processData(array $data, array $configuration) : array + { + $dataProcessor = GeneralUtility::makeInstance($configuration['_dataProcessor']); + $contentObjectRenderer = GeneralUtility::makeInstance(ContentObjectRenderer::class); + + $contentObjectRenderer->data = $data; + if (isset($configuration['_table'])) { + $contentObjectRenderer->start($data, $configuration['_table']); + } + + return $dataProcessor->process( + $contentObjectRenderer, + [], + $this->typoScriptService->convertPlainArrayToTypoScriptArray($configuration), + $data + ); + } +} diff --git a/Classes/DataProcessing/CopyToProcessor.php b/Classes/DataProcessing/CopyToProcessor.php new file mode 100644 index 0000000..28c4294 --- /dev/null +++ b/Classes/DataProcessing/CopyToProcessor.php @@ -0,0 +1,50 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +/** + * Copies values from one field to another one. + */ +class CopyToProcessor implements ProcessorInterface +{ + public function processData(array $record, array $configuration) : array + { + $all = []; + + $this->addArray($all, $record); + $all = array_filter($all); + $record[$configuration['to']] = implode(PHP_EOL, $all); + + return $record; + } + + protected function addArray(array &$target, array $from) + { + foreach ($from as $value) { + if (is_array($value)) { + $this->addArray($target, $value); + continue; + } + + $target[] = (string) $value; + } + } +} diff --git a/Classes/DataProcessing/GeoPointProcessor.php b/Classes/DataProcessing/GeoPointProcessor.php new file mode 100644 index 0000000..971e2c4 --- /dev/null +++ b/Classes/DataProcessing/GeoPointProcessor.php @@ -0,0 +1,59 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +/** + * Adds a new fields, ready to use as GeoPoint field for Elasticsearch. + */ +class GeoPointProcessor implements ProcessorInterface +{ + public function processData(array $record, array $configuration) : array + { + if (! $this->isApplyable($record, $configuration)) { + return $record; + } + + $record[$configuration['to']] = [ + 'lat' => (float) $record[$configuration['lat']], + 'lon' => (float) $record[$configuration['lon']], + ]; + + return $record; + } + + protected function isApplyable(array $record, array $configuration) : bool + { + if (!isset($record[$configuration['lat']]) + || !is_numeric($record[$configuration['lat']]) + || trim($record[$configuration['lat']]) === '' + ) { + return false; + } + if (!isset($record[$configuration['lon']]) + || !is_numeric($record[$configuration['lon']]) + || trim($record[$configuration['lon']]) === '' + ) { + return false; + } + + return true; + } +} diff --git a/Classes/DataProcessing/ProcessorInterface.php b/Classes/DataProcessing/ProcessorInterface.php new file mode 100644 index 0000000..f7513f2 --- /dev/null +++ b/Classes/DataProcessing/ProcessorInterface.php @@ -0,0 +1,33 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +/** + * All DataProcessing Processors should implement this interface. + */ +interface ProcessorInterface +{ + /** + * Processes the given data. + * Also retrieves the configuration for this processor instance. + */ + public function processData(array $record, array $configuration) : array; +} diff --git a/Classes/DataProcessing/RemoveProcessor.php b/Classes/DataProcessing/RemoveProcessor.php new file mode 100644 index 0000000..b8d6283 --- /dev/null +++ b/Classes/DataProcessing/RemoveProcessor.php @@ -0,0 +1,44 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Removes fields from record. + */ +class RemoveProcessor implements ProcessorInterface +{ + public function processData(array $record, array $configuration) : array + { + if (!isset($configuration['fields'])) { + return $record; + } + + foreach (GeneralUtility::trimExplode(',', $configuration['fields'], true) as $field) { + if (array_key_exists($field, $record)) { + unset($record[$field]); + } + } + + return $record; + } +} diff --git a/Classes/DataProcessing/Service.php b/Classes/DataProcessing/Service.php new file mode 100644 index 0000000..3e3b053 --- /dev/null +++ b/Classes/DataProcessing/Service.php @@ -0,0 +1,56 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use TYPO3\CMS\Extbase\Object\ObjectManagerInterface; + +/** + * Eases work with data processing. + */ +class Service +{ + /** + * @var ObjectManagerInterface + */ + protected $objectManager; + + public function __construct(ObjectManagerInterface $objectManager) + { + $this->objectManager = $objectManager; + } + + /** + * Executes the dataprocessor depending on configuration and returns the result. + * + * @param array|string $configuration Either the full configuration or only the class name. + */ + public function executeDataProcessor($configuration, array $data) : array + { + if (is_string($configuration)) { + $configuration = [ + '_typoScriptNodeValue' => $configuration, + ]; + } + + return $this->objectManager->get($configuration['_typoScriptNodeValue']) + ->processData($data, $configuration); + } +} diff --git a/Classes/Database/Doctrine/Join.php b/Classes/Database/Doctrine/Join.php new file mode 100644 index 0000000..df1a8c6 --- /dev/null +++ b/Classes/Database/Doctrine/Join.php @@ -0,0 +1,50 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +class Join +{ + /** + * @var string + */ + protected $table = ''; + + /** + * @var string + */ + protected $condition = ''; + + public function __construct(string $table, string $condition) + { + $this->table = $table; + $this->condition = $condition; + } + + public function getTable() : string + { + return $this->table; + } + + public function getCondition() : string + { + return $this->condition; + } +} diff --git a/Classes/Database/Doctrine/Where.php b/Classes/Database/Doctrine/Where.php new file mode 100644 index 0000000..6586b8a --- /dev/null +++ b/Classes/Database/Doctrine/Where.php @@ -0,0 +1,50 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +class Where +{ + /** + * @var string + */ + protected $statement = ''; + + /** + * @var array + */ + protected $parameters = []; + + public function __construct(string $statement, array $parameters) + { + $this->statement = $statement; + $this->parameters = $parameters; + } + + public function getStatement() : string + { + return $this->statement; + } + + public function getParameters() : array + { + return $this->parameters; + } +} diff --git a/Classes/Domain/Index/AbstractIndexer.php b/Classes/Domain/Index/AbstractIndexer.php new file mode 100644 index 0000000..6644716 --- /dev/null +++ b/Classes/Domain/Index/AbstractIndexer.php @@ -0,0 +1,184 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Configuration\ConfigurationContainerInterface; +use Codappix\SearchCore\Configuration\InvalidArgumentException; +use Codappix\SearchCore\Connection\ConnectionInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +abstract class AbstractIndexer implements IndexerInterface +{ + /** + * @var ConnectionInterface + */ + protected $connection; + + /** + * @var ConfigurationContainerInterface + */ + protected $configuration; + + /** + * @var string + */ + protected $identifier = ''; + + /** + * @var \Codappix\SearchCore\DataProcessing\Service + * @inject + */ + protected $dataProcessorService; + + /** + * @var \TYPO3\CMS\Core\Log\Logger + */ + protected $logger; + + /** + * Inject log manager to get concrete logger from it. + * + * @param \TYPO3\CMS\Core\Log\LogManager $logManager + */ + public function injectLogger(\TYPO3\CMS\Core\Log\LogManager $logManager) + { + $this->logger = $logManager->getLogger(__CLASS__); + } + + public function setIdentifier(string $identifier) + { + $this->identifier = $identifier; + } + + public function __construct(ConnectionInterface $connection, ConfigurationContainerInterface $configuration) + { + $this->connection = $connection; + $this->configuration = $configuration; + } + + public function indexAllDocuments() + { + $this->logger->info('Start indexing'); + foreach ($this->getRecordGenerator() as $records) { + if ($records === null) { + break; + } + + foreach ($records as &$record) { + $this->prepareRecord($record); + } + + $this->logger->debug('Index records.', [$records]); + $this->connection->addDocuments($this->getDocumentName(), $records); + } + $this->logger->info('Finish indexing'); + } + + public function indexDocument(string $identifier) + { + $this->logger->info('Start indexing single record.', [$identifier]); + try { + $record = $this->getRecord((int) $identifier); + $this->prepareRecord($record); + + $this->connection->addDocument($this->getDocumentName(), $record); + } catch (NoRecordFoundException $e) { + $this->logger->info('Could not index document. Try to delete it therefore.', [$e->getMessage()]); + $this->connection->deleteDocument($this->getDocumentName(), $identifier); + } + $this->logger->info('Finish indexing'); + } + + public function delete() + { + $this->logger->info('Start deletion of index.'); + $this->connection->deleteIndex($this->getDocumentName()); + $this->logger->info('Finish deletion.'); + } + + protected function getRecordGenerator() : \Generator + { + $offset = 0; + $limit = $this->getLimit(); + + while (($records = $this->getRecords($offset, $limit)) !== []) { + yield $records; + $offset += $limit; + } + } + + protected function prepareRecord(array &$record) + { + try { + foreach ($this->configuration->get('indexing.' . $this->identifier . '.dataProcessing') as $configuration) { + $record = $this->dataProcessorService->executeDataProcessor($configuration, $record); + } + } catch (InvalidArgumentException $e) { + // Nothing to do. + } + + $this->handleAbstract($record); + } + + protected function handleAbstract(array &$record) + { + $record['search_abstract'] = ''; + + try { + $fieldsToUse = GeneralUtility::trimExplode( + ',', + $this->configuration->get('indexing.' . $this->identifier . '.abstractFields') + ); + if ($fieldsToUse === []) { + return; + } + foreach ($fieldsToUse as $fieldToUse) { + if (isset($record[$fieldToUse]) && trim($record[$fieldToUse])) { + $record['search_abstract'] = trim($record[$fieldToUse]); + break; + } + } + } catch (InvalidArgumentException $e) { + return; + } + } + + /** + * Returns the limit to use to fetch records. + */ + protected function getLimit() : int + { + // TODO: Make configurable. + return 50; + } + + /** + * @return array|null + */ + abstract protected function getRecords(int $offset, int $limit); + + /** + * @throws NoRecordFoundException If record could not be found. + */ + abstract protected function getRecord(int $identifier) : array; + + abstract protected function getDocumentName() : string; +} diff --git a/Classes/Domain/Index/IndexerFactory.php b/Classes/Domain/Index/IndexerFactory.php index 3d3f460..668111d 100644 --- a/Classes/Domain/Index/IndexerFactory.php +++ b/Classes/Domain/Index/IndexerFactory.php @@ -20,6 +20,10 @@ namespace Codappix\SearchCore\Domain\Index; * 02110-1301, USA. */ +use Codappix\SearchCore\Configuration\ConfigurationContainerInterface; +use Codappix\SearchCore\Configuration\InvalidArgumentException; +use Codappix\SearchCore\Domain\Index\IndexerInterface; +use Codappix\SearchCore\Domain\Index\TcaIndexer\TcaTableServiceInterface; use TYPO3\CMS\Core\SingletonInterface as Singleton; use TYPO3\CMS\Extbase\Object\ObjectManagerInterface; @@ -34,27 +38,67 @@ class IndexerFactory implements Singleton protected $objectManager; /** - * @param ObjectManagerInterface $objectManager + * @var ConfigurationContainerInterface */ - public function __construct(ObjectManagerInterface $objectManager) - { + protected $configuration; + + /** + * @param ObjectManagerInterface $objectManager + * @param ConfigurationContainerInterface $configuration + */ + public function __construct( + ObjectManagerInterface $objectManager, + ConfigurationContainerInterface $configuration + ) { $this->objectManager = $objectManager; + $this->configuration = $configuration; } /** - * @param string $tableName - * - * @return IndexerInterface + * @throws NoMatchingIndexer */ - public function getIndexer($tableName) + public function getIndexer(string $identifier) : IndexerInterface { - // This is the place to use configuration to return different indexer. - return $this->objectManager->get( - TcaIndexer::Class, - $this->objectManager->get( - TcaIndexer\TcaTableService::class, - $tableName - ) - ); + try { + return $this->buildIndexer($this->configuration->get('indexing.' . $identifier . '.indexer'), $identifier); + } catch (NoMatchingIndexerException $e) { + // Nothing to do, we throw exception below + } catch (InvalidArgumentException $e) { + // Nothing to do, we throw exception below + } + + throw new NoMatchingIndexerException('Could not find an indexer for ' . $identifier, 1497341442); + } + + /** + * @throws NoMatchingIndexer + */ + protected function buildIndexer(string $indexerClass, string $identifier) : IndexerInterface + { + $indexer = null; + if (is_subclass_of($indexerClass, TcaIndexer\PagesIndexer::class) + || $indexerClass === TcaIndexer\PagesIndexer::class + ) { + $indexer = $this->objectManager->get( + $indexerClass, + $this->objectManager->get(TcaTableServiceInterface::class, $identifier), + $this->objectManager->get(TcaTableServiceInterface::class, 'tt_content') + ); + } elseif (is_subclass_of($indexerClass, TcaIndexer::class) || $indexerClass === TcaIndexer::class) { + $indexer = $this->objectManager->get( + $indexerClass, + $this->objectManager->get(TcaTableServiceInterface::class, $identifier) + ); + } elseif (class_exists($indexerClass) && in_array(IndexerInterface::class, class_implements($indexerClass))) { + $indexer = $this->objectManager->get($indexerClass); + } + + if ($indexer === null) { + throw new NoMatchingIndexerException('Could not find indexer: ' . $indexerClass, 1497341442); + } + + $indexer->setIdentifier($identifier); + + return $indexer; } } diff --git a/Classes/Domain/Index/IndexerInterface.php b/Classes/Domain/Index/IndexerInterface.php index 5fef64f..4acfb28 100644 --- a/Classes/Domain/Index/IndexerInterface.php +++ b/Classes/Domain/Index/IndexerInterface.php @@ -33,11 +33,23 @@ interface IndexerInterface public function indexAllDocuments(); /** - * Fetches a single document from the indexerService and pushes it to the connection. - * - * @param string $identifier identifier, the indexer needs to identify a single document + * Fetches a single document and pushes it to the connection. * * @return void */ - public function indexDocument($identifier); + public function indexDocument(string $identifier); + + /** + * Recieves the identifier of the indexer itself. + * + * @return void + */ + public function setIdentifier(string $identifier); + + /** + * Delete the whole index. + * + * @return void + */ + public function delete(); } diff --git a/Classes/Domain/Index/NoMatchingIndexerException.php b/Classes/Domain/Index/NoMatchingIndexerException.php new file mode 100644 index 0000000..3f6c094 --- /dev/null +++ b/Classes/Domain/Index/NoMatchingIndexerException.php @@ -0,0 +1,25 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +class NoMatchingIndexerException extends IndexingException +{ +} diff --git a/Classes/Domain/Index/TcaIndexer.php b/Classes/Domain/Index/TcaIndexer.php index 9d192d3..071b671 100644 --- a/Classes/Domain/Index/TcaIndexer.php +++ b/Classes/Domain/Index/TcaIndexer.php @@ -20,107 +20,41 @@ namespace Codappix\SearchCore\Domain\Index; * 02110-1301, USA. */ -use TYPO3\CMS\Extbase\Object\ObjectManagerInterface; +use Codappix\SearchCore\Configuration\ConfigurationContainerInterface; use Codappix\SearchCore\Connection\ConnectionInterface; +use Codappix\SearchCore\Domain\Index\TcaIndexer\TcaTableServiceInterface; /** * Will index the given table using configuration from TCA. */ -class TcaIndexer implements IndexerInterface +class TcaIndexer extends AbstractIndexer { /** - * @var ConnectionInterface - */ - protected $connection; - - /** - * @var TcaIndexer\TcaTableService + * @var TcaTableServiceInterface */ protected $tcaTableService; /** - * @var \TYPO3\CMS\Core\Log\Logger - */ - protected $logger; - - /** - * Inject log manager to get concrete logger from it. - * - * @param \TYPO3\CMS\Core\Log\LogManager $logManager - */ - public function injectLogger(\TYPO3\CMS\Core\Log\LogManager $logManager) - { - $this->logger = $logManager->getLogger(__CLASS__); - } - - /** - * @param TcaIndexer\TcaTableService $tcaTableService + * @param TcaTableServiceInterface $tcaTableService * @param ConnectionInterface $connection + * @param ConfigurationContainerInterface $configuration */ public function __construct( - TcaIndexer\TcaTableService $tcaTableService, - ConnectionInterface $connection + TcaTableServiceInterface $tcaTableService, + ConnectionInterface $connection, + ConfigurationContainerInterface $configuration ) { + parent::__construct($connection, $configuration); $this->tcaTableService = $tcaTableService; - $this->connection = $connection; - } - - public function indexAllDocuments() - { - $this->logger->info('Start indexing'); - foreach ($this->getRecordGenerator() as $records) { - $this->logger->debug('Index records.', [$records]); - if ($records === null) { - break; - } - - $this->connection->addDocuments($this->tcaTableService->getTableName(), $records); - } - $this->logger->info('Finish indexing'); - } - - public function indexDocument($identifier) - { - $this->logger->info('Start indexing single record.', [$identifier]); - try { - $this->connection->addDocument($this->tcaTableService->getTableName(), $this->getRecord($identifier)); - } catch (NoRecordFoundException $e) { - $this->logger->info('Could not index document.', [$e->getMessage()]); - } - $this->logger->info('Finish indexing'); } /** - * @return \Generator - */ - protected function getRecordGenerator() - { - $offset = 0; - // TODO: Make configurable. - $limit = 50; - - while (($records = $this->getRecords($offset, $limit)) !== []) { - yield $records; - $offset += $limit; - } - } - - /** - * @param int $offset - * @param int $limit * @return array|null */ - protected function getRecords($offset, $limit) + protected function getRecords(int $offset, int $limit) { - $records = $GLOBALS['TYPO3_DB']->exec_SELECTgetRows( - $this->tcaTableService->getFields(), - $this->tcaTableService->getTableClause(), - $this->tcaTableService->getWhereClause(), - '', - '', - (int) $offset . ',' . (int) $limit - ); - if ($records === null) { + $records = $this->tcaTableService->getRecords($offset, $limit); + if ($records === []) { return null; } @@ -133,20 +67,13 @@ class TcaIndexer implements IndexerInterface } /** - * @param int $identifier - * @return array * @throws NoRecordFoundException If record could not be found. */ - protected function getRecord($identifier) + protected function getRecord(int $identifier) : array { - $record = $GLOBALS['TYPO3_DB']->exec_SELECTgetSingleRow( - $this->tcaTableService->getFields(), - $this->tcaTableService->getTableClause(), - $this->tcaTableService->getWhereClause() - . ' AND ' . $this->tcaTableService->getTableName() . '.uid = ' . (int) $identifier - ); + $record = $this->tcaTableService->getRecord($identifier); - if ($record === false || $record === null) { + if ($record === []) { throw new NoRecordFoundException( 'Record could not be fetched from database: "' . $identifier . '". Perhaps record is not active.', 1484225364 @@ -156,4 +83,9 @@ class TcaIndexer implements IndexerInterface return $record; } + + protected function getDocumentName() : string + { + return $this->tcaTableService->getTableName(); + } } diff --git a/Classes/Domain/Index/TcaIndexer/PagesIndexer.php b/Classes/Domain/Index/TcaIndexer/PagesIndexer.php new file mode 100644 index 0000000..7124ca1 --- /dev/null +++ b/Classes/Domain/Index/TcaIndexer/PagesIndexer.php @@ -0,0 +1,139 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Configuration\ConfigurationContainerInterface; +use Codappix\SearchCore\Connection\ConnectionInterface; +use Codappix\SearchCore\Domain\Index\TcaIndexer; +use Codappix\SearchCore\Domain\Index\TcaIndexer\TcaTableService; + +/** + * Specific indexer for Pages, will basically add content of page. + */ +class PagesIndexer extends TcaIndexer +{ + /** + * @var TcaTableServiceInterface + */ + protected $contentTableService; + + /** + * @var \TYPO3\CMS\Core\Resource\FileRepository + * @inject + */ + protected $fileRepository; + + /** + * @param TcaTableServiceInterface $tcaTableService + * @param TcaTableServiceInterface $contentTableService + * @param ConnectionInterface $connection + * @param ConfigurationContainerInterface $configuration + */ + public function __construct( + TcaTableServiceInterface $tcaTableService, + TcaTableServiceInterface $contentTableService, + ConnectionInterface $connection, + ConfigurationContainerInterface $configuration + ) { + parent::__construct($tcaTableService, $connection, $configuration); + $this->contentTableService = $contentTableService; + } + + protected function prepareRecord(array &$record) + { + $possibleTitleFields = ['nav_title', 'tx_tqseo_pagetitle_rel', 'title']; + foreach ($possibleTitleFields as $searchTitleField) { + if (isset($record[$searchTitleField]) && trim($record[$searchTitleField])) { + $record['search_title'] = trim($record[$searchTitleField]); + break; + } + } + + $record['media'] = $this->fetchMediaForPage($record['uid']); + $content = $this->fetchContentForPage($record['uid']); + if ($content !== []) { + $record['content'] = $content['content']; + $record['media'] = array_values(array_unique(array_merge($record['media'], $content['images']))); + } + parent::prepareRecord($record); + } + + protected function fetchContentForPage(int $uid) : array + { + if ($this->contentTableService instanceof TcaTableService) { + $contentElements = $this->contentTableService->getQuery() + ->execute()->fetchAll(); + } else { + $contentElements = $GLOBALS['TYPO3_DB']->exec_SELECTgetRows( + $this->contentTableService->getFields(), + $this->contentTableService->getTableClause(), + $this->contentTableService->getWhereClause() . + sprintf(' AND %s.pid = %u', $this->contentTableService->getTableName(), $uid) + ); + } + + if ($contentElements === null) { + $this->logger->debug('No content for page ' . $uid); + return []; + } + + $this->logger->debug('Fetched content for page ' . $uid); + $images = []; + $content = []; + foreach ($contentElements as $contentElement) { + $images = array_merge( + $images, + $this->getContentElementImages($contentElement['uid']) + ); + $content[] = $contentElement['bodytext']; + } + + return [ + // Remove Tags. + // Interpret escaped new lines and special chars. + // Trim, e.g. trailing or leading new lines. + 'content' => trim(stripcslashes(strip_tags(implode(' ', $content)))), + 'images' => $images, + ]; + } + + protected function getContentElementImages(int $uidOfContentElement) : array + { + return $this->fetchSysFileReferenceUids($uidOfContentElement, 'tt_content', 'image'); + } + + protected function fetchMediaForPage(int $uid) : array + { + return $this->fetchSysFileReferenceUids($uid, 'pages', 'media'); + } + + protected function fetchSysFileReferenceUids(int $uid, string $tablename, string $fieldname) : array + { + $imageRelationUids = []; + $imageRelations = $this->fileRepository->findByRelation($tablename, $fieldname, $uid); + + foreach ($imageRelations as $relation) { + $imageRelationUids[] = $relation->getUid(); + } + + return $imageRelationUids; + } +} diff --git a/Classes/Domain/Index/TcaIndexer/RelationResolver.php b/Classes/Domain/Index/TcaIndexer/RelationResolver.php index b09e483..ae47e30 100644 --- a/Classes/Domain/Index/TcaIndexer/RelationResolver.php +++ b/Classes/Domain/Index/TcaIndexer/RelationResolver.php @@ -20,13 +20,10 @@ namespace Codappix\SearchCore\Domain\Index\TcaIndexer; * 02110-1301, USA. */ +use Codappix\SearchCore\Utility\FrontendUtility; use TYPO3\CMS\Backend\Utility\BackendUtility; use TYPO3\CMS\Core\SingletonInterface as Singleton; use TYPO3\CMS\Core\Utility\GeneralUtility; -use TYPO3\CMS\Extbase\Utility\LocalizationUtility; - -use TYPO3\CMS\Backend\Form\FormDataCompiler; -use TYPO3\CMS\Backend\Form\FormDataGroup\TcaDatabaseRecord; /** * Resolves relations from TCA using TCA. @@ -36,130 +33,76 @@ use TYPO3\CMS\Backend\Form\FormDataGroup\TcaDatabaseRecord; */ class RelationResolver implements Singleton { - /** - * Resolve relations for the given record. - * - * @param TcaTableService $service - * @param array $record - */ - public function resolveRelationsForRecord(TcaTableService $service, array &$record) + public function resolveRelationsForRecord(TcaTableServiceInterface $service, array &$record) { - $formData = GeneralUtility::makeInstance( - FormDataCompiler::class, - GeneralUtility::makeInstance(TcaDatabaseRecord::class) - )->compile([ - 'tableName' => $service->getTableName(), - 'vanillaUid' => (int)$record['uid'], - 'command' => 'edit', - ]); - $record = $formData['databaseRow']; - foreach (array_keys($record) as $column) { + // TODO: Define / configure fields to exclude?! + if ($column === 'pid') { + continue; + } + + $record[$column] = GeneralUtility::makeInstance($this->getUtilityForMode()) + ::getProcessedValueExtra( + $service->getTableName(), + $column, + $record[$column], + 0, + $record['uid'] + ); + try { $config = $service->getColumnConfig($column); + + if ($this->isRelation($config)) { + $record[$column] = $this->resolveValue($record[$column], $config); + } } catch (InvalidArgumentException $e) { // Column is not configured. continue; } - - if (! $this->isRelation($config) || !is_array($formData['processedTca']['columns'][$column])) { - continue; - } - - $record[$column] = $this->resolveValue($record[$column], $formData['processedTca']['columns'][$column]); } } - /** - * Resolve the given value from TYPO3 API response. - * - * @param string $value The value from FormEngine to resolve. - * @param array $tcaColumn The tca config of the relation. - * - * @return array|string - */ protected function resolveValue($value, array $tcaColumn) { - if ($value === '' || $value === '0') { - return ''; + if ($value === '' || $value === 'N/A') { + return []; } - if ($tcaColumn['config']['type'] === 'select') { - return $this->resolveSelectValue($value, $tcaColumn); - } - if ($tcaColumn['config']['type'] === 'group' && strpos($value, '|') !== false) { + + if ($tcaColumn['type'] === 'select' && strpos($value, ';') !== false) { return $this->resolveForeignDbValue($value); } - if ($tcaColumn['config']['type'] === 'inline') { - return $this->resolveInlineValue($tcaColumn); + if (in_array($tcaColumn['type'], ['inline', 'group', 'select'])) { + return $this->resolveInlineValue($value); } - return ''; + return []; } - /** - * @param array Column config. - * @return bool - */ - protected function isRelation(array &$config) + protected function isRelation(array &$config) : bool { return isset($config['foreign_table']) - || (isset($config['items']) && is_array($config['items'])) + || (isset($config['renderType']) && $config['renderType'] !== 'selectSingle') || (isset($config['internal_type']) && strtolower($config['internal_type']) === 'db') ; } - /** - * Resolves internal representation of select to array of labels. - * - * @param array $value - * @param array $tcaColumn - * @return array - */ - protected function resolveSelectValue(array $values, array $tcaColumn) + protected function resolveForeignDbValue(string $value) : array { - $resolvedValues = []; - - foreach ($tcaColumn['config']['items'] as $item) { - if (in_array($item[1], $values)) { - $resolvedValues[] = $item[0]; - } - } - - if ($tcaColumn['config']['renderType'] === 'selectSingle' || $tcaColumn['config']['maxitems'] === 1) { - return current($resolvedValues); - } - - return $resolvedValues; + return array_map('trim', explode(';', $value)); } - /** - * @param string $value - * - * @return array - */ - protected function resolveForeignDbValue($value) + protected function resolveInlineValue(string $value) : array { - $titles = []; - - foreach (explode(',', urldecode($value)) as $title) { - $titles[] = explode('|', $title)[1]; - } - - return $titles; + return array_map('trim', explode(',', $value)); } - /** - * @param array $tcaColumn - * @return array - */ - protected function resolveInlineValue(array $tcaColumn) + protected function getUtilityForMode() : string { - $titles = []; - - foreach ($tcaColumn['children'] as $selected) { - $titles[] = $selected['recordTitle']; + if (TYPO3_MODE === 'BE') { + return BackendUtility::class; } - return $titles; + return FrontendUtility::class; } } diff --git a/Classes/Domain/Index/TcaIndexer/TcaTableService.php b/Classes/Domain/Index/TcaIndexer/TcaTableService.php index b7b73bc..ee0a2d8 100644 --- a/Classes/Domain/Index/TcaIndexer/TcaTableService.php +++ b/Classes/Domain/Index/TcaIndexer/TcaTableService.php @@ -21,14 +21,21 @@ namespace Codappix\SearchCore\Domain\Index\TcaIndexer; */ use Codappix\SearchCore\Configuration\ConfigurationContainerInterface; +use Codappix\SearchCore\Database\Doctrine\Join; +use Codappix\SearchCore\Database\Doctrine\Where; use Codappix\SearchCore\Domain\Index\IndexingException; +use Codappix\SearchCore\Domain\Index\TcaIndexer\InvalidArgumentException; use TYPO3\CMS\Backend\Utility\BackendUtility; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Database\Query\QueryBuilder; use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\RootlineUtility; +use TYPO3\CMS\Extbase\Object\ObjectManagerInterface; /** * Encapsulate logik related to TCA configuration. */ -class TcaTableService +class TcaTableService implements TcaTableServiceInterface { /** * TCA for current table. @@ -47,15 +54,20 @@ class TcaTableService */ protected $configuration; + /** + * @var RelationResolver + */ + protected $relationResolver; + /** * @var \TYPO3\CMS\Core\Log\Logger */ protected $logger; /** - * @var RelationResolver + * @var ObjectManagerInterface */ - protected $relationResolver; + protected $objectManager; /** * Inject log manager to get concrete logger from it. @@ -67,6 +79,14 @@ class TcaTableService $this->logger = $logManager->getLogger(__CLASS__); } + /** + * @param ObjectManagerInterface $objectManager + */ + public function injectObjectManager(ObjectManagerInterface $objectManager) + { + $this->objectManager = $objectManager; + } + /** * @param string $tableName * @param ConfigurationContainerInterface $configuration @@ -89,28 +109,36 @@ class TcaTableService $this->relationResolver = $relationResolver; } - /** - * @return string - */ - public function getTableName() + public function getTableName() : string { return $this->tableName; } - /** - * @return string - */ - public function getTableClause() + public function getTableClause() : string { - return $this->tableName . ' LEFT JOIN pages on ' . $this->tableName . '.pid = pages.uid'; + return $this->tableName; + } + + public function getRecords(int $offset, int $limit) : array + { + $records = $this->getQuery() + ->setFirstResult($offset) + ->setMaxResults($limit) + ->execute() + ->fetchAll(); + + return $records ?: []; + } + + public function getRecord(int $identifier) : array + { + $query = $this->getQuery(); + $query = $query->andWhere($this->getTableName() . '.uid = ' . $identifier); + $record = $query->execute()->fetch(); + + return $record ?: []; } - /** - * Filter the given records by root line blacklist settings. - * - * @param array &$records - * @return void - */ public function filterRecordsByRootLineBlacklist(array &$records) { $records = array_filter( @@ -121,10 +149,6 @@ class TcaTableService ); } - /** - * Adjust record accordingly to configuration. - * @param array &$record - */ public function prepareRecord(array &$record) { $this->relationResolver->resolveRelationsForRecord($this, $record); @@ -137,49 +161,39 @@ class TcaTableService } } - /** - * @return string - */ - public function getWhereClause() + protected function getWhereClause() : Where { - $whereClause = '1=1' - . BackendUtility::BEenableFields($this->tableName) - . BackendUtility::deleteClause($this->tableName) + $parameters = []; + $whereClause = $this->getSystemWhereClause(); - . BackendUtility::BEenableFields('pages') - . BackendUtility::deleteClause('pages') - . ' AND pages.no_search = 0' - ; - - $userDefinedWhere = $this->configuration->getIfExists('indexer.tca.' . $this->tableName . '.additionalWhereClause'); + $userDefinedWhere = $this->configuration->getIfExists( + 'indexing.' . $this->getTableName() . '.additionalWhereClause' + ); if (is_string($userDefinedWhere)) { $whereClause .= ' AND ' . $userDefinedWhere; } - if ($this->isBlacklistedRootLineConfigured()) { - $whereClause .= ' AND pages.uid NOT IN (' - . implode(',', $this->getBlacklistedRootLine()) - . ')' - . ' AND pages.pid NOT IN (' - . implode(',', $this->getBlacklistedRootLine()) - . ')'; + if ($this->isBlackListedRootLineConfigured()) { + $parameters[':blacklistedRootLine'] = implode(',', $this->getBlackListedRootLine()); + $whereClause .= ' AND pages.uid NOT IN (:blacklistedRootLine)' + . ' AND pages.pid NOT IN (:blacklistedRootLine)'; } $this->logger->debug('Generated where clause.', [$this->tableName, $whereClause]); - return $whereClause; + return new Where($whereClause, $parameters); } - /** - * @return string - */ - public function getFields() + protected function getFields() : array { $fields = array_merge( ['uid','pid'], array_filter( array_keys($this->tca['columns']), function ($columnName) { - return !$this->isSystemField($columnName); + return !$this->isSystemField($columnName) + && !$this->isUserField($columnName) + && !$this->isPassthroughField($columnName) + ; } ) ); @@ -189,14 +203,42 @@ class TcaTableService } $this->logger->debug('Generated fields.', [$this->tableName, $fields]); - return implode(',', $fields); + return $fields; + } + + protected function getJoins() : array + { + if ($this->tableName === 'pages') { + return []; + } + + return [ + new Join('pages', 'pages.uid = ' . $this->tableName . '.pid'), + ]; } /** - * @param string - * @return bool + * Generate SQL for TYPO3 as a system, to make sure only available records + * are fetched. */ - protected function isSystemField($columnName) + protected function getSystemWhereClause() : string + { + $whereClause = '1=1' + . BackendUtility::BEenableFields($this->tableName) + . BackendUtility::deleteClause($this->tableName) + . ' AND pages.no_search = 0' + ; + + if ($this->tableName !== 'pages') { + $whereClause .= BackendUtility::BEenableFields('pages') + . BackendUtility::deleteClause('pages') + ; + } + + return $whereClause; + } + + protected function isSystemField(string $columnName) : bool { $systemFields = [ // Versioning fields, @@ -208,19 +250,28 @@ class TcaTableService $this->tca['ctrl']['cruser_id'], $this->tca['ctrl']['fe_cruser_id'], $this->tca['ctrl']['fe_crgroup_id'], - $this->tca['ctrl']['languageField'], $this->tca['ctrl']['origUid'], ]; return in_array($columnName, $systemFields); } + protected function isUserField(string $columnName) : bool + { + $config = $this->getColumnConfig($columnName); + return isset($config['type']) && $config['type'] === 'user'; + } + + protected function isPassthroughField(string $columnName) : bool + { + $config = $this->getColumnConfig($columnName); + return isset($config['type']) && $config['type'] === 'passthrough'; + } + /** - * @param string $columnName - * @return array * @throws InvalidArgumentException */ - public function getColumnConfig($columnName) + public function getColumnConfig(string $columnName) : array { if (!isset($this->tca['columns'][$columnName])) { throw new InvalidArgumentException( @@ -236,26 +287,56 @@ class TcaTableService * Checks whether the given record was blacklisted by root line. * This can be configured by typoscript as whole root lines can be black listed. * - * NOTE: Does not support pages yet. We have to add a switch once we - * support them to use uid instead. - * - * @param array &$record - * @return bool + * Also further TYPO3 mechanics are taken into account. Does a valid root + * line exist, is page inside a recycler, is inherited start- endtime + * excluded, etc. */ - protected function isRecordBlacklistedByRootline(array &$record) + protected function isRecordBlacklistedByRootline(array &$record) : bool { - // If no rootline exists, the record is on a unreachable page and therefore blacklisted. - $rootline = BackendUtility::BEgetRootLine($record['pid']); - if (!isset($rootline[0])) { + $pageUid = $record['pid']; + if ($this->tableName === 'pages') { + $pageUid = $record['uid']; + } + + try { + $rootline = $this->objectManager->get(RootlineUtility::class, $pageUid)->get(); + } catch (\RuntimeException $e) { + $this->logger->notice( + sprintf('Could not fetch rootline for page %u, because: %s', $pageUid, $e->getMessage()), + [$record, $e] + ); return true; } - // Check configured black list if present. - if ($this->isBlackListedRootLineConfigured()) { - foreach ($rootline as $pageInRootLine) { - if (in_array($pageInRootLine['uid'], $this->getBlackListedRootLine())) { - return true; - } + foreach ($rootline as $pageInRootLine) { + // Check configured black list if present. + if ($this->isBlackListedRootLineConfigured() + && in_array($pageInRootLine['uid'], $this->getBlackListedRootLine()) + ) { + $this->logger->info( + sprintf( + 'Record %u is black listed due to configured root line configuration of page %u.', + $record['uid'], + $pageInRootLine['uid'] + ), + [$record, $pageInRootLine] + ); + return true; + } + + if ($pageInRootLine['extendToSubpages'] && ( + ($pageInRootLine['endtime'] > 0 && $pageInRootLine['endtime'] <= time()) + || ($pageInRootLine['starttime'] > 0 && $pageInRootLine['starttime'] >= time()) + )) { + $this->logger->info( + sprintf( + 'Record %u is black listed due to configured timing of parent page %u.', + $record['uid'], + $pageInRootLine['uid'] + ), + [$record, $pageInRootLine] + ); + return true; } } @@ -264,12 +345,10 @@ class TcaTableService /** * Checks whether any page uids are black listed. - * - * @return bool */ - protected function isBlackListedRootLineConfigured() + protected function isBlackListedRootLineConfigured() : bool { - return (bool) $this->configuration->getIfExists('indexer.tca.rootLineBlacklist'); + return (bool) $this->configuration->getIfExists('indexing.' . $this->getTableName() . '.rootLineBlacklist'); } /** @@ -277,8 +356,33 @@ class TcaTableService * * @return array */ - protected function getBlackListedRootLine() + protected function getBlackListedRootLine() : array { - return GeneralUtility::intExplode(',', $this->configuration->getIfExists('indexer.tca.rootLineBlacklist')); + return GeneralUtility::intExplode( + ',', + $this->configuration->getIfExists('indexing.' . $this->getTableName() . '.rootLineBlacklist') + ); + } + + public function getQuery() : QueryBuilder + { + $queryBuilder = $this->getDatabaseConnection()->getQueryBuilderForTable($this->getTableName()); + $where = $this->getWhereClause(); + $query = $queryBuilder->select(... $this->getFields()) + ->from($this->getTableClause()) + ->where($where->getStatement()) + ->setParameters($where->getParameters()); + + foreach ($this->getJoins() as $join) { + $query->from($join->getTable()); + $query->andWhere($join->getCondition()); + } + + return $query; + } + + protected function getDatabaseConnection() : ConnectionPool + { + return GeneralUtility::makeInstance(ConnectionPool::class); } } diff --git a/Classes/Domain/Index/TcaIndexer/TcaTableService76.php b/Classes/Domain/Index/TcaIndexer/TcaTableService76.php new file mode 100644 index 0000000..f52027a --- /dev/null +++ b/Classes/Domain/Index/TcaIndexer/TcaTableService76.php @@ -0,0 +1,378 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Configuration\ConfigurationContainerInterface; +use Codappix\SearchCore\Domain\Index\IndexingException; +use Codappix\SearchCore\Domain\Index\TcaIndexer\InvalidArgumentException; +use TYPO3\CMS\Backend\Utility\BackendUtility; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\RootlineUtility; +use TYPO3\CMS\Extbase\Object\ObjectManagerInterface; + +/** + * Encapsulate logik related to TCA configuration. + */ +class TcaTableService76 implements TcaTableServiceInterface +{ + /** + * TCA for current table. + * !REFERENCE! To save memory. + * @var array + */ + protected $tca; + + /** + * @var string + */ + protected $tableName; + + /** + * @var ConfigurationContainerInterface + */ + protected $configuration; + + /** + * @var RelationResolver + */ + protected $relationResolver; + + /** + * @var \TYPO3\CMS\Core\Log\Logger + */ + protected $logger; + + /** + * @var ObjectManagerInterface + */ + protected $objectManager; + + /** + * Inject log manager to get concrete logger from it. + * + * @param \TYPO3\CMS\Core\Log\LogManager $logManager + */ + public function injectLogger(\TYPO3\CMS\Core\Log\LogManager $logManager) + { + $this->logger = $logManager->getLogger(__CLASS__); + } + + /** + * @param ObjectManagerInterface $objectManager + */ + public function injectObjectManager(ObjectManagerInterface $objectManager) + { + $this->objectManager = $objectManager; + } + + /** + * @param string $tableName + * @param ConfigurationContainerInterface $configuration + */ + public function __construct( + $tableName, + RelationResolver $relationResolver, + ConfigurationContainerInterface $configuration + ) { + if (!isset($GLOBALS['TCA'][$tableName])) { + throw new IndexingException( + 'Table "' . $tableName . '" is not configured in TCA.', + IndexingException::CODE_UNKOWN_TCA_TABLE + ); + } + + $this->tableName = $tableName; + $this->tca = &$GLOBALS['TCA'][$this->tableName]; + $this->configuration = $configuration; + $this->relationResolver = $relationResolver; + } + + public function getTableName() : string + { + return $this->tableName; + } + + public function getTableClause() : string + { + if ($this->tableName === 'pages') { + return $this->tableName; + } + + return $this->tableName . ' LEFT JOIN pages on ' . $this->tableName . '.pid = pages.uid'; + } + + public function getRecords(int $offset, int $limit) : array + { + $records = $this->getConnection()->exec_SELECTgetRows( + $this->getFields(), + $this->getTableClause(), + $this->getWhereClause(), + '', + '', + (int) $offset . ',' . (int) $limit + ); + + return $records ?: []; + } + + public function getRecord(int $identifier) : array + { + $record = $this->getConnection()->exec_SELECTgetSingleRow( + $this->getFields(), + $this->getTableClause(), + $this->getWhereClause() + . ' AND ' . $this->getTableName() . '.uid = ' . (int) $identifier + ); + + return $record ?: []; + } + + public function filterRecordsByRootLineBlacklist(array &$records) + { + $records = array_filter( + $records, + function ($record) { + return ! $this->isRecordBlacklistedByRootline($record); + } + ); + } + + public function prepareRecord(array &$record) + { + $this->relationResolver->resolveRelationsForRecord($this, $record); + + if (isset($record['uid']) && !isset($record['search_identifier'])) { + $record['search_identifier'] = $record['uid']; + } + if (isset($record[$this->tca['ctrl']['label']]) && !isset($record['search_title'])) { + $record['search_title'] = $record[$this->tca['ctrl']['label']]; + } + } + + public function getWhereClause() : string + { + $whereClause = '1=1' + . BackendUtility::BEenableFields($this->tableName) + . BackendUtility::deleteClause($this->tableName) + . ' AND pages.no_search = 0' + ; + + if ($this->tableName !== 'pages') { + $whereClause .= BackendUtility::BEenableFields('pages') + . BackendUtility::deleteClause('pages') + ; + } + + $userDefinedWhere = $this->configuration->getIfExists( + 'indexing.' . $this->getTableName() . '.additionalWhereClause' + ); + if (is_string($userDefinedWhere)) { + $whereClause .= ' AND ' . $userDefinedWhere; + } + if ($this->isBlacklistedRootLineConfigured()) { + $whereClause .= ' AND pages.uid NOT IN (' + . implode(',', $this->getBlacklistedRootLine()) + . ')' + . ' AND pages.pid NOT IN (' + . implode(',', $this->getBlacklistedRootLine()) + . ')'; + } + + $this->logger->debug('Generated where clause.', [$this->tableName, $whereClause]); + return $whereClause; + } + + public function getFields() : string + { + $fields = array_merge( + ['uid','pid'], + array_filter( + array_keys($this->tca['columns']), + function ($columnName) { + return !$this->isSystemField($columnName) + && !$this->isUserField($columnName) + && !$this->isPassthroughField($columnName) + ; + } + ) + ); + + foreach ($fields as $key => $field) { + $fields[$key] = $this->tableName . '.' . $field; + } + + $this->logger->debug('Generated fields.', [$this->tableName, $fields]); + return implode(',', $fields); + } + + + /** + * Generate SQL for TYPO3 as a system, to make sure only available records + * are fetched. + */ + protected function getSystemWhereClause() : string + { + $whereClause = '1=1' + . BackendUtility::BEenableFields($this->tableName) + . BackendUtility::deleteClause($this->tableName) + . ' AND pages.no_search = 0' + ; + + if ($this->tableName !== 'pages') { + $whereClause .= BackendUtility::BEenableFields('pages') + . BackendUtility::deleteClause('pages') + ; + } + + return $whereClause; + } + + protected function isSystemField(string $columnName) : bool + { + $systemFields = [ + // Versioning fields, + // https://docs.typo3.org/typo3cms/TCAReference/Reference/Ctrl/Index.html#versioningws + 't3ver_oid', 't3ver_id', 't3ver_label', 't3ver_wsid', + 't3ver_state', 't3ver_stage', 't3ver_count', 't3ver_tstamp', + 't3ver_move_id', 't3ver_swapmode', + $this->tca['ctrl']['transOrigDiffSourceField'], + $this->tca['ctrl']['cruser_id'], + $this->tca['ctrl']['fe_cruser_id'], + $this->tca['ctrl']['fe_crgroup_id'], + $this->tca['ctrl']['origUid'], + ]; + + return in_array($columnName, $systemFields); + } + + protected function isUserField(string $columnName) : bool + { + $config = $this->getColumnConfig($columnName); + return isset($config['type']) && $config['type'] === 'user'; + } + + protected function isPassthroughField(string $columnName) : bool + { + $config = $this->getColumnConfig($columnName); + return isset($config['type']) && $config['type'] === 'passthrough'; + } + + /** + * @throws InvalidArgumentException + */ + public function getColumnConfig(string $columnName) : array + { + if (!isset($this->tca['columns'][$columnName])) { + throw new InvalidArgumentException( + 'Column does not exist.', + InvalidArgumentException::COLUMN_DOES_NOT_EXIST + ); + } + + return $this->tca['columns'][$columnName]['config']; + } + + /** + * Checks whether the given record was blacklisted by root line. + * This can be configured by typoscript as whole root lines can be black listed. + * + * Also further TYPO3 mechanics are taken into account. Does a valid root + * line exist, is page inside a recycler, is inherited start- endtime + * excluded, etc. + */ + protected function isRecordBlacklistedByRootline(array &$record) : bool + { + $pageUid = $record['pid']; + if ($this->tableName === 'pages') { + $pageUid = $record['uid']; + } + + try { + $rootline = $this->objectManager->get(RootlineUtility::class, $pageUid)->get(); + } catch (\RuntimeException $e) { + $this->logger->notice( + sprintf('Could not fetch rootline for page %u, because: %s', $pageUid, $e->getMessage()), + [$record, $e] + ); + return true; + } + + foreach ($rootline as $pageInRootLine) { + // Check configured black list if present. + if ($this->isBlackListedRootLineConfigured() + && in_array($pageInRootLine['uid'], $this->getBlackListedRootLine()) + ) { + $this->logger->info( + sprintf( + 'Record %u is black listed due to configured root line configuration of page %u.', + $record['uid'], + $pageInRootLine['uid'] + ), + [$record, $pageInRootLine] + ); + return true; + } + + if ($pageInRootLine['extendToSubpages'] && ( + ($pageInRootLine['endtime'] > 0 && $pageInRootLine['endtime'] <= time()) + || ($pageInRootLine['starttime'] > 0 && $pageInRootLine['starttime'] >= time()) + )) { + $this->logger->info( + sprintf( + 'Record %u is black listed due to configured timing of parent page %u.', + $record['uid'], + $pageInRootLine['uid'] + ), + [$record, $pageInRootLine] + ); + return true; + } + } + + return false; + } + + /** + * Checks whether any page uids are black listed. + */ + protected function isBlackListedRootLineConfigured() : bool + { + return (bool) $this->configuration->getIfExists('indexing.' . $this->getTableName() . '.rootLineBlacklist'); + } + + /** + * Get the list of black listed root line page uids. + * + * @return array + */ + protected function getBlackListedRootLine() : array + { + return GeneralUtility::intExplode( + ',', + $this->configuration->getIfExists('indexing.' . $this->getTableName() . '.rootLineBlacklist') + ); + } + + protected function getConnection() : \TYPO3\CMS\Core\Database\DatabaseConnection + { + return $GLOBALS['TYPO3_DB']; + } +} diff --git a/Classes/Domain/Index/TcaIndexer/TcaTableServiceInterface.php b/Classes/Domain/Index/TcaIndexer/TcaTableServiceInterface.php new file mode 100644 index 0000000..0c9cfc4 --- /dev/null +++ b/Classes/Domain/Index/TcaIndexer/TcaTableServiceInterface.php @@ -0,0 +1,44 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +interface TcaTableServiceInterface +{ + public function getTableName() : string; + + public function getTableClause() : string; + + /** + * Filter the given records by root line blacklist settings. + */ + public function filterRecordsByRootLineBlacklist(array &$records); + + public function prepareRecord(array &$record); + + /** + * @throws InvalidArgumentException + */ + public function getColumnConfig(string $columnName) : array; + + public function getRecords(int $offset, int $limit) : array; + + public function getRecord(int $identifier) : array; +} diff --git a/Classes/Domain/Model/FacetRequest.php b/Classes/Domain/Model/FacetRequest.php new file mode 100644 index 0000000..0d82ad3 --- /dev/null +++ b/Classes/Domain/Model/FacetRequest.php @@ -0,0 +1,56 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Connection\FacetRequestInterface; + +class FacetRequest implements FacetRequestInterface +{ + /** + * @var string + */ + protected $identifier = ''; + + /** + * @var array + */ + protected $config = []; + + /** + * As the facets come from configuration this might be a good idea to help + * integrators find issues. + */ + public function __construct(string $identifier, array $config) + { + $this->identifier = $identifier; + $this->config = $config; + } + + public function getIdentifier() : string + { + return $this->identifier; + } + + public function getConfig() : array + { + return $this->config; + } +} diff --git a/Classes/Domain/Model/QueryResultInterfaceStub.php b/Classes/Domain/Model/QueryResultInterfaceStub.php new file mode 100644 index 0000000..960fd40 --- /dev/null +++ b/Classes/Domain/Model/QueryResultInterfaceStub.php @@ -0,0 +1,61 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +/** + * As we have to stay compatible with QueryResultInterface + * of extbase but can and need not to provide all methods, + * this stub will provde the non implemented methods to + * keep real implementations clean. + */ +trait QueryResultInterfaceStub +{ + public function getFirst() + { + throw new \BadMethodCallException('Method is not implemented yet.', 1502195121); + } + + public function toArray() + { + throw new \BadMethodCallException('Method is not implemented yet.', 1502195135); + } + + public function offsetExists($offset) + { + // Return false to allow Fluid to use appropriate getter methods. + return false; + } + + public function offsetGet($offset) + { + throw new \BadMethodCallException('Use getter to fetch properties.', 1502196933); + } + + public function offsetSet($offset, $value) + { + throw new \BadMethodCallException('You are not allowed to modify the result.', 1502196934); + } + + public function offsetUnset($offset) + { + throw new \BadMethodCallException('You are not allowed to modify the result.', 1502196936); + } +} diff --git a/Classes/Domain/Model/ResultItem.php b/Classes/Domain/Model/ResultItem.php new file mode 100644 index 0000000..d0e370a --- /dev/null +++ b/Classes/Domain/Model/ResultItem.php @@ -0,0 +1,61 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Connection\ResultItemInterface; + +class ResultItem implements ResultItemInterface +{ + /** + * @var array + */ + protected $data = []; + + public function __construct(array $result) + { + $this->data = $result; + } + + public function getPlainData() : array + { + return $this->data; + } + + public function offsetExists($offset) + { + return isset($this->data[$offset]); + } + + public function offsetGet($offset) + { + return $this->data[$offset]; + } + + public function offsetSet($offset, $value) + { + throw new \BadMethodCallException('It\'s not possible to change the search result.', 1499179077); + } + + public function offsetUnset($offset) + { + throw new \BadMethodCallException('It\'s not possible to change the search result.', 1499179077); + } +} diff --git a/Classes/Domain/Model/SearchRequest.php b/Classes/Domain/Model/SearchRequest.php index 0f8a98b..3c88871 100644 --- a/Classes/Domain/Model/SearchRequest.php +++ b/Classes/Domain/Model/SearchRequest.php @@ -20,7 +20,10 @@ namespace Codappix\SearchCore\Domain\Model; * 02110-1301, USA. */ +use Codappix\SearchCore\Connection\ConnectionInterface; +use Codappix\SearchCore\Connection\FacetRequestInterface; use Codappix\SearchCore\Connection\SearchRequestInterface; +use Codappix\SearchCore\Domain\Search\SearchService; /** * Represents a search request used to process an actual search. @@ -32,29 +35,263 @@ class SearchRequest implements SearchRequestInterface * * @var string */ - protected $query; + protected $query = ''; + + /** + * @var array + */ + protected $filter = []; + + /** + * @var array + */ + protected $facets = []; + + /** + * @var int + */ + protected $offset = 0; + + /** + * @var int + */ + protected $limit = 10; + + /** + * Used for QueryInterface implementation to allow execute method to work. + * + * @var ConnectionInterface + */ + protected $connection = null; + + /** + * @var SearchService + */ + protected $searchService = null; /** * @param string $query */ - public function __construct($query) + public function __construct(string $query = '') { $this->query = $query; } - /** - * @return string - */ - public function getQuery() + public function getQuery() : string + { + return $this->query; + } + + public function getSearchTerm() : string { return $this->query; } /** - * @return string + * @param array $filter */ - public function getSearchTerm() + public function setFilter(array $filter) { - return $this->query; + $filter = \TYPO3\CMS\Core\Utility\ArrayUtility::removeArrayEntryByValue($filter, ''); + $this->filter = \TYPO3\CMS\Extbase\Utility\ArrayUtility::removeEmptyElementsRecursively($filter); + } + + public function hasFilter() : bool + { + return count($this->filter) > 0; + } + + public function getFilter() : array + { + return $this->filter; + } + + /** + * Add a facet to gather in this search request. + */ + public function addFacet(FacetRequestInterface $facet) + { + $this->facets[$facet->getIdentifier()] = $facet; + } + + /** + * Returns all configured facets to fetch in this search request. + */ + public function getFacets() : array + { + return $this->facets; + } + + /** + * Define connection to use for this request. + * Necessary to allow implementation of execute for interface. + */ + public function setConnection(ConnectionInterface $connection) + { + $this->connection = $connection; + } + + public function setSearchService(SearchService $searchService) + { + $this->searchService = $searchService; + } + + // Extbase QueryInterface + // Current implementation covers only paginate widget support. + public function execute($returnRawQueryResult = false) + { + if (! ($this->connection instanceof ConnectionInterface)) { + throw new \InvalidArgumentException( + 'Connection was not set before, therefore execute can not work. Use `setConnection` before.', + 1502197732 + ); + } + if (! ($this->searchService instanceof SearchService)) { + throw new \InvalidArgumentException( + 'SearchService was not set before, therefore execute can not work. Use `setSearchService` before.', + 1520325175 + ); + } + + return $this->searchService->processResult($this->connection->search($this)); + } + + public function setLimit($limit) + { + $this->limit = (int) $limit; + + return $this; + } + + public function setOffset($offset) + { + $this->offset = (int) $offset; + + return $this; + } + + public function getLimit() + { + return $this->limit; + } + + public function getOffset() + { + return $this->offset; + } + + public function getSource() + { + throw new \BadMethodCallException('Method is not implemented yet.', 1502196146); + } + + public function setOrderings(array $orderings) + { + throw new \BadMethodCallException('Method is not implemented yet.', 1502196163); + } + + public function matching($constraint) + { + throw new \BadMethodCallException('Method is not implemented yet.', 1502196197); + } + + public function logicalAnd($constraint1) + { + throw new \BadMethodCallException('Method is not implemented yet.', 1502196166); + } + + public function logicalOr($constraint1) + { + throw new \BadMethodCallException('Method is not implemented yet.', 1502196198); + } + + public function logicalNot(\TYPO3\CMS\Extbase\Persistence\Generic\Qom\ConstraintInterface $constraint) + { + throw new \BadMethodCallException('Method is not implemented yet.', 1502196166); + } + + public function equals($propertyName, $operand, $caseSensitive = true) + { + throw new \BadMethodCallException('Method is not implemented yet.', 1502196199); + } + + public function like($propertyName, $operand, $caseSensitive = true) + { + throw new \BadMethodCallException('Method is not implemented yet.', 1502196167); + } + + public function contains($propertyName, $operand) + { + throw new \BadMethodCallException('Method is not implemented yet.', 1502196200); + } + + public function in($propertyName, $operand) + { + throw new \BadMethodCallException('Method is not implemented yet.', 1502196167); + } + + public function lessThan($propertyName, $operand) + { + throw new \BadMethodCallException('Method is not implemented yet.', 1502196201); + } + + public function lessThanOrEqual($propertyName, $operand) + { + throw new \BadMethodCallException('Method is not implemented yet.', 1502196168); + } + + public function greaterThan($propertyName, $operand) + { + throw new \BadMethodCallException('Method is not implemented yet.', 1502196202); + } + + public function greaterThanOrEqual($propertyName, $operand) + { + throw new \BadMethodCallException('Method is not implemented yet.', 1502196168); + } + + public function getType() + { + throw new \BadMethodCallException('Method is not implemented yet.', 1502196203); + } + + public function setQuerySettings(\TYPO3\CMS\Extbase\Persistence\Generic\QuerySettingsInterface $querySettings) + { + throw new \BadMethodCallException('Method is not implemented yet.', 1502196168); + } + + public function getQuerySettings() + { + throw new \BadMethodCallException('Method is not implemented yet.', 1502196205); + } + + public function count() + { + throw new \BadMethodCallException('Method is not implemented yet.', 1502196169); + } + + public function getOrderings() + { + throw new \BadMethodCallException('Method is not implemented yet.', 1502196206); + } + + public function getConstraint() + { + throw new \BadMethodCallException('Method is not implemented yet.', 1502196171); + } + + public function isEmpty($propertyName) + { + throw new \BadMethodCallException('Method is not implemented yet.', 1502196207); + } + + public function setSource(\TYPO3\CMS\Extbase\Persistence\Generic\Qom\SourceInterface $source) + { + throw new \BadMethodCallException('Method is not implemented yet.', 1502196172); + } + + public function getStatement() + { + throw new \BadMethodCallException('Method is not implemented yet.', 1502196208); } } diff --git a/Classes/Domain/Model/SearchResult.php b/Classes/Domain/Model/SearchResult.php new file mode 100644 index 0000000..163b996 --- /dev/null +++ b/Classes/Domain/Model/SearchResult.php @@ -0,0 +1,129 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Connection\ResultItemInterface; +use Codappix\SearchCore\Connection\SearchResultInterface; +use Codappix\SearchCore\Domain\Model\QueryResultInterfaceStub; + +/** + * Generic model for mapping a concrete search result from a connection. + */ +class SearchResult implements SearchResultInterface +{ + use QueryResultInterfaceStub; + + /** + * @var SearchResultInterface + */ + protected $originalSearchResult; + + /** + * @var array + */ + protected $resultItems = []; + + /** + * @var array + */ + protected $results = []; + + /** + * For Iterator interface. + * + * @var int + */ + protected $position = 0; + + public function __construct(SearchResultInterface $originalSearchResult, array $resultItems) + { + $this->originalSearchResult = $originalSearchResult; + $this->resultItems = $resultItems; + } + + /** + * @return array + */ + public function getResults() : array + { + $this->initResults(); + + return $this->results; + } + + protected function initResults() + { + if ($this->results !== []) { + return; + } + + foreach ($this->resultItems as $item) { + $this->results[] = new ResultItem($item); + } + } + + public function getFacets() : array + { + return $this->originalSearchResult->getFacets(); + } + + public function getCurrentCount() : int + { + return $this->originalSearchResult->getCurrentCount(); + } + + public function count() + { + return $this->originalSearchResult->count(); + } + + public function current() + { + return $this->getResults()[$this->position]; + } + + public function next() + { + ++$this->position; + + return $this->current(); + } + + public function key() + { + return $this->position; + } + + public function valid() + { + return isset($this->getResults()[$this->position]); + } + + public function rewind() + { + $this->position = 0; + } + + public function getQuery() + { + return $this->originalSearchResult->getQuery(); + } +} diff --git a/Classes/Domain/Search/QueryFactory.php b/Classes/Domain/Search/QueryFactory.php new file mode 100644 index 0000000..98e3324 --- /dev/null +++ b/Classes/Domain/Search/QueryFactory.php @@ -0,0 +1,270 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Configuration\ConfigurationContainerInterface; +use Codappix\SearchCore\Configuration\ConfigurationUtility; +use Codappix\SearchCore\Configuration\InvalidArgumentException; +use Codappix\SearchCore\Connection\SearchRequestInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Extbase\Utility\ArrayUtility; + +class QueryFactory +{ + /** + * @var \TYPO3\CMS\Core\Log\Logger + */ + protected $logger; + + /** + * @var ConfigurationContainerInterface + */ + protected $configuration; + + /** + * @var ConfigurationUtility + */ + protected $configurationUtility; + + public function __construct( + \TYPO3\CMS\Core\Log\LogManager $logManager, + ConfigurationContainerInterface $configuration, + ConfigurationUtility $configurationUtility + ) { + $this->logger = $logManager->getLogger(__CLASS__); + $this->configuration = $configuration; + $this->configurationUtility = $configurationUtility; + } + + /** + * TODO: This is not in scope Elasticsearch, therefore it should not return + * \Elastica\Query, but decide to use a more specific QueryFactory like + * ElasticaQueryFactory, once the second query is added? + */ + public function create(SearchRequestInterface $searchRequest) : \Elastica\Query + { + return $this->createElasticaQuery($searchRequest); + } + + protected function createElasticaQuery(SearchRequestInterface $searchRequest) : \Elastica\Query + { + $query = []; + $this->addSize($searchRequest, $query); + $this->addSearch($searchRequest, $query); + $this->addBoosts($searchRequest, $query); + $this->addFilter($searchRequest, $query); + $this->addFacets($searchRequest, $query); + $this->addFields($searchRequest, $query); + $this->addSort($searchRequest, $query); + + // Use last, as it might change structure of query. + // Better approach would be something like DQL to generate query and build result in the end. + $this->addFactorBoost($query); + + $this->logger->debug('Generated elasticsearch query.', [$query]); + return new \Elastica\Query($query); + } + + protected function addSize(SearchRequestInterface $searchRequest, array &$query) + { + $query = ArrayUtility::arrayMergeRecursiveOverrule($query, [ + 'from' => $searchRequest->getOffset(), + 'size' => $searchRequest->getLimit(), + ]); + } + + protected function addSearch(SearchRequestInterface $searchRequest, array &$query) + { + if (trim($searchRequest->getSearchTerm()) === '') { + return; + } + + $matchExpression = [ + 'type' => 'most_fields', + 'query' => $searchRequest->getSearchTerm(), + 'fields' => GeneralUtility::trimExplode(',', $this->configuration->get('searching.fields.query')), + ]; + + $minimumShouldMatch = $this->configuration->getIfExists('searching.minimumShouldMatch'); + if ($minimumShouldMatch) { + $matchExpression['minimum_should_match'] = $minimumShouldMatch; + } + + $query = ArrayUtility::setValueByPath($query, 'query.bool.must.0.multi_match', $matchExpression); + } + + protected function addBoosts(SearchRequestInterface $searchRequest, array &$query) + { + try { + $fields = $this->configuration->get('searching.boost'); + } catch (InvalidArgumentException $e) { + return; + } + + if (trim($searchRequest->getSearchTerm()) === '') { + return; + } + + $boostQueryParts = []; + + foreach ($fields as $fieldName => $boostValue) { + $boostQueryParts[] = [ + 'match' => [ + $fieldName => [ + 'query' => $searchRequest->getSearchTerm(), + 'boost' => $boostValue, + ], + ], + ]; + } + + if (!empty($boostQueryParts)) { + $query = ArrayUtility::arrayMergeRecursiveOverrule($query, [ + 'query' => [ + 'bool' => [ + 'should' => $boostQueryParts, + ], + ], + ]); + } + } + + protected function addFactorBoost(array &$query) + { + try { + $query['query'] = [ + 'function_score' => [ + 'query' => $query['query'], + 'field_value_factor' => $this->configuration->get('searching.fieldValueFactor'), + ], + ]; + } catch (InvalidArgumentException $e) { + return; + } + } + + protected function addFields(SearchRequestInterface $searchRequest, array &$query) + { + try { + $query = ArrayUtility::arrayMergeRecursiveOverrule($query, [ + 'stored_fields' => GeneralUtility::trimExplode( + ',', + $this->configuration->get('searching.fields.stored_fields'), + true + ), + ]); + } catch (InvalidArgumentException $e) { + // Nothing configured + } + + try { + $scriptFields = $this->configuration->get('searching.fields.script_fields'); + $scriptFields = $this->configurationUtility->replaceArrayValuesWithRequestContent( + $searchRequest, + $scriptFields + ); + $scriptFields = $this->configurationUtility->filterByCondition($scriptFields); + if ($scriptFields !== []) { + $query = ArrayUtility::arrayMergeRecursiveOverrule($query, ['script_fields' => $scriptFields]); + } + } catch (InvalidArgumentException $e) { + // Nothing configured + } + } + + protected function addSort(SearchRequestInterface $searchRequest, array &$query) + { + $sorting = $this->configuration->getIfExists('searching.sort') ?: []; + $sorting = $this->configurationUtility->replaceArrayValuesWithRequestContent($searchRequest, $sorting); + $sorting = $this->configurationUtility->filterByCondition($sorting); + if ($sorting !== []) { + $query = ArrayUtility::arrayMergeRecursiveOverrule($query, ['sort' => $sorting]); + } + } + + protected function addFilter(SearchRequestInterface $searchRequest, array &$query) + { + if (! $searchRequest->hasFilter()) { + return; + } + + $filter = []; + foreach ($searchRequest->getFilter() as $name => $value) { + $filter[] = $this->buildFilter( + $name, + $value, + $this->configuration->getIfExists('searching.mapping.filter.' . $name) ?: [] + ); + } + + $query = ArrayUtility::arrayMergeRecursiveOverrule($query, [ + 'query' => [ + 'bool' => [ + 'filter' => $filter, + ], + ], + ]); + } + + protected function buildFilter(string $name, $value, array $config) : array + { + if ($config === []) { + return [ + 'term' => [ + $name => $value, + ], + ]; + } + + $filter = []; + + if (isset($config['fields'])) { + foreach ($config['fields'] as $elasticField => $inputField) { + $filter[$elasticField] = $value[$inputField]; + } + } + + if (isset($config['raw'])) { + $filter = array_merge($config['raw'], $filter); + } + + if ($config['type'] === 'range') { + return [ + 'range' => [ + $config['field'] => $filter, + ], + ]; + } + + return [$config['field'] => $filter]; + } + + protected function addFacets(SearchRequestInterface $searchRequest, array &$query) + { + foreach ($searchRequest->getFacets() as $facet) { + $query = ArrayUtility::arrayMergeRecursiveOverrule($query, [ + 'aggs' => [ + $facet->getIdentifier() => $facet->getConfig(), + ], + ]); + } + } +} diff --git a/Classes/Domain/Search/SearchService.php b/Classes/Domain/Search/SearchService.php index 3f6375f..3a83b7b 100644 --- a/Classes/Domain/Search/SearchService.php +++ b/Classes/Domain/Search/SearchService.php @@ -20,10 +20,16 @@ namespace Codappix\SearchCore\Domain\Search; * 02110-1301, USA. */ +use Codappix\SearchCore\Configuration\ConfigurationContainerInterface; +use Codappix\SearchCore\Configuration\InvalidArgumentException; use Codappix\SearchCore\Connection\ConnectionInterface; use Codappix\SearchCore\Connection\SearchRequestInterface; use Codappix\SearchCore\Connection\SearchResultInterface; -use Codappix\SearchCore\Domain\Model\SearchRequest; +use Codappix\SearchCore\DataProcessing\Service as DataProcessorService; +use Codappix\SearchCore\Domain\Model\FacetRequest; +use Codappix\SearchCore\Domain\Model\SearchResult; +use TYPO3\CMS\Core\Utility\ArrayUtility; +use TYPO3\CMS\Extbase\Object\ObjectManagerInterface; /** * Service to process a search request. @@ -36,19 +42,124 @@ class SearchService protected $connection; /** - * @param ConnectionInterface $connection + * @var ConfigurationContainerInterface */ - public function __construct(ConnectionInterface $connection) - { + protected $configuration; + + /** + * @var ObjectManagerInterface + */ + protected $objectManager; + + /** + * @var DataProcessorService + */ + protected $dataProcessorService; + + /** + * @param ConnectionInterface $connection + * @param ConfigurationContainerInterface $configuration + * @param ObjectManagerInterface $objectManager + * @param DataProcessorService $dataProcessorService + */ + public function __construct( + ConnectionInterface $connection, + ConfigurationContainerInterface $configuration, + ObjectManagerInterface $objectManager, + DataProcessorService $dataProcessorService + ) { $this->connection = $connection; + $this->configuration = $configuration; + $this->objectManager = $objectManager; + $this->dataProcessorService = $dataProcessorService; + } + + public function search(SearchRequestInterface $searchRequest) : SearchResultInterface + { + $this->addSize($searchRequest); + $this->addConfiguredFacets($searchRequest); + $this->addConfiguredFilters($searchRequest); + + // Add connection to request to enable paginate widget support + $searchRequest->setConnection($this->connection); + $searchRequest->setSearchService($this); + + return $this->processResult($this->connection->search($searchRequest)); } /** - * @param SearchRequestInterface $searchRequest - * @return SearchResultInterface + * Add configured size of search result items to request. */ - public function search(SearchRequestInterface $searchRequest) + protected function addSize(SearchRequestInterface $searchRequest) { - return $this->connection->search($searchRequest); + $searchRequest->setLimit( + $this->configuration->getIfExists('searching.size') ?: 10 + ); + } + + /** + * Add facets from configuration to request. + */ + protected function addConfiguredFacets(SearchRequestInterface $searchRequest) + { + $facetsConfig = $this->configuration->getIfExists('searching.facets'); + if ($facetsConfig === null) { + return; + } + + foreach ($facetsConfig as $identifier => $facetConfig) { + $searchRequest->addFacet($this->objectManager->get( + FacetRequest::class, + $identifier, + $facetConfig + )); + } + } + + /** + * Add filters from configuration, e.g. flexform or TypoScript. + */ + protected function addConfiguredFilters(SearchRequestInterface $searchRequest) + { + try { + $filter = $searchRequest->getFilter(); + + ArrayUtility::mergeRecursiveWithOverrule( + $filter, + $this->configuration->get('searching.filter'), + true, + false + ); + + $searchRequest->setFilter($filter); + } catch (InvalidArgumentException $e) { + // Nothing todo, no filter configured. + } + } + + /** + * Processes the result, e.g. applies configured data processing to result. + */ + public function processResult(SearchResultInterface $searchResult) : SearchResultInterface + { + try { + $newSearchResultItems = []; + foreach ($this->configuration->get('searching.dataProcessing') as $configuration) { + foreach ($searchResult as $resultItem) { + $newSearchResultItems[] = $this->dataProcessorService->executeDataProcessor( + $configuration, + $resultItem->getPlainData() + ); + } + } + + return $this->objectManager->get( + SearchResult::class, + $searchResult, + $newSearchResultItems + ); + } catch (InvalidArgumentException $e) { + return $searchResult; + } } } diff --git a/Classes/Domain/Service/DataHandler.php b/Classes/Domain/Service/DataHandler.php index 06b287c..de226b9 100644 --- a/Classes/Domain/Service/DataHandler.php +++ b/Classes/Domain/Service/DataHandler.php @@ -21,8 +21,10 @@ namespace Codappix\SearchCore\Domain\Service; */ use Codappix\SearchCore\Configuration\ConfigurationContainerInterface; +use Codappix\SearchCore\Domain\Index\IndexerFactory; +use Codappix\SearchCore\Domain\Index\IndexerInterface; +use Codappix\SearchCore\Domain\Index\NoMatchingIndexerException; use TYPO3\CMS\Core\SingletonInterface as Singleton; -use TYPO3\CMS\Core\Utility\GeneralUtility; /** * Handles all data related things like updates, deletes and inserts. @@ -46,8 +48,7 @@ class DataHandler implements Singleton protected $connection; /** - * @var \Codappix\SearchCore\Domain\Index\IndexerFactory - * @inject + * @var IndexerFactory */ protected $indexerFactory; @@ -73,48 +74,41 @@ class DataHandler implements Singleton /** * @param ConfigurationContainerInterface $configuration + * @param IndexerFactory $indexerFactory */ - public function __construct(ConfigurationContainerInterface $configuration) + public function __construct(ConfigurationContainerInterface $configuration, IndexerFactory $indexerFactory) { $this->configuration = $configuration; + $this->indexerFactory = $indexerFactory; } - /** - * Get all tables that are allowed for indexing. - * - * @return array - */ - public function getAllowedTablesForIndexing() - { - return GeneralUtility::trimExplode(',', $this->configuration->get('indexer.tca.allowedTables')); - } - - /** - * @param string $table - * @param array $record - */ - public function add($table, array $record) - { - $this->logger->debug('Record received for add.', [$table, $record]); - $this->indexerFactory->getIndexer($table)->indexDocument($record['uid']); - } - - /** - * @param string $table - */ - public function update($table, array $record) + public function update(string $table, array $record) { $this->logger->debug('Record received for update.', [$table, $record]); - $this->indexerFactory->getIndexer($table)->indexDocument($record['uid']); + $this->getIndexer($table)->indexDocument($record['uid']); } - /** - * @param string $table - * @param int $identifier - */ - public function delete($table, $identifier) + public function delete(string $table, string $identifier) { $this->logger->debug('Record received for delete.', [$table, $identifier]); $this->connection->deleteDocument($table, $identifier); } + + /** + * @throws NoMatchingIndexerException + */ + protected function getIndexer(string $table) : IndexerInterface + { + return $this->indexerFactory->getIndexer($table); + } + + public function supportsTable(string $table) : bool + { + try { + $this->getIndexer($table); + return true; + } catch (NoMatchingIndexerException $e) { + return false; + } + } } diff --git a/Classes/Hook/DataHandler.php b/Classes/Hook/DataHandler.php index 54e20ab..e0b8e40 100644 --- a/Classes/Hook/DataHandler.php +++ b/Classes/Hook/DataHandler.php @@ -48,99 +48,99 @@ class DataHandler implements Singleton /** * Dependency injection as TYPO3 doesn't provide it on it's own. * Still you can submit your own dataHandler. - * - * @param OwnDataHandler $dataHandler - * @param Logger $logger */ public function __construct(OwnDataHandler $dataHandler = null, Logger $logger = null) { - $this->dataHandler = $dataHandler; - if ($this->dataHandler === null) { + if ($dataHandler === null) { try { - $this->dataHandler = GeneralUtility::makeInstance(ObjectManager::class) + $dataHandler = GeneralUtility::makeInstance(ObjectManager::class) ->get(OwnDataHandler::class); } catch (NoConfigurationException $e) { // We have no configuration. That's fine, hooks will not be // executed due to check for existing DataHandler. } } + $this->dataHandler = $dataHandler; - $this->logger = $logger; - if ($this->logger === null) { - $this->logger = GeneralUtility::makeInstance(LogManager::class) + if ($logger === null) { + $logger = GeneralUtility::makeInstance(LogManager::class) ->getLogger(__CLASS__); } + $this->logger = $logger; } /** * Called by CoreDataHandler on deletion of records. - * - * @param string $table - * @param int $uid - * - * @return bool False if hook was not processed. */ - public function processCmdmap_deleteAction($table, $uid) + public function processCmdmap_deleteAction(string $table, int $uid) : bool { if (! $this->shouldProcessHookForTable($table)) { $this->logger->debug('Delete not processed.', [$table, $uid]); return false; } - $this->dataHandler->delete($table, $uid); + $this->dataHandler->delete($table, (string) $uid); return true; } - /** - * Called by CoreDataHandler on database operations, e.g. if new records were created or records were updated. - * - * @param string $status - * @param string $table - * @param string|int $uid - * @param array $fieldArray - * @param CoreDataHandler $dataHandler - * - * @return bool False if hook was not processed. - */ - public function processDatamap_afterDatabaseOperations($status, $table, $uid, array $fieldArray, CoreDataHandler $dataHandler) + public function processDatamap_afterAllOperations(CoreDataHandler $dataHandler) + { + foreach ($dataHandler->datamap as $table => $record) { + $uid = key($record); + $fieldData = current($record); + + if (isset($fieldData['uid'])) { + $uid = $fieldData['uid']; + } elseif (isset($dataHandler->substNEWwithIDs[$uid])) { + $uid = $dataHandler->substNEWwithIDs[$uid]; + } + + $this->processRecord($table, $uid); + } + } + + public function clearCachePostProc(array $parameters, CoreDataHandler $dataHandler) + { + $pageUid = 0; + + // If editor uses "small page blizzard" + if (isset($parameters['cacheCmd']) && is_numeric($parameters['cacheCmd'])) { + $pageUid = $parameters['cacheCmd']; + } + // If records were changed + if (isset($parameters['uid_page']) && is_numeric($parameters['uid_page'])) { + $pageUid = $parameters['uid_page']; + } + + if ($pageUid > 0) { + $this->processRecord('pages', (int) $pageUid); + } + } + + protected function processRecord(string $table, int $uid) : bool { if (! $this->shouldProcessHookForTable($table)) { - $this->logger->debug('Database update not processed.', [$table, $uid]); + $this->logger->debug('Indexing of record not processed.', [$table, $uid]); return false; } - if ($status === 'new') { - $fieldArray['uid'] = $dataHandler->substNEWwithIDs[$uid]; - $this->dataHandler->add($table, $fieldArray); + $record = $this->getRecord($table, $uid); + if ($record !== null) { + $this->dataHandler->update($table, $record); return true; } - if ($status === 'update') { - $record = $this->getRecord($table, $uid); - if ($record !== null) { - $this->dataHandler->update($table, $record); - } - return true; - } - - $this->logger->debug( - 'Database update not processed, cause status is unhandled.', - [$status, $table, $uid, $fieldArray] - ); + $this->logger->debug('Indexing of record not processed, as he was not found in Database.', [$table, $uid]); return false; } - /** - * @param string $table - * @return bool - */ - protected function shouldProcessHookForTable($table) + protected function shouldProcessHookForTable(string $table) : bool { if ($this->dataHandler === null) { $this->logger->debug('Datahandler could not be setup.'); return false; } - if (! $this->shouldProcessTable($table)) { + if (! $this->dataHandler->supportsTable($table)) { $this->logger->debug('Table is not allowed.', [$table]); return false; } @@ -148,23 +148,12 @@ class DataHandler implements Singleton return true; } - /** - * @param string $table - * @return bool - */ - protected function shouldProcessTable($table) - { - return in_array($table, $this->dataHandler->getAllowedTablesForIndexing()); - } - /** * Wrapper to allow unit testing. * - * @param string $table - * @param int $uid - * @return null|array + * @return array|null */ - protected function getRecord($table, $uid) + protected function getRecord(string $table, int $uid) { return BackendUtility::getRecord($table, $uid); } diff --git a/Classes/Integration/Form/Finisher/DataHandlerFinisher.php b/Classes/Integration/Form/Finisher/DataHandlerFinisher.php new file mode 100644 index 0000000..6a90e01 --- /dev/null +++ b/Classes/Integration/Form/Finisher/DataHandlerFinisher.php @@ -0,0 +1,69 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use TYPO3\CMS\Form\Domain\Finishers\AbstractFinisher; +use TYPO3\CMS\Form\Domain\Finishers\Exception\FinisherException; + +/** + * Integrates search_core indexing into TYPO3 Form extension. + * + * Add this finisher AFTER all database operations, as search_core will fetch + * information from database. + */ +class DataHandlerFinisher extends AbstractFinisher +{ + /** + * @var \Codappix\SearchCore\Domain\Service\DataHandler + * @inject + */ + protected $dataHandler; + + /** + * @var array + */ + protected $defaultOptions = [ + 'indexIdentifier' => null, + 'recordUid' => null, + 'action' => '', + ]; + + protected function executeInternal() + { + $action = $this->parseOption('action'); + $record = ['uid' => (int) $this->parseOption('recordUid')]; + $tableName = $this->parseOption('indexIdentifier'); + + if ($action === '' || $tableName === '' || !is_string($tableName) || $record['uid'] === 0) { + throw new FinisherException('Not all necessary options were set.', 1510313095); + } + + switch ($action) { + case 'update': + case 'add': + $this->dataHandler->update($tableName, $record); + break; + case 'delete': + $this->dataHandler->delete($tableName, (string) $record['uid']); + break; + } + } +} diff --git a/Classes/Utility/FrontendUtility.php b/Classes/Utility/FrontendUtility.php new file mode 100644 index 0000000..ffdbb6d --- /dev/null +++ b/Classes/Utility/FrontendUtility.php @@ -0,0 +1,36 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use TYPO3\CMS\Backend\Utility\BackendUtility; +use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController; + +/** + * Overwrite BackendUtility to use in frontend. + * LanguageService was only usable in backend. + */ +class FrontendUtility extends BackendUtility +{ + protected static function getLanguageService() : TypoScriptFrontendController + { + return $GLOBALS['TSFE']; + } +} diff --git a/Configuration/TCA/Overrides/tt_content.php b/Configuration/TCA/Overrides/tt_content.php new file mode 100644 index 0000000..6a976c6 --- /dev/null +++ b/Configuration/TCA/Overrides/tt_content.php @@ -0,0 +1,3 @@ + v documentation" by default. -#html_title = u'TYPO3 Extension search_core v1.0.0' +#html_title = u'TYPO3 Extension search_core v0.0.1' # A shorter title for the navigation bar. Default is the same as html_title. #html_short_title = None @@ -150,7 +150,7 @@ html_theme_options = { # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +# html_static_path = ['_static'] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied @@ -304,6 +304,7 @@ texinfo_documents = [ # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = { 't3tcaref': ('https://docs.typo3.org/typo3cms/TCAReference/', None), + 't3tsref': ('https://docs.typo3.org/typo3cms/TyposcriptReference/', None), } extlinks = { 'project': ('https://github.com/Codappix/search_core/projects/%s', 'Github project: '), diff --git a/Documentation/source/configuration.rst b/Documentation/source/configuration.rst index fb51d11..53ac469 100644 --- a/Documentation/source/configuration.rst +++ b/Documentation/source/configuration.rst @@ -5,9 +5,12 @@ Configuration ============= -The extension offers the following configuration options through TypoScript. If you overwrite them -through `setup` make sure to keep them in the `module` area as they will be accessed from backend -mode of TYPO3. Do so by placing the following line at the end:: +Installation wide configuration is handled inside of the extension manager. Just check out the +options there, they all have labels. + +Everything else is configured through TypoScript. If you overwrite them through `setup` make sure to +keep them in the `module` area as they will be accessed from backend mode of TYPO3 for indexing. Do +so by placing the following line at the end:: module.tx_searchcore < plugin.tx_searchcore @@ -23,12 +26,10 @@ Here is the example default configuration that's provided through static include .. literalinclude:: ../../Configuration/TypoScript/constants.txt :language: typoscript - :linenos: :caption: Static TypoScript Constants .. literalinclude:: ../../Configuration/TypoScript/setup.txt :language: typoscript - :linenos: :caption: Static TypoScript Setup .. _configuration_options: @@ -36,143 +37,14 @@ Here is the example default configuration that's provided through static include Options ------- -The following section contains the different options, e.g. for :ref:`connections` and -:ref:`indexer`: ``plugin.tx_searchcore.settings.connection`` or -``plugin.tx_searchcore.settings.index``. +The following sections contains the different options grouped by their applied area, e.g. for +:ref:`connections` and :ref:`indexer`: ``plugin.tx_searchcore.settings.connection`` or +``plugin.tx_searchcore.settings.indexing``: -.. _configuration_options_connection: +.. toctree:: + :maxdepth: 1 + :glob: -connections -^^^^^^^^^^^ - -Holds settings regarding the different possible connections for search services like Elasticsearch -or Solr. - -Configured as:: - - plugin { - tx_searchcore { - settings { - connections { - connectionName { - // the settings - } - } - } - } - } - -Where ``connectionName`` is one of the available :ref:`connections`. - -The following settings are available. For each setting its documented which connection consumes it. - -.. _host: - -``host`` -"""""""" - - Used by: :ref:`Elasticsearch`. - - The host, e.g. ``localhost`` or an IP where the search service is reachable from TYPO3 - installation. - - Example:: - - plugin.tx_searchcore.settings.connections.elasticsearch.host = localhost - -.. _port: - -``port`` -"""""""" - - Used by: :ref:`Elasticsearch`. - - The port where search service is reachable. E.g. default ``9200`` for Elasticsearch. - - Example:: - - plugin.tx_searchcore.settings.connections.elasticsearch.port = 9200 - - -.. _configuration_options_index: - -index -^^^^^ - -Holds settings regarding the indexing, e.g. of TYPO3 records, to search services. - -Configured as:: - - plugin { - tx_searchcore { - settings { - indexer { - indexerName { - // the settings - } - } - } - } - } - -Where ``indexerName`` is one of the available :ref:`indexer`. - -The following settings are available. For each setting its documented which indexer consumes it. - -.. _allowedTables: - -``allowedTables`` -""""""""""""""""" - - Used by: :ref:`TcaIndexer`. - - Defines which TYPO3 tables are allowed to be indexed. Only white listed tables will be processed - through Command Line Interface and Hooks. - - Contains a comma separated list of table names. Spaces are trimmed. - - Example:: - - plugin.tx_searchcore.settings.indexer.tca.allowedTables = tt_content, fe_users - -.. _rootLineBlacklist: - -``rootLineBlacklist`` -""""""""""""""""""""" - - Used by: :ref:`TcaIndexer`. - - Defines a blacklist of page uids. Records below any of these pages, or subpages, are not - indexed. This allows you to define areas that should not be indexed. - The page attribute *No Search* is also taken into account to prevent indexing records from only one - page without recursion. - - Contains a comma separated list of page uids. Spaces are trimmed. - - Example:: - - plugin.tx_searchcore.settings.index.tca.rootLineBlacklist = 3, 10, 100 - -Also it's possible to define some behaviour for the different document types. In context of TYPO3 -tables are used as document types 1:1. It's possible to configure different tables. The following -options are available: - -.. _additionalWhereClause: - -``additionalWhereClause`` -""""""""""""""""""""""""" - - Used by: :ref:`TcaIndexer`. - - Add additional SQL to where clauses to determine indexable records from the table. This way you - can exclude specific records like ``tt_content`` records with specific ``CType`` values or - something else. E.g. you can add a new field to the table to exclude records from indexing. - - Example:: - - plugin.tx_searchcore.settings.index.tca.tt_content.additionalWhereClause = tt_content.CType NOT IN ('gridelements_pi1', 'list', 'div', 'menu') - - .. attention:: - - Make sure to prefix all fields with the corresponding table name. The selection from - database will contain joins and can lead to SQL errors if a field exists in multiple tables. + configuration/connections + configuration/indexing + configuration/searching diff --git a/Documentation/source/configuration/connections.rst b/Documentation/source/configuration/connections.rst new file mode 100644 index 0000000..6d0c524 --- /dev/null +++ b/Documentation/source/configuration/connections.rst @@ -0,0 +1,48 @@ +.. _configuration_options_connection: + +Connections +=========== + +Holds settings regarding the different possible connections for search services like Elasticsearch +or Algolia. + +Configured as:: + + plugin { + tx_searchcore { + settings { + connections { + connectionName { + // the settings + } + } + } + } + } + +Where ``connectionName`` is one of the available :ref:`connections`. + +The following settings are available. + +.. _host: + +``host`` +-------- + +The host, e.g. ``localhost`` or an IP where the search service is reachable from TYPO3 +installation. + +Example:: + + plugin.tx_searchcore.settings.connections.elasticsearch.host = localhost + +.. _port: + +``port`` +-------- + +The port where search service is reachable. E.g. default ``9200`` for Elasticsearch. + +Example:: + + plugin.tx_searchcore.settings.connections.elasticsearch.port = 9200 diff --git a/Documentation/source/configuration/dataProcessing/ContentObjectDataProcessorAdapterProcessor.rst b/Documentation/source/configuration/dataProcessing/ContentObjectDataProcessorAdapterProcessor.rst new file mode 100644 index 0000000..7d8f0b2 --- /dev/null +++ b/Documentation/source/configuration/dataProcessing/ContentObjectDataProcessorAdapterProcessor.rst @@ -0,0 +1,32 @@ +``Codappix\SearchCore\DataProcessing\ContentObjectDataProcessorAdapterProcessor`` +================================================================================= + +Will execute an existing TYPO3 data processor. + +Possible Options: + +``_dataProcessor`` + Necessary, defined which data processor to apply. Provide the same as you would to call the + processor. +``_table`` + Defines the "current" table as used by some processors, e.g. + ``TYPO3\CMS\Frontend\DataProcessing\FilesProcessor``. + +All further options are passed to the configured data processor. Therefore they are documented at +each data processor. + +Example:: + + plugin.tx_searchcore.settings.searching.dataProcessing { + 1 = Codappix\SearchCore\DataProcessing\ContentObjectDataProcessorAdapterProcessor + 1 { + _table = pages + _dataProcessor = TYPO3\CMS\Frontend\DataProcessing\FilesProcessor + + references.fieldName = media + as = images + } + } + +The above example will create a new field ``images`` with resolved FAL relations from ``media`` +field. diff --git a/Documentation/source/configuration/dataProcessing/CopyToProcessor.rst b/Documentation/source/configuration/dataProcessing/CopyToProcessor.rst new file mode 100644 index 0000000..6d83d70 --- /dev/null +++ b/Documentation/source/configuration/dataProcessing/CopyToProcessor.rst @@ -0,0 +1,23 @@ +``Codappix\SearchCore\DataProcessing\CopyToProcessor`` +====================================================== + +Will copy contents of fields to other fields. + +Possible Options: + +``to`` + Defines the field to copy the values into. All values not false will be copied at the moment. + +Example:: + + plugin.tx_searchcore.settings.indexing.tt_content.dataProcessing { + 1 = Codappix\SearchCore\DataProcessing\CopyToProcessor + 1 { + to = all + } + 2 = Codappix\SearchCore\DataProcessing\CopyToProcessor + 2 { + to = spellcheck + } + } + diff --git a/Documentation/source/configuration/dataProcessing/GeoPointProcessor.rst b/Documentation/source/configuration/dataProcessing/GeoPointProcessor.rst new file mode 100644 index 0000000..a19928f --- /dev/null +++ b/Documentation/source/configuration/dataProcessing/GeoPointProcessor.rst @@ -0,0 +1,27 @@ +``Codappix\SearchCore\DataProcessing\GeoPointProcessor`` +======================================================== + +Will create a new field, ready to use as GeoPoint field for Elasticsearch. + +Possible Options: + +``to`` + Defines the field to create as GeoPoint. +``lat`` + Defines the field containing the latitude. +``lon`` + Defines the field containing the longitude. + +Example:: + + plugin.tx_searchcore.settings.indexing.tt_content.dataProcessing { + 1 = Codappix\SearchCore\DataProcessing\GeoPointProcessor + 1 { + to = location + lat = lat + lon = lng + } + } + +The above example will create a new field ``location`` as GeoPoint with latitude fetched from field +``lat`` and longitude fetched from field ``lng``. diff --git a/Documentation/source/configuration/dataProcessing/RemoveProcessor.rst b/Documentation/source/configuration/dataProcessing/RemoveProcessor.rst new file mode 100644 index 0000000..d39b42d --- /dev/null +++ b/Documentation/source/configuration/dataProcessing/RemoveProcessor.rst @@ -0,0 +1,23 @@ +``Codappix\SearchCore\DataProcessing\RemoveProcessor`` +====================================================== + +Will remove fields from record. + +Possible Options: + +``fields`` + Comma separated list of fields to remove from record. + +Example:: + + plugin.tx_searchcore.settings.indexing.tt_content.dataProcessing { + 1 = Codappix\SearchCore\DataProcessing\RemoveProcessor + 1 { + fields = description + } + 2 = Codappix\SearchCore\DataProcessing\RemoveProcessor + 2 { + fields = description, another_field + } + } + diff --git a/Documentation/source/configuration/indexing.rst b/Documentation/source/configuration/indexing.rst new file mode 100644 index 0000000..050f0ab --- /dev/null +++ b/Documentation/source/configuration/indexing.rst @@ -0,0 +1,152 @@ +.. _configuration_options_index: + +Indexing +======== + +Holds settings regarding the indexing, e.g. of TYPO3 records, to search services. + +Configured as:: + + plugin { + tx_searchcore { + settings { + indexing { + identifier { + indexer = FullyQualifiedClassname + // the settings + } + } + } + } + } + +Where ``identifier`` is up to you, but should match table names to make :ref:`TcaIndexer` work. + +The following settings are available. For each setting its documented which indexer consumes it. + +.. _rootLineBlacklist: + +rootLineBlacklist +----------------- + +Used by: :ref:`TcaIndexer`, :ref:`PagesIndexer`. + +Defines a blacklist of page uids. Records below any of these pages, or subpages, are not +indexed. This allows you to define areas that should not be indexed. +The page attribute *No Search* is also taken into account to prevent indexing records from only one +page without recursion. + +Contains a comma separated list of page uids. Spaces are trimmed. + +Example:: + + plugin.tx_searchcore.settings.indexing.pages.rootLineBlacklist = 3, 10, 100 + +.. _additionalWhereClause: + +additionalWhereClause +--------------------- + +Used by: :ref:`TcaIndexer`, :ref:`PagesIndexer`. + +Add additional SQL to where clauses to determine indexable records from the table. This way you +can exclude specific records like ``tt_content`` records with specific ``CType`` values or +something else. + +Example:: + + plugin.tx_searchcore.settings.indexing.tt_content.additionalWhereClause = tt_content.CType NOT IN ('gridelements_pi1', 'list', 'div', 'menu') + +.. attention:: + + Make sure to prefix all fields with the corresponding table name. The selection from + database might contain joins and can lead to SQL errors if a field exists in multiple tables. + +.. _abstractFields: + +abstractFields +-------------- + +Used by: :ref:`PagesIndexer`. + +.. note:: + + Will be migrated to :ref:`dataprocessors` in the future. + +Define which field should be used to provide the auto generated field "search_abstract". +The fields have to exist in the record to be indexed. Therefore fields like ``content`` are also +possible. + +Example:: + + # As last fallback we use the content of the page + plugin.tx_searchcore.settings.indexing.pages.abstractFields := addToList(content) + +Default:: + + abstract, description, bodytext + +.. _mapping: + +mapping +------- + +Used by: :ref:`connection_elasticsearch` connection while indexing. + +Define mapping for Elasticsearch, have a look at the official docs: https://www.elastic.co/guide/en/elasticsearch/reference/5.2/mapping.html +You are able to define the mapping for each property / column. + +Example:: + + plugin.tx_searchcore.settings.indexing.tt_content.mapping { + CType { + type = keyword + } + } + +The above example will define the ``CType`` field of ``tt_content`` as ``type: keyword``. This +makes building a facet possible. + +.. _index: + +index +----- + +Used by: :ref:`connection_elasticsearch` connection while indexing. + +Define index for Elasticsearch, have a look at the official docs: https://www.elastic.co/guide/en/elasticsearch/reference/5.2/indices-create-index.html + +Example:: + + plugin.tx_searchcore.settings.indexing.tt_content.index { + analysis { + analyzer { + ngram4 { + type = custom + tokenizer = ngram4 + char_filter = html_strip + filter = lowercase, asciifolding + } + } + + tokenizer { + ngram4 { + type = ngram + min_gram = 4 + max_gram = 4 + } + } + } + } + +``char_filter`` and ``filter`` are a comma separated list of options. + +.. _indexing_dataProcessing: + +dataProcessing +-------------- + +Used by: All connections while indexing, due to implementation inside ``AbstractIndexer``. + +Configure modifications on each document before sending it to the configured connection. +For full documentation check out :ref:`dataprocessors`. diff --git a/Documentation/source/configuration/searching.rst b/Documentation/source/configuration/searching.rst new file mode 100644 index 0000000..52abc07 --- /dev/null +++ b/Documentation/source/configuration/searching.rst @@ -0,0 +1,261 @@ +.. _configuration_options_search: + +Searching +========= + +.. _size: + +size +---- + +Defined how many search results should be fetched to be available in search result. + +Example:: + + plugin.tx_searchcore.settings.searching.size = 50 + +Default is ``10``. + +.. _facets: + +facets +------ + +Define aggregations for Elasticsearch, have a look at the official docs: https://www.elastic.co/guide/en/elasticsearch/reference/5.2/search-aggregations-bucket-terms-aggregation.html + +Example:: + + category { + terms { + field = categories + } + } + + month { + date_histogram { + field = released + interval = month + format = Y-MM-01 + order { + _time = desc + } + } + } + + +The above example will provide a facet with options for all found ``categories`` results together +with a count. Also a facet for ``released`` will be provided. + +.. _filter: + +filter +------ + +Define filter that should be set for all search requests. + +Example:: + + plugin.tx_searchcore.settings.searching.filter { + property = value + } + +Also see :ref:`mapping.filter` to map incoming request information, e.g. from a ``select``, to build +more complex filters. + +For Elasticsearch the fields have to be filterable, e.g. need a mapping as ``keyword``. + +.. _minimumShouldMatch: + +minimumShouldMatch +------------------ + +Define the minimum match for Elasticsearch, have a look at the official docs: +https://www.elastic.co/guide/en/elasticsearch/reference/5.2/query-dsl-minimum-should-match.html + +Example:: + + plugin.tx_searchcore.settings.searching.minimumShouldMatch = 50% + +.. _boost: + +boost +----- + +Define fields that should boost the score for results. + +Example:: + + plugin.tx_searchcore.settings.searching.boost { + search_title = 3 + search_abstract = 1.5 + } + +For further information take a look at +https://www.elastic.co/guide/en/elasticsearch/guide/2.x/_boosting_query_clauses.html + +.. _fieldValueFactor: + +fieldValueFactor +---------------- + +Define a field to use as a factor for scoring. The configuration is passed through to Elasticsearch +``field_value_factor``, see: +https://www.elastic.co/guide/en/elasticsearch/reference/5.2/query-dsl-function-score-query.html#function-field-value-factor + +Example:: + + plugin.tx_searchcore.settings.searching.field_value_factor { + field = rootlineLevel + modifier = reciprocal + factor = 2 + missing = 1 + } + +.. _mapping.filter: + +mapping.filter +-------------- + +Allows to configure filter more in depth. If a filter with the given key exists, the TypoScript will +be added. + +E.g. you submit a filter in form of: + +.. code-block:: html + + + Due to TYPO3 7.x fluid limitations, we build this input ourself. + No longer necessary in 8 and above + + + + +This will create a ``month`` filter with sub properties. To make this filter actually work, you +can add the following TypoScript, which will be added to the filter:: + + mapping { + filter { + month { + type = range + field = released + raw { + format = yyyy-MM + } + + fields { + gte = from + lte = to + } + } + } + } + +``fields`` has a special meaning here. This will actually map the properties of the filter to fields +in Elasticsearch. On the left hand side is the Elasticsearch field name, on the right side the one +submitted as a filter. + +The ``field``, in above example ``released``, will be used as the Elasticsearch field for +filtering. This way you can use arbitrary filter names and map them to existing Elasticsearch fields. + +Everything that is configured inside ``raw`` is passed, as is, to search service, e.g. +Elasticsearch. + +.. _fields: + +fields +------ + +Defines the fields to fetch and search from Elasticsearch. With the following sub keys: + +``query`` defines the fields to search in. Configure a comma separated list of fields to search in. +This is necessary if you have configured special mapping for some fields, or just want to search +some fields. The following is an example configuration:: + + fields { + query = _all, city + } + +The following sub properties configure the fields to fetch from Elasticsearch: + +First ``stored_fields`` which is a list of comma separated fields which actually exist and will be +added. Typically you will use ``_source`` to fetch the whole indexed fields. + +Second is ``script_fields``, which allow you to configure scripted fields for Elasticsearch. +An example might look like the following:: + + fields { + script_fields { + distance { + condition = {request.filter.distance.location} + script { + params { + lat = {request.filter.distance.location.lat -> f:format.number()} + lon = {request.filter.distance.location.lon -> f:format.number()} + } + lang = painless + inline = doc["location"].arcDistance(params.lat,params.lon) * 0.001 + } + } + } + } + +In above example we add a single ``script_field`` called ``distance``. We add a condition when this +field should be added. The condition will be parsed as Fluidtemplate and is casted to bool via PHP. +If the condition is true, or no ``condition`` exists, the ``script_field`` will be added to the +query. The ``condition`` will be removed and everything else is submitted one to one to +Elasticsearch, except each property is run through Fluidtemplate, to allow you to use information +from search request, e.g. to insert latitude and longitude from a filter, like in the above example. + +.. _sort: + +sort +---- + +Sort is handled like :ref:`fields`. + +.. _mode: + +mode +---- + +Used by: Controller while preparing action. + +Define to switch from search to filter mode. + +Example:: + + plugin.tx_searchcore.settings.searching { + mode = filter + } + +Only ``filter`` is allowed as value, as ``search`` is default behaviour. Using ``filter`` will +trigger a search to provide data while visiting the page, possible :ref:`filter` allow you to build +pages like "News". + +.. _searching_dataprocessing: + +dataProcessing +-------------- + +Configure modifications on each document before returning search result. +For full documentation check out :ref:`dataprocessors`. diff --git a/Documentation/source/connections.rst b/Documentation/source/connections.rst index 678a92d..73ce273 100644 --- a/Documentation/source/connections.rst +++ b/Documentation/source/connections.rst @@ -5,22 +5,25 @@ Connections See Concept of :ref:`concepts_connections` for further background information. -The extension provides the following connections out of the box: +For information about implementing a new connection, take a look at :ref:`development_connection`. -.. _Elasticsearch: +The following connections were developed, or are in development, for ``search_core``: + +.. _connection_elasticsearch: Elasticsearch ------------- -Integrates `elastic Elasticsearch`_ using `elastica`_ into TYPO3. +Support for `Elasticsearch`_ is provided out of the box by `search_core` at the moment. -Provides basic support like indexing without mappings and full text search at the moment. +.. _Elasticsearch: https://www.elastic.co/products/elasticsearch -The connection is configurable through the following options: +.. _connection_algolia: -* :ref:`host` +Algolia +------- -* :ref:`port` +`search_algolia`_ will integrate `Algolia`_ and is currently under development by Martin Hummer. -.. _elastic Elasticsearch: https://www.elastic.co/products/elasticsearch -.. _elastica: http://elastica.io/ +.. _search_algolia: https://github.com/martinhummer/search_algolia/ +.. _Algolia: https://www.algolia.com/ diff --git a/Documentation/source/dataprocessors.rst b/Documentation/source/dataprocessors.rst new file mode 100644 index 0000000..67ff655 --- /dev/null +++ b/Documentation/source/dataprocessors.rst @@ -0,0 +1,98 @@ +.. _dataprocessors: + +DataProcessors +============== + +See Concept of :ref:`concepts_dataprocessing` for further background information. + +For information about implementing a new DataProcessor, take a look at +:ref:`development_dataprocessor`. + +Same as provided by TYPO3 for :ref:`t3tsref:cobj-fluidtemplate` through +:ref:`t3tsref:cobj-fluidtemplate-properties-dataprocessing`. + +.. _dataprocessors_usage: + +Usage +----- + +All processors are applied in configured order. Allowing to work with already processed data. +They can be applied during indexing and for search results. + +Example for indexing:: + + plugin.tx_searchcore.settings.indexing.pages.dataProcessing { + 1 = Codappix\SearchCore\DataProcessing\CopyToProcessor + 1 { + to = search_spellcheck + } + + 2 = Codappix\SearchCore\DataProcessing\CopyToProcessor + 2 { + to = search_all + } + } + +The above example will copy all existing fields to the field ``search_spellcheck``. Afterwards +all fields, including ``search_spellcheck`` will be copied to ``search_all``. + +Example for search results:: + + plugin.tx_searchcore.settings.searching.dataProcessing { + 1 = Codappix\SearchCore\DataProcessing\CopyToProcessor + 1 { + to = search_spellcheck + } + + 2 = Codappix\SearchCore\DataProcessing\CopyToProcessor + 2 { + to = search_all + } + } + +The above example will copy all existing fields to the field ``search_spellcheck``. Afterwards +all fields, including ``search_spellcheck`` will be copied to ``search_all``. + +.. _dataprocessors_availableDataProcessors: + +Available DataProcessors +------------------------ + +.. toctree:: + :maxdepth: 1 + :glob: + + /configuration/dataProcessing/ContentObjectDataProcessorAdapterProcessor + /configuration/dataProcessing/CopyToProcessor + /configuration/dataProcessing/GeoPointProcessor + /configuration/dataProcessing/RemoveProcessor + +.. _dataprocessors_plannedDataProcessors: + +Planned DataProcessors +---------------------- + + ``Codappix\SearchCore\DataProcessing\ReplaceProcessor`` + Will execute a search and replace on configured fields. + + ``Codappix\SearchCore\DataProcessing\RootLevelProcessor`` + Will attach the root level to the record. + + ``Codappix\SearchCore\DataProcessing\ChannelProcessor`` + Will add a configurable channel to the record, e.g. if you have different areas in your + website like "products" and "infos". + + ``Codappix\SearchCore\DataProcessing\RelationResolverProcessor`` + Resolves all relations using the TCA. + This is currently done through indexer. + +.. Of course you are able to provide further processors. Just implement +.. ``Codappix\SearchCore\DataProcessing\ProcessorInterface`` and use the FQCN (=Fully qualified +.. class name) as done in the examples above. + +.. By implementing also the same interface as necessary for TYPO3 +.. :ref:`t3tsref:cobj-fluidtemplate-properties-dataprocessing`, you are able to reuse the same code +.. also for Fluid to prepare the same record fetched from DB for your fluid. + +.. Dependency injection is possible inside of processors, as we instantiate through extbase +.. ``ObjectManager``. diff --git a/Documentation/source/development.rst b/Documentation/source/development.rst index 8026473..e65dcdf 100644 --- a/Documentation/source/development.rst +++ b/Documentation/source/development.rst @@ -1,70 +1,16 @@ .. highlight:: bash -.. _contribution: - -Contribution -============ - -Everyone is welcome to contribute, whether it's code, issues, feature requests or any other kind. - -Below is a documentation what to respect during contributions. - -.. _contribution_setup: - -Setup ------ - -To start contributions regarding code, make sure your environment matches the following -requirements: - -* composer is executable - -* PHP on CLI is executable - -* MySQL is up and running with user *dev* and password *dev* on *127.0.0.1* or to overwrite the - environment variables, see :file:`Makefile`. - And MySQL is not set to strict mode as TYPO3 doesn't support strict mode, see - https://review.typo3.org/#/c/26725/3/INSTALL.md. - -* Elasticsearch is installed and up and running on *localhost:9200*. - -Then setup your system:: - - git clone git@github.com:DanielSiepmann/search_core.git \ - && cd search_core \ - && export typo3DatabaseName="searchcoretest62" \ - && export TYPO3_VERSION="~6.2" \ - && make install \ - && make functionalTests - -If all tests are okay, start your work. - -If you are working with multiple TYPO3 versions make sure to export `typo3DatabaseName` and -`TYPO3_VERSION` in your environment like:: - - export typo3DatabaseName="searchcoretest76" - export TYPO3_VERSION="~7.6" - -Also run the install command for each version before running any tests. Only this will make sure you -are testing against the actual TYPO3 Version and database scheme. - -.. _contribution_development: - Development ------------ +=========== -All changes are introduced through pull requests at `Github`_ and should contain the following: +There are some ways we will cover here. One is how you can develop own parts like Indexer, +DataProcessor and Connection. The other is how to contribute. -* Adjusted tests if tests existed before. Otherwise they will break on `travis-ci`_. +.. toctree:: + :maxdepth: 1 + :glob: -* New tests whenever possible and useful. - -* Code has to follow `PSR-2`_. - -* Adjusted documentation. - -* Make sure to follow the documented :ref:`concepts`. - -.. _Github: https://github.com/DanielSiepmann/search_core -.. _travis-ci: https://travis-ci.org/ -.. _PSR-2: http://www.php-fig.org/psr/psr-2/ + development/indexer + development/dataProcessor + development/connection + development/contribution diff --git a/Documentation/source/development/connection.rst b/Documentation/source/development/connection.rst new file mode 100644 index 0000000..af0ab14 --- /dev/null +++ b/Documentation/source/development/connection.rst @@ -0,0 +1,11 @@ +.. _development_connection: + +Develop a new Connection +======================== + +Make sure you understood :ref:`concepts_connections`. + +Each Connection has to be a single class which implements +``Codappix\SearchCore\Connection\ConnectionInterface``. + +Dependency Injection is working for custom Connections. diff --git a/Documentation/source/development/contribution.rst b/Documentation/source/development/contribution.rst new file mode 100644 index 0000000..f65bb21 --- /dev/null +++ b/Documentation/source/development/contribution.rst @@ -0,0 +1,69 @@ +.. _contribution: + +Contribution +============ + +Everyone is welcome to contribute, whether it's code, issues, feature requests or any other kind. + +Below is a documentation what to respect during contributions. + +.. _contribution_setup: + +Setup +----- + +To start contributions regarding code, make sure your environment matches the following +requirements: + +* composer is executable + +* PHP on CLI is executable + +* MySQL is up and running with user *dev* and password *dev* on *127.0.0.1* or to overwrite the + environment variables, see :file:`Makefile`. + And MySQL is not set to strict mode as TYPO3 doesn't support strict mode, see + https://review.typo3.org/#/c/26725/3/INSTALL.md. + +* Elasticsearch is installed and up and running on *localhost:9200*. + +Then setup your system:: + + git clone git@github.com:codappix/search_core.git \ + && cd search_core \ + && export typo3DatabaseName="searchcoretest87" \ + && export TYPO3_VERSION="~8.7" \ + && make install \ + && make unitTests \ + && make functionalTests + +If all tests are okay, start your work. + +If you are working with multiple TYPO3 versions make sure to export `typo3DatabaseName` and +`TYPO3_VERSION` in your environment like:: + + export typo3DatabaseName="searchcoretest76" + export TYPO3_VERSION="~7.6" + +Also run the install command for each version before running any tests. Only this will make sure you +are testing against the actual TYPO3 Version and database scheme. + +.. _contribution_development: + +Development +----------- + +All changes are introduced through pull requests at `Github`_ and should contain the following: + +* Adjusted tests if tests existed before. Otherwise they will break on `travis-ci`_. + +* New tests whenever possible and useful. + +* Code has to follow `PSR-2`_. + +* Adjusted documentation. + +* Make sure to follow the documented :ref:`concepts`. + +.. _Github: https://github.com/codappix/search_core +.. _travis-ci: https://travis-ci.org/ +.. _PSR-2: http://www.php-fig.org/psr/psr-2/ diff --git a/Documentation/source/development/dataProcessor.rst b/Documentation/source/development/dataProcessor.rst new file mode 100644 index 0000000..af2732b --- /dev/null +++ b/Documentation/source/development/dataProcessor.rst @@ -0,0 +1,15 @@ +.. _development_dataprocessor: + +Develop a new DataProcessor +=========================== + +Make sure you understood :ref:`concepts_dataprocessing`. + +Each DataProcessor has to be a single class which implements +``Codappix\SearchCore\DataProcessing\ProcessorInterface``. + +Make sure you support both, Frontend and Backend, as processors can be called during searching and +indexing. Therefore do not rely on e.g. ``TSFE``, make sure dependencies are met and your code will +work in both environments. + +Dependency Injection is working for custom DataProcessors. diff --git a/Documentation/source/development/indexer.rst b/Documentation/source/development/indexer.rst new file mode 100644 index 0000000..d5a067e --- /dev/null +++ b/Documentation/source/development/indexer.rst @@ -0,0 +1,21 @@ +.. _development_indexer: + +Develop a new Indexer +===================== + +Make sure you understood :ref:`concepts_indexing`. + +Each indexer has to be a single class which implements +``Codappix\SearchCore\Domain\Index\IndexerInterface``. + +The indexer should call the connection with all necessary information about the document(s) to +trigger indexing or deletion of whole index. + +As this is the "indexer", deletion of single documents is directly processed by the connection. + +``setIdentifier`` is called with the identifier of the current Indexer. This might be useful to +fetch configuration, related to the indexing, from +``Codappix\SearchCore\Configuration\ConfigurationContainerInterface``. + +Dependency Injection is working for custom indexers, therefore you are able to inject the +``ConfigurationContainerInterface``. diff --git a/Documentation/source/features.rst b/Documentation/source/features.rst new file mode 100644 index 0000000..57014bd --- /dev/null +++ b/Documentation/source/features.rst @@ -0,0 +1,62 @@ +.. _features: + +Features +======== + +The following features are available: + +.. _features_indexing: + +Indexing +-------- + +Indexing of data is possible. We deliver an indexer for TCA with zero configuration needs. You can +also provide custom indexer for arbitrary data which is not indexable through TCA. + +Also a finisher for TYPO3 Form-Extension is provided to integrate indexing after something was +update through the Form-Extension. + +Indexing is done through Hooks and CLI. We therefore provide commands to index and delete indexed +data. + +.. _features_search: + +Searching +--------- + +.. note:: + Currently only integrated for Elasticsearch with no abstraction. + If you need to implement your own search, please open an issue on Github and we will change the code + base. + +Via TypoScript it's possible to configure the fields to query, minimum match and script fields. +Also multiple filter are supported, filtering results by fields. + +Facets / aggregates are also possible. Therefore a mapping has to be defined in TypoScript for +indexing, and the facets itself while searching. + +.. _features_dataProcessing: + +DataProcessing +-------------- + +DataProcessing, as known from ``FLUIDTEMPLATE``, is available while indexing and for search results. +Each record and result item can be processed by multiple processor to prepare data for indexing and +output. + +See :ref:`concepts_dataprocessing` in :ref:`concepts` section. + +.. _features_planned: + +Planned +------- + +The following features are currently planned and will be integrated: + +#. :issue:`25` Multi language. +#. :issue:`94` Respect access rights while indexing relations. +#. :issue:`75` Configuration of index name (for Elasticsearch). + +For a full list, check out our `open issues`_. + +.. _open issues: https://github.com/Codappix/search_core/issues diff --git a/Documentation/source/index.rst b/Documentation/source/index.rst index dfda13b..1cc9eb6 100644 --- a/Documentation/source/index.rst +++ b/Documentation/source/index.rst @@ -1,16 +1,19 @@ .. include:: readme.rst Table of Contents -================= +----------------- .. toctree:: :maxdepth: 1 :glob: + features installation configuration usage concepts connections indexer + dataprocessors development + changelog diff --git a/Documentation/source/indexer.rst b/Documentation/source/indexer.rst index 01d7b18..733f5b9 100644 --- a/Documentation/source/indexer.rst +++ b/Documentation/source/indexer.rst @@ -5,6 +5,8 @@ Indexer See Concept of :ref:`concepts_indexing` for further background information. +For information about implementing a new indexer, take a look at :ref:`development_indexer`. + The extension provides the following indexer out of the box: .. _TcaIndexer: @@ -15,19 +17,24 @@ TcaIndexer Provides zero configuration TYPO3 integration by using the :ref:`t3tcaref:start`. You just can start indexing TYPO3. +Just add the indexer for a TYPO3 table. The indexer will use the TCA to fetch all necessary +information like relations. + +.. note:: + + Not all relations are resolved yet, see :issue:`17`. + +.. _PagesIndexer: + +PagesIndexer +------------ + +Provides zero configuration TYPO3 integration by using the :ref:`t3tcaref:start`. You just can +start indexing TYPO3. + The indexer will use the TCA to fetch all necessary information like relations. Currently the -implementation is very basic. In future it will also provide mapping for :ref:`Elasticsearch` and -further stuff. - -The indexer is configurable through the following options: - -* :ref:`allowedTables` - -* :ref:`rootLineBlacklist` - -* :ref:`additionalWhereClause` +implementation is very basic. .. note:: Not all relations are resolved yet, see :issue:`17` and :pr:`20`. - Also the `pages`-Table is not available yet, see :issue:`24`. diff --git a/Documentation/source/installation.rst b/Documentation/source/installation.rst index 614b6e6..0a0e5b6 100644 --- a/Documentation/source/installation.rst +++ b/Documentation/source/installation.rst @@ -4,20 +4,41 @@ Installation ============ +Composer +-------- + The extension can be installed through composer:: - composer require "leonmrni/search_core dev-feature/integrate-elasticsearch" + composer require "codappix/search_core" "~1.0.0" -or by `downloading`_ and placing it inside the :file:`typo3conf/ext`-Folder of your installation. -In that case you need to install all dependencies yourself. Dependencies are: +Note that you have to allow unstable packages: + +.. code-block:: json + + { + "minimum-stability": "dev", + "prefer-stable": true + } + +Download +-------- + +You can also `download`_ the extension and placing it inside the :file:`typo3conf/ext`-Folder of +your installation. In that case you need to install all dependencies yourself. Dependencies are: .. literalinclude:: ../../composer.json :caption: Dependencies from composer.json :lines: 19-21 :dedent: 8 +Setup +----- Afterwards you need to enable the extension through the extension manager and include the static -typoscript setup. +TypoScript setup. -.. _downloading: https://github.com/DanielSiepmann/search_core/archive/feature/integrate-elasticsearch.zip +If you **don't** want to use the included Elasticsearch integration, you have to disable it in the +extension manager configuration of the extension by checking the checkbox. +It's currently enabled by default but will be moved into its own extension in the future. + +.. _download: https://github.com/codappix/search_core/archive/develop.zip diff --git a/Documentation/source/readme.rst b/Documentation/source/readme.rst index 16448a9..f4ad917 100644 --- a/Documentation/source/readme.rst +++ b/Documentation/source/readme.rst @@ -1,5 +1,5 @@ -TYPO3 Extension search_core's documentation! -============================================ +TYPO3 Extension search_core +=========================== Introduction ============ @@ -8,23 +8,21 @@ What does it do? ---------------- The goal of this extension is to provide search integrations into TYPO3 CMS. The extension will -abstract the concrete implementations to allow exchange of concrete backends like Elasticsearch or -solr. +provide a convenient API to allow developers to provide concrete implementations of backends like +Elasticsearch, Algolia or Solr. The extension provides integration into TYPO3 like a frontend plugin for searches and hooks to update search indexes on updates. Also a command line interface is provided for interactions like -reindexing. +re-indexing. Current state ------------- -This is still a very early alpha version. More information can be taken from Github at -`current issues`_ and `current projects`_. +This is still a very early beta version. More information can be taken from Github at +`current issues`_. -We are also focusing on Code Quality and Testing through `travis ci`_, `scrutinizer`_ and `codacy`_. +We are also focusing on Code Quality and Testing through `travis ci`_, ``phpcs``, ``phpunit`` and +``phpstan``. .. _current issues: https://github.com/Codappix/search_core/issues -.. _current projects: https://github.com/Codappix/search_core/projects .. _travis ci: https://travis-ci.org/Codappix/search_core -.. _scrutinizer: https://scrutinizer-ci.com/g/Codappix/search_core/inspections -.. _codacy: https://www.codacy.com/app/Codappix/search_core/dashboard diff --git a/Documentation/source/usage.rst b/Documentation/source/usage.rst index 1fc0e51..fc6d08a 100644 --- a/Documentation/source/usage.rst +++ b/Documentation/source/usage.rst @@ -11,12 +11,27 @@ Manual indexing You can trigger indexing from CLI:: - ./typo3/cli_dispatch.phpsh extbase index:index --table 'tt_content' + ./typo3/cli_dispatch.phpsh extbase index:index --identifier 'pages' + ./bin/typo3cms index:index --identifier 'pages' -This will index the table ``tt_content`` using the :ref:`TcaIndexer`. +This will index the table ``pages`` using the :ref:`TcaIndexer`. -Only one table per call is available, to index multiple tables just make multiple calls. -The tables have to be white listed through :ref:`allowedTables` option. +Only one index per call is available, to run multiple indexers, just make multiple calls. +The indexers have to be defined in TypoScript via :ref:`configuration_options_index`. + +.. _usage_manual_deletion: + +Manual deletion +--------------- + +You can trigger deletion for a single index from CLI:: + + ./typo3/cli_dispatch.phpsh extbase index:delete --identifier 'pages' + ./bin/typo3cms index:delete --identifier 'pages' + +This will delete the index for the table ``pages``. + +Only one delete per call is available, to run multiple deletions, just make multiple calls. .. _usage_auto_indexing: @@ -24,12 +39,39 @@ Auto indexing ------------- Indexing is done through hooks every time an record is changed. -The tables have to be white listed through :ref:`allowedTables` option. +The tables have to be configured via :ref:`configuration_options_index`. .. note:: Not all hook operations are supported yet, see :issue:`27`. +.. _usage_form_finisher: + +Form finisher +------------- + +A form finisher is provided to integrate indexing into form extension. + +Add form finisher to your available finishers and configure it like: + +.. code-block:: yaml + + - + identifier: SearchCoreIndexer + options: + action: 'delete' + indexIdentifier: 'fe_users' + recordUid: '{FeUser.user.uid}' + +All three options are necessary, where: + +``action`` + Is one of ``delete``, ``update`` or ``add``. +``indexIdentifier`` + Is a configured index identifier. +``recordUid`` + Has to be the uid of the record to index. + .. _usage_searching: Searching / Frontend Plugin @@ -37,3 +79,47 @@ Searching / Frontend Plugin To provide a search interface you can insert the frontend Plugin as normal content element of type plugin. The plugin is named *Search Core*. + +Please provide your own template, the extension will not deliver a useful template for now. + +The Extbase mapping is used, this way you can create a form: + +.. code-block:: html + + + + + + +.. _usage_searching_filter: + +Filter +"""""" + +Thanks to Extbase mapping, filter are added to the form: + +.. code-block:: html + + + +.. _usage_searching_facets: + +Facets +"""""" + +To add a facet as criteria for searching, use :ref:`usage_searching_filter`. + +To display facet results use: + +.. code-block:: html + + + + + + + diff --git a/Makefile b/Makefile index 589dd7d..b1f4af0 100644 --- a/Makefile +++ b/Makefile @@ -1,19 +1,32 @@ mkfile_path := $(abspath $(lastword $(MAKEFILE_LIST))) current_dir := $(dir $(mkfile_path)) -TYPO3_WEB_DIR := $(current_dir).Build/Web +TYPO3_WEB_DIR := $(current_dir).Build/web +TYPO3_PATH_ROOT := $(current_dir).Build/web # Allow different versions on travis -TYPO3_VERSION ?= ~7.6 +TYPO3_VERSION ?= ~8.7 typo3DatabaseName ?= "searchcore_test" typo3DatabaseUsername ?= "dev" typo3DatabasePassword ?= "dev" typo3DatabaseHost ?= "127.0.0.1" +sourceOrDist=--prefer-dist +ifeq ($(TYPO3_VERSION),~7.6) + sourceOrDist=--prefer-source +endif + .PHONY: install install: clean - COMPOSER_PROCESS_TIMEOUT=1000 composer require -vv --dev --prefer-source --ignore-platform-reqs typo3/cms="$(TYPO3_VERSION)" + if [ $(TYPO3_VERSION) = ~7.6 ]; then \ + patch composer.json Tests/InstallPatches/composer.json.patch; \ + fi + + COMPOSER_PROCESS_TIMEOUT=1000 composer require -vv --dev $(sourceOrDist) typo3/cms="$(TYPO3_VERSION)" git checkout composer.json +cgl: + ./.Build/bin/phpcs + functionalTests: typo3DatabaseName=$(typo3DatabaseName) \ typo3DatabaseUsername=$(typo3DatabaseUsername) \ @@ -23,6 +36,11 @@ functionalTests: .Build/bin/phpunit --colors --debug -v \ -c Tests/Functional/FunctionalTests.xml +unitTests: + TYPO3_PATH_WEB=$(TYPO3_WEB_DIR) \ + .Build/bin/phpunit --colors --debug -v \ + -c Tests/Unit/UnitTests.xml + uploadCodeCoverage: uploadCodeCoverageToScrutinizer uploadCodeCoverageToCodacy uploadCodeCoverageToScrutinizer: diff --git a/Tests/Functional/AbstractFunctionalTestCase.php b/Tests/Functional/AbstractFunctionalTestCase.php index 7f808c7..d015457 100644 --- a/Tests/Functional/AbstractFunctionalTestCase.php +++ b/Tests/Functional/AbstractFunctionalTestCase.php @@ -66,4 +66,9 @@ abstract class AbstractFunctionalTestCase extends CoreTestCase { return ['EXT:search_core/Tests/Functional/Fixtures/BasicSetup.ts']; } + + protected function isLegacyVersion() : bool + { + return \TYPO3\CMS\Core\Utility\VersionNumberUtility::convertVersionNumberToInteger(TYPO3_version) < 8000000; + } } diff --git a/Tests/Functional/Bootstrap.php b/Tests/Functional/Bootstrap.php new file mode 100644 index 0000000..66b8eba --- /dev/null +++ b/Tests/Functional/Bootstrap.php @@ -0,0 +1,9 @@ + getenv('ES_HOST') ?: \Elastica\Connection::DEFAULT_HOST, 'port' => getenv('ES_PORT') ?: \Elastica\Connection::DEFAULT_PORT, ]); + + // Start with clean system for test. + $this->cleanUp(); } public function tearDown() { - // Delete everything so next test starts clean. + // Make system clean again. + $this->cleanUp(); + } + + protected function cleanUp() + { $this->client->getIndex('_all')->delete(); $this->client->getIndex('_all')->clearCache(); } diff --git a/Tests/Functional/Connection/Elasticsearch/FacetTest.php b/Tests/Functional/Connection/Elasticsearch/FacetTest.php new file mode 100644 index 0000000..1c38c07 --- /dev/null +++ b/Tests/Functional/Connection/Elasticsearch/FacetTest.php @@ -0,0 +1,78 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Domain\Index\IndexerFactory; +use Codappix\SearchCore\Domain\Model\SearchRequest; +use Codappix\SearchCore\Domain\Search\SearchService; +use TYPO3\CMS\Extbase\Object\ObjectManager; + +class FacetTest extends AbstractFunctionalTestCase +{ + protected function getTypoScriptFilesForFrontendRootPage() + { + return array_merge( + parent::getTypoScriptFilesForFrontendRootPage(), + ['EXT:search_core/Tests/Functional/Fixtures/Searching/Facet.ts'] + ); + } + + protected function getDataSets() + { + return array_merge( + parent::getDataSets(), + ['Tests/Functional/Fixtures/Searching/Filter.xml'] + ); + } + + /** + * @test + */ + public function itsPossibleToFetchFacetsForField() + { + \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(ObjectManager::class) + ->get(IndexerFactory::class) + ->getIndexer('tt_content') + ->indexAllDocuments() + ; + + $searchService = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(ObjectManager::class) + ->get(SearchService::class); + + $searchRequest = new SearchRequest(); + $result = $searchService->search($searchRequest); + + $this->assertSame(1, count($result->getFacets()), 'Did not receive the single defined facet.'); + + $facet = current($result->getFacets()); + $this->assertSame('contentTypes', $facet->getName(), 'Name of facet was not as expected.'); + $this->assertSame('CType', $facet->getField(), 'Field of facet was not expected.'); + + $options = $facet->getOptions(); + $this->assertSame(2, count($options), 'Did not receive the expected number of possible options for facet.'); + $option = $options['HTML']; + $this->assertSame('HTML', $option->getName(), 'Option did not have expected Name.'); + $this->assertSame(1, $option->getCount(), 'Option did not have expected count.'); + $option = $options['Header']; + $this->assertSame('Header', $option->getName(), 'Option did not have expected Name.'); + $this->assertSame(1, $option->getCount(), 'Option did not have expected count.'); + } +} diff --git a/Tests/Functional/Connection/Elasticsearch/FilterTest.php b/Tests/Functional/Connection/Elasticsearch/FilterTest.php new file mode 100644 index 0000000..894fb3f --- /dev/null +++ b/Tests/Functional/Connection/Elasticsearch/FilterTest.php @@ -0,0 +1,61 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Domain\Index\IndexerFactory; +use Codappix\SearchCore\Domain\Model\SearchRequest; +use Codappix\SearchCore\Domain\Search\SearchService; +use TYPO3\CMS\Extbase\Object\ObjectManager; + +class FilterTest extends AbstractFunctionalTestCase +{ + protected function getDataSets() + { + return array_merge( + parent::getDataSets(), + ['Tests/Functional/Fixtures/Searching/Filter.xml'] + ); + } + + /** + * @test + */ + public function itsPossibleToFilterResultsByASingleField() + { + \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(ObjectManager::class) + ->get(IndexerFactory::class) + ->getIndexer('tt_content') + ->indexAllDocuments() + ; + + $searchService = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(ObjectManager::class) + ->get(SearchService::class); + $searchRequest = new SearchRequest('Search Word'); + + $result = $searchService->search($searchRequest); + $this->assertSame(2, count($result), 'Did not receive both indexed elements without filter.'); + + $searchRequest->setFilter(['CType' => 'HTML']); + $result = $searchService->search($searchRequest); + $this->assertSame(5, (int) $result->getResults()[0]['uid'], 'Did not get the expected result entry.'); + $this->assertSame(1, count($result), 'Did not receive the single filtered element.'); + } +} diff --git a/Tests/Functional/Connection/Elasticsearch/IndexDeletionTest.php b/Tests/Functional/Connection/Elasticsearch/IndexDeletionTest.php new file mode 100644 index 0000000..8297a2a --- /dev/null +++ b/Tests/Functional/Connection/Elasticsearch/IndexDeletionTest.php @@ -0,0 +1,50 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Domain\Index\IndexerFactory; +use TYPO3\CMS\Extbase\Object\ObjectManager; + +class IndexDeletionTest extends AbstractFunctionalTestCase +{ + /** + * @test + */ + public function indexIsDeleted() + { + $this->client->getIndex('typo3content')->create(); + $this->assertTrue( + $this->client->getIndex('typo3content')->exists(), + 'Could not create index for test.' + ); + + \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(ObjectManager::class) + ->get(IndexerFactory::class) + ->getIndexer('tt_content') + ->delete() + ; + + $this->assertFalse( + $this->client->getIndex('typo3content')->exists(), + 'Index could not be deleted through command controller.' + ); + } +} diff --git a/Tests/Functional/Connection/Elasticsearch/IndexTcaTableTest.php b/Tests/Functional/Connection/Elasticsearch/IndexTcaTableTest.php index 9678d83..e9eab37 100644 --- a/Tests/Functional/Connection/Elasticsearch/IndexTcaTableTest.php +++ b/Tests/Functional/Connection/Elasticsearch/IndexTcaTableTest.php @@ -49,6 +49,29 @@ class IndexTcaTableTest extends AbstractFunctionalTestCase $response = $this->client->request('typo3content/_search?q=*:*'); + $this->assertTrue($response->isOK(), 'Elastica did not answer with ok code.'); + $this->assertSame($response->getData()['hits']['total'], 2, 'Not exactly 2 documents were indexed.'); + $this->assertArraySubset( + ['_source' => ['header' => 'indexed content element']], + $response->getData()['hits']['hits'][1], + false, + 'Record was not indexed.' + ); + } + + /** + * @test + */ + public function indexSingleBasicTtContent() + { + \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(ObjectManager::class) + ->get(IndexerFactory::class) + ->getIndexer('tt_content') + ->indexDocument(6) + ; + + $response = $this->client->request('typo3content/_search?q=*:*'); + $this->assertTrue($response->isOK(), 'Elastica did not answer with ok code.'); $this->assertSame($response->getData()['hits']['total'], 1, 'Not exactly 1 document was indexed.'); $this->assertArraySubset( @@ -90,7 +113,7 @@ class IndexTcaTableTest extends AbstractFunctionalTestCase $response = $this->client->request('typo3content/_search?q=*:*'); $this->assertTrue($response->isOK(), 'Elastica did not answer with ok code.'); - $this->assertSame($response->getData()['hits']['total'], 1, 'Not exactly 1 document was indexed.'); + $this->assertSame($response->getData()['hits']['total'], 2, 'Not exactly 2 documents were indexed.'); } /** @@ -113,16 +136,18 @@ class IndexTcaTableTest extends AbstractFunctionalTestCase $response = $this->client->request('typo3content/_search?q=*:*'); $this->assertTrue($response->isOK(), 'Elastica did not answer with ok code.'); - $this->assertSame($response->getData()['hits']['total'], 2, 'Not exactly 2 documents were indexed.'); + $this->assertSame($response->getData()['hits']['total'], 3, 'Not exactly 3 documents were indexed.'); + $response = $this->client->request('typo3content/_search?q=uid:11'); $this->assertArraySubset( ['_source' => ['header' => 'Also indexable record']], $response->getData()['hits']['hits'][0], false, 'Record was not indexed.' ); + $response = $this->client->request('typo3content/_search?q=uid:6'); $this->assertArraySubset( ['_source' => ['header' => 'indexed content element']], - $response->getData()['hits']['hits'][1], + $response->getData()['hits']['hits'][0], false, 'Record was not indexed.' ); @@ -143,24 +168,24 @@ class IndexTcaTableTest extends AbstractFunctionalTestCase $response = $this->client->request('typo3content/_search?q=*:*'); $this->assertTrue($response->isOK(), 'Elastica did not answer with ok code.'); - $this->assertSame($response->getData()['hits']['total'], 3, 'Not exactly 3 documents were indexed.'); + $this->assertSame($response->getData()['hits']['total'], 4, 'Not exactly 4 documents were indexed.'); - $response = $this->client->request('typo3content/_search?q=uid:9'); + $response = $this->client->request('typo3content/_search?q=uid:11'); $this->assertArraySubset( ['_source' => [ - 'uid' => '9', + 'uid' => '11', 'CType' => 'Header', // Testing items - 'categories' => ['Category 1', 'Category 2'], // Testing mm (with sorting) + 'categories' => ['Category 2', 'Category 1'], // Testing mm ]], $response->getData()['hits']['hits'][0], false, 'Record was not indexed with resolved category relations to multiple values.' ); - $response = $this->client->request('typo3content/_search?q=uid:10'); + $response = $this->client->request('typo3content/_search?q=uid:12'); $this->assertArraySubset( ['_source' => [ - 'uid' => '10', + 'uid' => '12', 'CType' => 'Header', 'categories' => ['Category 2'], ]], @@ -180,4 +205,40 @@ class IndexTcaTableTest extends AbstractFunctionalTestCase 'Record was indexed with resolved category relation, but should not have any.' ); } + + /** + * @test + */ + public function indexingDeltedRecordIfRecordShouldBeIndexedButIsNoLongerAvailableAndWasAlreadyIndexed() + { + \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(ObjectManager::class) + ->get(IndexerFactory::class) + ->getIndexer('tt_content') + ->indexAllDocuments() + ; + + $response = $this->client->request('typo3content/_search?q=*:*'); + $this->assertSame($response->getData()['hits']['total'], 2, 'Not exactly 2 documents were indexed.'); + + if ($this->isLegacyVersion()) { + $this->getDatabaseConnection() + ->exec_UPDATEquery('tt_content', 'uid = 10', ['hidden' => 1]); + } else { + $this->getConnectionPool()->getConnectionForTable('tt_content') + ->update( + 'tt_content', + ['hidden' => true], + ['uid' => 10] + ); + } + + \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(ObjectManager::class) + ->get(IndexerFactory::class) + ->getIndexer('tt_content') + ->indexDocument(10) + ; + + $response = $this->client->request('typo3content/_search?q=*:*'); + $this->assertSame($response->getData()['hits']['total'], 1, 'Not exactly 1 document is in index.'); + } } diff --git a/Tests/Functional/DataProcessing/ContentObjectDataProcessorAdapterProcessorTest.php b/Tests/Functional/DataProcessing/ContentObjectDataProcessorAdapterProcessorTest.php new file mode 100644 index 0000000..bb997cf --- /dev/null +++ b/Tests/Functional/DataProcessing/ContentObjectDataProcessorAdapterProcessorTest.php @@ -0,0 +1,62 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Compatibility\TypoScriptService76; +use Codappix\SearchCore\Compatibility\TypoScriptService; +use Codappix\SearchCore\DataProcessing\ContentObjectDataProcessorAdapterProcessor; +use Codappix\SearchCore\Tests\Functional\AbstractFunctionalTestCase; +use TYPO3\CMS\Frontend\DataProcessing\SplitProcessor; + +class ContentObjectDataProcessorAdapterProcessorTest extends AbstractFunctionalTestCase +{ + /** + * @test + */ + public function contentObjectDataProcessorIsExecuted() + { + $record = ['content' => 'value1, value2']; + $configuration = [ + '_dataProcessor' => SplitProcessor::class, + 'delimiter' => ',', + 'fieldName' => 'content', + 'as' => 'new_content', + ]; + $expectedData = [ + 'content' => 'value1, value2', + 'new_content' => ['value1', 'value2'], + ]; + + if ($this->isLegacyVersion()) { + $typoScriptService = new TypoScriptService76(); + } else { + $typoScriptService = new TypoScriptService(); + } + + $subject = new ContentObjectDataProcessorAdapterProcessor($typoScriptService); + $processedData = $subject->processData($record, $configuration); + $this->assertSame( + $expectedData, + $processedData, + 'The processor did not return the expected processed record.' + ); + } +} diff --git a/Tests/Functional/Fixtures/BasicSetup.ts b/Tests/Functional/Fixtures/BasicSetup.ts index 510e2a1..4129362 100644 --- a/Tests/Functional/Fixtures/BasicSetup.ts +++ b/Tests/Functional/Fixtures/BasicSetup.ts @@ -8,9 +8,37 @@ plugin { } } - indexer { - tca { - allowedTables = tt_content + indexing { + tt_content { + indexer = Codappix\SearchCore\Domain\Index\TcaIndexer + + additionalWhereClause ( + tt_content.CType NOT IN ('gridelements_pi1', 'list', 'div', 'menu', 'shortcut', 'search', 'login') + AND tt_content.bodytext != '' + ) + + mapping { + CType { + type = keyword + } + } + } + + pages { + indexer = Codappix\SearchCore\Domain\Index\TcaIndexer\PagesIndexer + abstractFields = abstract, description, bodytext + + mapping { + CType { + type = keyword + } + } + } + } + + searching { + fields { + query = _all } } } diff --git a/Tests/Functional/Fixtures/BasicSetup.xml b/Tests/Functional/Fixtures/BasicSetup.xml index a85b72a..1a46f3b 100644 --- a/Tests/Functional/Fixtures/BasicSetup.xml +++ b/Tests/Functional/Fixtures/BasicSetup.xml @@ -4,5 +4,6 @@ 1 0 Root page containing necessary TypoScript + Used as abstract as no abstract is defined. diff --git a/Tests/Functional/Fixtures/Indexing/IndexTcaTable.xml b/Tests/Functional/Fixtures/Indexing/IndexTcaTable.xml index 75a1f35..c236f07 100644 --- a/Tests/Functional/Fixtures/Indexing/IndexTcaTable.xml +++ b/Tests/Functional/Fixtures/Indexing/IndexTcaTable.xml @@ -60,4 +60,43 @@ 0 + + 9 + 1 + 1480686370 + 1480686370 + 0 + 72 + div +
not indexed due to ctype
+ this is the content of div content element that should not get indexed + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 +
+ + + 10 + 1 + 1480686370 + 1480686370 + 0 + 72 + html +
Indexed without html tags
+ Some text in paragraph

]]>
+ 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 +
diff --git a/Tests/Functional/Fixtures/Indexing/PagesIndexer/BrokenRootLine.xml b/Tests/Functional/Fixtures/Indexing/PagesIndexer/BrokenRootLine.xml new file mode 100644 index 0000000..81f2316 --- /dev/null +++ b/Tests/Functional/Fixtures/Indexing/PagesIndexer/BrokenRootLine.xml @@ -0,0 +1,20 @@ + + + + + 3 + 2 + Some disabled page due broken root line + + + 4 + 3 + Some disabled page due to parent pages root line being broken + + + + 6 + 1 + Some enabled page due valid root line + + diff --git a/Tests/Functional/Fixtures/Indexing/PagesIndexer/InheritedTiming.xml b/Tests/Functional/Fixtures/Indexing/PagesIndexer/InheritedTiming.xml new file mode 100644 index 0000000..552ba74 --- /dev/null +++ b/Tests/Functional/Fixtures/Indexing/PagesIndexer/InheritedTiming.xml @@ -0,0 +1,34 @@ + + + + + 2 + 1 + Some disabled page due to timing + 1502186635 + 1 + + + 3 + 2 + Some disabled page due to inherited timing + + + 4 + 1 + Some disabled page due to timing + 2147483647 + 1 + + + 5 + 4 + Some disabled page due to inherited timing + + + + 6 + 1 + Some enabled page due to no be below inherited disabled timing + + diff --git a/Tests/Functional/Fixtures/Indexing/PagesIndexer/Recycler.xml b/Tests/Functional/Fixtures/Indexing/PagesIndexer/Recycler.xml new file mode 100644 index 0000000..1421ad5 --- /dev/null +++ b/Tests/Functional/Fixtures/Indexing/PagesIndexer/Recycler.xml @@ -0,0 +1,21 @@ + + + + + 2 + 1 + Some disabled page due being recycler + 255 + + + 3 + 2 + Some disabled page due to parent page being recycler + + + + 6 + 1 + Some enabled page due to no be below recycler + + diff --git a/Tests/Functional/Fixtures/Indexing/ResolveRelations.xml b/Tests/Functional/Fixtures/Indexing/ResolveRelations.xml index 8b41246..bb76b81 100644 --- a/Tests/Functional/Fixtures/Indexing/ResolveRelations.xml +++ b/Tests/Functional/Fixtures/Indexing/ResolveRelations.xml @@ -1,7 +1,7 @@ - 9 + 11 1 1480686370 1480686370 @@ -22,7 +22,7 @@ - 10 + 12 1 1480686370 1480686370 @@ -92,7 +92,7 @@ 1 - 9 + 11 tt_content categories 2 @@ -100,7 +100,7 @@ 2 - 9 + 11 tt_content categories 1 @@ -108,7 +108,7 @@ 3 - 9 + 11 tt_content categories 3 @@ -117,7 +117,7 @@ 2 - 10 + 12 tt_content categories 1 diff --git a/Tests/Functional/Fixtures/Indexing/TcaIndexer/KeepSysLanguageUid.xml b/Tests/Functional/Fixtures/Indexing/TcaIndexer/KeepSysLanguageUid.xml new file mode 100644 index 0000000..f17051c --- /dev/null +++ b/Tests/Functional/Fixtures/Indexing/TcaIndexer/KeepSysLanguageUid.xml @@ -0,0 +1,23 @@ + + + + 1 + 1 + 1480686370 + 1480686370 + 0 + 72 + 2 + header +
indexed content element
+ this is the content of header content element that should get indexed + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 +
+
diff --git a/Tests/Functional/Fixtures/Indexing/TcaIndexer/RespectRootLineBlacklist.ts b/Tests/Functional/Fixtures/Indexing/TcaIndexer/RespectRootLineBlacklist.ts index 44013e7..c0c1cd6 100644 --- a/Tests/Functional/Fixtures/Indexing/TcaIndexer/RespectRootLineBlacklist.ts +++ b/Tests/Functional/Fixtures/Indexing/TcaIndexer/RespectRootLineBlacklist.ts @@ -1,8 +1,8 @@ plugin { tx_searchcore { settings { - indexer { - tca { + indexing { + tt_content { rootLineBlacklist = 3 } } diff --git a/Tests/Functional/Fixtures/Indexing/UserWhereClause.ts b/Tests/Functional/Fixtures/Indexing/UserWhereClause.ts index d79bb14..7478ef1 100644 --- a/Tests/Functional/Fixtures/Indexing/UserWhereClause.ts +++ b/Tests/Functional/Fixtures/Indexing/UserWhereClause.ts @@ -1,11 +1,9 @@ plugin { tx_searchcore { settings { - indexer { - tca { - tt_content { - additionalWhereClause = tt_content.CType NOT IN ('div') - } + indexing { + tt_content { + additionalWhereClause = tt_content.CType NOT IN ('div') } } } diff --git a/Tests/Functional/Fixtures/Indexing/UserWhereClause.xml b/Tests/Functional/Fixtures/Indexing/UserWhereClause.xml index 71212b2..12347ef 100644 --- a/Tests/Functional/Fixtures/Indexing/UserWhereClause.xml +++ b/Tests/Functional/Fixtures/Indexing/UserWhereClause.xml @@ -1,7 +1,7 @@ - 9 + 11 1 1480686370 1480686370 @@ -21,7 +21,7 @@ - 10 + 12 1 1480686370 1480686370 diff --git a/Tests/Functional/Fixtures/Searching/Facet.ts b/Tests/Functional/Fixtures/Searching/Facet.ts new file mode 100644 index 0000000..10eeae6 --- /dev/null +++ b/Tests/Functional/Fixtures/Searching/Facet.ts @@ -0,0 +1,17 @@ +plugin { + tx_searchcore { + settings { + searching { + facets { + contentTypes { + terms { + field = CType + } + } + } + } + } + } +} + +module.tx_searchcore < plugin.tx_searchcore diff --git a/Tests/Functional/Fixtures/Searching/Filter.xml b/Tests/Functional/Fixtures/Searching/Filter.xml new file mode 100644 index 0000000..103a4da --- /dev/null +++ b/Tests/Functional/Fixtures/Searching/Filter.xml @@ -0,0 +1,42 @@ + + + + 5 + 1 + 1480686370 + 1480686370 + 0 + 72 + html +
indexed content element with html ctype
+ Search Word + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 +
+ + + 6 + 1 + 1480686370 + 1480686370 + 0 + 72 + header +
indexed content element with header ctype
+ Search Word + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 +
+
diff --git a/Tests/Functional/FunctionalTests.xml b/Tests/Functional/FunctionalTests.xml index d42ef21..e4f78fe 100644 --- a/Tests/Functional/FunctionalTests.xml +++ b/Tests/Functional/FunctionalTests.xml @@ -1,8 +1,7 @@ subject = $this->getMockBuilder(DataHandlerService::class) - ->setConstructorArgs([$objectManager->get(ConfigurationContainerInterface::class)]) + ->setConstructorArgs([ + $objectManager->get(ConfigurationContainerInterface::class), + $objectManager->get(IndexerFactory::class) + ]) ->setMethods(['add', 'update', 'delete']) ->getMock(); - // This way TYPO3 will use our mock instead of a new instance. - $GLOBALS['T3_VAR']['getUserObj']['&' . DataHandlerHook::class] = new DataHandlerHook($this->subject); + GeneralUtility::setSingletonInstance(DataHandlerHook::class, new DataHandlerHook($this->subject)); } } diff --git a/Tests/Functional/Hooks/DataHandler/IgnoresUnkownOperationTest.php b/Tests/Functional/Hooks/DataHandler/IgnoresUnkownOperationTest.php deleted file mode 100644 index b1676e3..0000000 --- a/Tests/Functional/Hooks/DataHandler/IgnoresUnkownOperationTest.php +++ /dev/null @@ -1,54 +0,0 @@ - - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU General Public License - * as published by the Free Software Foundation; either version 2 - * of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA - * 02110-1301, USA. - */ - -use Codappix\SearchCore\Configuration\ConfigurationContainerInterface; -use Codappix\SearchCore\Domain\Service\DataHandler as DataHandlerService; -use Codappix\SearchCore\Hook\DataHandler as DataHandlerHook; -use TYPO3\CMS\Core\DataHandling\DataHandler as Typo3DataHandler; -use TYPO3\CMS\Core\Utility\GeneralUtility; -use TYPO3\CMS\Extbase\Object\ObjectManager; - -class IgnoresUnkownOperationTest extends AbstractDataHandlerTest -{ - /** - * @var DataHandlerService|\PHPUnit_Framework_MockObject_MockObject|AccessibleObjectInterface - */ - protected $subject; - - /** - * @test - */ - public function dataHandlerCommandSomethingIsIgnored() - { - $subject = new DataHandlerHook($this->subject); - $this->assertFalse( - $subject->processDatamap_afterDatabaseOperations( - 'something', - 'tt_content', - 1, - [], - new Typo3DataHandler - ), - 'Hook processed status "something".' - ); - } -} diff --git a/Tests/Functional/Hooks/DataHandler/NonAllowedTablesTest.php b/Tests/Functional/Hooks/DataHandler/NonAllowedTablesTest.php index c33701d..a9cbaae 100644 --- a/Tests/Functional/Hooks/DataHandler/NonAllowedTablesTest.php +++ b/Tests/Functional/Hooks/DataHandler/NonAllowedTablesTest.php @@ -47,7 +47,15 @@ class NonAllowedTablesTest extends AbstractDataHandlerTest */ public function deletionWillNotBeTriggeredForSysCategories() { - $this->subject->expects($this->exactly(0))->method('delete'); + $this->subject->expects($this->exactly(1)) + ->method('update') + ->with('pages', $this->callback(function (array $record) { + if ($this->isLegacyVersion()) { + return isset($record['uid']) && $record['uid'] === '1'; + } else { + return isset($record['uid']) && $record['uid'] === 1; + } + })); $tce = GeneralUtility::makeInstance(Typo3DataHandler::class); $tce->stripslashes_values = 0; @@ -64,9 +72,17 @@ class NonAllowedTablesTest extends AbstractDataHandlerTest /** * @test */ - public function updateWillNotBeTriggeredForSysCategory() + public function updateWillNotBeTriggeredForExistingSysCategory() { - $this->subject->expects($this->exactly(0))->method('update'); + $this->subject->expects($this->exactly(1)) + ->method('update') + ->with('pages', $this->callback(function (array $record) { + if ($this->isLegacyVersion()) { + return isset($record['uid']) && $record['uid'] === '1'; + } else { + return isset($record['uid']) && $record['uid'] === 1; + } + })); $tce = GeneralUtility::makeInstance(Typo3DataHandler::class); $tce->stripslashes_values = 0; @@ -83,9 +99,17 @@ class NonAllowedTablesTest extends AbstractDataHandlerTest /** * @test */ - public function addWillNotBeTriggeredForSysCategoy() + public function updateWillNotBeTriggeredForNewSysCategoy() { - $this->subject->expects($this->exactly(0))->method('add'); + $this->subject->expects($this->exactly(1)) + ->method('update') + ->with('pages', $this->callback(function (array $record) { + if ($this->isLegacyVersion()) { + return isset($record['uid']) && $record['uid'] === '1'; + } else { + return isset($record['uid']) && $record['uid'] === 1; + } + })); $tce = GeneralUtility::makeInstance(Typo3DataHandler::class); $tce->stripslashes_values = 0; diff --git a/Tests/Functional/Hooks/DataHandler/ProcessesAllowedTablesTest.php b/Tests/Functional/Hooks/DataHandler/ProcessesAllowedTablesTest.php index 715fe29..5a571d3 100644 --- a/Tests/Functional/Hooks/DataHandler/ProcessesAllowedTablesTest.php +++ b/Tests/Functional/Hooks/DataHandler/ProcessesAllowedTablesTest.php @@ -47,8 +47,18 @@ class ProcessesAllowedTablesTest extends AbstractDataHandlerTest */ public function deletionWillBeTriggeredForTtContent() { - $this->subject->expects($this->exactly(1))->method('delete') + $this->subject->expects($this->exactly(1)) + ->method('delete') ->with($this->equalTo('tt_content'), $this->equalTo('1')); + $this->subject->expects($this->exactly(1)) + ->method('update') + ->with('pages', $this->callback(function (array $record) { + if ($this->isLegacyVersion()) { + return isset($record['uid']) && $record['uid'] === '1'; + } else { + return isset($record['uid']) && $record['uid'] === 1; + } + })); $tce = GeneralUtility::makeInstance(Typo3DataHandler::class); $tce->stripslashes_values = 0; @@ -65,17 +75,36 @@ class ProcessesAllowedTablesTest extends AbstractDataHandlerTest /** * @test */ - public function updateWillBeTriggeredForTtContent() + public function updateWillBeTriggeredForExistingTtContent() { - $this->subject->expects($this->exactly(1))->method('update') - ->with( - $this->equalTo('tt_content'), - $this->callback(function ($record) { - return isset($record['uid']) && $record['uid'] === '1' - && isset($record['pid']) && $record['pid'] === '1' - && isset($record['colPos']) && $record['colPos'] === '1' - ; - }) + $this->subject->expects($this->exactly(2))->method('update') + ->withConsecutive( + [ + $this->equalTo('tt_content'), + $this->callback(function ($record) { + if ($this->isLegacyVersion()) { + return isset($record['uid']) && $record['uid'] === '1' + && isset($record['pid']) && $record['pid'] === '1' + && isset($record['colPos']) && $record['colPos'] === '1' + ; + } + + return isset($record['uid']) && $record['uid'] === 1 + && isset($record['pid']) && $record['pid'] === 1 + && isset($record['colPos']) && $record['colPos'] === 1 + ; + }) + ], + [ + $this->equalTo('pages'), + $this->callback(function ($record) { + if ($this->isLegacyVersion()) { + return isset($record['uid']) && $record['uid'] === '1'; + } else { + return isset($record['uid']) && $record['uid'] === 1; + } + }) + ] ); $tce = GeneralUtility::makeInstance(Typo3DataHandler::class); @@ -93,17 +122,36 @@ class ProcessesAllowedTablesTest extends AbstractDataHandlerTest /** * @test */ - public function addWillBeTriggeredForTtContent() + public function updateWillBeTriggeredForNewTtContent() { - $this->subject->expects($this->exactly(1))->method('add') - ->with( - $this->equalTo('tt_content'), - $this->callback(function ($record) { - return isset($record['uid']) && $record['uid'] === 2 - && isset($record['pid']) && $record['pid'] === 1 - && isset($record['header']) && $record['header'] === 'a new record' - ; - }) + $this->subject->expects($this->exactly(2))->method('update') + ->withConsecutive( + [ + $this->equalTo('tt_content'), + $this->callback(function ($record) { + if ($this->isLegacyVersion()) { + return isset($record['uid']) && $record['uid'] === '2' + && isset($record['pid']) && $record['pid'] === '1' + && isset($record['header']) && $record['header'] === 'a new record' + ; + } + + return isset($record['uid']) && $record['uid'] === 2 + && isset($record['pid']) && $record['pid'] === 1 + && isset($record['header']) && $record['header'] === 'a new record' + ; + }) + ], + [ + $this->equalTo('pages'), + $this->callback(function ($record) { + if ($this->isLegacyVersion()) { + return isset($record['uid']) && $record['uid'] === '1'; + } else { + return isset($record['uid']) && $record['uid'] === 1; + } + }) + ] ); $tce = GeneralUtility::makeInstance(Typo3DataHandler::class); diff --git a/Tests/Functional/Indexing/PagesIndexerTest.php b/Tests/Functional/Indexing/PagesIndexerTest.php new file mode 100644 index 0000000..244413d --- /dev/null +++ b/Tests/Functional/Indexing/PagesIndexerTest.php @@ -0,0 +1,104 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Configuration\ConfigurationContainerInterface; +use Codappix\SearchCore\Connection\Elasticsearch; +use Codappix\SearchCore\Domain\Index\IndexerFactory; +use Codappix\SearchCore\Tests\Functional\AbstractFunctionalTestCase; +use TYPO3\CMS\Extbase\Object\ObjectManager; + +class PagesIndexerTest extends AbstractFunctionalTestCase +{ + /** + * @test + */ + public function pagesContainAllAdditionalInformation() + { + $this->importDataSet('Tests/Functional/Fixtures/Indexing/IndexTcaTable.xml'); + + $objectManager = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(ObjectManager::class); + $tableName = 'pages'; + + $connection = $this->getMockBuilder(Elasticsearch::class) + ->setMethods(['addDocuments']) + ->disableOriginalConstructor() + ->getMock(); + + $connection->expects($this->once()) + ->method('addDocuments') + ->with( + $this->stringContains($tableName), + $this->callback(function ($documents) { + return count($documents) === 1 + && isset($documents[0]['content']) && $documents[0]['content'] === + 'this is the content of header content element that should get indexed Some text in paragraph' + && isset($documents[0]['search_abstract']) && $documents[0]['search_abstract'] === + 'Used as abstract as no abstract is defined.' + ; + }) + ); + + $indexer = $objectManager->get(IndexerFactory::class)->getIndexer($tableName); + $this->inject($indexer, 'connection', $connection); + $indexer->indexAllDocuments(); + } + + /** + * @test + * @dataProvider rootLineDataSets + * @param string $dataSetPath + */ + public function rootLineIsRespectedDuringIndexing($dataSetPath) + { + $this->importDataSet($dataSetPath); + + $objectManager = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(ObjectManager::class); + $tableName = 'pages'; + + $connection = $this->getMockBuilder(Elasticsearch::class) + ->setMethods(['addDocuments']) + ->disableOriginalConstructor() + ->getMock(); + + $connection->expects($this->once()) + ->method('addDocuments') + ->with( + $this->stringContains($tableName), + $this->callback(function ($documents) { + return count($documents) === 2; + }) + ); + + $indexer = $objectManager->get(IndexerFactory::class)->getIndexer($tableName); + $this->inject($indexer, 'connection', $connection); + $indexer->indexAllDocuments(); + } + + public function rootLineDataSets() + { + return [ + 'Broken root line' => ['Tests/Functional/Fixtures/Indexing/PagesIndexer/BrokenRootLine.xml'], + 'Recycler doktype' => ['Tests/Functional/Fixtures/Indexing/PagesIndexer/Recycler.xml'], + 'Extended timing to sub pages' => ['Tests/Functional/Fixtures/Indexing/PagesIndexer/InheritedTiming.xml'], + ]; + } +} diff --git a/Tests/Functional/Indexing/TcaIndexer/RelationResolverTest.php b/Tests/Functional/Indexing/TcaIndexer/RelationResolverTest.php index 5577ac0..a491677 100644 --- a/Tests/Functional/Indexing/TcaIndexer/RelationResolverTest.php +++ b/Tests/Functional/Indexing/TcaIndexer/RelationResolverTest.php @@ -37,10 +37,6 @@ class RelationResolverTest extends AbstractFunctionalTestCase $objectManager = GeneralUtility::makeInstance(ObjectManager::class); $table = 'sys_file'; - // Only by adding the field to showitem, it will be processed by FormEngine. - // We use this field to test inline relations, as there is only one alternative. - $GLOBALS['TCA']['sys_file']['types'][1]['showitem'] .= ',metadata'; - $subject = $objectManager->get(TcaTableService::class, $table); $record = BackendUtility::getRecord($table, 1); $subject->prepareRecord($record); @@ -113,8 +109,8 @@ class RelationResolverTest extends AbstractFunctionalTestCase $this->assertEquals( [ - 'Category 1', 'Category 2', + 'Category 1', ], $record['categories'], 'Foreign mm select relation was not resolved as expected.' diff --git a/Tests/Functional/Indexing/TcaIndexerTest.php b/Tests/Functional/Indexing/TcaIndexerTest.php index 2b3f817..d7ecc3a 100644 --- a/Tests/Functional/Indexing/TcaIndexerTest.php +++ b/Tests/Functional/Indexing/TcaIndexerTest.php @@ -24,12 +24,20 @@ use Codappix\SearchCore\Configuration\ConfigurationContainerInterface; use Codappix\SearchCore\Connection\Elasticsearch; use Codappix\SearchCore\Domain\Index\TcaIndexer; use Codappix\SearchCore\Domain\Index\TcaIndexer\RelationResolver; -use Codappix\SearchCore\Domain\Index\TcaIndexer\TcaTableService; +use Codappix\SearchCore\Domain\Index\TcaIndexer\TcaTableServiceInterface; use Codappix\SearchCore\Tests\Functional\AbstractFunctionalTestCase; use TYPO3\CMS\Extbase\Object\ObjectManager; class TcaIndexerTest extends AbstractFunctionalTestCase { + protected function getTypoScriptFilesForFrontendRootPage() + { + return array_merge( + parent::getTypoScriptFilesForFrontendRootPage(), + ['EXT:search_core/Tests/Functional/Fixtures/Indexing/TcaIndexer/RespectRootLineBlacklist.ts'] + ); + } + /** * @test */ @@ -39,7 +47,7 @@ class TcaIndexerTest extends AbstractFunctionalTestCase $objectManager = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(ObjectManager::class); $tableName = 'tt_content'; $tableService = $objectManager->get( - TcaTableService::class, + TcaTableServiceInterface::class, $tableName, $objectManager->get(RelationResolver::class), $objectManager->get(ConfigurationContainerInterface::class) @@ -70,11 +78,39 @@ class TcaIndexerTest extends AbstractFunctionalTestCase $objectManager->get(TcaIndexer::class, $tableService, $connection)->indexAllDocuments(); } - protected function getTypoScriptFilesForFrontendRootPage() + /** + * @test + */ + public function sysLanguageIsKept() { - return array_merge( - parent::getTypoScriptFilesForFrontendRootPage(), - ['EXT:search_core/Tests/Functional/Fixtures/Indexing/TcaIndexer/RespectRootLineBlacklist.ts'] + $this->importDataSet('Tests/Functional/Fixtures/Indexing/TcaIndexer/KeepSysLanguageUid.xml'); + $objectManager = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(ObjectManager::class); + $tableName = 'tt_content'; + $tableService = $objectManager->get( + TcaTableServiceInterface::class, + $tableName, + $objectManager->get(RelationResolver::class), + $objectManager->get(ConfigurationContainerInterface::class) ); + + $connection = $this->getMockBuilder(Elasticsearch::class) + ->setMethods(['addDocuments']) + ->disableOriginalConstructor() + ->getMock(); + + $connection->expects($this->once()) + ->method('addDocuments') + ->with( + $this->stringContains('tt_content'), + $this->callback(function ($documents) { + if ($this->isLegacyVersion()) { + return isset($documents[0]['sys_language_uid']) && $documents[0]['sys_language_uid'] === '2'; + } else { + return isset($documents[0]['sys_language_uid']) && $documents[0]['sys_language_uid'] === 2; + } + }) + ); + + $objectManager->get(TcaIndexer::class, $tableService, $connection)->indexAllDocuments(); } } diff --git a/Tests/InstallPatches/composer.json.patch b/Tests/InstallPatches/composer.json.patch new file mode 100644 index 0000000..6f11cdc --- /dev/null +++ b/Tests/InstallPatches/composer.json.patch @@ -0,0 +1,14 @@ +diff --git a/composer.json b/composer.json +index 83e5f47..e9fa296 100644 +--- a/composer.json ++++ b/composer.json +@@ -21,8 +21,7 @@ + "ruflin/elastica": "~3.2" + }, + "require-dev": { +- "phpunit/phpunit": "~6.4.4", +- "typo3/testing-framework": "~1.1.5", ++ "phpunit/phpunit": "~5.7.0", + "squizlabs/php_codesniffer": "~3.1.1" + }, + "config": { diff --git a/Tests/Unit/AbstractUnitTestCase.php b/Tests/Unit/AbstractUnitTestCase.php new file mode 100644 index 0000000..fa29d57 --- /dev/null +++ b/Tests/Unit/AbstractUnitTestCase.php @@ -0,0 +1,103 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use TYPO3\CMS\Core\Tests\UnitTestCase as CoreTestCase; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Extbase\Object\ObjectManager; +use TYPO3\CMS\Form\Service\TranslationService; + +abstract class AbstractUnitTestCase extends CoreTestCase +{ + /** + * @var array A backup of registered singleton instances + */ + protected $singletonInstances = []; + + public function setUp() + { + parent::setUp(); + + $this->singletonInstances = GeneralUtility::getSingletonInstances(); + + // Disable caching backends to make TYPO3 parts work in unit test mode. + + \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance( + \TYPO3\CMS\Core\Cache\CacheManager::class + )->setCacheConfigurations([ + 'extbase_object' => [ + 'backend' => \TYPO3\CMS\Core\Cache\Backend\NullBackend::class, + ], + ]); + } + + public function tearDown() + { + GeneralUtility::resetSingletonInstances($this->singletonInstances); + parent::tearDown(); + } + + /** + * @return \TYPO3\CMS\Core\Log\LogManager + */ + protected function getMockedLogger() + { + $logger = $this->getMockBuilder(\TYPO3\CMS\Core\Log\LogManager::class) + ->disableOriginalConstructor() + ->setMethods(['getLogger']) + ->getMock(); + $logger->expects($this->once()) + ->method('getLogger') + ->will($this->returnValue( + $this->getMockBuilder(\TYPO3\CMS\Core\Log\Logger::class) + ->disableOriginalConstructor() + ->getMock() + )); + + return $logger; + } + + /** + * Configure translation service mock for Form Finisher. + * + * This way parseOption will always return the configured value. + */ + protected function configureMockedTranslationService() + { + $translationService = $this->getMockBuilder(TranslationService::class)->getMock(); + $translationService->expects($this->any()) + ->method('translateFinisherOption') + ->willReturnCallback(function ($formRuntime, $finisherIdentifier, $optionKey, $optionValue) { + return $optionValue; + }); + $objectManager = $this->getMockBuilder(ObjectManager::class)->getMock(); + $objectManager->expects($this->any()) + ->method('get') + ->with(TranslationService::class) + ->willReturn($translationService); + GeneralUtility::setSingletonInstance(ObjectManager::class, $objectManager); + } + + protected function isLegacyVersion() : bool + { + return \TYPO3\CMS\Core\Utility\VersionNumberUtility::convertVersionNumberToInteger(TYPO3_version) < 8000000; + } +} diff --git a/Tests/Unit/Bootstrap.php b/Tests/Unit/Bootstrap.php new file mode 100644 index 0000000..0f7b382 --- /dev/null +++ b/Tests/Unit/Bootstrap.php @@ -0,0 +1,9 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Command\IndexCommandController; +use Codappix\SearchCore\Domain\Index\IndexerFactory; +use Codappix\SearchCore\Domain\Index\NoMatchingIndexerException; +use Codappix\SearchCore\Domain\Index\TcaIndexer; +use Codappix\SearchCore\Tests\Unit\AbstractUnitTestCase; +use TYPO3\CMS\Extbase\Mvc\Controller\CommandController; +use TYPO3\CMS\Extbase\Mvc\Exception\StopActionException; + +class IndexCommandControllerTest extends AbstractUnitTestCase +{ + /** + * @var IndexCommandController + */ + protected $subject; + + /** + * @var IndexerFactory + */ + protected $indexerFactory; + + public function setUp() + { + parent::setUp(); + + $this->indexerFactory = $this->getMockBuilder(IndexerFactory::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->subject = $this->getMockBuilder(IndexCommandController::class) + ->disableOriginalConstructor() + ->setMethods(['quit', 'outputLine']) + ->getMock(); + $this->subject->injectIndexerFactory($this->indexerFactory); + } + + /** + * @test + */ + public function indexerStopsForNonAllowedTable() + { + $this->subject->expects($this->once()) + ->method('outputLine') + ->with('No indexer found for: nonAllowedTable'); + $this->indexerFactory->expects($this->once()) + ->method('getIndexer') + ->with('nonAllowedTable') + ->will($this->throwException(new NoMatchingIndexerException)); + + $this->subject->indexCommand('nonAllowedTable'); + } + + /** + * @test + */ + public function indexerExecutesForAllowedTable() + { + $indexerMock = $this->getMockBuilder(TcaIndexer::class) + ->disableOriginalConstructor() + ->getMock(); + $this->subject->expects($this->never()) + ->method('quit'); + $this->subject->expects($this->once()) + ->method('outputLine') + ->with('allowedTable was indexed.'); + $this->indexerFactory->expects($this->once()) + ->method('getIndexer') + ->with('allowedTable') + ->will($this->returnValue($indexerMock)); + + $this->subject->indexCommand('allowedTable'); + } + + /** + * @test + */ + public function deletionIsPossible() + { + $indexerMock = $this->getMockBuilder(TcaIndexer::class) + ->disableOriginalConstructor() + ->getMock(); + $this->subject->expects($this->once()) + ->method('outputLine') + ->with('allowedTable was deleted.'); + $this->indexerFactory->expects($this->once()) + ->method('getIndexer') + ->with('allowedTable') + ->will($this->returnValue($indexerMock)); + + $indexerMock->expects($this->once()) + ->method('delete'); + $this->subject->deleteCommand('allowedTable'); + } + + /** + * @test + */ + public function deletionForNonExistingIndexerDoesNotWork() + { + $this->subject->expects($this->once()) + ->method('outputLine') + ->with('No indexer found for: nonAllowedTable'); + $this->indexerFactory->expects($this->once()) + ->method('getIndexer') + ->with('nonAllowedTable') + ->will($this->throwException(new NoMatchingIndexerException)); + + $this->subject->deleteCommand('nonAllowedTable'); + } +} diff --git a/Tests/Unit/Configuration/ConfigurationUtilityTest.php b/Tests/Unit/Configuration/ConfigurationUtilityTest.php new file mode 100644 index 0000000..4db367c --- /dev/null +++ b/Tests/Unit/Configuration/ConfigurationUtilityTest.php @@ -0,0 +1,146 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Configuration\ConfigurationUtility; +use Codappix\SearchCore\Connection\SearchRequestInterface; +use Codappix\SearchCore\Domain\Model\SearchRequest; +use Codappix\SearchCore\Tests\Unit\AbstractUnitTestCase; + +class ConfigurationUtilityTest extends AbstractUnitTestCase +{ + /** + * @test + * @dataProvider possibleRequestAndConfigurationForFluidtemplate + */ + public function recursiveEntriesAreProcessedAsFluidtemplate( + SearchRequestInterface $searchRequest, + array $array, + array $expected + ) { + $subject = new ConfigurationUtility(); + + $this->assertSame( + $expected, + $subject->replaceArrayValuesWithRequestContent($searchRequest, $array), + 'Entries in array were not parsed as fluid template with search request.' + ); + } + + public function possibleRequestAndConfigurationForFluidtemplate() : array + { + return [ + 'Nothing in array' => [ + 'searchRequest' => new SearchRequest(), + 'array' => [], + 'expected' => [], + ], + 'Small array with nothing to replace' => [ + 'searchRequest' => new SearchRequest(), + 'array' => [ + 'key1' => 'value1', + ], + 'expected' => [ + 'key1' => 'value1', + ], + ], + 'Rescursive array with replacements' => [ + 'searchRequest' => call_user_func(function () { + $request = new SearchRequest(); + $request->setFilter([ + 'distance' => [ + 'location' => '10', + ], + ]); + return $request; + }), + 'array' => [ + 'sub1' => [ + 'sub1.1' => '{request.filter.distance.location}', + 'sub1.2' => '{request.nonExisting}', + ], + ], + 'expected' => [ + 'sub1' => [ + // Numberics are casted to double + 'sub1.1' => 10.0, + 'sub1.2' => null, + ], + ], + ], + ]; + } + + /** + * @test + * @dataProvider possibleConditionEntries + */ + public function conditionsAreHandledAsExpected(array $entries, array $expected) + { + $subject = new ConfigurationUtility(); + + $this->assertSame( + $expected, + $subject->filterByCondition($entries), + 'Conditions were not processed as expected.' + ); + } + + public function possibleConditionEntries() : array + { + return [ + 'Nothing in array' => [ + 'entries' => [], + 'expected' => [], + ], + 'Entries without condition' => [ + 'entries' => [ + 'key1' => 'value1', + ], + 'expected' => [ + 'key1' => 'value1', + ], + ], + 'Entry with matching condition' => [ + 'entries' => [ + 'sub1' => [ + 'condition' => true, + 'sub1.2' => 'something', + ], + ], + 'expected' => [ + 'sub1' => [ + 'sub1.2' => 'something', + ], + ], + ], + 'Entry with non matching condition' => [ + 'entries' => [ + 'sub1' => [ + 'condition' => false, + 'sub1.2' => 'something', + ], + ], + 'expected' => [], + ], + ]; + } +} diff --git a/Tests/Unit/Connection/Elasticsearch/FacetOptionTest.php b/Tests/Unit/Connection/Elasticsearch/FacetOptionTest.php new file mode 100644 index 0000000..696762c --- /dev/null +++ b/Tests/Unit/Connection/Elasticsearch/FacetOptionTest.php @@ -0,0 +1,64 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Connection\Elasticsearch\FacetOption; +use Codappix\SearchCore\Tests\Unit\AbstractUnitTestCase; + +class FacetOptionTest extends AbstractUnitTestCase +{ + /** + * @test + */ + public function displayNameIsReturnedAsExpected() + { + $bucket = [ + 'key' => 'Name', + 'key_as_string' => 'DisplayName', + 'doc_count' => 10, + ]; + $subject = new FacetOption($bucket); + + $this->assertSame( + $bucket['key_as_string'], + $subject->getDisplayName(), + 'Display name was not returned as expected.' + ); + } + + /** + * @test + */ + public function displayNameIsReturnedAsExpectedIfNotProvided() + { + $bucket = [ + 'key' => 'Name', + 'doc_count' => 10, + ]; + $subject = new FacetOption($bucket); + + $this->assertSame( + $bucket['key'], + $subject->getDisplayName(), + 'Display name was not returned as expected.' + ); + } +} diff --git a/Tests/Unit/Connection/Elasticsearch/IndexFactoryTest.php b/Tests/Unit/Connection/Elasticsearch/IndexFactoryTest.php new file mode 100644 index 0000000..4ede3cb --- /dev/null +++ b/Tests/Unit/Connection/Elasticsearch/IndexFactoryTest.php @@ -0,0 +1,133 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Configuration\ConfigurationContainerInterface; +use Codappix\SearchCore\Connection\Elasticsearch\Connection; +use Codappix\SearchCore\Connection\Elasticsearch\IndexFactory; +use Codappix\SearchCore\Tests\Unit\AbstractUnitTestCase; + +class IndexFactoryTest extends AbstractUnitTestCase +{ + /** + * @var IndexFactory + */ + protected $subject; + + public function setUp() + { + parent::setUp(); + + $this->configuration = $this->getMockBuilder(ConfigurationContainerInterface::class)->getMock(); + $this->subject = new IndexFactory($this->configuration); + $this->subject->injectLogger($this->getMockedLogger()); + } + + /** + * @test + */ + public function indexIsNotCreatedIfAlreadyExisting() + { + $indexMock = $this->getMockBuilder(\Elastica\Index::class) + ->disableOriginalConstructor() + ->getMock(); + $indexMock->expects($this->once()) + ->method('exists') + ->willReturn(true); + $indexMock->expects($this->never()) + ->method('create'); + $clientMock = $this->getMockBuilder(\Elastica\Client::class) + ->disableOriginalConstructor() + ->getMock(); + $clientMock->expects($this->once()) + ->method('getIndex') + ->with('typo3content') + ->willReturn($indexMock); + $connection = $this->getMockBuilder(Connection::class) + ->disableOriginalConstructor() + ->getMock(); + $connection->expects($this->once()) + ->method('getClient') + ->willReturn($clientMock); + + $this->subject->getIndex($connection, 'someIndex'); + } + + /** + * @test + */ + public function typoScriptConfigurationIsProvidedToIndex() + { + $configuration = [ + 'analysis' => [ + 'analyzer' => [ + 'ngram4' => [ + 'type' => 'custom', + 'tokenizer' => 'ngram4', + 'char_filter' => 'html_strip', + 'filter' => 'lowercase, , asciifolding', + ], + ], + 'tokenizer' => [ + 'ngram4' => [ + 'type' => 'ngram', + 'min_gram' => 4, + 'max_gram' => 4, + ], + ], + ], + ]; + + $expectedConfiguration = $configuration; + $expectedConfiguration['analysis']['analyzer']['ngram4']['char_filter'] = ['html_strip']; + $expectedConfiguration['analysis']['analyzer']['ngram4']['filter'] = ['lowercase', 'asciifolding']; + + $indexMock = $this->getMockBuilder(\Elastica\Index::class) + ->disableOriginalConstructor() + ->getMock(); + $indexMock->expects($this->once()) + ->method('exists') + ->willReturn(false); + $indexMock->expects($this->once()) + ->method('create') + ->with($expectedConfiguration); + $clientMock = $this->getMockBuilder(\Elastica\Client::class) + ->disableOriginalConstructor() + ->getMock(); + $clientMock->expects($this->once()) + ->method('getIndex') + ->with('typo3content') + ->willReturn($indexMock); + $connection = $this->getMockBuilder(Connection::class) + ->disableOriginalConstructor() + ->getMock(); + $connection->expects($this->once()) + ->method('getClient') + ->willReturn($clientMock); + + $this->configuration->expects($this->once()) + ->method('get') + ->with('indexing.someIndex.index') + ->willReturn($configuration); + + $this->subject->getIndex($connection, 'someIndex'); + } +} diff --git a/Tests/Unit/Connection/Elasticsearch/MappingFactoryTest.php b/Tests/Unit/Connection/Elasticsearch/MappingFactoryTest.php new file mode 100644 index 0000000..8dde1cb --- /dev/null +++ b/Tests/Unit/Connection/Elasticsearch/MappingFactoryTest.php @@ -0,0 +1,86 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Configuration\ConfigurationContainerInterface; +use Codappix\SearchCore\Connection\Elasticsearch\MappingFactory; +use Codappix\SearchCore\Tests\Unit\AbstractUnitTestCase; + +class MappingFactoryTest extends AbstractUnitTestCase +{ + /** + * @var MappingFactory + */ + protected $subject; + + public function setUp() + { + parent::setUp(); + + $this->configuration = $this->getMockBuilder(ConfigurationContainerInterface::class)->getMock(); + $this->subject = new MappingFactory($this->configuration); + } + + /** + * @test + */ + public function typoScriptConfigurationIsProvidedToIndex() + { + $indexName = 'someIndex'; + $configuration = [ + '_all' => [ + 'type' => 'text', + 'analyzer' => 'ngram4', + ], + 'channel' => [ + 'type' => 'keyword', + ], + ]; + $type = $this->getMockBuilder(\Elastica\Type::class) + ->disableOriginalConstructor() + ->getMock(); + $type->expects($this->any()) + ->method('getName') + ->willReturn($indexName); + $this->configuration->expects($this->once()) + ->method('get') + ->with('indexing.' . $indexName . '.mapping') + ->willReturn($configuration); + + $mapping = $this->subject->getMapping($type)->toArray()[$indexName]; + $this->assertArraySubset( + [ + '_all' => $configuration['_all'] + ], + $mapping, + true, + 'Configuration of _all field was not set for mapping.' + ); + $this->assertArraySubset( + [ + 'channel' => $configuration['channel'] + ], + $mapping['properties'], + true, + 'Configuration for properties was not set for mapping.' + ); + } +} diff --git a/Tests/Unit/Controller/SearchControllerTest.php b/Tests/Unit/Controller/SearchControllerTest.php new file mode 100644 index 0000000..67c6d98 --- /dev/null +++ b/Tests/Unit/Controller/SearchControllerTest.php @@ -0,0 +1,128 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Controller\SearchController; +use Codappix\SearchCore\Domain\Model\SearchRequest; +use Codappix\SearchCore\Domain\Search\SearchService; +use Codappix\SearchCore\Tests\Unit\AbstractUnitTestCase; +use TYPO3\CMS\Extbase\Mvc\Web\Request; +use TYPO3\CMS\Extbase\Object\ObjectManager; + +class SearchControllerTest extends AbstractUnitTestCase +{ + /** + * @var SearchController + */ + protected $subject; + + /** + * @var Request + */ + protected $request; + + public function setUp() + { + \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance( + \TYPO3\CMS\Core\Cache\CacheManager::class + )->setCacheConfigurations([ + 'extbase_object' => [ + 'backend' => \TYPO3\CMS\Core\Cache\Backend\NullBackend::class, + ], + 'extbase_datamapfactory_datamap' => [ + 'backend' => \TYPO3\CMS\Core\Cache\Backend\NullBackend::class, + ], + ]); + + parent::setUp(); + + $searchService = $this->getMockBuilder(SearchService::class) + ->disableOriginalConstructor() + ->getMock(); + $this->request = new Request(); + + $this->subject = new SearchController($searchService); + $this->inject($this->subject, 'request', $this->request); + $this->inject($this->subject, 'objectManager', new ObjectManager()); + } + + /** + * @test + */ + public function searchRequestArgumentIsAddedIfModeIsFilterAndArgumentDoesNotExist() + { + $this->inject($this->subject, 'settings', [ + 'searching' => [ + 'mode' => 'filter', + ] + ]); + + $this->subject->initializeSearchAction(); + $this->assertInstanceOf( + SearchRequest::class, + $this->request->getArgument('searchRequest'), + 'Search request was not created.' + ); + } + + /** + * @test + */ + public function searchRequestArgumentIsAddedToExistingArguments() + { + $this->request->setArguments([ + '@widget_0' => [ + 'currentPage' => '7', + ] + ]); + $this->inject($this->subject, 'settings', [ + 'searching' => [ + 'mode' => 'filter', + ] + ]); + + $this->subject->initializeSearchAction(); + $this->assertInstanceOf( + SearchRequest::class, + $this->request->getArgument('searchRequest'), + 'Search request was not created.' + ); + $this->assertSame( + ['currentPage' => '7'], + $this->request->getArgument('@widget_0'), + 'Existing arguments were not kept.' + ); + } + + /** + * @test + */ + public function searchRequestArgumentIsNotAddedIfModeIsNotFilter() + { + $this->inject($this->subject, 'settings', ['searching' => []]); + + $this->subject->initializeSearchAction(); + $this->assertFalse( + $this->request->hasArgument('searchRequest'), + 'Search request should not exist.' + ); + } +} diff --git a/Tests/Unit/DataProcessing/CopyToProcessorTest.php b/Tests/Unit/DataProcessing/CopyToProcessorTest.php new file mode 100644 index 0000000..28545a3 --- /dev/null +++ b/Tests/Unit/DataProcessing/CopyToProcessorTest.php @@ -0,0 +1,85 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\DataProcessing\CopyToProcessor; +use Codappix\SearchCore\Tests\Unit\AbstractUnitTestCase; + +class CopyToProcessorTest extends AbstractUnitTestCase +{ + /** + * @test + * @dataProvider getPossibleDataConfigurationCombinations + */ + public function fieldsAreCopiedAsConfigured(array $record, array $configuration, array $expectedData) + { + $subject = new CopyToProcessor(); + $processedData = $subject->processData($record, $configuration); + $this->assertSame( + $expectedData, + $processedData, + 'The processor did not return the expected processed record.' + ); + } + + /** + * @return array + */ + public function getPossibleDataConfigurationCombinations() + { + return [ + 'Copy all fields to new field' => [ + 'record' => [ + 'field 1' => 'Some content like lorem', + 'field 2' => 'Some more content like ipsum', + ], + 'configuration' => [ + 'to' => 'new_field', + ], + 'expectedData' => [ + 'field 1' => 'Some content like lorem', + 'field 2' => 'Some more content like ipsum', + 'new_field' => 'Some content like lorem' . PHP_EOL . 'Some more content like ipsum', + ], + ], + 'Copy all fields with sub array to new field' => [ + 'record' => [ + 'field 1' => 'Some content like lorem', + 'field with sub2' => [ + 'Tag 1', + 'Tag 2', + ], + ], + 'configuration' => [ + 'to' => 'new_field', + ], + 'expectedData' => [ + 'field 1' => 'Some content like lorem', + 'field with sub2' => [ + 'Tag 1', + 'Tag 2', + ], + 'new_field' => 'Some content like lorem' . PHP_EOL . 'Tag 1' . PHP_EOL . 'Tag 2', + ], + ], + ]; + } +} diff --git a/Tests/Unit/DataProcessing/GeoPointProcessorTest.php b/Tests/Unit/DataProcessing/GeoPointProcessorTest.php new file mode 100644 index 0000000..02db659 --- /dev/null +++ b/Tests/Unit/DataProcessing/GeoPointProcessorTest.php @@ -0,0 +1,113 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\DataProcessing\GeoPointProcessor; +use Codappix\SearchCore\Tests\Unit\AbstractUnitTestCase; + +class GeoPointProcessorTest extends AbstractUnitTestCase +{ + /** + * @test + * @dataProvider getPossibleDataConfigurationCombinations + */ + public function geoPointsAreAddedAsConfigured(array $record, array $configuration, array $expectedData) + { + $subject = new GeoPointProcessor(); + $processedData = $subject->processData($record, $configuration); + $this->assertSame( + $expectedData, + $processedData, + 'The processor did not return the expected processed record.' + ); + } + + /** + * @return array + */ + public function getPossibleDataConfigurationCombinations() + { + return [ + 'Create new field with existing lat and lng' => [ + 'record' => [ + 'lat' => 23.232, + 'lng' => 45.43, + ], + 'configuration' => [ + 'to' => 'location', + 'lat' => 'lat', + 'lon' => 'lng', + ], + 'expectedData' => [ + 'lat' => 23.232, + 'lng' => 45.43, + 'location' => [ + 'lat' => 23.232, + 'lon' => 45.43, + ], + ], + ], + 'Do not create new field due to missing configuration' => [ + 'record' => [ + 'lat' => 23.232, + 'lng' => 45.43, + ], + 'configuration' => [ + 'to' => 'location', + ], + 'expectedData' => [ + 'lat' => 23.232, + 'lng' => 45.43, + ], + ], + 'Do not create new field due to missing lat and lon' => [ + 'record' => [ + 'lat' => '', + 'lng' => '', + ], + 'configuration' => [ + 'to' => 'location', + 'lat' => 'lat', + 'lon' => 'lng', + ], + 'expectedData' => [ + 'lat' => '', + 'lng' => '', + ], + ], + 'Do not create new field due to invalid lat and lon' => [ + 'record' => [ + 'lat' => 'av', + 'lng' => 'dsf', + ], + 'configuration' => [ + 'to' => 'location', + 'lat' => 'lat', + 'lon' => 'lng', + ], + 'expectedData' => [ + 'lat' => 'av', + 'lng' => 'dsf', + ], + ], + ]; + } +} diff --git a/Tests/Unit/DataProcessing/RemoveProcessorTest.php b/Tests/Unit/DataProcessing/RemoveProcessorTest.php new file mode 100644 index 0000000..cd23d16 --- /dev/null +++ b/Tests/Unit/DataProcessing/RemoveProcessorTest.php @@ -0,0 +1,133 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\DataProcessing\RemoveProcessor; +use Codappix\SearchCore\Tests\Unit\AbstractUnitTestCase; + +class RemoveProcessorTest extends AbstractUnitTestCase +{ + /** + * @test + * @dataProvider getPossibleDataConfigurationCombinations + */ + public function fieldsAreCopiedAsConfigured(array $record, array $configuration, array $expectedData) + { + $subject = new RemoveProcessor(); + $processedData = $subject->processData($record, $configuration); + $this->assertSame( + $expectedData, + $processedData, + 'The processor did not return the expected processed record.' + ); + } + + /** + * @return array + */ + public function getPossibleDataConfigurationCombinations() + { + return [ + 'Nothing configured' => [ + 'record' => [ + 'field 1' => 'Some content like lorem', + 'field with sub2' => [ + 'Tag 1', + 'Tag 2', + ], + ], + 'configuration' => [ + ], + 'expectedData' => [ + 'field 1' => 'Some content like lorem', + 'field with sub2' => [ + 'Tag 1', + 'Tag 2', + ], + ], + ], + 'Single field configured' => [ + 'record' => [ + 'field 1' => 'Some content like lorem', + 'field with sub2' => [ + 'Tag 1', + 'Tag 2', + ], + ], + 'configuration' => [ + 'fields' => 'field with sub2', + '_typoScriptNodeValue' => 'Codappix\SearchCore\DataProcessing\RemoveProcessor', + ], + 'expectedData' => [ + 'field 1' => 'Some content like lorem', + ], + ], + 'Non existing field configured' => [ + 'record' => [ + 'field 1' => 'Some content like lorem', + 'field with sub2' => [ + 'Tag 1', + 'Tag 2', + ], + ], + 'configuration' => [ + 'fields' => 'non existing', + '_typoScriptNodeValue' => 'Codappix\SearchCore\DataProcessing\RemoveProcessor', + ], + 'expectedData' => [ + 'field 1' => 'Some content like lorem', + 'field with sub2' => [ + 'Tag 1', + 'Tag 2', + ], + ], + ], + 'Multiple fields configured' => [ + 'record' => [ + 'field 1' => 'Some content like lorem', + 'field with sub2' => [ + 'Tag 1', + 'Tag 2', + ], + 'field 3' => 'Some more like lorem', + ], + 'configuration' => [ + 'fields' => 'field 3, field with sub2', + '_typoScriptNodeValue' => 'Codappix\SearchCore\DataProcessing\RemoveProcessor', + ], + 'expectedData' => [ + 'field 1' => 'Some content like lorem', + ], + ], + 'Fields with "null" san be removed' => [ + 'record' => [ + 'field 1' => null, + ], + 'configuration' => [ + 'fields' => 'field 1', + '_typoScriptNodeValue' => 'Codappix\SearchCore\DataProcessing\RemoveProcessor', + ], + 'expectedData' => [ + ], + ], + ]; + } +} diff --git a/Tests/Unit/Domain/Index/AbstractIndexerTest.php b/Tests/Unit/Domain/Index/AbstractIndexerTest.php new file mode 100644 index 0000000..411cc24 --- /dev/null +++ b/Tests/Unit/Domain/Index/AbstractIndexerTest.php @@ -0,0 +1,154 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Configuration\ConfigurationContainerInterface; +use Codappix\SearchCore\Configuration\InvalidArgumentException; +use Codappix\SearchCore\Connection\ConnectionInterface; +use Codappix\SearchCore\DataProcessing\CopyToProcessor; +use Codappix\SearchCore\DataProcessing\Service as DataProcessorService; +use Codappix\SearchCore\Domain\Index\AbstractIndexer; +use Codappix\SearchCore\Tests\Unit\AbstractUnitTestCase; + +class AbstractIndexerTest extends AbstractUnitTestCase +{ + /** + * @var TcaTableService + */ + protected $subject; + + /** + * @var ConfigurationContainerInterface + */ + protected $configuration; + + /** + * @var ConnectionInterface + */ + protected $connection; + + /** + * @var DataProcessorService + */ + protected $dataProcessorService; + + public function setUp() + { + parent::setUp(); + + $this->configuration = $this->getMockBuilder(ConfigurationContainerInterface::class)->getMock(); + $this->connection = $this->getMockBuilder(ConnectionInterface::class)->getMock(); + $this->dataProcessorService = $this->getMockBuilder(DataProcessorService::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->subject = $this->getMockForAbstractClass(AbstractIndexer::class, [ + $this->connection, + $this->configuration + ]); + $this->inject($this->subject, 'dataProcessorService', $this->dataProcessorService); + $this->subject->injectLogger($this->getMockedLogger()); + $this->subject->setIdentifier('testTable'); + $this->subject->expects($this->any()) + ->method('getDocumentName') + ->willReturn('testTable'); + } + + /** + * @test + */ + public function executesConfiguredDataProcessingWithConfiguration() + { + $record = ['field 1' => 'test']; + $expectedRecord = $record; + $expectedRecord['new_test_field'] = 'test'; + $expectedRecord['new_test_field2'] = 'test' . PHP_EOL . 'test'; + $expectedRecord['search_abstract'] = ''; + + $this->dataProcessorService->expects($this->any()) + ->method('executeDataProcessor') + ->withConsecutive( + [ + [ + '_typoScriptNodeValue' => CopyToProcessor::class, + 'to' => 'new_test_field', + ], + $record, + ], + [ + [ + '_typoScriptNodeValue' => CopyToProcessor::class, + 'to' => 'new_test_field2', + ], + array_merge($record, ['new_test_field' => 'test']), + ] + ) + ->will($this->onConsecutiveCalls( + array_merge($record, ['new_test_field' => 'test']), + $expectedRecord + )); + + $this->configuration->expects($this->any()) + ->method('get') + ->withConsecutive(['indexing.testTable.dataProcessing'], ['indexing.testTable.abstractFields']) + ->will($this->onConsecutiveCalls([ + '1' => [ + '_typoScriptNodeValue' => CopyToProcessor::class, + 'to' => 'new_test_field', + ], + '2' => [ + '_typoScriptNodeValue' => CopyToProcessor::class, + 'to' => 'new_test_field2', + ], + ], $this->throwException(new InvalidArgumentException))); + $this->subject->expects($this->once()) + ->method('getRecord') + ->with(1) + ->willReturn($record) + ; + + $this->connection->expects($this->once())->method('addDocument')->with('testTable', $expectedRecord); + $this->subject->indexDocument(1); + } + + /** + * @test + */ + public function executesNoDataProcessingForMissingConfiguration() + { + $record = ['field 1' => 'test']; + $expectedRecord = $record; + $expectedRecord['search_abstract'] = ''; + + $this->configuration->expects($this->exactly(2)) + ->method('get') + ->withConsecutive(['indexing.testTable.dataProcessing'], ['indexing.testTable.abstractFields']) + ->will($this->throwException(new InvalidArgumentException)); + $this->subject->expects($this->once()) + ->method('getRecord') + ->with(1) + ->willReturn($record) + ; + + $this->connection->expects($this->once())->method('addDocument')->with('testTable', $expectedRecord); + $this->subject->indexDocument(1); + } +} diff --git a/Tests/Unit/Domain/Index/TcaIndexer/TcaTableServiceTest.php b/Tests/Unit/Domain/Index/TcaIndexer/TcaTableServiceTest.php new file mode 100644 index 0000000..f470613 --- /dev/null +++ b/Tests/Unit/Domain/Index/TcaIndexer/TcaTableServiceTest.php @@ -0,0 +1,177 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Configuration\ConfigurationContainerInterface; +use Codappix\SearchCore\DataProcessing\CopyToProcessor; +use Codappix\SearchCore\Domain\Index\TcaIndexer\RelationResolver; +use Codappix\SearchCore\Domain\Index\TcaIndexer\TcaTableService76; +use Codappix\SearchCore\Domain\Index\TcaIndexer\TcaTableService; +use Codappix\SearchCore\Tests\Unit\AbstractUnitTestCase; +use TYPO3\CMS\Core\Database\DatabaseConnection; + +class TcaTableServiceTest extends AbstractUnitTestCase +{ + /** + * @var TcaTableService + */ + protected $subject; + + /** + * @var ConfigurationContainerInterface + */ + protected $configuration; + + /** + * @var DatabaseConnection + */ + protected $databaseConnection; + + public function setUp() + { + parent::setUp(); + + $this->configuration = $this->getMockBuilder(ConfigurationContainerInterface::class)->getMock(); + $this->databaseConnection = $this->getMockBuilder(DatabaseConnection::class)->getMock(); + + $className = TcaTableService::class; + if ($this->isLegacyVersion()) { + $className = TcaTableService76::class; + } + $this->subject = $this->getMockBuilder($className) + ->disableOriginalConstructor() + ->setMethods(['getConnection', 'getSystemWhereClause']) + ->getMock(); + $this->subject->expects($this->any()) + ->method('getConnection') + ->willReturn($this->databaseConnection); + + $this->inject($this->subject, 'configuration', $this->configuration); + $this->inject($this->subject, 'logger', $this->getMockedLogger()); + $this->inject($this->subject, 'tableName', 'table'); + } + + /** + * @test + */ + public function doUsePlainQueryIfNoAdditionalWhereClauseIsDefined() + { + $this->markTestIncomplete('We have to migrate this test for TYPO3 CMS 8.x'); + $this->configuration->expects($this->exactly(2)) + ->method('getIfExists') + ->withConsecutive(['indexing.table.additionalWhereClause'], ['indexing.table.rootLineBlacklist']) + ->will($this->onConsecutiveCalls(null, false)); + $this->subject->expects($this->once()) + ->method('getSystemWhereClause') + ->will($this->returnValue('1=1 AND pages.no_search = 0')); + + $this->assertSame( + '1=1 AND pages.no_search = 0', + $whereClause->getStatement() + ); + $this->assertSame( + [], + $whereClause->getParameters() + ); + } + + /** + * @test + */ + public function configuredAdditionalWhereClauseIsAdded() + { + $this->markTestIncomplete('We have to migrate this test for TYPO3 CMS 8.x'); + $this->configuration->expects($this->exactly(2)) + ->method('getIfExists') + ->withConsecutive(['indexing.table.additionalWhereClause'], ['indexing.table.rootLineBlacklist']) + ->will($this->onConsecutiveCalls('table.field = "someValue"', false)); + + $this->subject->expects($this->once()) + ->method('getSystemWhereClause') + ->will($this->returnValue('1=1 AND pages.no_search = 0')); + + $this->subject->getRecord(10); + + $whereClause = $this->subject->getWhereClause(); + $this->assertSame( + '1=1 AND pages.no_search = 0 AND table.field = "someValue"', + $whereClause->getStatement() + ); + $this->assertSame( + [], + $whereClause->getParameters() + ); + } + + /** + * @test + */ + public function allConfiguredAndAllowedTcaColumnsAreReturnedAsFields() + { + $this->markTestIncomplete('We have to migrate this test'); + $GLOBALS['TCA']['test_table'] = [ + 'ctrl' => [ + 'languageField' => 'sys_language_uid', + ], + 'columns' => [ + 'sys_language_uid' => [ + 'config' => [ + 'type' => 'select', + ], + ], + 't3ver_oid' => [], + 'available_column' => [ + 'config' => [ + 'type' => 'input', + ], + ], + 'user_column' => [ + 'config' => [ + 'type' => 'user', + ], + ], + 'passthrough_column' => [ + 'config' => [ + 'type' => 'passthrough', + ], + ], + ], + ]; + $subject = new TcaTableService( + 'test_table', + $this->getMockBuilder(RelationResolver::class)->getMock(), + $this->configuration + ); + $this->inject($subject, 'logger', $this->getMockedLogger()); + + $this->assertSame( + [ + 'test_table.uid', + 'test_table.pid', + 'test_table.sys_language_uid', + 'test_table.available_column', + ], + $subject->getFields(), + '' + ); + unset($GLOBALS['TCA']['test_table']); + } +} diff --git a/Tests/Unit/Domain/Model/ResultItemTest.php b/Tests/Unit/Domain/Model/ResultItemTest.php new file mode 100644 index 0000000..53477c7 --- /dev/null +++ b/Tests/Unit/Domain/Model/ResultItemTest.php @@ -0,0 +1,110 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Domain\Model\ResultItem; +use Codappix\SearchCore\Tests\Unit\AbstractUnitTestCase; + +class ResultItemTest extends AbstractUnitTestCase +{ + /** + * @test + */ + public function plainDataCanBeRetrieved() + { + $originalData = [ + 'uid' => 10, + 'title' => 'Some title', + ]; + $expectedData = $originalData; + + $subject = new ResultItem($originalData); + $this->assertSame( + $expectedData, + $subject->getPlainData(), + 'Could not retrieve plain data from result item.' + ); + } + + /** + * @test + */ + public function dataCanBeRetrievedInArrayNotation() + { + $originalData = [ + 'uid' => 10, + 'title' => 'Some title', + ]; + $expectedData = $originalData; + + $subject = new ResultItem($originalData); + $this->assertSame( + $originalData['title'], + $subject['title'], + 'Could not retrieve title in array notation.' + ); + } + + /** + * @test + */ + public function existenceOfDataCanBeChecked() + { + $originalData = [ + 'uid' => 10, + 'title' => 'Some title', + ]; + + $subject = new ResultItem($originalData); + $this->assertTrue(isset($subject['title']), 'Could not determine that title exists.'); + $this->assertFalse(isset($subject['title2']), 'Could not determine that title2 does not exists.'); + } + + /** + * @test + */ + public function dataCanNotBeChanged() + { + $originalData = [ + 'uid' => 10, + 'title' => 'Some title', + ]; + + $subject = new ResultItem($originalData); + $this->expectException(\BadMethodCallException::class); + $subject['title'] = 'New Title'; + } + + /** + * @test + */ + public function dataCanNotBeRemoved() + { + $originalData = [ + 'uid' => 10, + 'title' => 'Some title', + ]; + + $subject = new ResultItem($originalData); + $this->expectException(\BadMethodCallException::class); + unset($subject['title']); + } +} diff --git a/Tests/Unit/Domain/Model/SearchRequestTest.php b/Tests/Unit/Domain/Model/SearchRequestTest.php new file mode 100644 index 0000000..28f90e3 --- /dev/null +++ b/Tests/Unit/Domain/Model/SearchRequestTest.php @@ -0,0 +1,137 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Connection\ConnectionInterface; +use Codappix\SearchCore\Connection\SearchResultInterface; +use Codappix\SearchCore\Domain\Model\SearchRequest; +use Codappix\SearchCore\Domain\Search\SearchService; +use Codappix\SearchCore\Tests\Unit\AbstractUnitTestCase; + +class SearchRequestTest extends AbstractUnitTestCase +{ + /** + * @test + * @dataProvider possibleEmptyFilter + */ + public function emptyFilterWillNotBeSet(array $filter) + { + $subject = new SearchRequest(); + $subject->setFilter($filter); + + $this->assertSame( + [], + $subject->getFilter(), + 'Empty filter were set, even if they should not.' + ); + } + + public function possibleEmptyFilter() + { + return [ + 'Complete empty Filter' => [ + 'filter' => [], + ], + 'Single filter with empty value' => [ + 'filter' => [ + 'someFilter' => '', + ], + ], + 'Single filter with empty recursive values' => [ + 'filter' => [ + 'someFilter' => [ + 'someKey' => '', + ], + ], + ], + ]; + } + + /** + * @test + */ + public function filterIsSet() + { + $filter = ['someField' => 'someValue']; + $subject = new SearchRequest(); + $subject->setFilter($filter); + + $this->assertSame( + $filter, + $subject->getFilter(), + 'Filter was not set.' + ); + } + + /** + * @test + */ + public function exceptionIsThrownIfSearchServiceWasNotSet() + { + $subject = new SearchRequest(); + $subject->setConnection($this->getMockBuilder(ConnectionInterface::class)->getMock()); + $this->expectException(\InvalidArgumentException::class); + $subject->execute(); + } + + /** + * @test + */ + public function exceptionIsThrownIfConnectionWasNotSet() + { + $subject = new SearchRequest(); + $subject->setSearchService( + $this->getMockBuilder(SearchService::class) + ->disableOriginalConstructor() + ->getMock() + ); + $this->expectException(\InvalidArgumentException::class); + $subject->execute(); + } + + /** + * @test + */ + public function executionMakesUseOfProvidedConnectionAndSearchService() + { + $searchServiceMock = $this->getMockBuilder(SearchService::class) + ->disableOriginalConstructor() + ->getMock(); + $connectionMock = $this->getMockBuilder(ConnectionInterface::class) + ->getMock(); + $searchResultMock = $this->getMockBuilder(SearchResultInterface::class) + ->getMock(); + + $subject = new SearchRequest(); + $subject->setSearchService($searchServiceMock); + $subject->setConnection($connectionMock); + + $connectionMock->expects($this->once()) + ->method('search') + ->with($subject) + ->willReturn($searchResultMock); + $searchServiceMock->expects($this->once()) + ->method('processResult') + ->with($searchResultMock); + + $subject->execute(); + } +} diff --git a/Tests/Unit/Domain/Model/SearchResultTest.php b/Tests/Unit/Domain/Model/SearchResultTest.php new file mode 100644 index 0000000..eebb3c1 --- /dev/null +++ b/Tests/Unit/Domain/Model/SearchResultTest.php @@ -0,0 +1,100 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Connection\ResultItemInterface; +use Codappix\SearchCore\Connection\SearchResultInterface; +use Codappix\SearchCore\Domain\Model\SearchResult; +use Codappix\SearchCore\Tests\Unit\AbstractUnitTestCase; + +class SearchResultTest extends AbstractUnitTestCase +{ + /** + * @test + */ + public function countIsRetrievedFromOriginalResult() + { + $originalSearchResultMock = $this->getMockBuilder(SearchResultInterface::class)->getMock(); + $originalSearchResultMock->expects($this->once())->method('count'); + + $subject = new SearchResult($originalSearchResultMock, []); + $subject->count(); + } + + /** + * @test + */ + public function currentCountIsRetrievedFromOriginalResult() + { + $originalSearchResultMock = $this->getMockBuilder(SearchResultInterface::class)->getMock(); + $originalSearchResultMock->expects($this->once())->method('getCurrentCount'); + + $subject = new SearchResult($originalSearchResultMock, []); + $subject->getCurrentCount(); + } + + /** + * @test + */ + public function facetsAreRetrievedFromOriginalResult() + { + $originalSearchResultMock = $this->getMockBuilder(SearchResultInterface::class)->getMock(); + $originalSearchResultMock->expects($this->once())->method('getFacets'); + + $subject = new SearchResult($originalSearchResultMock, []); + $subject->getFacets(); + } + + /** + * @test + */ + public function resultItemsCanBeRetrieved() + { + $originalSearchResultMock = $this->getMockBuilder(SearchResultInterface::class)->getMock(); + $data = [ + [ + 'uid' => 10, + 'title' => 'Some Title', + ], + [ + 'uid' => 11, + 'title' => 'Some Title 2', + ], + [ + 'uid' => 12, + 'title' => 'Some Title 3', + ], + ]; + + $subject = new SearchResult($originalSearchResultMock, $data); + $resultItems = $subject->getResults(); + + $this->assertCount(3, $resultItems); + + $this->assertSame($data[0]['uid'], $resultItems[0]['uid']); + $this->assertSame($data[1]['uid'], $resultItems[1]['uid']); + $this->assertSame($data[2]['uid'], $resultItems[2]['uid']); + + $this->assertInstanceOf(ResultItemInterface::class, $resultItems[0]); + $this->assertInstanceOf(ResultItemInterface::class, $resultItems[1]); + $this->assertInstanceOf(ResultItemInterface::class, $resultItems[2]); + } +} diff --git a/Tests/Unit/Domain/Search/QueryFactoryTest.php b/Tests/Unit/Domain/Search/QueryFactoryTest.php new file mode 100644 index 0000000..dc824c5 --- /dev/null +++ b/Tests/Unit/Domain/Search/QueryFactoryTest.php @@ -0,0 +1,677 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Configuration\ConfigurationContainerInterface; +use Codappix\SearchCore\Configuration\ConfigurationUtility; +use Codappix\SearchCore\Configuration\InvalidArgumentException; +use Codappix\SearchCore\Domain\Model\FacetRequest; +use Codappix\SearchCore\Domain\Model\SearchRequest; +use Codappix\SearchCore\Domain\Search\QueryFactory; +use Codappix\SearchCore\Tests\Unit\AbstractUnitTestCase; + +class QueryFactoryTest extends AbstractUnitTestCase +{ + /** + * @var QueryFactory + */ + protected $subject; + + /** + * @var ConfigurationContainerInterface + */ + protected $configuration; + + public function setUp() + { + parent::setUp(); + + $this->configuration = $this->getMockBuilder(ConfigurationContainerInterface::class)->getMock(); + $configurationUtility = new ConfigurationUtility(); + $this->subject = new QueryFactory($this->getMockedLogger(), $this->configuration, $configurationUtility); + } + + /** + * @test + */ + public function creationOfQueryWorksInGeneral() + { + $searchRequest = new SearchRequest('SearchWord'); + + $this->configureConfigurationMockWithDefault(); + + $query = $this->subject->create($searchRequest); + $this->assertInstanceOf( + \Elastica\Query::class, + $query, + 'Factory did not create the expected instance.' + ); + } + + /** + * @test + */ + public function filterIsAddedToQuery() + { + $this->configureConfigurationMockWithDefault(); + + $searchRequest = new SearchRequest('SearchWord'); + $searchRequest->setFilter(['field' => 'content']); + + $query = $this->subject->create($searchRequest); + $this->assertSame( + [ + ['term' => ['field' => 'content']] + ], + $query->toArray()['query']['bool']['filter'], + 'Filter was not added to query.' + ); + } + + /** + * @test + */ + public function rangeFilterIsAddedToQuery() + { + $this->configureConfigurationMockWithDefault(); + $this->configuration->expects($this->any()) + ->method('getIfExists') + ->will($this->returnCallback(function ($configName) { + if ($configName === 'searching.mapping.filter.month') { + return [ + 'type' => 'range', + 'field' => 'released', + 'raw' => [ + 'format' => 'yyyy-MM', + ], + 'fields' => [ + 'gte' => 'from', + 'lte' => 'to', + ], + ]; + } + + return []; + })); + + $searchRequest = new SearchRequest('SearchWord'); + $searchRequest->setFilter([ + 'month' => [ + 'from' => '2016-03', + 'to' => '2017-11', + ], + ]); + + $query = $this->subject->create($searchRequest); + $this->assertSame( + [ + [ + 'range' => [ + 'released' => [ + 'format' => 'yyyy-MM', + 'gte' => '2016-03', + 'lte' => '2017-11', + ], + ], + ] + ], + $query->toArray()['query']['bool']['filter'], + 'Filter was not added to query.' + ); + } + + /** + * @test + */ + public function emptyFilterIsNotAddedToQuery() + { + $this->configureConfigurationMockWithDefault(); + + $searchRequest = new SearchRequest('SearchWord'); + $searchRequest->setFilter([ + 'field' => '', + ]); + + $this->assertFalse( + $searchRequest->hasFilter(), + 'Search request contains filter even if it should not.' + ); + + $query = $this->subject->create($searchRequest); + $this->assertSame( + null, + $query->toArray()['query']['bool']['filter'], + 'Filter was added to query, even if no filter exists.' + ); + } + + /** + * @test + */ + public function facetsAreAddedToQuery() + { + $this->configureConfigurationMockWithDefault(); + $searchRequest = new SearchRequest('SearchWord'); + $searchRequest->addFacet(new FacetRequest('Identifier', ['terms' => ['field' => 'FieldName']])); + $searchRequest->addFacet(new FacetRequest('Identifier 2', ['terms' => ['field' => 'FieldName 2']])); + + $query = $this->subject->create($searchRequest); + $this->assertSame( + [ + 'Identifier' => [ + 'terms' => [ + 'field' => 'FieldName', + ], + ], + 'Identifier 2' => [ + 'terms' => [ + 'field' => 'FieldName 2', + ], + ], + ], + $query->toArray()['aggs'], + 'Facets were not added to query.' + ); + } + + /** + * @test + */ + public function sizeIsAddedToQuery() + { + $this->configureConfigurationMockWithDefault(); + $searchRequest = new SearchRequest('SearchWord'); + $searchRequest->setLimit(45); + $searchRequest->setOffset(35); + + $query = $this->subject->create($searchRequest); + $this->assertSame( + 45, + $query->toArray()['size'], + 'Limit was not added to query.' + ); + $this->assertSame( + 35, + $query->toArray()['from'], + 'From was not added to query.' + ); + } + + /** + * @test + */ + public function searchTermIsAddedToQuery() + { + $searchRequest = new SearchRequest('SearchWord'); + $this->configureConfigurationMockWithDefault(); + $query = $this->subject->create($searchRequest); + + $this->assertSame( + [ + 'bool' => [ + 'must' => [ + [ + 'multi_match' => [ + 'type' => 'most_fields', + 'query' => 'SearchWord', + 'fields' => [ + '_all', + ], + ], + ], + ], + ], + ], + $query->toArray()['query'], + 'Search term was not added to query as expected.' + ); + } + + /** + * @test + */ + public function minimumShouldMatchIsAddedToQuery() + { + $searchRequest = new SearchRequest('SearchWord'); + $this->configuration->expects($this->any()) + ->method('getIfExists') + ->withConsecutive( + ['searching.minimumShouldMatch'], + ['searching.sort'] + ) + ->will($this->onConsecutiveCalls( + '50%', + null + )); + $this->configureConfigurationMockWithDefault(); + $query = $this->subject->create($searchRequest); + + $this->assertArraySubset( + [ + 'bool' => [ + 'must' => [ + [ + 'multi_match' => [ + 'type' => 'most_fields', + 'query' => 'SearchWord', + 'fields' => [ + '_all', + ], + 'minimum_should_match' => '50%', + ], + ], + ], + ], + ], + $query->toArray()['query'], + 'minimum_should_match was not added to query as configured.' + ); + } + + /** + * @test + */ + public function boostsAreAddedToQuery() + { + $searchRequest = new SearchRequest('SearchWord'); + + $this->configuration->expects($this->any()) + ->method('get') + ->withConsecutive( + ['searching.fields.query'], + ['searching.boost'], + ['searching.fields.stored_fields'], + ['searching.fields.script_fields'], + ['searching.fieldValueFactor'] + ) + ->will($this->onConsecutiveCalls( + '_all', + [ + 'search_title' => 3, + 'search_abstract' => 1.5, + ], + $this->throwException(new InvalidArgumentException), + $this->throwException(new InvalidArgumentException), + $this->throwException(new InvalidArgumentException) + )); + + $query = $this->subject->create($searchRequest); + $this->assertSame( + [ + [ + 'match' => [ + 'search_title' => [ + 'query' => 'SearchWord', + 'boost' => 3, + ], + ], + ], + [ + 'match' => [ + 'search_abstract' => [ + 'query' => 'SearchWord', + 'boost' => 1.5, + ], + ], + ], + ], + $query->toArray()['query']['bool']['should'], + 'Boosts were not added to query.' + ); + } + + /** + * @test + */ + public function factorBoostIsAddedToQuery() + { + $searchRequest = new SearchRequest('SearchWord'); + $fieldConfig = [ + 'field' => 'rootlineLevel', + 'modifier' => 'reciprocal', + 'factor' => '2', + 'missing' => '1', + ]; + $this->configuration->expects($this->any()) + ->method('get') + ->withConsecutive( + ['searching.fields.query'], + ['searching.boost'], + ['searching.fields.stored_fields'], + ['searching.fields.script_fields'], + ['searching.fieldValueFactor'] + ) + ->will($this->onConsecutiveCalls( + '_all', + $this->throwException(new InvalidArgumentException), + $this->throwException(new InvalidArgumentException), + $this->throwException(new InvalidArgumentException), + $fieldConfig + )); + + $query = $this->subject->create($searchRequest); + $this->assertSame( + [ + 'function_score' => [ + 'query' => [ + 'bool' => [ + 'must' => [ + [ + 'multi_match' => [ + 'type' => 'most_fields', + 'query' => 'SearchWord', + 'fields' => [ + '_all', + ], + ], + ], + ], + ], + ], + 'field_value_factor' => $fieldConfig, + ], + ], + $query->toArray()['query'], + 'Boosts were not added to query.' + ); + } + + /** + * @test + */ + public function emptySearchStringWillNotAddSearchToQuery() + { + $searchRequest = new SearchRequest(); + + $this->configureConfigurationMockWithDefault(); + + $query = $this->subject->create($searchRequest); + $this->assertInstanceOf( + stdClass, + $query->toArray()['query']['match_all'], + 'Empty search request does not create expected query.' + ); + } + + /** + * @test + */ + public function configuredQueryFieldsAreAddedToQuery() + { + $searchRequest = new SearchRequest('SearchWord'); + + $this->configuration->expects($this->any()) + ->method('get') + ->withConsecutive( + ['searching.fields.query'], + ['searching.boost'], + ['searching.fields.stored_fields'], + ['searching.fields.script_fields'], + ['searching.fieldValueFactor'] + ) + ->will($this->onConsecutiveCalls( + '_all, field1, field2', + $this->throwException(new InvalidArgumentException), + $this->throwException(new InvalidArgumentException), + $this->throwException(new InvalidArgumentException), + $this->throwException(new InvalidArgumentException) + )); + + $query = $this->subject->create($searchRequest); + $this->assertArraySubset( + [ + 'bool' => [ + 'must' => [ + [ + 'multi_match' => [ + 'type' => 'most_fields', + 'query' => 'SearchWord', + 'fields' => [ + '_all', + 'field1', + 'field2', + ], + ], + ], + ], + ], + ], + $query->toArray()['query'], + 'Configured fields were not added to query as configured.' + ); + } + + /** + * @test + */ + public function storedFieldsAreAddedToQuery() + { + $searchRequest = new SearchRequest(); + + $this->configuration->expects($this->any()) + ->method('get') + ->withConsecutive( + ['searching.boost'], + ['searching.fields.stored_fields'], + ['searching.fields.script_fields'], + ['searching.fieldValueFactor'] + ) + ->will($this->onConsecutiveCalls( + $this->throwException(new InvalidArgumentException), + '_source, something,nothing', + $this->throwException(new InvalidArgumentException), + $this->throwException(new InvalidArgumentException) + )); + + $query = $this->subject->create($searchRequest); + $this->assertSame( + ['_source', 'something', 'nothing'], + $query->toArray()['stored_fields'], + 'Stored fields were not added to query as expected.' + ); + } + + /** + * @test + */ + public function storedFieldsAreNotAddedToQuery() + { + $searchRequest = new SearchRequest(); + + $this->configuration->expects($this->any()) + ->method('get') + ->withConsecutive( + ['searching.boost'], + ['searching.fields.stored_fields'], + ['searching.fields.script_fields'], + ['searching.fieldValueFactor'] + ) + ->will($this->onConsecutiveCalls( + $this->throwException(new InvalidArgumentException), + $this->throwException(new InvalidArgumentException), + $this->throwException(new InvalidArgumentException), + $this->throwException(new InvalidArgumentException) + )); + + $query = $this->subject->create($searchRequest); + $this->assertFalse( + isset($query->toArray()['stored_fields']), + 'Stored fields were added to query even if not configured.' + ); + } + + /** + * @test + */ + public function scriptFieldsAreAddedToQuery() + { + $searchRequest = new SearchRequest('query value'); + + $this->configuration->expects($this->any()) + ->method('get') + ->withConsecutive( + ['searching.fields.query'], + ['searching.boost'], + ['searching.fields.stored_fields'], + ['searching.fields.script_fields'], + ['searching.fieldValueFactor'] + ) + ->will($this->onConsecutiveCalls( + '_all', + $this->throwException(new InvalidArgumentException), + $this->throwException(new InvalidArgumentException), + [ + 'field1' => [ + 'config' => 'something', + ], + 'field2' => [ + 'config' => '{request.query}', + ], + ], + $this->throwException(new InvalidArgumentException) + )); + + $query = $this->subject->create($searchRequest); + $this->assertSame( + [ + 'field1' => [ + 'config' => 'something', + ], + 'field2' => [ + 'config' => 'query value', + ], + ], + $query->toArray()['script_fields'], + 'Script fields were not added to query as expected.' + ); + } + + /** + * @test + */ + public function scriptFieldsAreNotAddedToQuery() + { + $searchRequest = new SearchRequest(); + + $this->configuration->expects($this->any()) + ->method('get') + ->withConsecutive( + ['searching.boost'], + ['searching.fields.stored_fields'], + ['searching.fields.script_fields'], + ['searching.fieldValueFactor'] + ) + ->will($this->onConsecutiveCalls( + $this->throwException(new InvalidArgumentException), + $this->throwException(new InvalidArgumentException), + $this->throwException(new InvalidArgumentException), + $this->throwException(new InvalidArgumentException) + )); + + $query = $this->subject->create($searchRequest); + $this->assertTrue( + !isset($query->toArray()['script_fields']), + 'Script fields were added to query even if not configured.' + ); + } + + /** + * @test + */ + public function sortIsAddedToQuery() + { + $searchRequest = new SearchRequest('query value'); + + $this->configuration->expects($this->any()) + ->method('getIfExists') + ->withConsecutive( + ['searching.minimumShouldMatch'], + ['searching.sort'] + ) + ->will($this->onConsecutiveCalls( + null, + [ + 'field1' => [ + 'config' => 'something', + ], + 'field2' => [ + 'config' => '{request.query}', + ], + ] + )); + + $this->configureConfigurationMockWithDefault(); + + $query = $this->subject->create($searchRequest); + $this->assertSame( + [ + 'field1' => [ + 'config' => 'something', + ], + 'field2' => [ + 'config' => 'query value', + ], + ], + $query->toArray()['sort'], + 'Sort was not added to query as expected.' + ); + } + + /** + * @test + */ + public function sortIsNotAddedToQuery() + { + $searchRequest = new SearchRequest('query value'); + + $this->configuration->expects($this->any()) + ->method('getIfExists') + ->withConsecutive( + ['searching.minimumShouldMatch'], + ['searching.sort'] + ) + ->will($this->onConsecutiveCalls( + null, + null + )); + + $this->configureConfigurationMockWithDefault(); + + $query = $this->subject->create($searchRequest); + $this->assertTrue( + !isset($query->toArray()['sort']), + 'Sort was added to query even if not configured.' + ); + } + + protected function configureConfigurationMockWithDefault() + { + $this->configuration->expects($this->any()) + ->method('get') + ->will($this->returnCallback(function ($configName) { + if ($configName === 'searching.fields.query') { + return '_all'; + } + + throw new InvalidArgumentException(); + })); + } +} diff --git a/Tests/Unit/Domain/Search/SearchServiceTest.php b/Tests/Unit/Domain/Search/SearchServiceTest.php new file mode 100644 index 0000000..3d6791e --- /dev/null +++ b/Tests/Unit/Domain/Search/SearchServiceTest.php @@ -0,0 +1,325 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Configuration\ConfigurationContainerInterface; +use Codappix\SearchCore\Configuration\InvalidArgumentException; +use Codappix\SearchCore\Connection\ConnectionInterface; +use Codappix\SearchCore\Connection\SearchResultInterface; +use Codappix\SearchCore\DataProcessing\Service as DataProcessorService; +use Codappix\SearchCore\Domain\Model\SearchRequest; +use Codappix\SearchCore\Domain\Model\SearchResult; +use Codappix\SearchCore\Domain\Search\SearchService; +use Codappix\SearchCore\Tests\Unit\AbstractUnitTestCase; +use TYPO3\CMS\Extbase\Object\ObjectManagerInterface; + +class SearchServiceTest extends AbstractUnitTestCase +{ + /** + * @var SearchService + */ + protected $subject; + + /** + * @var SearchResultInterface + */ + protected $result; + + /** + * @var ConnectionInterface + */ + protected $connection; + + /** + * @var ConfigurationContainerInterface + */ + protected $configuration; + + /** + * @var ObjectManagerInterface + */ + protected $objectManager; + + /** + * @var DataProcessorService + */ + protected $dataProcessorService; + + public function setUp() + { + parent::setUp(); + + $this->result = $this->getMockBuilder(SearchResultInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->connection = $this->getMockBuilder(ConnectionInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->configuration = $this->getMockBuilder(ConfigurationContainerInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->objectManager = $this->getMockBuilder(ObjectManagerInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $this->dataProcessorService = $this->getMockBuilder(DataProcessorService::class) + ->setConstructorArgs([$this->objectManager]) + ->getMock(); + + $this->subject = new SearchService( + $this->connection, + $this->configuration, + $this->objectManager, + $this->dataProcessorService + ); + } + + /** + * @test + */ + public function sizeIsAddedFromConfiguration() + { + $this->configuration->expects($this->any()) + ->method('getIfExists') + ->withConsecutive(['searching.size'], ['searching.facets']) + ->will($this->onConsecutiveCalls(45, null)); + $this->configuration->expects($this->any()) + ->method('get') + ->will($this->throwException(new InvalidArgumentException)); + $this->connection->expects($this->once()) + ->method('search') + ->with($this->callback(function ($searchRequest) { + return $searchRequest->getLimit() === 45; + })) + ->willReturn($this->getMockBuilder(SearchResultInterface::class)->getMock()); + + $searchRequest = new SearchRequest('SearchWord'); + $this->subject->search($searchRequest); + } + + /** + * @test + */ + public function defaultSizeIsAddedIfNothingIsConfigured() + { + $this->configuration->expects($this->any()) + ->method('getIfExists') + ->withConsecutive(['searching.size'], ['searching.facets']) + ->will($this->onConsecutiveCalls(null, null)); + $this->configuration->expects($this->any()) + ->method('get') + ->will($this->throwException(new InvalidArgumentException)); + $this->connection->expects($this->once()) + ->method('search') + ->with($this->callback(function ($searchRequest) { + return $searchRequest->getLimit() === 10; + })) + ->willReturn($this->getMockBuilder(SearchResultInterface::class)->getMock()); + + $searchRequest = new SearchRequest('SearchWord'); + $this->subject->search($searchRequest); + } + + /** + * @test + */ + public function configuredFilterAreAddedToRequestWithoutAnyFilter() + { + $this->configuration->expects($this->any()) + ->method('getIfExists') + ->withConsecutive(['searching.size'], ['searching.facets']) + ->will($this->onConsecutiveCalls(null, null)); + $this->configuration->expects($this->any()) + ->method('get') + ->will($this->onConsecutiveCalls( + ['property' => 'something'], + $this->throwException(new InvalidArgumentException) + )); + + $this->connection->expects($this->once()) + ->method('search') + ->with($this->callback(function ($searchRequest) { + return $searchRequest->getFilter() === ['property' => 'something']; + })) + ->willReturn($this->getMockBuilder(SearchResultInterface::class)->getMock()); + + $searchRequest = new SearchRequest('SearchWord'); + $this->subject->search($searchRequest); + } + + /** + * @test + */ + public function configuredFilterAreAddedToRequestWithExistingFilter() + { + $this->configuration->expects($this->any()) + ->method('getIfExists') + ->withConsecutive(['searching.size'], ['searching.facets']) + ->will($this->onConsecutiveCalls(null, null)); + $this->configuration->expects($this->any()) + ->method('get') + ->will($this->onConsecutiveCalls( + ['property' => 'something'], + $this->throwException(new InvalidArgumentException) + )); + + $this->connection->expects($this->once()) + ->method('search') + ->with($this->callback(function ($searchRequest) { + return $searchRequest->getFilter() === [ + 'anotherProperty' => 'anything', + 'property' => 'something', + ]; + })) + ->willReturn($this->getMockBuilder(SearchResultInterface::class)->getMock()); + + $searchRequest = new SearchRequest('SearchWord'); + $searchRequest->setFilter(['anotherProperty' => 'anything']); + $this->subject->search($searchRequest); + } + + /** + * @test + */ + public function nonConfiguredFilterIsNotChangingRequestWithExistingFilter() + { + $this->configuration->expects($this->any()) + ->method('getIfExists') + ->withConsecutive(['searching.size'], ['searching.facets']) + ->will($this->onConsecutiveCalls(null, null)); + $this->configuration->expects($this->any()) + ->method('get') + ->will($this->throwException(new InvalidArgumentException)); + + $this->connection->expects($this->once()) + ->method('search') + ->with($this->callback(function ($searchRequest) { + return $searchRequest->getFilter() === ['anotherProperty' => 'anything']; + })) + ->willReturn($this->getMockBuilder(SearchResultInterface::class)->getMock()); + + $searchRequest = new SearchRequest('SearchWord'); + $searchRequest->setFilter(['anotherProperty' => 'anything']); + $this->subject->search($searchRequest); + } + + /** + * @test + */ + public function emptyConfiguredFilterIsNotChangingRequestWithExistingFilter() + { + $this->configuration->expects($this->any()) + ->method('getIfExists') + ->withConsecutive(['searching.size'], ['searching.facets']) + ->will($this->onConsecutiveCalls(null, null)); + $this->configuration->expects($this->any()) + ->method('get') + ->will($this->onConsecutiveCalls( + ['anotherProperty' => ''], + $this->throwException(new InvalidArgumentException) + )); + + $this->connection->expects($this->once()) + ->method('search') + ->with($this->callback(function ($searchRequest) { + return $searchRequest->getFilter() === ['anotherProperty' => 'anything']; + })) + ->willReturn($this->getMockBuilder(SearchResultInterface::class)->getMock()); + + $searchRequest = new SearchRequest('SearchWord'); + $searchRequest->setFilter(['anotherProperty' => 'anything']); + $this->subject->search($searchRequest); + } + + /** + * @test + */ + public function originalSearchResultIsReturnedIfNoDataProcessorIsConfigured() + { + $this->configuration->expects($this->any()) + ->method('getIfExists') + ->withConsecutive(['searching.size'], ['searching.facets']) + ->will($this->onConsecutiveCalls(null, null)); + $this->configuration->expects($this->any()) + ->method('get') + ->will($this->throwException(new InvalidArgumentException)); + + $searchResultMock = $this->getMockBuilder(SearchResultInterface::class)->getMock(); + + $this->connection->expects($this->once()) + ->method('search') + ->willReturn($searchResultMock); + + $this->dataProcessorService->expects($this->never())->method('executeDataProcessor'); + + $searchRequest = new SearchRequest(''); + $this->assertSame( + $searchResultMock, + $this->subject->search($searchRequest), + 'Did not get created result without applied data processing' + ); + } + + /** + * @test + */ + public function configuredDataProcessorsAreExecutedOnSearchResult() + { + $this->configuration->expects($this->any()) + ->method('getIfExists') + ->withConsecutive(['searching.size'], ['searching.facets']) + ->will($this->onConsecutiveCalls(null, null)); + $this->configuration->expects($this->any()) + ->method('get') + ->will($this->onConsecutiveCalls( + $this->throwException(new InvalidArgumentException), + ['SomeProcessorClass'] + )); + + $searchResultMock = $this->getMockBuilder(SearchResultInterface::class)->getMock(); + $searchResult = new SearchResult($searchResultMock, [['field 1' => 'value 1']]); + + $this->connection->expects($this->once()) + ->method('search') + ->willReturn($searchResult); + + $this->dataProcessorService->expects($this->once()) + ->method('executeDataProcessor') + ->with('SomeProcessorClass', ['field 1' => 'value 1']) + ->willReturn([ + 'field 1' => 'value 1', + 'field 2' => 'value 2', + ]); + + $this->objectManager->expects($this->once()) + ->method('get') + ->with(SearchResult::class, $searchResult, [ + ['field 1' => 'value 1', 'field 2' => 'value 2'] + ]) + ->willReturn($searchResultMock); + + $searchRequest = new SearchRequest(''); + $this->assertSame( + $searchResultMock, + $this->subject->search($searchRequest), + 'Did not get created result with applied data processing' + ); + } +} diff --git a/Tests/Unit/Hook/DataHandlerTest.php b/Tests/Unit/Hook/DataHandlerTest.php new file mode 100644 index 0000000..ed68993 --- /dev/null +++ b/Tests/Unit/Hook/DataHandlerTest.php @@ -0,0 +1,94 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Domain\Service\DataHandler as OwnDataHandler; +use Codappix\SearchCore\Hook\DataHandler; +use Codappix\SearchCore\Tests\Unit\AbstractUnitTestCase; +use TYPO3\CMS\Core\DataHandling\DataHandler as CoreDataHandler; + +class DataHandlerToProcessorTest extends AbstractUnitTestCase +{ + /** + * @test + * @dataProvider getPossibleCallCombinations + */ + public function fieldsAreCopiedAsConfigured(array $parameters, bool $expectCall) + { + $coreDataHandlerMock = $this->getMockBuilder(CoreDataHandler::class)->getMock(); + $ownDataHandlerMock = $this->getMockBuilder(OwnDataHandler::class) + ->disableOriginalConstructor() + ->getMock(); + $subject = $this->getMockBuilder(DataHandler::class) + ->setConstructorArgs([$ownDataHandlerMock]) + ->setMethods(['getRecord']) + ->getMock(); + + $ownDataHandlerMock->expects($this->any()) + ->method('supportsTable') + ->willReturn(true); + + if ($expectCall) { + $subject->expects($this->once()) + ->method('getRecord') + ->with('pages', 10) + ->willReturn(['uid' => 10]); + $ownDataHandlerMock->expects($this->once()) + ->method('update') + ->with('pages', ['uid' => 10]); + } else { + $subject->expects($this->never()) + ->method('getRecord'); + $ownDataHandlerMock->expects($this->never()) + ->method('update'); + } + + $subject->clearCachePostProc($parameters, $coreDataHandlerMock); + } + + public function getPossibleCallCombinations() : array + { + return [ + 'Editor triggered cache clear of page manual' => [ + 'parameters' => [ + 'cacheCmd' => '10', + ], + 'expectCall' => true, + ], + 'Editor changed records on a page' => [ + 'parameters' => [ + 'uid_page' =>10, + ], + 'expectCall' => true, + ], + 'Something unexpected' => [ + 'parameters' => [], + 'expectCall' => false, + ], + 'Something unexpected' => [ + 'parameters' => [ + 'cacheCmd' => 'something like a tag?!', + ], + 'expectCall' => false, + ], + ]; + } +} diff --git a/Tests/Unit/Integration/Form/Finisher/DataHandlerFinisherTest.php b/Tests/Unit/Integration/Form/Finisher/DataHandlerFinisherTest.php new file mode 100644 index 0000000..30745cd --- /dev/null +++ b/Tests/Unit/Integration/Form/Finisher/DataHandlerFinisherTest.php @@ -0,0 +1,137 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Codappix\SearchCore\Domain\Service\DataHandler; +use Codappix\SearchCore\Integration\Form\Finisher\DataHandlerFinisher; +use Codappix\SearchCore\Tests\Unit\AbstractUnitTestCase; +use TYPO3\CMS\Form\Domain\Finishers\Exception\FinisherException; +use TYPO3\CMS\Form\Domain\Finishers\FinisherContext; + +class DataHandlerFinisherTest extends AbstractUnitTestCase +{ + /** + * @var DataHandlerFinisher + */ + protected $subject; + + /** + * @var DataHandler + */ + protected $dataHandlerMock; + + /** + * @var FinisherContext + */ + protected $finisherContextMock; + + public function setUp() + { + parent::setUp(); + + $this->configureMockedTranslationService(); + $this->dataHandlerMock = $this->getMockBuilder(DataHandler::class) + ->disableOriginalConstructor() + ->getMock(); + $this->finisherContextMock = $this->getMockBuilder(FinisherContext::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->subject = new DataHandlerFinisher(); + $this->inject($this->subject, 'dataHandler', $this->dataHandlerMock); + } + + /** + * @test + * @requires function \TYPO3\CMS\Form\Domain\Finishers\AbstractFinisher::setOptions + * @dataProvider possibleFinisherSetup + */ + public function validConfiguration(string $action, array $nonCalledActions, $expectedSecondArgument) + { + $this->subject->setOptions([ + 'indexIdentifier' => 'test_identifier', + 'recordUid' => '23', + 'action' => $action, + ]); + + foreach ($nonCalledActions as $nonCalledAction) { + $this->dataHandlerMock->expects($this->never())->method($nonCalledAction); + } + $this->dataHandlerMock->expects($this->once())->method($action) + ->with('test_identifier', $expectedSecondArgument); + + $this->subject->execute($this->finisherContextMock); + } + + public function possibleFinisherSetup() : array + { + return [ + 'valid update configuration' => [ + 'action' => 'update', + 'nonCalledActions' => ['delete'], + 'expectedSecondArgument' => ['uid' => 23], + ], + 'valid delete configuration' => [ + 'action' => 'delete', + 'nonCalledActions' => ['update'], + 'expectedSecondArgument' => 23, + ], + ]; + } + + /** + * @test + * @requires function \TYPO3\CMS\Form\Domain\Finishers\AbstractFinisher::setOptions + * @dataProvider invalidFinisherSetup + */ + public function nothingHappensIfUnknownActionIsConfigured(array $options) + { + $this->subject->setOptions($options); + + foreach (['update', 'delete'] as $nonCalledAction) { + $this->dataHandlerMock->expects($this->never())->method($nonCalledAction); + } + + $this->expectException(FinisherException::class); + $this->subject->execute($this->finisherContextMock); + } + + public function invalidFinisherSetup() : array + { + return [ + 'missing options' => [ + 'options' => [], + ], + 'missing action option' => [ + 'options' => [ + 'indexIdentifier' => 'identifier', + 'recordUid' => '20', + ], + ], + 'missing record uid option' => [ + 'options' => [ + 'indexIdentifier' => 'identifier', + 'action' => 'update', + ], + ], + ]; + } +} diff --git a/Tests/Unit/UnitTests.xml b/Tests/Unit/UnitTests.xml new file mode 100644 index 0000000..d8285be --- /dev/null +++ b/Tests/Unit/UnitTests.xml @@ -0,0 +1,28 @@ + + + + + . + + + + + + ../../Classes + + + diff --git a/composer.json b/composer.json index 9cfe8b3..83e5f47 100644 --- a/composer.json +++ b/composer.json @@ -15,13 +15,15 @@ "TYPO3\\CMS\\Core\\Tests\\": ".Build/vendor/typo3/cms/typo3/sysext/core/Tests/" } }, - "require" : { - "php": ">=5.6.0", - "typo3/cms": "~7.6", + "require": { + "php": ">=7.0.0", + "typo3/cms": ">= 7.6.0 < 9.0.0", "ruflin/elastica": "~3.2" }, "require-dev": { - "phpunit/phpunit": "~5.7.0" + "phpunit/phpunit": "~6.4.4", + "typo3/testing-framework": "~1.1.5", + "squizlabs/php_codesniffer": "~3.1.1" }, "config": { "optimize-autoloader": true, @@ -30,14 +32,17 @@ }, "scripts": { "post-autoload-dump": [ - "mkdir -p .Build/Web/typo3conf/ext/", - "[ -L .Build/Web/typo3conf/ext/search_core ] || ln -snvf ../../../../. .Build/Web/typo3conf/ext/search_core" + "mkdir -p .Build/web/typo3conf/ext/", + "[ -L .Build/web/typo3conf/ext/search_core ] || ln -snvf ../../../../. .Build/web/typo3conf/ext/search_core" ] }, "extra": { + "branch-alias": { + "dev-develop": "1.0.x-dev" + }, "typo3/cms": { "cms-package-dir": "{$vendor-dir}/typo3/cms", - "web-dir": ".Build/Web" + "web-dir": ".Build/web" } }, "authors": [ diff --git a/ext_conf_template.txt b/ext_conf_template.txt new file mode 100644 index 0000000..5ddadb4 --- /dev/null +++ b/ext_conf_template.txt @@ -0,0 +1,4 @@ +disable { + # cat=basic/enable; type=boolean; label=Disable Elasticsearch, which is enabled by default + elasticsearch = true +} diff --git a/ext_emconf.php b/ext_emconf.php index de4fd8c..7c3a087 100644 --- a/ext_emconf.php +++ b/ext_emconf.php @@ -4,10 +4,11 @@ $EM_CONF[$_EXTKEY] = [ 'title' => 'Search Core', 'description' => 'Search core for implementing various search types.', 'category' => 'be', + 'clearCacheOnLoad' => 1, 'constraints' => [ 'depends' => [ - 'typo3' => '7.6.0-7.6.99', - 'php' => '5.6.0-7.99.99' + 'typo3' => '7.6.0-8.7.99', + 'php' => '7.0.0-7.2.99' ], 'conflicts' => [], ], @@ -16,9 +17,8 @@ $EM_CONF[$_EXTKEY] = [ 'Codappix\\SearchCore\\' => 'Classes', ], ], - 'state' => 'alpha', - 'clearCacheOnLoad' => 1, + 'state' => 'beta', + 'version' => '0.0.1', 'author' => 'Daniel Siepmann', 'author_email' => 'coding@daniel-siepmann.de', - 'version' => '1.0.0', ]; diff --git a/ext_localconf.php b/ext_localconf.php index 1c1f9cd..658d683 100644 --- a/ext_localconf.php +++ b/ext_localconf.php @@ -15,11 +15,14 @@ call_user_func( ], ], 't3lib/class.t3lib_tcemain.php' => [ + 'clearCachePostProc' => [ + $extensionKey => \Codappix\SearchCore\Hook\DataHandler::class . '->clearCachePostProc', + ], 'processCmdmapClass' => [ - $extensionKey => '&' . \Codappix\SearchCore\Hook\DataHandler::class, + $extensionKey => \Codappix\SearchCore\Hook\DataHandler::class, ], 'processDatamapClass' => [ - $extensionKey => '&' . \Codappix\SearchCore\Hook\DataHandler::class, + $extensionKey => \Codappix\SearchCore\Hook\DataHandler::class, ], ], ], @@ -33,15 +36,24 @@ call_user_func( 'Search' => 'search' ], [ - 'Search' => 'search' // TODO: Enable caching. But submitting form results in previous result?! + 'Search' => 'search' ] ); - \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance('TYPO3\CMS\Extbase\Object\Container\Container') - ->registerImplementation( - 'Codappix\SearchCore\Connection\ConnectionInterface', - 'Codappix\SearchCore\Connection\Elasticsearch' - ); + \Codappix\SearchCore\Compatibility\ImplementationRegistrationService::registerImplementations(); + + // API does make use of object manager, therefore use GLOBALS + $extensionConfiguration = unserialize($GLOBALS['TYPO3_CONF_VARS']['EXT']['extConf'][$extensionKey]); + if ($extensionConfiguration === false + || !isset($extensionConfiguration['disable.']['elasticsearch']) + || $extensionConfiguration['disable.']['elasticsearch'] !== '1' + ) { + \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\TYPO3\CMS\Extbase\Object\Container\Container::class) + ->registerImplementation( + \Codappix\SearchCore\Connection\ConnectionInterface::class, + \Codappix\SearchCore\Connection\Elasticsearch::class + ); + } }, $_EXTKEY ); diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..e722968 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,23 @@ + + + The coding standard for search_core. + + Classes/ + Tests/ + + + + + + + + + + + + + + + Classes/Hook/DataHandler.php + +