Add Form for ElasticSearch Configuration.

Also changed type from array to Options object.
This commit is contained in:
Benoît Burnichon
2015-06-29 15:04:50 +02:00
parent 7271e28cf5
commit 28fee99fc4
20 changed files with 306 additions and 332 deletions

View File

@@ -10,37 +10,62 @@
namespace Alchemy\Phrasea\Controller\Admin;
use Alchemy\Phrasea\SearchEngine\ConfigurationPanelInterface;
use Alchemy\Phrasea\Controller\Controller;
use Alchemy\Phrasea\SearchEngine\Elastic\ElasticSearchSettingFormType;
use Alchemy\Phrasea\SearchEngine\Elastic\GlobalElasticOptions;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class SearchEngineController
class SearchEngineController extends Controller
{
/**
* @var ConfigurationPanelInterface
*/
private $configurationPanel;
public function __construct(ConfigurationPanelInterface $configurationPanel)
{
$this->configurationPanel = $configurationPanel;
}
/**
* @param Request $request
* @return Response
*/
public function getConfigurationPanelAction(Request $request)
public function formConfigurationPanelAction(Request $request)
{
return $this->configurationPanel->get($request);
$options = $this->getElasticSearchOptions();
$form = $this->getConfigurationForm($options);
$form->handleRequest($request);
if ($form->isValid()) {
$this->saveElasticSearchOptions($form->getData());
return $this->app->redirectPath('admin_searchengine_form');
}
return $this->render('admin/search-engine/elastic-search.html.twig', [
'form' => $form->createView(),
]);
}
/**
* @param Request $request
* @return Response
* @return GlobalElasticOptions
*/
public function postConfigurationPanelAction(Request $request)
private function getElasticSearchOptions()
{
return $this->configurationPanel->post($request);
return $this->app['elasticsearch.options'];
}
/**
* @param GlobalElasticOptions $configuration
* @return void
*/
private function saveElasticSearchOptions(GlobalElasticOptions $configuration)
{
$this->getConf()->set(['main', 'search-engine', 'options'], $configuration->toArray());
}
/**
* @param GlobalElasticOptions $options
* @return FormInterface
*/
private function getConfigurationForm(GlobalElasticOptions $options)
{
return $this->app->form(new ElasticSearchSettingFormType(), $options, [
'action' => $this->app->url('admin_searchengine_form'),
]);
}
}

View File

@@ -356,7 +356,7 @@ class V1Controller extends Controller
'engine' => [
'type' => $searchEngine->getName(),
'status' => $SEStatus,
'configuration' => $searchEngine->getConfigurationPanel()->getConfiguration(),
'configuration' => $conf->get(['main', 'searchengine', 'options']),
],
],
'binary' => [

View File

@@ -9,7 +9,6 @@
*/
namespace Alchemy\Phrasea\Controller\Prod;
use Alchemy\Phrasea\Application;
use Alchemy\Phrasea\Controller\Controller;
use Alchemy\Phrasea\Controller\RecordsRequest;
use Symfony\Component\HttpFoundation\Request;

View File

@@ -24,9 +24,7 @@ class SearchEngine implements ControllerProviderInterface, ServiceProviderInterf
public function register(Application $app)
{
$app['controller.admin.search-engine'] = $app->share(function (PhraseaApplication $app) {
/** @var SearchEngineInterface $searchEngine */
$searchEngine = $app['search_engine'];
return new SearchEngineController($searchEngine->getConfigurationPanel());
return new SearchEngineController($app);
});
}
@@ -39,11 +37,9 @@ class SearchEngine implements ControllerProviderInterface, ServiceProviderInterf
/** @var ControllerCollection $controllers */
$controllers = $app['controllers_factory'];
$controllers->get('/', 'controller.admin.search-engine:getConfigurationPanelAction')
->bind('admin_searchengine_get');
$controllers->post('/', 'controller.admin.search-engine:postConfigurationPanelAction')
->bind('admin_searchengine_post');
$controllers->match('/', 'controller.admin.search-engine:formConfigurationPanelAction')
->method('GET|POST')
->bind('admin_searchengine_form');
return $controllers;
}

View File

