/*
* Copyright 2000-2013 Enonic AS
* http://www.enonic.com/license
*/
package com.enonic.cms.core.search;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.Map;
import javax.annotation.PostConstruct;
import org.elasticsearch.action.admin.cluster.health.ClusterHealthResponse;
import org.elasticsearch.action.search.SearchResponse;
import org.elasticsearch.discovery.MasterNotDiscoveredException;
import org.elasticsearch.index.get.GetField;
import org.elasticsearch.search.SearchHit;
import org.elasticsearch.search.SearchHits;
import org.elasticsearch.search.builder.SearchSourceBuilder;
import org.elasticsearch.search.facet.statistical.StatisticalFacet;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import com.google.common.primitives.Ints;
import com.enonic.cms.core.content.ContentEntityFetcherImpl;
import com.enonic.cms.core.content.ContentKey;
import com.enonic.cms.core.content.category.CategoryKey;
import com.enonic.cms.core.content.contenttype.ContentTypeKey;
import com.enonic.cms.core.content.index.ContentIndexQuery;
import com.enonic.cms.core.content.resultset.ContentResultSet;
import com.enonic.cms.core.content.resultset.ContentResultSetLazyFetcher;
import com.enonic.cms.core.content.resultset.ContentResultSetNonLazy;
import com.enonic.cms.core.portal.livetrace.ContentIndexQueryTrace;
import com.enonic.cms.core.portal.livetrace.ContentIndexQueryTracer;
import com.enonic.cms.core.portal.livetrace.LivePortalTraceService;
import com.enonic.cms.core.search.builder.ContentIndexData;
import com.enonic.cms.core.search.builder.ContentIndexDataFactory;
import com.enonic.cms.core.search.query.AggregatedQuery;
import com.enonic.cms.core.search.query.AggregatedQueryTranslator;
import com.enonic.cms.core.search.query.AggregatedResult;
import com.enonic.cms.core.search.query.AggregatedResultImpl;
import com.enonic.cms.core.search.query.ContentDocument;
import com.enonic.cms.core.search.query.ContentIndexService;
import com.enonic.cms.core.search.query.IndexQueryException;
import com.enonic.cms.core.search.query.IndexValueQuery;
import com.enonic.cms.core.search.query.IndexValueQueryTranslator;
import com.enonic.cms.core.search.query.IndexValueResultImpl;
import com.enonic.cms.core.search.query.IndexValueResultSet;
import com.enonic.cms.core.search.query.IndexValueResultSetImpl;
import com.enonic.cms.core.search.query.QueryField;
import com.enonic.cms.core.search.query.QueryFieldFactory;
import com.enonic.cms.core.search.query.QueryFieldNameResolver;
import com.enonic.cms.core.search.query.QueryTranslator;
import com.enonic.cms.core.search.result.FacetsResultSet;
import com.enonic.cms.core.search.result.FacetsResultSetCreator;
import com.enonic.cms.core.time.TimeService;
import com.enonic.cms.store.dao.ContentDao;
/**
* This class implements the content index service based on elasticsearch
*/
@Component
public class ContentIndexServiceImpl
implements ContentIndexService
{
public final static String CONTENT_INDEX_NAME = "cms";
public static final int COUNT_OPTIMIZER_THRESHOULD_VALUE = 1000;
private IndexMappingProvider indexMappingProvider;
private ElasticSearchIndexService elasticSearchIndexService;
private final static Logger LOG = LoggerFactory.getLogger( ContentIndexServiceImpl.class );
private final ContentIndexDataFactory contentIndexDataFactory = new ContentIndexDataFactory();
private QueryTranslator queryTranslator;
private final IndexValueQueryTranslator indexValueQueryTranslator = new IndexValueQueryTranslator();
private final AggregatedQueryTranslator aggregatedQueryTranslator = new AggregatedQueryTranslator();
private final FacetsResultSetCreator facetsResultSetCreator = new FacetsResultSetCreator();
private ContentDao contentDao;
@Autowired
private LivePortalTraceService livePortalTraceService;
@Autowired
private TimeService timeService;
@PostConstruct
public void initializeContentIndex()
{
final ClusterHealthResponse clusterHealth;
try
{
clusterHealth = elasticSearchIndexService.getClusterHealth( CONTENT_INDEX_NAME, true );
LOG.info( "Cluster in state: " + clusterHealth.getStatus().toString() );
}
catch ( MasterNotDiscoveredException e )
{
LOG.error( "Master node not discovered, could not start/join cluster", e );
return;
}
final boolean indexExists = elasticSearchIndexService.indexExists( CONTENT_INDEX_NAME );
if ( !indexExists )
{
try
{
elasticSearchIndexService.createIndex( CONTENT_INDEX_NAME );
}
catch ( org.elasticsearch.indices.IndexAlreadyExistsException e )
{
LOG.warn( "Tried to create index, but index already exists, skipping" );
}
addMapping();
}
}
private void addMapping()
{
doAddMapping( IndexType.Content );
doAddMapping( IndexType.Binaries );
}
private void doAddMapping( final IndexType indexType )
{
String mapping = getMapping( indexType );
elasticSearchIndexService.putMapping( CONTENT_INDEX_NAME, indexType.toString(), mapping );
}
private String getMapping( final IndexType indexType )
{
return indexMappingProvider.getMapping( CONTENT_INDEX_NAME, indexType.toString() );
}
public void remove( final ContentKey contentKey )
{
doRemoveEntryWithId( contentKey );
}
public void removeByCategory( final CategoryKey categoryKey )
{
ContentIndexQuery contentIndexQuery = new ContentIndexQuery( "" );
contentIndexQuery.setCategoryFilter( Arrays.asList( categoryKey ) );
doRemoveByQuery( contentIndexQuery );
}
public void removeByContentType( final ContentTypeKey contentTypeKey )
{
ContentIndexQuery contentIndexQuery = new ContentIndexQuery( "" );
contentIndexQuery.setContentTypeFilter( Arrays.asList( contentTypeKey ) );
doRemoveByQuery( contentIndexQuery );
}
private void doRemoveByQuery( final ContentIndexQuery contentIndexQuery )
{
final SearchSourceBuilder build;
build = queryTranslator.build( contentIndexQuery );
SearchResponse searchResponse = doExecuteSearchRequest( build );
SearchHits hits = searchResponse.getHits();
final int entriesToDelete = hits.getHits().length;
LOG.debug( "Prepare to delete: " + entriesToDelete + " entries from index " + CONTENT_INDEX_NAME );
for ( SearchHit hit : hits )
{
final ContentKey contentKey = new ContentKey( hit.getId() );
doRemoveEntryWithId( contentKey );
}
LOG.debug( "Deleted from index " + CONTENT_INDEX_NAME + ", " + entriesToDelete + " entries successfully" );
}
private void doRemoveEntryWithId( final ContentKey contentKey )
{
elasticSearchIndexService.delete( CONTENT_INDEX_NAME, IndexType.Binaries, contentKey );
elasticSearchIndexService.delete( CONTENT_INDEX_NAME, IndexType.Content, contentKey );
}
public void index( final ContentDocument doc )
{
doIndex( doc, false );
}
public void index( final ContentDocument doc, final boolean updateMetadataOnly )
{
doIndex( doc, updateMetadataOnly );
}
private void doIndex( final ContentDocument doc, final boolean updateMetadataOnly )
{
ContentIndexData contentIndexData = contentIndexDataFactory.create( doc, updateMetadataOnly );
if ( !updateMetadataOnly )
{
doRemoveEntryWithId( doc.getContentKey() );
}
elasticSearchIndexService.index( CONTENT_INDEX_NAME, contentIndexData );
}
public boolean isIndexed( final ContentKey contentKey, final IndexType indexType )
{
return elasticSearchIndexService.get( CONTENT_INDEX_NAME, indexType, contentKey );
}
public void optimize()
{
elasticSearchIndexService.optimize( CONTENT_INDEX_NAME );
}
public ContentResultSet query( final ContentIndexQuery query )
{
final SearchSourceBuilder translatedQuerySource;
if ( isFilterBlockingAllContent( query ) )
{
return new ContentResultSetLazyFetcher( new ContentEntityFetcherImpl( contentDao ), new LinkedList<ContentKey>(), 0, 0 );
}
final ContentIndexQueryTrace trace = ContentIndexQueryTracer.startTracing( livePortalTraceService );
try
{
optimizeCount( query );
try
{
translatedQuerySource = buildQuerySource( query );
}
catch ( Exception e )
{
final ContentResultSetNonLazy rs = new ContentResultSetNonLazy( query.getIndex() );
rs.addError( "Failed to translate contentQuery ( " + query + " ): " + e.getMessage() );
return rs;
}
ContentIndexQueryTracer.traceQuery( query, query.getIndex(), query.getCount(), translatedQuerySource.toString(), trace );
ContentIndexQueryTracer.traceElasticSearchStartTime( trace, timeService );
final SearchResponse searchResponse = doExecuteSearchRequest( translatedQuerySource );
ContentIndexQueryTracer.traceElasticSearchFinishedTime( trace, timeService );
SearchHits searchHits = searchResponse.getHits();
LOG.debug(
"query: " + translatedQuerySource.toString() + " executed with " + searchHits.getHits().length + " searchHits of total " +
searchHits.getTotalHits() );
final int queryResultTotalSize = new Long( searchHits.getTotalHits() ).intValue();
ContentIndexQueryTracer.traceMatchCount( queryResultTotalSize, trace );
final int fromIndex = Math.max( query.getIndex(), 0 );
final ArrayList<ContentKey> keys = new ArrayList<ContentKey>();
for ( final SearchHit hit : searchHits )
{
keys.add( new ContentKey( hit.getId() ) );
}
final ContentResultSetLazyFetcher contentResult =
new ContentResultSetLazyFetcher( new ContentEntityFetcherImpl( contentDao ), keys, fromIndex, queryResultTotalSize );
final FacetsResultSet facetsResultSet = facetsResultSetCreator.createResultSet( searchResponse );
contentResult.setFacetsResultSet( facetsResultSet );
return contentResult;
}
finally
{
ContentIndexQueryTracer.stopTracing( trace, livePortalTraceService );
}
}
/**
* Check the filters to see if they may be set so that everything is filtered out. This happens if the filters are not <code>null</code>
* so that they are applied, but does not contain any elements. If so, there's no point in running the query to the database, as all
* results will be filtered out anyway.
*
* @param query The query, containing all the filters.
* @return <code>true</code> if the filter does have openings. <code>false</code> if the filters are set so that no result will be let
* through the filter, and running the query is superfluous.
*/
private boolean isFilterBlockingAllContent( final ContentIndexQuery query )
{
final boolean isCategoryFilterBlocked = ( ( query.getCategoryFilter() != null ) && ( query.getCategoryFilter().size() == 0 ) );
final boolean isContentFilterBlocked = ( ( query.getContentFilter() != null ) && ( query.getContentFilter().size() == 0 ) );
final boolean isContentTypeFilterBlocked =
( ( query.getContentTypeFilter() != null ) && ( query.getContentTypeFilter().size() == 0 ) );
final boolean isSectionFilterBlocked = ( ( query.getSectionFilter() != null ) && ( query.getSectionFilter().size() == 0 ) );
return isCategoryFilterBlocked || isContentFilterBlocked || isContentTypeFilterBlocked || isSectionFilterBlocked;
}
private void optimizeCount( final ContentIndexQuery query )
{
if ( query.getCount() >= COUNT_OPTIMIZER_THRESHOULD_VALUE )
{
final int actualNumberOfHits = getActualNumberOfHits( query );
if ( actualNumberOfHits < query.getCount() )
{
query.setCount( actualNumberOfHits == 0 ? 1 : actualNumberOfHits );
}
else if ( actualNumberOfHits > query.getCount() && query.doReturnAllHits() )
{
query.setCount( actualNumberOfHits );
}
}
}
private SearchSourceBuilder buildQuerySource( final ContentIndexQuery query )
{
return this.queryTranslator.build( query );
}
private int getActualNumberOfHits( final ContentIndexQuery query )
{
final SearchSourceBuilder searchSource = queryTranslator.build( query, 1 );
final long actualCount = elasticSearchIndexService.count( CONTENT_INDEX_NAME, IndexType.Content.toString(), searchSource );
return Ints.saturatedCast( actualCount );
}
public IndexValueResultSet query( final IndexValueQuery query )
{
final SearchSourceBuilder build;
final String path = QueryFieldNameResolver.resolveQueryFieldName( query.getField() );
final QueryField queryField = QueryFieldFactory.resolveQueryField( path );
optimizeCount( query, queryField, query.doReturnAllHits() );
try
{
build = this.indexValueQueryTranslator.build( query, queryField );
}
catch ( Exception e )
{
throw new IndexQueryException( "Failed to translate query: " + query, e );
}
final SearchResponse searchResponse = doExecuteSearchRequest( build );
final SearchHits hits = searchResponse.getHits();
final IndexValueResultSetImpl resultSet = new IndexValueResultSetImpl( query.getIndex(), Ints.saturatedCast( hits.totalHits() ) );
for ( SearchHit hit : hits )
{
resultSet.add( createIndexValueResult( hit, queryField ) );
}
LOG.debug( "query: " + build.toString() + " executed with " + resultSet.getCount() + " hits of total " +
resultSet.getTotalCount() );
return resultSet;
}
private void optimizeCount( final IndexValueQuery query, final QueryField queryField, final boolean returnAllHits )
{
if ( query.getCount() >= COUNT_OPTIMIZER_THRESHOULD_VALUE )
{
final int actualNumberOfHits = getActualNumberOfHits( query, queryField );
if ( actualNumberOfHits < query.getCount() )
{
query.setCount( actualNumberOfHits == 0 ? 1 : actualNumberOfHits );
}
else if ( actualNumberOfHits > query.getCount() && returnAllHits )
{
query.setCount( actualNumberOfHits );
}
}
}
private int getActualNumberOfHits( final IndexValueQuery query, QueryField queryField )
{
final SearchSourceBuilder searchSource = indexValueQueryTranslator.build( query, queryField, 1 );
final long actualCount = elasticSearchIndexService.count( CONTENT_INDEX_NAME, IndexType.Content.toString(), searchSource );
return Ints.saturatedCast( actualCount );
}
private IndexValueResultImpl createIndexValueResult( final SearchHit hit, final QueryField queryField )
{
Assert.notNull( hit.getSource(), "Source is empty from search result" );
final Map<String, Object> fields = hit.getSource();
ContentKey contentKey = new ContentKey( hit.getId() );
final ArrayList<String> fieldValue = (ArrayList<String>) fields.get( queryField.getFieldName() );
return new IndexValueResultImpl( contentKey, fieldValue.get( 0 ) );
}
private SearchResponse doExecuteSearchRequest( final SearchSourceBuilder searchSourceBuilder )
{
final SearchResponse searchResponse =
elasticSearchIndexService.search( CONTENT_INDEX_NAME, IndexType.Content.toString(), searchSourceBuilder );
return searchResponse;
}
public SearchResponse query( final String query )
{
return elasticSearchIndexService.search( CONTENT_INDEX_NAME, IndexType.Content.toString(), query );
}
public void flush()
{
elasticSearchIndexService.flush( CONTENT_INDEX_NAME );
}
public AggregatedResult query( final AggregatedQuery query )
{
final SearchSourceBuilder builder;
try
{
builder = this.aggregatedQueryTranslator.build( query );
}
catch ( Exception e )
{
throw new IndexQueryException( "Failed to translate aggregated query: " + query, e );
}
final SearchResponse response = elasticSearchIndexService.search( CONTENT_INDEX_NAME, IndexType.Content.toString(), builder );
final StatisticalFacet statisticalFacet =
FacetExtractor.getStatisticalFacet( response, AggregatedQueryTranslator.AGGREGATED_FACET_NAME );
return new AggregatedResultImpl( statisticalFacet.getCount(), statisticalFacet.getMin(), statisticalFacet.getMax(),
statisticalFacet.getTotal(), statisticalFacet.getMean() );
}
@Override
public void reinitializeIndex()
{
elasticSearchIndexService.deleteMapping( CONTENT_INDEX_NAME, IndexType.Content );
elasticSearchIndexService.deleteMapping( CONTENT_INDEX_NAME, IndexType.Binaries );
addMapping();
}
@Override
public boolean indexExists()
{
elasticSearchIndexService.getClusterHealth( CONTENT_INDEX_NAME, true );
return elasticSearchIndexService.indexExists( CONTENT_INDEX_NAME );
}
@Override
public void createIndex()
{
elasticSearchIndexService.createIndex( CONTENT_INDEX_NAME );
addMapping();
}
@Autowired
public void setIndexMappingProvider( IndexMappingProvider indexMappingProvider )
{
this.indexMappingProvider = indexMappingProvider;
}
@Autowired
public void setContentDao( ContentDao contentDao )
{
this.contentDao = contentDao;
}
@Autowired
public void setQueryTranslator( QueryTranslator queryTranslator )
{
this.queryTranslator = queryTranslator;
}
@Autowired
public void setElasticSearchIndexService( ElasticSearchIndexService elasticSearchIndexService )
{
this.elasticSearchIndexService = elasticSearchIndexService;
}
@Override
public Collection<ContentIndexedFields> getContentIndexedFields( ContentKey contentKey )
{
final Map<String, GetField> contentFields = elasticSearchIndexService.search( CONTENT_INDEX_NAME, IndexType.Content, contentKey );
final Map<String, GetField> binaryFields = elasticSearchIndexService.search( CONTENT_INDEX_NAME, IndexType.Binaries, contentKey );
// merge content and binary fields, overwrite using content value if same field name exists in both
final Map<String, GetField> fields = new HashMap<String, GetField>( binaryFields );
fields.putAll( contentFields );
final ElasticSearchIndexedFieldsTranslator indexFieldsTranslator = new ElasticSearchIndexedFieldsTranslator();
return indexFieldsTranslator.generateContentIndexFieldSet( contentKey, fields );
}
}