/* * Hibernate Search, full-text search for your domain model * * License: GNU Lesser General Public License (LGPL), version 2.1 or later * See the lgpl.txt file in the root directory or <http://www.gnu.org/licenses/lgpl-2.1.html>. */ package org.hibernate.search.elasticsearch.impl; import java.util.List; import java.util.Set; import org.apache.lucene.document.Document; import org.apache.lucene.facet.FacetsConfig; import org.apache.lucene.index.DocValuesType; import org.apache.lucene.index.IndexableField; import org.hibernate.search.backend.AddLuceneWork; import org.hibernate.search.backend.DeleteLuceneWork; import org.hibernate.search.backend.FlushLuceneWork; import org.hibernate.search.backend.IndexWorkVisitor; import org.hibernate.search.backend.IndexingMonitor; import org.hibernate.search.backend.LuceneWork; import org.hibernate.search.backend.OptimizeLuceneWork; import org.hibernate.search.backend.PurgeAllLuceneWork; import org.hibernate.search.backend.UpdateLuceneWork; import org.hibernate.search.backend.spi.DeleteByQueryLuceneWork; import org.hibernate.search.bridge.FieldBridge; import org.hibernate.search.bridge.TwoWayFieldBridge; import org.hibernate.search.bridge.spi.NullMarker; import org.hibernate.search.elasticsearch.client.impl.URLEncodedString; import org.hibernate.search.elasticsearch.gson.impl.JsonAccessor; import org.hibernate.search.elasticsearch.gson.impl.JsonElementType; import org.hibernate.search.elasticsearch.gson.impl.UnexpectedJsonElementTypeException; import org.hibernate.search.elasticsearch.impl.NestingMarker.NestingPathComponent; import org.hibernate.search.elasticsearch.logging.impl.Log; import org.hibernate.search.elasticsearch.util.impl.FieldHelper; import org.hibernate.search.elasticsearch.util.impl.FieldHelper.ExtendedFieldType; import org.hibernate.search.elasticsearch.util.impl.ParentPathMismatchException; import org.hibernate.search.elasticsearch.work.impl.ElasticsearchWork; import org.hibernate.search.elasticsearch.work.impl.builder.DeleteByQueryWorkBuilder; import org.hibernate.search.elasticsearch.work.impl.builder.IndexWorkBuilder; import org.hibernate.search.elasticsearch.work.impl.factory.ElasticsearchWorkFactory; import org.hibernate.search.engine.ProjectionConstants; import org.hibernate.search.engine.integration.impl.ExtendedSearchIntegrator; import org.hibernate.search.engine.metadata.impl.DocumentFieldMetadata; import org.hibernate.search.engine.metadata.impl.EmbeddedTypeMetadata; import org.hibernate.search.engine.metadata.impl.EmbeddedTypeMetadata.Container; import org.hibernate.search.engine.spi.DocumentBuilderIndexedEntity; import org.hibernate.search.engine.spi.EntityIndexBinding; import org.hibernate.search.exception.AssertionFailure; import org.hibernate.search.spatial.impl.SpatialHelper; import org.hibernate.search.util.logging.impl.LoggerFactory; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; /** * Converts {@link LuceneWork}s into corresponding {@link ElasticsearchWork}s. Instances are specific * to one index. * * @author Gunnar Morling */ class ElasticsearchIndexWorkVisitor implements IndexWorkVisitor<IndexingMonitor, ElasticsearchWork<?>> { private static final Log LOG = LoggerFactory.make( Log.class ); private final URLEncodedString indexName; private final boolean refreshAfterWrite; private final ExtendedSearchIntegrator searchIntegrator; private final ElasticsearchWorkFactory workFactory; public ElasticsearchIndexWorkVisitor(URLEncodedString indexName, boolean refreshAfterWrite, ExtendedSearchIntegrator searchIntegrator, ElasticsearchWorkFactory workFactory) { this.indexName = indexName; this.refreshAfterWrite = refreshAfterWrite; this.searchIntegrator = searchIntegrator; this.workFactory = workFactory; } @Override public ElasticsearchWork<?> visitAddWork(AddLuceneWork work, IndexingMonitor monitor) { return indexDocument( getDocumentId( work ), work.getDocument(), work.getEntityClass() ) .monitor( monitor ) .luceneWork( work ) .markIndexDirty( refreshAfterWrite ) .build(); } @Override public ElasticsearchWork<?> visitDeleteWork(DeleteLuceneWork work, IndexingMonitor monitor) { return workFactory.delete( indexName, entityName( work ), getDocumentId( work ) ) .luceneWork( work ) .markIndexDirty( refreshAfterWrite ) .build(); } @Override public ElasticsearchWork<?> visitOptimizeWork(OptimizeLuceneWork work, IndexingMonitor monitor) { return workFactory.optimize().index( indexName ) .luceneWork( work ) .build(); } @Override public ElasticsearchWork<?> visitPurgeAllWork(PurgeAllLuceneWork work, IndexingMonitor monitor) { JsonObject payload = createDeleteByQueryPayload( JsonBuilder.object().add( "match_all", new JsonObject() ).build(), work.getTenantId() ); DeleteByQueryWorkBuilder builder = workFactory.deleteByQuery( indexName, payload ) .luceneWork( work ) .markIndexDirty( refreshAfterWrite ); Set<Class<?>> typesToDelete = searchIntegrator.getIndexedTypesPolymorphic( new Class<?>[] { work.getEntityClass() } ); for ( Class<?> typeToDelete : typesToDelete ) { builder.type( URLEncodedString.fromString( typeToDelete.getName() ) ); } return builder.build(); } @Override public ElasticsearchWork<?> visitUpdateWork(UpdateLuceneWork work, IndexingMonitor monitor) { return indexDocument( getDocumentId( work ), work.getDocument(), work.getEntityClass() ) .monitor( monitor ) .luceneWork( work ) .markIndexDirty( refreshAfterWrite ) .build(); } @Override public ElasticsearchWork<?> visitFlushWork(FlushLuceneWork work, IndexingMonitor monitor) { return workFactory.flush() .index( indexName ) .luceneWork( work ) .build(); } @Override public ElasticsearchWork<?> visitDeleteByQueryWork(DeleteByQueryLuceneWork work, IndexingMonitor monitor) { JsonObject convertedQuery = ToElasticsearch.fromDeletionQuery( searchIntegrator.getIndexBinding( work.getEntityClass() ).getDocumentBuilder(), work.getDeletionQuery() ); URLEncodedString typeName = URLEncodedString.fromString( work.getEntityClass().getName() ); JsonObject payload = createDeleteByQueryPayload( convertedQuery, work.getTenantId() ); return workFactory.deleteByQuery( indexName, payload ) .luceneWork( work ) .type( typeName ) .markIndexDirty( refreshAfterWrite ) .build(); } private JsonObject createDeleteByQueryPayload(JsonObject query, String tenantId) { // Add filter on tenant id if needed if ( tenantId != null ) { return JsonBuilder.object() .add( "query", JsonBuilder.object() .add( "bool", JsonBuilder.object() .add( "filter", JsonBuilder.object() .add( "term", JsonBuilder.object() .addProperty( DocumentBuilderIndexedEntity.TENANT_ID_FIELDNAME, tenantId ) ) ) .add( "must", query ) ) ) .build(); } else { return JsonBuilder.object() .add( "query", query ) .build(); } } private IndexWorkBuilder indexDocument(URLEncodedString id, Document document, Class<?> entityType) { JsonObject source = convertDocumentToJson( document, entityType ); URLEncodedString typeName = URLEncodedString.fromString( entityType.getName() ); return workFactory.index( indexName, typeName, id, source ); } private JsonObject convertDocumentToJson(Document document, Class<?> entityType) { EntityIndexBinding indexBinding = searchIntegrator.getIndexBinding( entityType ); JsonObject root = new JsonObject(); NestingMarker nestingMarker = null; JsonAccessorBuilder accessorBuilder = new JsonAccessorBuilder(); for ( IndexableField field : document.getFields() ) { if ( field instanceof NestingMarker ) { nestingMarker = (NestingMarker) field; accessorBuilder.reset(); accessorBuilder.append( ((NestingMarker) field).getPath() ); continue; // Inspect the next field taking into account this metadata } convertFieldToJson( root, accessorBuilder, indexBinding, nestingMarker, document, field ); } return root; } private void convertFieldToJson( JsonObject root, JsonAccessorBuilder accessorBuilder, EntityIndexBinding indexBinding, NestingMarker nestingMarker, Document document, IndexableField field ) { try { String fieldPath = field.name(); List<NestingPathComponent> nestingPath = nestingMarker == null ? null : nestingMarker.getPath(); NestingPathComponent lastPathComponent = nestingPath == null ? null : nestingPath.get( nestingPath.size() - 1 ); EmbeddedTypeMetadata embeddedType = lastPathComponent == null ? null : lastPathComponent.getEmbeddedTypeMetadata(); if ( embeddedType != null && fieldPath.equals( embeddedType.getEmbeddedNullFieldName() ) ) { // Case of a null indexed embedded // Exclude the last path component: it represents the null embedded nestingPath = nestingPath.subList( 0, nestingPath.size() - 1 ); accessorBuilder.reset(); accessorBuilder.append( nestingPath ); Container containerType = embeddedType.getEmbeddedContainer(); switch ( containerType ) { case ARRAY: case COLLECTION: case MAP: /* * When a indexNullAs is set for array/collection/map embeddeds, and we get a null replacement * token from the engine, just keep the token, and don't replace it back with null (which is * what we do for other fields, see below). * This behavior is necessary because Elasticsearch treats null arrays exactly as arrays * containing only null, so propagating null for an array/collection/map as a whole would * lead to conflicts when querying. */ String value = field.stringValue(); accessorBuilder.buildForPath( fieldPath ).set( root, value != null ? new JsonPrimitive( value ) : null ); break; case OBJECT: // TODO HSEARCH-2389 Support indexNullAs for @IndexedEmbedded applied on objects with Elasticsearch break; default: throw new AssertionFailure( "Unexpected container type: " + containerType ); } } else if ( FacetsConfig.DEFAULT_INDEX_FIELD_NAME.equals( field.name() ) ) { /* * Lucene-specific fields for facets. * Just ignore such fields: Elasticsearch handles that internally. */ return; } else if ( isDocValueField( field ) ) { /* * Doc value fields for facets or sorts. * Just ignore such fields: Elasticsearch handles that internally. */ return; } else if ( fieldPath.equals( ProjectionConstants.OBJECT_CLASS ) ) { // Object class: no need to index that in Elasticsearch, because documents are typed. return; } else { DocumentFieldMetadata documentFieldMetadata = indexBinding.getDocumentBuilder().getTypeMetadata() .getDocumentFieldMetadataFor( field.name() ); if ( documentFieldMetadata == null ) { if ( SpatialHelper.isSpatialField( fieldPath ) ) { if ( SpatialHelper.isSpatialFieldLatitude( fieldPath ) ) { Number value = field.numericValue(); String spatialPropertyPath = SpatialHelper.stripSpatialFieldSuffix( fieldPath ); accessorBuilder.buildForPath( spatialPropertyPath + ".lat" ) .add( root, value != null ? new JsonPrimitive( value ) : null ); } else if ( SpatialHelper.isSpatialFieldLongitude( fieldPath ) ) { Number value = field.numericValue(); String spatialPropertyPath = SpatialHelper.stripSpatialFieldSuffix( fieldPath ); accessorBuilder.buildForPath( spatialPropertyPath + ".lon" ) .add( root, value != null ? new JsonPrimitive( value ) : null ); } else { // here, we have the hash fields used for spatial hash indexing String value = field.stringValue(); accessorBuilder.buildForPath( fieldPath ) .add( root, value != null ? new JsonPrimitive( value ) : null ); } } else { // should only be the case for class-bridge fields; in that case we'd miss proper handling of boolean/Date for now JsonAccessor accessor = accessorBuilder.buildForPath( fieldPath ); String stringValue = field.stringValue(); Number numericValue = field.numericValue(); if ( stringValue != null ) { accessor.add( root, new JsonPrimitive( stringValue ) ); } else if ( numericValue != null ) { accessor.add( root, new JsonPrimitive( numericValue ) ); } else { accessor.add( root, null ); } } } else { JsonAccessor accessor = accessorBuilder.buildForPath( fieldPath ); // If the value was initially null, explicitly propagate null and let ES handle the default token. if ( field instanceof NullMarker ) { accessor.add( root, null ); return; } ExtendedFieldType type = FieldHelper.getType( documentFieldMetadata ); if ( ExtendedFieldType.BOOLEAN.equals( type ) ) { FieldBridge fieldBridge = documentFieldMetadata.getFieldBridge(); Boolean value = (Boolean) ( (TwoWayFieldBridge) fieldBridge ).get( field.name(), document ); accessor.add( root, value != null ? new JsonPrimitive( value ) : null ); } else { Number numericValue = field.numericValue(); if ( numericValue != null ) { accessor.add( root, numericValue != null ? new JsonPrimitive( numericValue ) : null ); } else { String stringValue = field.stringValue(); accessor.add( root, stringValue != null ? new JsonPrimitive( stringValue ) : null ); } } } } } catch (ParentPathMismatchException e) { throw LOG.indexedEmbeddedPrefixBypass( indexBinding.getDocumentBuilder().getBeanClass(), e.getMismatchingPath(), e.getExpectedParentPath() ); } catch (UnexpectedJsonElementTypeException e) { List<JsonElementType<?>> expectedTypes = e.getExpectedTypes(); JsonAccessor accessor = e.getAccessor(); JsonElement actualValue = e.getActualElement(); if ( expectedTypes.contains( JsonElementType.OBJECT ) || JsonElementType.OBJECT.isInstance( actualValue ) ) { throw LOG.fieldIsBothCompositeAndConcrete( indexBinding.getDocumentBuilder().getBeanClass(), accessor.getStaticAbsolutePath() ); } else { throw new AssertionFailure( "Unexpected field naming conflict when indexing;" + " this kind of issue should have been detected when building the metadata.", e ); } } } private URLEncodedString getDocumentId(LuceneWork work) { return URLEncodedString.fromString( work.getTenantId() == null ? work.getIdInString() : work.getTenantId() + "_" + work.getIdInString() ); } private boolean isDocValueField(IndexableField field) { return field.fieldType().docValuesType() != DocValuesType.NONE; } private static URLEncodedString entityName(LuceneWork work) { return URLEncodedString.fromString( work.getEntityClass().getName() ); } }