@@ -12,6 +12,7 @@
namespace Alchemy\Phrasea\Core\Provider;
use Alchemy\Phrasea\Controller\LazyLocator;
use Alchemy\Phrasea\SearchEngine\Elastic\GlobalElasticOptions;
use Alchemy\Phrasea\SearchEngine\SearchEngineLogger;
use Alchemy\Phrasea\Exception\InvalidArgumentException;
use Alchemy\Phrasea\SearchEngine\SearchEngineInterface;
@@ -53,11 +54,14 @@ class SearchEngineServiceProvider implements ServiceProviderInterface
if ($type !== SearchEngineInterface::TYPE_ELASTICSEARCH) {
throw new InvalidArgumentException(sprintf('Invalid search engine type "%s".', $type));
}
/** @var GlobalElasticOptions $options */
$options = $app['elasticsearch.options'];
return new ElasticSearchEngine(
$app,
$app['search_engine.structure'],
$app['elasticsearch.client'],
$app['elasticsearch.options']['index'],
$options->getIndexName(),
$app['locales.available'],
$app['elasticsearch.facets_response.factory']
);
@@ -132,11 +136,13 @@ class SearchEngineServiceProvider implements ServiceProviderInterface
/* Low-level elasticsearch services */
$app['elasticsearch.client'] = $app->share(function($app) {
/** @var GlobalElasticOptions $options */
$options = $app['elasticsearch.options'];
$clientParams = ['hosts' => [sprintf('%s:%s', $options['host'], $options['port'])]];
$clientParams = ['hosts' => [sprintf('%s:%s', $options->getHost(), $options->getPort())]];
// Create file logger for debug
if ($app['debug']) {
/** @var Logger $logger */
$logger = new $app['monolog.logger.class']('search logger');
$logger->pushHandler(new RotatingFileHandler($app['log.path'].DIRECTORY_SEPARATOR.'elasticsearch.log', 2), Logger::INFO);
@@ -148,22 +154,16 @@ class SearchEngineServiceProvider implements ServiceProviderInterface
});
$app['elasticsearch.options'] = $app->share(function($app) {
$options = $app['conf']->get(['main', 'search-engine', 'options'], []);
$options = GlobalElasticOptions::fromArray($app['conf']->get(['main', 'search-engine', 'options'], []));
$indexName = sprintf('phraseanet_%s', str_replace(
if (empty($options->getIndexName())) {
$options->setIndexName(strtolower(sprintf('phraseanet_%s', str_replace(
array('/', '.'), array('', ''),
$app['conf']->get(['main', 'key'])
));
))));
}
$defaults = [
'host' => '127.0.0.1',
'port' => 9200,
'index' => strtolower($indexName),
'shards' => 3,
'replicas' => 0
];
return array_replace($defaults, $options);
return $options;
});
@@ -175,7 +175,7 @@ class SearchEngineServiceProvider implements ServiceProviderInterface
$logger->pushHandler(new \Monolog\Handler\ErrorLogHandler());
return new Thesaurus(
$app['elasticsearch.client'],
$app['elasticsearch.options']['index'],
$app['elasticsearch.options'],
$logger
);
});

View File

@@ -1,57 +0,0 @@
<?php
/*
* This file is part of Phraseanet
*
* (c) 2005-2015 Alchemy
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Alchemy\Phrasea\SearchEngine;
use Alchemy\Phrasea\Core\Configuration\PropertyAccess;
abstract class AbstractConfigurationPanel implements ConfigurationPanelInterface
{
/** @var PropertyAccess */
protected $conf;
public function __construct(PropertyAccess $conf)
{
$this->conf = $conf;
}
/**
* @param \databox[] $databoxes
* @return array
*/
public function getAvailableDateFields(array $databoxes)
{
$date_fields = [];
foreach ($databoxes as $databox) {
/** @var \databox_field $field */
foreach ($databox->get_meta_structure() as $field) {
if ($field->get_type() !== \databox_field::TYPE_DATE) {
continue;
}
$date_fields[] = $field->get_name();
}
}
return $date_fields;
}
/**
* {@inheritdoc}
*/
public function saveConfiguration(array $configuration)
{
$this->conf->set(['main', 'search-engine', 'options'], $configuration);
return $this;
}
}

View File

@@ -1,65 +0,0 @@
<?php
/*
* This file is part of Phraseanet
*
* (c) 2005-2015 Alchemy
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Alchemy\Phrasea\SearchEngine;
use Alchemy\Phrasea\Application;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
interface ConfigurationPanelInterface
{
/**
* Handles the GET request to the configuration panel
*
* @param Request $request
* @return Response
*/
public function get(Request $request);
/**
* Handles the POST request to the configuration panel
*
* @param Request $request
* @return Response
*/
public function post(Request $request);
/**
* Return the associated search engine name
*
* @return string The name
*/
public function getName();
/**
* Returns the configuration of the search engine
*
* @return array The configuration
*/
public function getConfiguration();
/**
* Saves the search engine configuration
*
* @param array $configuration
* @return ConfigurationPanelInterface
*/
public function saveConfiguration(array $configuration);
/**
* Return the names of the date fields
*
* @param \databox[] $databoxes
* @return array An array of date fields names
*/
public function getAvailableDateFields(array $databoxes);
}

