From 4419b1b3a7ebf7c42829d36cfd8ce5cb3b8011de Mon Sep 17 00:00:00 2001 From: Mathieu Darse Date: Tue, 17 Mar 2015 18:24:18 +0100 Subject: [PATCH] Refactor facets handling --- lib/Alchemy/Phrasea/Controller/Prod/Query.php | 2 +- .../Elastic/ElasticSearchEngine.php | 15 +- .../Elastic/Search/FacetsResponse.php | 67 ++++++++ .../SearchEngine/SearchEngineResult.php | 11 +- www/skins/prod/jquery.main-prod.js | 149 +++++++----------- 5 files changed, 144 insertions(+), 100 deletions(-) create mode 100644 lib/Alchemy/Phrasea/SearchEngine/Elastic/Search/FacetsResponse.php diff --git a/lib/Alchemy/Phrasea/Controller/Prod/Query.php b/lib/Alchemy/Phrasea/Controller/Prod/Query.php index 6aa4af94cd..2df2d09934 100644 --- a/lib/Alchemy/Phrasea/Controller/Prod/Query.php +++ b/lib/Alchemy/Phrasea/Controller/Prod/Query.php @@ -183,9 +183,9 @@ class Query implements ControllerProviderInterface /** Debug */ $json['parsed_query'] = $result->getQuery(); - $json['aggregations'] = $result->getAggregations(); /** End debug */ + $json['facets'] = $result->getFacets(); $json['phrasea_props'] = $proposals; $json['total_answers'] = (int) $result->getAvailable(); $json['next_page'] = ($page < $npages && $result->getAvailable() > 0) ? ($page + 1) : false; diff --git a/lib/Alchemy/Phrasea/SearchEngine/Elastic/ElasticSearchEngine.php b/lib/Alchemy/Phrasea/SearchEngine/Elastic/ElasticSearchEngine.php index dabc89d057..dac8229ede 100644 --- a/lib/Alchemy/Phrasea/SearchEngine/Elastic/ElasticSearchEngine.php +++ b/lib/Alchemy/Phrasea/SearchEngine/Elastic/ElasticSearchEngine.php @@ -13,6 +13,7 @@ namespace Alchemy\Phrasea\SearchEngine\Elastic; use Alchemy\Phrasea\SearchEngine\Elastic\Indexer\RecordIndexer; use Alchemy\Phrasea\SearchEngine\Elastic\Indexer\TermIndexer; +use Alchemy\Phrasea\SearchEngine\Elastic\Search\FacetsResponse; use Alchemy\Phrasea\SearchEngine\Elastic\Search\QueryContext; use Alchemy\Phrasea\SearchEngine\SearchEngineInterface; use Alchemy\Phrasea\SearchEngine\SearchEngineOptions; @@ -291,6 +292,8 @@ class ElasticSearchEngine implements SearchEngineInterface $results[] = ElasticsearchRecordHydrator::hydrate($hit['_source'], $n++); } + $facets = new FacetsResponse($res); + $query['ast'] = $this->app['query_parser']->parse($string)->dump(); $query['query_main'] = $recordQuery; $query['query'] = $params['body']; @@ -298,7 +301,7 @@ class ElasticSearchEngine implements SearchEngineInterface return new SearchEngineResult($results, json_encode($query), $res['took'], $offset, $res['hits']['total'], $res['hits']['total'], null, null, $suggestions, [], - $this->indexName, isset($res['aggregations']) ? $res['aggregations'] : []); + $this->indexName, $facets); } /** @@ -419,9 +422,13 @@ class ElasticSearchEngine implements SearchEngineInterface // filter aggregation to allowed databoxes // declare aggregation on current field $agg = array(); - // array_values is needed to ensure array serialization - $agg['filter']['terms']['databox_id'] = array_values($databoxes); - $agg['aggs']['distinct_occurrence']['terms']['field'] = + // TODO (mdarse) Remove databox filtering. It's already done by the + // ACL filter in the query scope, so no document that shouldn't be + // displayed can go this far. + // // array_values is needed to ensure array serialization + // $agg['filter']['terms']['databox_id'] = array_values($databoxes); + // $agg['aggs']['distinct_occurrence']['terms']['field'] = + $agg['terms']['field'] = sprintf('%s.%s.raw', $prefix, $field_name); $aggs[$field_name] = $agg; diff --git a/lib/Alchemy/Phrasea/SearchEngine/Elastic/Search/FacetsResponse.php b/lib/Alchemy/Phrasea/SearchEngine/Elastic/Search/FacetsResponse.php new file mode 100644 index 0000000000..52d0ccdcd6 --- /dev/null +++ b/lib/Alchemy/Phrasea/SearchEngine/Elastic/Search/FacetsResponse.php @@ -0,0 +1,67 @@ + $aggregation) { + if (!isset($aggregation['buckets'])) { + $this->throwAggregationResponseError(); + } + $values = $this->buildBucketsValues($name, $aggregation['buckets']); + if ($values) { + $this->facets[] = array( + 'name' => $name, + 'values' => $values, + ); + } + } + } + + private function buildBucketsValues($name, $buckets) + { + $values = array(); + foreach ($buckets as $bucket) { + if (!isset($bucket['key']) || !isset($bucket['doc_count'])) { + $this->throwAggregationResponseError(); + } + $values[] = array( + 'value' => $bucket['key'], + 'count' => $bucket['doc_count'], + 'query' => $this->buildQuery($name, $bucket['key']), + ); + } + + return $values; + } + + private function buildQuery($name, $value) + { + // Strip double quotes from values to prevent broken queries + $value = str_replace('/"/u', ' ', $value); + // TODO escape value when escaping is supported in query parser + return ($name === 'Collection') ? + sprintf('collection:"%s"', $value) : + sprintf('"%s" IN %s', $value, $name); + } + + private function throwAggregationResponseError() + { + throw new RuntimeException('Invalid aggregation response'); + } + + public function jsonSerialize() + { + return $this->facets; + } +} diff --git a/lib/Alchemy/Phrasea/SearchEngine/SearchEngineResult.php b/lib/Alchemy/Phrasea/SearchEngine/SearchEngineResult.php index fb9a5f826c..5056f165cf 100644 --- a/lib/Alchemy/Phrasea/SearchEngine/SearchEngineResult.php +++ b/lib/Alchemy/Phrasea/SearchEngine/SearchEngineResult.php @@ -11,6 +11,7 @@ namespace Alchemy\Phrasea\SearchEngine; +use Alchemy\Phrasea\SearchEngine\Elastic\Search\FacetsResponse; use Doctrine\Common\Collections\ArrayCollection; class SearchEngineResult @@ -26,10 +27,10 @@ class SearchEngineResult protected $suggestions; protected $propositions; protected $indexes; - protected $aggregations; + protected $facets; public function __construct(ArrayCollection $results, $query, $duration, $offsetStart, $available, $total, $error, - $warning, ArrayCollection $suggestions, $propositions, $indexes, $aggregations = array()) + $warning, ArrayCollection $suggestions, $propositions, $indexes, FacetsResponse $facets = null) { $this->results = $results; $this->query = $query; @@ -42,7 +43,7 @@ class SearchEngineResult $this->suggestions = $suggestions; $this->propositions = $propositions; $this->indexes = $indexes; - $this->aggregations = $aggregations; + $this->facets = $facets; return $this; } @@ -179,8 +180,8 @@ class SearchEngineResult /** * @return array */ - public function getAggregations() + public function getFacets() { - return $this->aggregations; + return $this->facets; } } diff --git a/www/skins/prod/jquery.main-prod.js b/www/skins/prod/jquery.main-prod.js index 3f50c0eaf0..ae2755a2ee 100644 --- a/www/skins/prod/jquery.main-prod.js +++ b/www/skins/prod/jquery.main-prod.js @@ -464,100 +464,12 @@ function initAnswerForm() { success: function (datas) { // DEBUG QUERY PARSER - var query = datas.parsed_query; - try { - query = JSON.parse(query); - } - catch (e) {} - console.info(query); + console.info(JSON.parse(datas.parsed_query)); - var aggs = datas.aggregations; - try { - aggs = JSON.parse(aggs); - } - catch (e) {} - - console.debug('Aggregations:'); - var toDisplay = []; - _.each(aggs, function(field_aggs, key) { - _.each(field_aggs, function(value) { - _.each(value.buckets, function(bucket, keyBis) { - if (!toDisplay[keyBis]) { toDisplay[keyBis] = {}; } - toDisplay[keyBis][key] = bucket.key + ' ('+ bucket.doc_count + ')'; - }); - }); - }); - - console.table(toDisplay); - - var treeData = []; - _.each(aggs, function(field_aggs, key) { - var entry = { - "title" : key, - "key": key, - "folder": true, - "children" : [] - }; - - if(field_aggs.hasOwnProperty('buckets')){ - _.each(field_aggs.buckets, function(bucket) { - entry.children.push({ - "title": bucket.key + ' ('+ bucket.doc_count + ')', - "key": bucket.key, - "query": '"'+ bucket.key + '" IN ' + key - }); - }); - } else { - _.each(field_aggs, function (agg) { - _.each(agg.buckets, function(bucket) { - entry.children.push({ - "title": bucket.key + ' ('+ bucket.doc_count + ')', - "key": bucket.key, - "query": '"'+ bucket.key + '" IN ' + key - }); - }); - }); - } - - treeData.push(entry); - }); $('#answers').empty().append(datas.results).removeClass('loading'); - var $tree = $("#proposals"); - - if ($tree.data("ui-fancytree")) { - $tree.fancytree("destroy"); - } - - if (treeData.length > 0) { - $tree.fancytree({ - source: treeData, - activate: function(event, data){ - var node = data.node; - if (typeof node.data.query === "undefined") { - return; - } - - var $input = $('form[name="phrasea_query"] input[name="qry"]'); - var current_query = $input.val(); - var query = node.data.query; - - if (current_query != '') { - query = '('+current_query+') AND ('+query+')'; - } - $input.val(query); - - checkFilters(); - newSearch(); - $('searchForm').trigger('submit'); - } - }); - - $tree.fancytree("getRootNode").visit(function(node){ - node.setExpanded(true); - }); - } + loadFacets(datas.facets); $("#answers img.lazyload").lazyload({ container: $('#answers') @@ -600,6 +512,63 @@ function initAnswerForm() { searchForm.removeClass('triggerAfterInit').trigger('submit'); } } + +function loadFacets(facets) { + // Convert facets data to fancytree source format + var treeSource = _.map(facets, function(facet) { + // Values + var values = _.map(facet.values, function(value) { + return { + title: value.value + ' (' + value.count + ')', + query: value.query + } + }); + // Facet + return { + title: facet.name, + folder: true, + children: values, + expanded: true + }; + }); + return getFacetsTree().reload(treeSource); +} + +function getFacetsTree() { + var $facetsTree = $('#proposals'); + if (!$facetsTree.data('ui-fancytree')) { + $facetsTree.fancytree({ + source: [], + activate: function(event, data){ + var query = data.node.data.query; + if (!query) return; + facetCombinedSearch(query); + } + }); + } + return $facetsTree.fancytree('getTree'); +} + +var $searchForm; +var $searchInput; +var $facetsBackButton; + +function facetSearch(query) { + var currentQuery = $searchInput.val(); + if (currentQuery) { + query = '(' + currentQuery + ') AND (' + query + ')'; + } + checkFilters(); + newSearch(); + $searchInput.val(query); + $searchForm.trigger('submit'); +} + +$(document).ready(function() { + $searchForm = $('#searchForm'); + $searchInput = $searchForm.find('input[name="qry"]'); +}); + function answerSizer() { var el = $('#idFrameC').outerWidth(); if (!$.support.cssFloat) {