View File

@@ -1,68 +0,0 @@
<?php
/*
* This file is part of Phraseanet
*
* (c) 2005-2014 Alchemy
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Alchemy\Phrasea\SearchEngine\Elastic;
use Alchemy\Phrasea\Application;
use Alchemy\Phrasea\Core\Configuration\PropertyAccess;
use Alchemy\Phrasea\SearchEngine\AbstractConfigurationPanel;
use Symfony\Component\HttpFoundation\Request;
class ConfigurationPanel extends AbstractConfigurationPanel
{
/** @var Application */
private $app;
public function __construct(Application $app, PropertyAccess $conf)
{
parent::__construct($conf);
$this->app = $app;
}
/**
* {@inheritdoc}
*/
public function getName()
{
return 'elastic-search-engine';
}
/**
* {@inheritdoc}
*/
public function get(Request $request)
{
return $this->app['twig']->render('admin/search-engine/elastic-search.html.twig', ['configuration' => $this->getConfiguration()]);
}
/**
* {@inheritdoc}
*/
public function post(Request $request)
{
$configuration = $this->getConfiguration();
$configuration['host'] = $request->request->get('host');
$configuration['port'] = $request->request->get('port');
$this->saveConfiguration($configuration);
return $this->app->redirectPath('admin_searchengine_get');
}
/**
* {@inheritdoc}
*/
public function getConfiguration()
{
return $this->conf->get(['main', 'search-engine', 'options'], []);
}
}

View File

@@ -100,18 +100,6 @@ class ElasticSearchEngine implements SearchEngineInterface
return $ret;
}
/**
* {@inheritdoc}
*/
public function getConfigurationPanel()
{
if (!$this->configurationPanel) {
$this->configurationPanel = new ConfigurationPanel($this->app, $this->app['conf']);
}
return $this->configurationPanel;
}
/**
* {@inheritdoc}
*/

View File

@@ -0,0 +1,53 @@
<?php
/*
* This file is part of Phraseanet
*
* (c) 2005-2015 Alchemy
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Alchemy\Phrasea\SearchEngine\Elastic;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Range;
class ElasticSearchSettingFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('host', 'text', [
'label' => 'ElasticSearch server host',
])
->add('port', 'integer', [
'label' => 'ElasticSearch service port',
'constraints' => new Range(['min' => 1, 'max' => 65535]),
])
->add('indexName', 'text', [
'label' => 'ElasticSearch index name',
'constraints' => new NotBlank(),
])
->add('shards', 'integer', [
'label' => 'Number of shards',
'constraints' => new Range(['min' => 1]),
])
->add('replicas', 'integer', [
'label' => 'Number of replicas',
'constraints' => new Range(['min' => 0]),
])
->add('minScore', 'integer', [
'label' => 'Thesaurus Min score',
'constraints' => new Range(['min' => 0]),
])
->add('save', 'submit')
;
}
public function getName()
{
return 'elastic_settings';
}
}

View File

@@ -0,0 +1,165 @@
<?php
/*
* This file is part of Phraseanet
*
* (c) 2005-2015 Alchemy
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Alchemy\Phrasea\SearchEngine\Elastic;
class GlobalElasticOptions
{
/** @var string */
private $host;
/** @var int */
private $port;
/** @var string */
private $indexName;
/** @var int */
private $shards;
/** @var int */
private $replicas;
/** @var int */
private $minScore;
/**
* Factory method to hydrate an instance from serialized options
*
* @param array $options
* @return self
*/
public static function fromArray(array $options)
{
$options = array_replace([
'host' => '127.0.0.1',
'port' => 9200,
'index' => '',
'shards' => 3,
'replicas' => 0,
'minScore' => 4,
], $options);
$self = new self();
$self->setHost($options['host']);
$self->setPort($options['port']);
$self->setIndexName($options['index']);
$self->setShards($options['shards']);
$self->setReplicas($options['replicas']);
$self->setMinScore($options['minScore']);
return $self;
}
/**
* @return array
*/
public function toArray()
{
return [
'host' => $this->host,
'port' => $this->port,
'index' => $this->indexName,
'shards' => $this->shards,
'replicas' => $this->replicas,
'minScore' => $this->minScore,
];
}
/**
* @param string $host
*/
public function setHost($host)
{
$this->host = $host;
}
/**
* @return string
*/
public function getHost()
{
return $this->host;
}
/**
* @param int $port
*/
public function setPort($port)
{
$this->port = (int)$port;
}
/**
* @return int
*/
public function getPort()
{
return $this->port;
}
/**
* @param int $minScore
*/
public function setMinScore($minScore)
{
$this->minScore = (int)$minScore;
}
/**
* @return int
*/
public function getMinScore()
{
return $this->minScore;
}
/**
* @param string $indexName
*/
public function setIndexName($indexName)
{
$this->indexName = $indexName;
}
/**
* @return string
*/
public function getIndexName()
{
return $this->indexName;
}
/**
* @param int $shards
*/
public function setShards($shards)
{
$this->shards = (int)$shards;
}
/**
* @return int
*/
public function getShards()
{
return $this->shards;
}
/**
* @param int $replicas
*/
public function setReplicas($replicas)
{
$this->replicas = (int)$replicas;
}
/**
* @return int
*/
public function getReplicas()
{
return $this->replicas;
}
}

View File

@@ -32,6 +32,7 @@ class Indexer
/** @var \Elasticsearch\Client */
private $client;
/** @var GlobalElasticOptions */
private $options;
private $appbox;
/** @var LoggerInterface|null */
@@ -48,7 +49,7 @@ class Indexer
const DEFAULT_REFRESH_INTERVAL = '1s';
const REFRESH_INTERVAL_KEY = 'index.refresh_interval';
public function __construct(Client $client, array $options, TermIndexer $termIndexer, RecordIndexer $recordIndexer, appbox $appbox, LoggerInterface $logger = null)
public function __construct(Client $client, GlobalElasticOptions $options, TermIndexer $termIndexer, RecordIndexer $recordIndexer, appbox $appbox, LoggerInterface $logger = null)
{
$this->client = $client;
$this->options = $options;
@@ -64,9 +65,9 @@ class Indexer
public function createIndex($withMapping = true)
{
$params = array();
$params['index'] = $this->options['index'];
$params['body']['settings']['number_of_shards'] = $this->options['shards'];
$params['body']['settings']['number_of_replicas'] = $this->options['replicas'];
$params['index'] = $this->options->getIndexName();
$params['body']['settings']['number_of_shards'] = $this->options->getShards();
$params['body']['settings']['number_of_replicas'] = $this->options->getReplicas();
$params['body']['settings']['analysis'] = $this->getAnalysis();;
if ($withMapping) {
@@ -80,7 +81,7 @@ class Indexer
public function updateMapping()
{
$params = array();
$params['index'] = $this->options['index'];
$params['index'] = $this->options->getIndexName();
$params['type'] = RecordIndexer::TYPE_NAME;
$params['body'][RecordIndexer::TYPE_NAME] = $this->recordIndexer->getMapping();
$params['body'][TermIndexer::TYPE_NAME] = $this->termIndexer->getMapping();
@@ -91,13 +92,13 @@ class Indexer
public function deleteIndex()
{
$params = array('index' => $this->options['index']);
$params = array('index' => $this->options->getIndexName());
$this->client->indices()->delete($params);
}
public function indexExists()
{
$params = array('index' => $this->options['index']);
$params = array('index' => $this->options->getIndexName());
return $this->client->indices()->exists($params);
}
@@ -132,7 +133,7 @@ class Indexer
}
// Optimize index
$params = array('index' => $this->options['index']);
$params = array('index' => $this->options->getIndexName());
$this->client->indices()->optimize($params);
});
@@ -203,7 +204,7 @@ class Indexer
try {
// Prepare the bulk operation
$bulk = new BulkOperation($this->client, $this->logger);
$bulk->setDefaultIndex($this->options['index']);
$bulk->setDefaultIndex($this->options->getIndexName());
$bulk->setAutoFlushLimit(1000);
// Do the work
$work($bulk);
@@ -233,7 +234,7 @@ class Indexer
private function getSetting($name)
{
$index = $this->options['index'];
$index = $this->options->getIndexName();
$params = array();
$params['index'] = $index;
$params['name'] = $name;
@@ -245,7 +246,7 @@ class Indexer
private function setSetting($name, $value)
{
$index = $this->options['index'];
$index = $this->options->getIndexName();
$params = array();
$params['index'] = $index;
$params['body'][$name] = $value;

View File

@@ -22,16 +22,17 @@ use Psr\Log\LoggerInterface;
class Thesaurus
{
/** @var Client */
private $client;
private $index;
/** @var GlobalElasticOptions */
private $options;
/** @var LoggerInterface */
private $logger;
const MIN_SCORE = 4;
public function __construct(Client $client, $index, LoggerInterface $logger)
public function __construct(Client $client, GlobalElasticOptions $options, LoggerInterface $logger)
{
$this->client = $client;
$this->index = $index;
$this->options = $options;
$this->logger = $logger;
}
@@ -136,7 +137,7 @@ class Thesaurus
// Search request
$params = array();
$params['index'] = $this->index;
$params['index'] = $this->options->getIndexName();
$params['type'] = TermIndexer::TYPE_NAME;
$params['body']['query'] = $query;
$params['body']['aggs'] = $aggs;
@@ -144,7 +145,7 @@ class Thesaurus
// inexact concepts.
// We also need to disable TF/IDF on terms, and try to boost score only
// when the search match nearly all tokens of term's value field.
$params['body']['min_score'] = self::MIN_SCORE;
$params['body']['min_score'] = $this->options->getMinScore();
// No need to get any hits since we extract data from aggs
$params['body']['size'] = 0;

View File

@@ -40,11 +40,6 @@ interface SearchEngineInterface
*/
public function getStatus();
/**
* @return ConfigurationPanelInterface
*/
public function getConfigurationPanel();
/**
* @return array an array of field names
*/

View File

@@ -408,7 +408,7 @@ class SearchEngineOptions
}, $value);
}
if (in_array($key, ['collections', 'business_fields'])) {
$value = array_map(function ($collection) {
$value = array_map(function (\collection $collection) {
return $collection->get_base_id();
}, $value);
}

View File

@@ -1,11 +1,3 @@
<h1>{{ 'ElasticSearch configuration' | trans }}</h1>
<form method="post" action="{{ path('admin_searchengine_post') }}">
<div>{{ 'ElasticSearch connection configuration' | trans }}</div>
<div>{{ 'ElasticSearch server' | trans }}</div>
<input type="text" name="host" value="{{ configuration['host'] | default('localhost') }}"/>
<input type="text" name="port" value="{{ configuration['port'] | default('9200') }}"/>
<button type="submit" class="btn btn-warning" >{{ 'boutton::valider' | trans }}</button>
</form>
{{ form(form) }}

View File

@@ -23,7 +23,7 @@
</a>
</li>
<li>
<a target="right" href="{{ path('admin_searchengine_get') }}">
<a target="right" href="{{ path('admin_searchengine_form') }}">
<span>{{ 'SearchEngine settings' | trans }}</span>
</a>
</li>

View File

@@ -1,43 +0,0 @@
<?php
namespace Alchemy\Tests\Phrasea\SearchEngine;
abstract class ConfigurationPanelAbstractTest extends \PhraseanetTestCase
{
abstract public function getPanel();
public function testGetName()
{
$this->assertInternalType('string', $this->getPanel()->getName());
}
public function testGetConfiguration()
{
$this->assertInternalType('array', $this->getPanel()->getConfiguration());
}
public function testSaveConfiguration()
{
$config = $this->getPanel()->getConfiguration();
$data = 'Yodelali' . mt_rand();
$config['test'] = $data;
$this->getPanel()->saveConfiguration($config);
$config = $this->getPanel()->getConfiguration();
$this->assertEquals($data, $config['test']);
unset($config['test']);
$this->getPanel()->saveConfiguration($config);
}
public function testGetAvailableDateFields()
{
$dateFields = $this->getPanel()->getAvailableDateFields(self::$DI['app']['phraseanet.appbox']->get_databoxes());
$this->assertInternalType('array', $dateFields);
foreach ($dateFields as $dateField) {
$this->assertInternalType('string', $dateField);
}
}
}

View File

@@ -556,11 +556,6 @@ abstract class SearchEngineAbstractTest extends \PhraseanetAuthenticatedTestCase
}
}
public function testConfigurationPanel()
{
$this->assertInstanceOf('\\Alchemy\\Phrasea\\SearchEngine\\ConfigurationPanelInterface', self::$searchEngine->getConfigurationPanel());
}
public function testStatus()
{
foreach (self::$searchEngine->getStatus() as $StatusKeyValue) {

View File

@@ -711,9 +711,6 @@ abstract class PhraseanetTestCase extends WebTestCase
$mock->expects($this->any())
->method('createSubscriber')
->will($this->returnValue($this->getMock('Symfony\Component\EventDispatcher\EventSubscriberInterface')));
$mock->expects($this->any())
->method('getConfigurationPanel')
->will($this->returnValue($this->getMock('Alchemy\Phrasea\SearchEngine\ConfigurationPanelInterface')));
$mock->expects($this->any())
->method('getStatus')
->will($this->returnValue([]));