diff --git a/src/main/java/org/springframework/data/elasticsearch/core/AbstractElasticsearchTemplate.java b/src/main/java/org/springframework/data/elasticsearch/core/AbstractElasticsearchTemplate.java index 5aa64604ae..16d85ad749 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/AbstractElasticsearchTemplate.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/AbstractElasticsearchTemplate.java @@ -53,6 +53,7 @@ import org.springframework.data.elasticsearch.core.query.MoreLikeThisQuery; import org.springframework.data.elasticsearch.core.query.NativeSearchQueryBuilder; import org.springframework.data.elasticsearch.core.query.Query; +import org.springframework.data.elasticsearch.core.query.SeqNoPrimaryTerm; import org.springframework.data.elasticsearch.support.VersionInfo; import org.springframework.data.mapping.callback.EntityCallbacks; import org.springframework.data.util.CloseableIterator; @@ -438,6 +439,22 @@ private Long getEntityVersion(Object entity) { return null; } + @Nullable + private SeqNoPrimaryTerm getEntitySeqNoPrimaryTerm(Object entity) { + ElasticsearchPersistentEntity persistentEntity = getRequiredPersistentEntity(entity.getClass()); + ElasticsearchPersistentProperty property = persistentEntity.getSeqNoPrimaryTermProperty(); + + if (property != null) { + Object seqNoPrimaryTerm = persistentEntity.getPropertyAccessor(entity).getProperty(property); + + if (seqNoPrimaryTerm != null && SeqNoPrimaryTerm.class.isAssignableFrom(seqNoPrimaryTerm.getClass())) { + return (SeqNoPrimaryTerm) seqNoPrimaryTerm; + } + } + + return null; + } + private IndexQuery getIndexQuery(T entity) { String id = getEntityId(entity); @@ -445,11 +462,17 @@ private IndexQuery getIndexQuery(T entity) { id = elasticsearchConverter.convertId(id); } - return new IndexQueryBuilder() // + IndexQueryBuilder builder = new IndexQueryBuilder() // .withId(id) // - .withVersion(getEntityVersion(entity)) // - .withObject(entity) // - .build(); + .withObject(entity); + SeqNoPrimaryTerm seqNoPrimaryTerm = getEntitySeqNoPrimaryTerm(entity); + if (seqNoPrimaryTerm != null) { + builder.withSeqNoPrimaryTerm(seqNoPrimaryTerm); + } else { + // version cannot be used together with seq_no and primary_term + builder.withVersion(getEntityVersion(entity)); + } + return builder.build(); } /** diff --git a/src/main/java/org/springframework/data/elasticsearch/core/DocumentOperations.java b/src/main/java/org/springframework/data/elasticsearch/core/DocumentOperations.java index 1c05cd801b..279f2156e4 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/DocumentOperations.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/DocumentOperations.java @@ -250,7 +250,7 @@ default void bulkUpdate(List queries, IndexCoordinates index) { * @param clazz the type of the object to be returned * @param index the index from which the object is read. * @return the found object - * @deprecated since 4.0, use {@link #getById(String, Class, IndexCoordinates)} + * @deprecated since 4.0, use {@link #get(String, Class, IndexCoordinates)} */ @Deprecated @Nullable diff --git a/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchExceptionTranslator.java b/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchExceptionTranslator.java index f4aeb1c00e..77a8de10dc 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchExceptionTranslator.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchExceptionTranslator.java @@ -22,9 +22,12 @@ import org.elasticsearch.ElasticsearchException; import org.elasticsearch.ElasticsearchStatusException; import org.elasticsearch.common.ValidationException; +import org.elasticsearch.index.engine.VersionConflictEngineException; +import org.elasticsearch.rest.RestStatus; import org.springframework.dao.DataAccessException; import org.springframework.dao.DataAccessResourceFailureException; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.dao.support.PersistenceExceptionTranslator; import org.springframework.data.elasticsearch.NoSuchIndexException; import org.springframework.data.elasticsearch.UncategorizedElasticsearchException; @@ -35,6 +38,7 @@ /** * @author Christoph Strobl * @author Peter-Josef Meisch + * @author Roman Puchkovskiy * @since 3.2 */ public class ElasticsearchExceptionTranslator implements PersistenceExceptionTranslator { @@ -50,6 +54,12 @@ public DataAccessException translateExceptionIfPossible(RuntimeException ex) { return new NoSuchIndexException(ObjectUtils.nullSafeToString(elasticsearchException.getMetadata("es.index")), ex); } + + if (isSeqNoConflict(elasticsearchException)) { + return new OptimisticLockingFailureException("Cannot index a document due to seq_no+primary_term conflict", + elasticsearchException); + } + return new UncategorizedElasticsearchException(ex.getMessage(), ex); } @@ -65,6 +75,25 @@ public DataAccessException translateExceptionIfPossible(RuntimeException ex) { return null; } + private boolean isSeqNoConflict(ElasticsearchException exception) { + + if (exception instanceof ElasticsearchStatusException) { + ElasticsearchStatusException statusException = (ElasticsearchStatusException) exception; + return statusException.status() == RestStatus.CONFLICT + && statusException.getMessage() != null + && statusException.getMessage().contains("type=version_conflict_engine_exception") + && statusException.getMessage().contains("version conflict, required seqNo"); + } + + if (exception instanceof VersionConflictEngineException) { + VersionConflictEngineException versionConflictEngineException = (VersionConflictEngineException) exception; + return versionConflictEngineException.getMessage() != null + && versionConflictEngineException.getMessage().contains("version conflict, required seqNo"); + } + + return false; + } + private boolean indexAvailable(ElasticsearchException ex) { List metadata = ex.getMetadata("es.index_uuid"); diff --git a/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplate.java b/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplate.java index 43ca3b1c5f..066fc8bcfa 100755 --- a/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplate.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplate.java @@ -27,6 +27,7 @@ import org.elasticsearch.action.get.GetResponse; import org.elasticsearch.action.get.MultiGetRequestBuilder; import org.elasticsearch.action.index.IndexRequestBuilder; +import org.elasticsearch.action.index.IndexResponse; import org.elasticsearch.action.search.MultiSearchRequest; import org.elasticsearch.action.search.MultiSearchResponse; import org.elasticsearch.action.search.SearchRequestBuilder; @@ -89,6 +90,8 @@ public class ElasticsearchTemplate extends AbstractElasticsearchTemplate { private Client client; @Nullable private String searchTimeout; + private final ElasticsearchExceptionTranslator exceptionTranslator = new ElasticsearchExceptionTranslator(); + // region Initialization public ElasticsearchTemplate(Client client) { this.client = client; @@ -145,7 +148,14 @@ public String index(IndexQuery query, IndexCoordinates index) { maybeCallbackBeforeConvertWithQuery(query, index); IndexRequestBuilder indexRequestBuilder = requestFactory.indexRequestBuilder(client, query, index); - String documentId = indexRequestBuilder.execute().actionGet().getId(); + ActionFuture future = indexRequestBuilder.execute(); + IndexResponse response; + try { + response = future.actionGet(); + } catch (RuntimeException e) { + throw translateException(e); + } + String documentId = response.getId(); // We should call this because we are not going through a mapper. Object queryObject = query.getObject(); @@ -360,4 +370,22 @@ public Client getClient() { return client; } // endregion + + /** + * translates an Exception if possible. Exceptions that are no {@link RuntimeException}s are wrapped in a + * RuntimeException + * + * @param exception the Exception to map + * @return the potentially translated RuntimeException. + * @since 4.0 + */ + private RuntimeException translateException(Exception exception) { + + RuntimeException runtimeException = exception instanceof RuntimeException ? (RuntimeException) exception + : new RuntimeException(exception.getMessage(), exception); + RuntimeException potentiallyTranslatedException = exceptionTranslator + .translateExceptionIfPossible(runtimeException); + + return potentiallyTranslatedException != null ? potentiallyTranslatedException : runtimeException; + } } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/EntityOperations.java b/src/main/java/org/springframework/data/elasticsearch/core/EntityOperations.java index 1a8567d9a0..65467ec0d1 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/EntityOperations.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/EntityOperations.java @@ -21,6 +21,7 @@ import org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersistentEntity; import org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersistentProperty; import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates; +import org.springframework.data.elasticsearch.core.query.SeqNoPrimaryTerm; import org.springframework.data.mapping.IdentifierAccessor; import org.springframework.data.mapping.PersistentPropertyAccessor; import org.springframework.data.mapping.context.MappingContext; @@ -35,6 +36,7 @@ * @author Mark Paluch * @author Christoph Strobl * @author Peter-Josef Meisch + * @author Roman Puchkovskiy * @since 3.2 */ class EntityOperations { @@ -256,6 +258,21 @@ interface AdaptibleEntity extends Entity { @Override @Nullable Number getVersion(); + + /** + * Returns whether there is a property with type SeqNoPrimaryTerm in this entity. + * + * @return true if there is SeqNoPrimaryTerm property + * @since 4.0 + */ + boolean hasSeqNoPrimaryTerm(); + + /** + * Returns SeqNoPropertyTerm for this entity. + * + * @return SeqNoPrimaryTerm, may be {@literal null} + */ + @Nullable SeqNoPrimaryTerm getSeqNoPrimaryTerm(); } /** @@ -333,6 +350,16 @@ public Number getVersion() { return null; } + @Override + public boolean hasSeqNoPrimaryTerm() { + return false; + } + + @Override + public SeqNoPrimaryTerm getSeqNoPrimaryTerm() { + return null; + } + /* * (non-Javadoc) * @see org.springframework.data.elasticsearch.core.EntityOperations.AdaptibleEntity#incrementVersion() @@ -588,6 +615,19 @@ public Number getVersion() { return propertyAccessor.getProperty(versionProperty, Number.class); } + @Override + public boolean hasSeqNoPrimaryTerm() { + return entity.hasSeqNoPrimaryTermProperty(); + } + + @Override + public SeqNoPrimaryTerm getSeqNoPrimaryTerm() { + + ElasticsearchPersistentProperty seqNoPrimaryTermProperty = entity.getRequiredSeqNoPrimaryTermProperty(); + + return propertyAccessor.getProperty(seqNoPrimaryTermProperty, SeqNoPrimaryTerm.class); + } + /* * (non-Javadoc) * @see org.springframework.data.elasticsearch.core.EntityOperations.AdaptibleEntity#initializeVersionProperty() diff --git a/src/main/java/org/springframework/data/elasticsearch/core/ReactiveElasticsearchTemplate.java b/src/main/java/org/springframework/data/elasticsearch/core/ReactiveElasticsearchTemplate.java index 8d5e859f7a..cdbfb1af35 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/ReactiveElasticsearchTemplate.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/ReactiveElasticsearchTemplate.java @@ -84,6 +84,7 @@ import org.springframework.data.elasticsearch.core.query.IndexQuery; import org.springframework.data.elasticsearch.core.query.NativeSearchQuery; import org.springframework.data.elasticsearch.core.query.Query; +import org.springframework.data.elasticsearch.core.query.SeqNoPrimaryTerm; import org.springframework.data.elasticsearch.core.query.StringQuery; import org.springframework.data.elasticsearch.core.query.UpdateQuery; import org.springframework.data.elasticsearch.support.VersionInfo; @@ -347,7 +348,19 @@ private IndexRequest getIndexRequest(Object value, AdaptibleEntity entity, In request.source(converter.mapObject(value).toJson(), Requests.INDEX_CONTENT_TYPE); - if (entity.isVersionedEntity()) { + boolean usingSeqNo = false; + if (entity.hasSeqNoPrimaryTerm()) { + SeqNoPrimaryTerm seqNoPrimaryTerm = entity.getSeqNoPrimaryTerm(); + + if (seqNoPrimaryTerm != null) { + request.setIfSeqNo(seqNoPrimaryTerm.getSequenceNumber()); + request.setIfPrimaryTerm(seqNoPrimaryTerm.getPrimaryTerm()); + usingSeqNo = true; + } + } + + // seq_no and version are incompatible in the same request + if (!usingSeqNo && entity.isVersionedEntity()) { Number version = entity.getVersion(); @@ -356,6 +369,7 @@ private IndexRequest getIndexRequest(Object value, AdaptibleEntity entity, In request.versionType(EXTERNAL); } } + return request; } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/RequestFactory.java b/src/main/java/org/springframework/data/elasticsearch/core/RequestFactory.java index 73220fce2a..c4361cf325 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/RequestFactory.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/RequestFactory.java @@ -83,6 +83,7 @@ * * @author Peter-Josef Meisch * @author Sascha Woo + * @author Roman Puchkovskiy * @since 4.0 */ class RequestFactory { @@ -342,6 +343,13 @@ public IndexRequest indexRequest(IndexQuery query, IndexCoordinates index) { indexRequest.versionType(versionType); } + if (query.getSeqNo() != null) { + indexRequest.setIfSeqNo(query.getSeqNo()); + } + if (query.getPrimaryTerm() != null) { + indexRequest.setIfPrimaryTerm(query.getPrimaryTerm()); + } + return indexRequest; } @@ -374,6 +382,13 @@ public IndexRequestBuilder indexRequestBuilder(Client client, IndexQuery query, indexRequestBuilder.setVersionType(versionType); } + if (query.getSeqNo() != null) { + indexRequestBuilder.setIfSeqNo(query.getSeqNo()); + } + if (query.getPrimaryTerm() != null) { + indexRequestBuilder.setIfPrimaryTerm(query.getPrimaryTerm()); + } + return indexRequestBuilder; } @@ -618,6 +633,9 @@ private SearchRequest prepareSearchRequest(Query query, @Nullable Class clazz SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); sourceBuilder.version(true); sourceBuilder.trackScores(query.getTrackScores()); + if (hasSeqNoPrimaryTermProperty(clazz)) { + sourceBuilder.seqNoAndPrimaryTerm(true); + } if (query.getSourceFilter() != null) { SourceFilter sourceFilter = query.getSourceFilter(); @@ -681,7 +699,20 @@ private SearchRequest prepareSearchRequest(Query query, @Nullable Class clazz return request; } - @SuppressWarnings("unchecked") + private boolean hasSeqNoPrimaryTermProperty(@Nullable Class entityClass) { + + if (entityClass == null) { + return false; + } + + if (!elasticsearchConverter.getMappingContext().hasPersistentEntityFor(entityClass)) { + return false; + } + + ElasticsearchPersistentEntity entity = elasticsearchConverter.getMappingContext().getRequiredPersistentEntity(entityClass); + return entity.hasSeqNoPrimaryTermProperty(); + } + public PutMappingRequest putMappingRequest(IndexCoordinates index, Document mapping) { PutMappingRequest request = new PutMappingRequest(index.getIndexName()); request.source(mapping); @@ -784,6 +815,9 @@ private SearchRequestBuilder prepareSearchRequestBuilder(Query query, Client cli .setSearchType(query.getSearchType()) // .setVersion(true) // .setTrackScores(query.getTrackScores()); + if (hasSeqNoPrimaryTermProperty(clazz)) { + searchRequestBuilder.seqNoAndPrimaryTerm(true); + } if (query.getSourceFilter() != null) { SourceFilter sourceFilter = query.getSourceFilter(); diff --git a/src/main/java/org/springframework/data/elasticsearch/core/convert/MappingElasticsearchConverter.java b/src/main/java/org/springframework/data/elasticsearch/core/convert/MappingElasticsearchConverter.java index 7865660938..9e196a2734 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/convert/MappingElasticsearchConverter.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/convert/MappingElasticsearchConverter.java @@ -15,17 +15,8 @@ */ package org.springframework.data.elasticsearch.core.convert; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.Iterator; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.Map.Entry; -import java.util.Optional; -import java.util.Set; import org.springframework.beans.BeansException; import org.springframework.beans.factory.InitializingBean; @@ -44,6 +35,7 @@ import org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersistentProperty; import org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersistentPropertyConverter; import org.springframework.data.elasticsearch.core.query.CriteriaQuery; +import org.springframework.data.elasticsearch.core.query.SeqNoPrimaryTerm; import org.springframework.data.mapping.PersistentPropertyAccessor; import org.springframework.data.mapping.context.MappingContext; import org.springframework.data.mapping.model.ConvertingPropertyAccessor; @@ -205,6 +197,14 @@ protected R readEntity(ElasticsearchPersistentEntity entity, Map R readEntity(ElasticsearchPersistentEntity entity, Map= 0; + } + + private boolean isAssignedPrimaryTerm(long primaryTerm) { + return primaryTerm > 0; + } + protected R readProperties(ElasticsearchPersistentEntity entity, R instance, ElasticsearchPropertyValueProvider valueProvider) { @@ -228,7 +236,7 @@ protected R readProperties(ElasticsearchPersistentEntity entity, R instan for (ElasticsearchPersistentProperty prop : entity) { - if (entity.isConstructorArgument(prop) || prop.isScoreProperty()) { + if (entity.isConstructorArgument(prop) || prop.isScoreProperty() || !prop.isReadable()) { continue; } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/document/Document.java b/src/main/java/org/springframework/data/elasticsearch/core/document/Document.java index 82c91ccfc7..c6311ed75a 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/document/Document.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/document/Document.java @@ -39,6 +39,7 @@ * * @author Mark Paluch * @author Peter-Josef Meisch + * @author Roman Puchkovskiy * @since 4.0 */ public interface Document extends Map { @@ -165,6 +166,70 @@ default void setVersion(long version) { throw new UnsupportedOperationException(); } + /** + * Return {@literal true} if this {@link Document} is associated with a seq_no. + * + * @return {@literal true} if this {@link Document} is associated with a seq_no, {@literal false} otherwise. + */ + default boolean hasSeqNo() { + return false; + } + + /** + * Retrieve the seq_no associated with this {@link Document}. + *

+ * The default implementation throws {@link UnsupportedOperationException}. It's recommended to check + * {@link #hasSeqNo()} prior to calling this method. + * + * @return the seq_no associated with this {@link Document}. + * @throws IllegalStateException if the underlying implementation supports seq_no's but no seq_no was yet + * associated with the document. + */ + default long getSeqNo() { + throw new UnsupportedOperationException(); + } + + /** + * Set the seq_no for this {@link Document}. + *

+ * The default implementation throws {@link UnsupportedOperationException}. + */ + default void setSeqNo(long seqNo) { + throw new UnsupportedOperationException(); + } + + /** + * Return {@literal true} if this {@link Document} is associated with a primary_term. + * + * @return {@literal true} if this {@link Document} is associated with a primary_term, {@literal false} otherwise. + */ + default boolean hasPrimaryTerm() { + return false; + } + + /** + * Retrieve the primary_term associated with this {@link Document}. + *

+ * The default implementation throws {@link UnsupportedOperationException}. It's recommended to check + * {@link #hasPrimaryTerm()} prior to calling this method. + * + * @return the primary_term associated with this {@link Document}. + * @throws IllegalStateException if the underlying implementation supports primary_term's but no primary_term was + * yet associated with the document. + */ + default long getPrimaryTerm() { + throw new UnsupportedOperationException(); + } + + /** + * Set the primary_term for this {@link Document}. + *

+ * The default implementation throws {@link UnsupportedOperationException}. + */ + default void setPrimaryTerm(long primaryTerm) { + throw new UnsupportedOperationException(); + } + /** * Returns the value to which the specified {@code key} is mapped, or {@literal null} if this document contains no * mapping for the key. The value is casted within the method which makes it useful for calling code as it does not diff --git a/src/main/java/org/springframework/data/elasticsearch/core/document/DocumentAdapters.java b/src/main/java/org/springframework/data/elasticsearch/core/document/DocumentAdapters.java index fb1a491a81..0dbc7aed98 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/document/DocumentAdapters.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/document/DocumentAdapters.java @@ -54,6 +54,7 @@ * * @author Mark Paluch * @author Peter-Josef Meisch + * @author Roman Puchkovskiy * @since 4.0 */ public class DocumentAdapters { @@ -76,12 +77,15 @@ public static Document from(GetResponse source) { } if (source.isSourceEmpty()) { - return fromDocumentFields(source, source.getId(), source.getVersion()); + return fromDocumentFields(source, source.getId(), source.getVersion(), + source.getSeqNo(), source.getPrimaryTerm()); } Document document = Document.from(source.getSourceAsMap()); document.setId(source.getId()); document.setVersion(source.getVersion()); + document.setSeqNo(source.getSeqNo()); + document.setPrimaryTerm(source.getPrimaryTerm()); return document; } @@ -104,12 +108,15 @@ public static Document from(GetResult source) { } if (source.isSourceEmpty()) { - return fromDocumentFields(source, source.getId(), source.getVersion()); + return fromDocumentFields(source, source.getId(), source.getVersion(), + source.getSeqNo(), source.getPrimaryTerm()); } Document document = Document.from(source.getSource()); document.setId(source.getId()); document.setVersion(source.getVersion()); + document.setSeqNo(source.getSeqNo()); + document.setPrimaryTerm(source.getPrimaryTerm()); return document; } @@ -150,7 +157,8 @@ public static SearchDocument from(SearchHit source) { if (sourceRef == null || sourceRef.length() == 0) { return new SearchDocumentAdapter(source.getScore(), source.getSortValues(), source.getFields(), highlightFields, - fromDocumentFields(source, source.getId(), source.getVersion())); + fromDocumentFields(source, source.getId(), source.getVersion(), + source.getSeqNo(), source.getPrimaryTerm())); } Document document = Document.from(source.getSourceAsMap()); @@ -159,6 +167,8 @@ public static SearchDocument from(SearchHit source) { if (source.getVersion() >= 0) { document.setVersion(source.getVersion()); } + document.setSeqNo(source.getSeqNo()); + document.setPrimaryTerm(source.getPrimaryTerm()); return new SearchDocumentAdapter(source.getScore(), source.getSortValues(), source.getFields(), highlightFields, document); @@ -170,10 +180,11 @@ public static SearchDocument from(SearchHit source) { * @param documentFields the {@link DocumentField}s backing the {@link Document}. * @return the adapted {@link Document}. */ - public static Document fromDocumentFields(Iterable documentFields, String id, long version) { + public static Document fromDocumentFields(Iterable documentFields, String id, long version, + long seqNo, long primaryTerm) { if (documentFields instanceof Collection) { - return new DocumentFieldAdapter((Collection) documentFields, id, version); + return new DocumentFieldAdapter((Collection) documentFields, id, version, seqNo, primaryTerm); } List fields = new ArrayList<>(); @@ -181,7 +192,7 @@ public static Document fromDocumentFields(Iterable documentFields fields.add(documentField); } - return new DocumentFieldAdapter(fields, id, version); + return new DocumentFieldAdapter(fields, id, version, seqNo, primaryTerm); } // TODO: Performance regarding keys/values/entry-set @@ -190,11 +201,16 @@ static class DocumentFieldAdapter implements Document { private final Collection documentFields; private final String id; private final long version; + private final long seqNo; + private final long primaryTerm; - DocumentFieldAdapter(Collection documentFields, String id, long version) { + DocumentFieldAdapter(Collection documentFields, String id, long version, + long seqNo, long primaryTerm) { this.documentFields = documentFields; this.id = id; this.version = version; + this.seqNo = seqNo; + this.primaryTerm = primaryTerm; } /* @@ -238,6 +254,52 @@ public long getVersion() { return this.version; } + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.core.document.Document#hasSeqNo() + */ + @Override + public boolean hasSeqNo() { + return true; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.core.document.Document#getSeqNo() + */ + @Override + public long getSeqNo() { + + if (!hasSeqNo()) { + throw new IllegalStateException("No seq_no associated with this Document"); + } + + return this.seqNo; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.core.document.Document#hasPrimaryTerm() + */ + @Override + public boolean hasPrimaryTerm() { + return true; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.core.document.Document#getPrimaryTerm() + */ + @Override + public long getPrimaryTerm() { + + if (!hasPrimaryTerm()) { + throw new IllegalStateException("No primary_term associated with this Document"); + } + + return this.primaryTerm; + } + /* * (non-Javadoc) * @see java.util.Map#size() @@ -556,6 +618,60 @@ public long getVersion() { public void setVersion(long version) { delegate.setVersion(version); } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.core.document.Document#hasSeqNo() + */ + @Override + public boolean hasSeqNo() { + return delegate.hasSeqNo(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.core.document.Document#getSeqNo() + */ + @Override + public long getSeqNo() { + return delegate.getSeqNo(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.core.document.Document#setSeqNo(long) + */ + @Override + public void setSeqNo(long seqNo) { + delegate.setSeqNo(seqNo); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.core.document.Document#hasPrimaryTerm() + */ + @Override + public boolean hasPrimaryTerm() { + return delegate.hasPrimaryTerm(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.core.document.Document#getPrimaryTerm() + */ + @Override + public long getPrimaryTerm() { + return delegate.getPrimaryTerm(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.core.document.Document#setPrimaryTerm(long) + */ + @Override + public void setPrimaryTerm(long primaryTerm) { + delegate.setPrimaryTerm(primaryTerm); + } /* * (non-Javadoc) diff --git a/src/main/java/org/springframework/data/elasticsearch/core/document/MapDocument.java b/src/main/java/org/springframework/data/elasticsearch/core/document/MapDocument.java index a9f114969b..fb80c35e12 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/document/MapDocument.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/document/MapDocument.java @@ -31,6 +31,7 @@ * {@link Document} implementation backed by a {@link LinkedHashMap}. * * @author Mark Paluch + * @author Roman Puchkovskiy * @since 4.0 */ class MapDocument implements Document { @@ -41,6 +42,8 @@ class MapDocument implements Document { private @Nullable String id; private @Nullable Long version; + private @Nullable Long seqNo; + private @Nullable Long primaryTerm; MapDocument() { this(new LinkedHashMap<>()); @@ -114,6 +117,68 @@ public void setVersion(long version) { this.version = version; } + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.core.document.Document#hasSeqNo() + */ + @Override + public boolean hasSeqNo() { + return this.seqNo != null; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.core.document.Document#getSeqNo() + */ + @Override + public long getSeqNo() { + + if (!hasSeqNo()) { + throw new IllegalStateException("No seq_no associated with this Document"); + } + + return this.seqNo; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.core.document.Document#setSeqNo() + */ + public void setSeqNo(long seqNo) { + this.seqNo = seqNo; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.core.document.Document#hasPrimaryTerm() + */ + @Override + public boolean hasPrimaryTerm() { + return this.primaryTerm != null; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.core.document.Document#getPrimaryTerm() + */ + @Override + public long getPrimaryTerm() { + + if (!hasPrimaryTerm()) { + throw new IllegalStateException("No primary_term associated with this Document"); + } + + return this.primaryTerm; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.core.document.Document#setPrimaryTerm() + */ + public void setPrimaryTerm(long primaryTerm) { + this.primaryTerm = primaryTerm; + } + /* * (non-Javadoc) * @see java.util.Map#size() diff --git a/src/main/java/org/springframework/data/elasticsearch/core/index/MappingBuilder.java b/src/main/java/org/springframework/data/elasticsearch/core/index/MappingBuilder.java index c75e1d3328..2eea93ca91 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/index/MappingBuilder.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/index/MappingBuilder.java @@ -167,6 +167,15 @@ private void mapEntity(XContentBuilder builder, @Nullable ElasticsearchPersisten return; } + if (property.isSeqNoPrimaryTermProperty()) { + if (property.isAnnotationPresent(Field.class)) { + logger.warn("Property {} of {} is annotated for inclusion in mapping, but its type is " + // + "SeqNoPrimaryTerm that is never mapped, so it is skipped", // + property.getFieldName(), entity.getType()); + } + return; + } + buildPropertyMapping(builder, isRootObject, property); } catch (IOException e) { logger.warn("error mapping property with name {}", property.getName(), e); diff --git a/src/main/java/org/springframework/data/elasticsearch/core/mapping/ElasticsearchPersistentEntity.java b/src/main/java/org/springframework/data/elasticsearch/core/mapping/ElasticsearchPersistentEntity.java index 1111d172c3..9e2c0d9597 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/mapping/ElasticsearchPersistentEntity.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/mapping/ElasticsearchPersistentEntity.java @@ -17,6 +17,7 @@ import org.elasticsearch.index.VersionType; import org.springframework.data.elasticsearch.annotations.Field; +import org.springframework.data.elasticsearch.core.query.SeqNoPrimaryTerm; import org.springframework.data.mapping.PersistentEntity; import org.springframework.lang.Nullable; @@ -30,6 +31,7 @@ * @author Oliver Gierke * @author Ivan Greene * @author Peter-Josef Meisch + * @author Roman Puchkovskiy */ public interface ElasticsearchPersistentEntity extends PersistentEntity { @@ -96,4 +98,41 @@ public interface ElasticsearchPersistentEntity extends PersistentEntity { @@ -64,6 +65,14 @@ public interface ElasticsearchPersistentProperty extends PersistentProperty { INSTANCE; diff --git a/src/main/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentEntity.java b/src/main/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentEntity.java index 7d53d916a4..348ac676db 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentEntity.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentEntity.java @@ -23,6 +23,8 @@ import java.util.concurrent.atomic.AtomicReference; import org.elasticsearch.index.VersionType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.BeansException; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; @@ -53,10 +55,13 @@ * @author Sascha Woo * @author Ivan Greene * @author Peter-Josef Meisch + * @author Roman Puchkovskiy */ public class SimpleElasticsearchPersistentEntity extends BasicPersistentEntity implements ElasticsearchPersistentEntity, ApplicationContextAware { + private static final Logger LOGGER = LoggerFactory.getLogger(SimpleElasticsearchPersistentEntity.class); + private final StandardEvaluationContext context; private final SpelExpressionParser parser; @@ -70,6 +75,7 @@ public class SimpleElasticsearchPersistentEntity extends BasicPersistentEntit private @Nullable String parentType; private @Nullable ElasticsearchPersistentProperty parentIdProperty; private @Nullable ElasticsearchPersistentProperty scoreProperty; + private @Nullable ElasticsearchPersistentProperty seqNoPrimaryTermProperty; private @Nullable String settingPath; private @Nullable VersionType versionType; private boolean createIndexAndMapping; @@ -231,6 +237,36 @@ public void addPersistentProperty(ElasticsearchPersistentProperty property) { this.scoreProperty = property; } + + if (property.isSeqNoPrimaryTermProperty()) { + + ElasticsearchPersistentProperty seqNoPrimaryTermProperty = this.seqNoPrimaryTermProperty; + + if (seqNoPrimaryTermProperty != null) { + throw new MappingException(String.format( + "Attempt to add SeqNoPrimaryTerm property %s but already have property %s registered " + + "as SeqNoPrimaryTerm property. Check your entity configuration!", + property.getField(), seqNoPrimaryTermProperty.getField())); + } + + this.seqNoPrimaryTermProperty = property; + + if (hasVersionProperty()) { + warnAboutBothSeqNoPrimaryTermAndVersionProperties(); + } + } + + if (property.isVersionProperty()) { + if (hasSeqNoPrimaryTermProperty()) { + warnAboutBothSeqNoPrimaryTermAndVersionProperties(); + } + } + } + + private void warnAboutBothSeqNoPrimaryTermAndVersionProperties() { + LOGGER.warn( + "Both SeqNoPrimaryTerm and @Version properties are defined on {}. Version will not be sent in index requests when seq_no is sent!", + getType()); } /* @@ -262,4 +298,15 @@ public ElasticsearchPersistentProperty getPersistentPropertyWithFieldName(String return propertyRef.get(); }); } + + @Override + public boolean hasSeqNoPrimaryTermProperty() { + return seqNoPrimaryTermProperty != null; + } + + @Override + @Nullable + public ElasticsearchPersistentProperty getSeqNoPrimaryTermProperty() { + return seqNoPrimaryTermProperty; + } } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentProperty.java b/src/main/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentProperty.java index a69f6fa20f..beab6b433e 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentProperty.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentProperty.java @@ -26,6 +26,7 @@ import org.springframework.data.elasticsearch.annotations.Parent; import org.springframework.data.elasticsearch.annotations.Score; import org.springframework.data.elasticsearch.core.convert.ElasticsearchDateConverter; +import org.springframework.data.elasticsearch.core.query.SeqNoPrimaryTerm; import org.springframework.data.mapping.Association; import org.springframework.data.mapping.MappingException; import org.springframework.data.mapping.PersistentEntity; @@ -44,6 +45,7 @@ * @author Sascha Woo * @author Oliver Gierke * @author Peter-Josef Meisch + * @author Roman Puchkovskiy */ public class SimpleElasticsearchPersistentProperty extends AnnotationBasedPersistentProperty implements ElasticsearchPersistentProperty { @@ -53,6 +55,7 @@ public class SimpleElasticsearchPersistentProperty extends private final boolean isScore; private final boolean isParent; private final boolean isId; + private final boolean isSeqNoPrimaryTerm; private final @Nullable String annotatedFieldName; @Nullable private ElasticsearchPersistentPropertyConverter propertyConverter; @@ -65,6 +68,7 @@ public SimpleElasticsearchPersistentProperty(Property property, this.isId = super.isIdProperty() || SUPPORTED_ID_PROPERTY_NAMES.contains(getFieldName()); this.isScore = isAnnotationPresent(Score.class); this.isParent = isAnnotationPresent(Parent.class); + this.isSeqNoPrimaryTerm = SeqNoPrimaryTerm.class.isAssignableFrom(getRawType()); if (isVersionProperty() && !getType().equals(Long.class)) { throw new MappingException(String.format("Version property %s must be of type Long!", property.getName())); @@ -93,6 +97,16 @@ public ElasticsearchPersistentPropertyConverter getPropertyConverter() { return propertyConverter; } + @Override + public boolean isWritable() { + return super.isWritable() && !isSeqNoPrimaryTermProperty(); + } + + @Override + public boolean isReadable() { + return !isTransient() && !isSeqNoPrimaryTermProperty(); + } + /** * Initializes an {@link ElasticsearchPersistentPropertyConverter} if this property is annotated as a Field with type * {@link FieldType#Date}, has a {@link DateFormat} set and if the type of the property is one of the Java8 temporal @@ -209,4 +223,13 @@ public boolean isImmutable() { public boolean isParentProperty() { return isParent; } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersistentProperty#isSeqNoPrimaryTermProperty() + */ + @Override + public boolean isSeqNoPrimaryTermProperty() { + return isSeqNoPrimaryTerm; + } } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/query/IndexQuery.java b/src/main/java/org/springframework/data/elasticsearch/core/query/IndexQuery.java index 5865571e41..fa0ba5748f 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/query/IndexQuery.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/query/IndexQuery.java @@ -23,8 +23,8 @@ * @author Rizwan Idrees * @author Mohsin Husen * @author Peter-Josef Meisch + * @author Roman Puchkovskiy */ - public class IndexQuery { @Nullable private String id; @@ -32,6 +32,8 @@ public class IndexQuery { @Nullable private Long version; @Nullable private String source; @Nullable private String parentId; + @Nullable private Long seqNo; + @Nullable private Long primaryTerm; @Nullable public String getId() { @@ -87,4 +89,22 @@ public String getParentId() { public void setParentId(String parentId) { this.parentId = parentId; } + + @Nullable + public Long getSeqNo() { + return seqNo; + } + + public void setSeqNo(Long seqNo) { + this.seqNo = seqNo; + } + + @Nullable + public Long getPrimaryTerm() { + return primaryTerm; + } + + public void setPrimaryTerm(Long primaryTerm) { + this.primaryTerm = primaryTerm; + } } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/query/IndexQueryBuilder.java b/src/main/java/org/springframework/data/elasticsearch/core/query/IndexQueryBuilder.java index 33652ba60f..588f557d50 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/query/IndexQueryBuilder.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/query/IndexQueryBuilder.java @@ -23,6 +23,7 @@ * @author Rizwan Idrees * @author Mohsin Husen * @author Peter-Josef Meisch + * @author Roman Puchkovskiy */ public class IndexQueryBuilder { @@ -31,6 +32,8 @@ public class IndexQueryBuilder { @Nullable private Long version; @Nullable private String source; @Nullable private String parentId; + @Nullable private Long seqNo; + @Nullable private Long primaryTerm; public IndexQueryBuilder withId(String id) { this.id = id; @@ -57,6 +60,12 @@ public IndexQueryBuilder withParentId(String parentId) { return this; } + public IndexQueryBuilder withSeqNoPrimaryTerm(SeqNoPrimaryTerm seqNoPrimaryTerm) { + this.seqNo = seqNoPrimaryTerm.getSequenceNumber(); + this.primaryTerm = seqNoPrimaryTerm.getPrimaryTerm(); + return this; + } + public IndexQuery build() { IndexQuery indexQuery = new IndexQuery(); indexQuery.setId(id); @@ -64,6 +73,8 @@ public IndexQuery build() { indexQuery.setParentId(parentId); indexQuery.setSource(source); indexQuery.setVersion(version); + indexQuery.setSeqNo(seqNo); + indexQuery.setPrimaryTerm(primaryTerm); return indexQuery; } } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/query/SeqNoPrimaryTerm.java b/src/main/java/org/springframework/data/elasticsearch/core/query/SeqNoPrimaryTerm.java new file mode 100644 index 0000000000..c7bf434d03 --- /dev/null +++ b/src/main/java/org/springframework/data/elasticsearch/core/query/SeqNoPrimaryTerm.java @@ -0,0 +1,99 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.elasticsearch.core.query; + +import java.util.Objects; + +/** + *

A container for seq_no and primary_term values. When an entity class contains a field of this type, + * it will be automatically filled with SeqNoPrimaryTerm instance on read operations (like get or search), + * and also, when the SeqNoPrimaryTerm is not {@literal null} and filled with seq_no and primary_term, + * they will be sent to Elasticsearch when indexing such an entity. + *

+ *

This allows to implement optimistic locking pattern for full-update scenario, when an entity is first + * read from Elasticsearch and then gets reindexed with new _content. + * Index operations will throw an {@link org.springframework.dao.OptimisticLockingFailureException} if the + * seq_no + primary_term pair already has different values for the given document. See Elasticsearch documentation + * for more information: https://www.elastic.co/guide/en/elasticsearch/reference/current/optimistic-concurrency-control.html + *

+ *

A property of this type is implicitly @{@link org.springframework.data.annotation.Transient} and never gets included + * into a mapping at Elasticsearch side. + *

+ *

A SeqNoPrimaryTerm instance cannot contain an invalid or unassigned seq_no or primary_term. + *

+ * + * @author Roman Puchkovskiy + * @since 4.0 + */ +public final class SeqNoPrimaryTerm { + private final long sequenceNumber; + private final long primaryTerm; + + /** + * Creates an instance of SeqNoPrimaryTerm with the given seq_no and primary_term. The passed values are validated: + * sequenceNumber must be non-negative, primaryTerm must be positive. If validation fails, + * an IllegalArgumentException is thrown. + * + * @param sequenceNumber seq_no, must not be negative + * @param primaryTerm primary_term, must be positive + * @throws IllegalArgumentException if seq_no or primary_term is not valid + */ + public SeqNoPrimaryTerm(long sequenceNumber, long primaryTerm) { + if (sequenceNumber < 0) { + throw new IllegalArgumentException("seq_no should not be negative, but it's " + sequenceNumber); + } + if (primaryTerm <= 0) { + throw new IllegalArgumentException("primary_term should be positive, but it's " + primaryTerm); + } + + this.sequenceNumber = sequenceNumber; + this.primaryTerm = primaryTerm; + } + + public long getSequenceNumber() { + return sequenceNumber; + } + + public long getPrimaryTerm() { + return primaryTerm; + } + + @Override + public String toString() { + return "SeqNoPrimaryTerm{" + + "sequenceNumber=" + sequenceNumber + + ", primaryTerm=" + primaryTerm + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + SeqNoPrimaryTerm that = (SeqNoPrimaryTerm) o; + return sequenceNumber == that.sequenceNumber && + primaryTerm == that.primaryTerm; + } + + @Override + public int hashCode() { + return Objects.hash(sequenceNumber, primaryTerm); + } +} diff --git a/src/test/java/org/springframework/data/elasticsearch/core/DocumentAdaptersUnitTests.java b/src/test/java/org/springframework/data/elasticsearch/core/DocumentAdaptersUnitTests.java index 852e8f0dae..8568e6d463 100644 --- a/src/test/java/org/springframework/data/elasticsearch/core/DocumentAdaptersUnitTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/DocumentAdaptersUnitTests.java @@ -38,6 +38,7 @@ * * @author Mark Paluch * @author Peter-Josef Meisch + * @author Roman Puchkovskiy */ public class DocumentAdaptersUnitTests { @@ -47,7 +48,7 @@ public void shouldAdaptGetResponse() { Map fields = Collections.singletonMap("field", new DocumentField("field", Collections.singletonList("value"))); - GetResult getResult = new GetResult("index", "type", "my-id", 1, 1, 42, true, null, fields, null); + GetResult getResult = new GetResult("index", "type", "my-id", 1, 2, 42, true, null, fields, null); GetResponse response = new GetResponse(getResult); Document document = DocumentAdapters.from(response); @@ -57,6 +58,10 @@ public void shouldAdaptGetResponse() { assertThat(document.hasVersion()).isTrue(); assertThat(document.getVersion()).isEqualTo(42); assertThat(document.get("field")).isEqualTo("value"); + assertThat(document.hasSeqNo()).isTrue(); + assertThat(document.getSeqNo()).isEqualTo(1); + assertThat(document.hasPrimaryTerm()).isTrue(); + assertThat(document.getPrimaryTerm()).isEqualTo(2); } @Test // DATAES-628 @@ -64,7 +69,7 @@ public void shouldAdaptGetResponseSource() { BytesArray source = new BytesArray("{\"field\":\"value\"}"); - GetResult getResult = new GetResult("index", "type", "my-id", 1, 1, 42, true, source, Collections.emptyMap(), null); + GetResult getResult = new GetResult("index", "type", "my-id", 1, 2, 42, true, source, Collections.emptyMap(), null); GetResponse response = new GetResponse(getResult); Document document = DocumentAdapters.from(response); @@ -74,6 +79,51 @@ public void shouldAdaptGetResponseSource() { assertThat(document.hasVersion()).isTrue(); assertThat(document.getVersion()).isEqualTo(42); assertThat(document.get("field")).isEqualTo("value"); + assertThat(document.hasSeqNo()).isTrue(); + assertThat(document.getSeqNo()).isEqualTo(1); + assertThat(document.hasPrimaryTerm()).isTrue(); + assertThat(document.getPrimaryTerm()).isEqualTo(2); + } + + @Test // DATAES-799 + public void shouldAdaptGetResult() { + + Map fields = Collections.singletonMap("field", + new DocumentField("field", Collections.singletonList("value"))); + + GetResult getResult = new GetResult("index", "type", "my-id", 1, 2, 42, true, null, fields, null); + + Document document = DocumentAdapters.from(getResult); + + assertThat(document.hasId()).isTrue(); + assertThat(document.getId()).isEqualTo("my-id"); + assertThat(document.hasVersion()).isTrue(); + assertThat(document.getVersion()).isEqualTo(42); + assertThat(document.get("field")).isEqualTo("value"); + assertThat(document.hasSeqNo()).isTrue(); + assertThat(document.getSeqNo()).isEqualTo(1); + assertThat(document.hasPrimaryTerm()).isTrue(); + assertThat(document.getPrimaryTerm()).isEqualTo(2); + } + + @Test // DATAES-799 + public void shouldAdaptGetResultSource() { + + BytesArray source = new BytesArray("{\"field\":\"value\"}"); + + GetResult getResult = new GetResult("index", "type", "my-id", 1, 2, 42, true, source, Collections.emptyMap(), null); + + Document document = DocumentAdapters.from(getResult); + + assertThat(document.hasId()).isTrue(); + assertThat(document.getId()).isEqualTo("my-id"); + assertThat(document.hasVersion()).isTrue(); + assertThat(document.getVersion()).isEqualTo(42); + assertThat(document.get("field")).isEqualTo("value"); + assertThat(document.hasSeqNo()).isTrue(); + assertThat(document.getSeqNo()).isEqualTo(1); + assertThat(document.hasPrimaryTerm()).isTrue(); + assertThat(document.getPrimaryTerm()).isEqualTo(2); } @Test // DATAES-628 @@ -83,6 +133,8 @@ public void shouldAdaptSearchResponse() { new DocumentField("field", Collections.singletonList("value"))); SearchHit searchHit = new SearchHit(123, "my-id", new Text("type"), fields); + searchHit.setSeqNo(1); + searchHit.setPrimaryTerm(2); searchHit.score(42); SearchDocument document = DocumentAdapters.from(searchHit); @@ -92,6 +144,10 @@ public void shouldAdaptSearchResponse() { assertThat(document.hasVersion()).isFalse(); assertThat(document.getScore()).isBetween(42f, 42f); assertThat(document.get("field")).isEqualTo("value"); + assertThat(document.hasSeqNo()).isTrue(); + assertThat(document.getSeqNo()).isEqualTo(1); + assertThat(document.hasPrimaryTerm()).isTrue(); + assertThat(document.getPrimaryTerm()).isEqualTo(2); } @Test // DATAES-628 @@ -151,6 +207,8 @@ public void shouldAdaptSearchResponseSource() { SearchHit searchHit = new SearchHit(123, "my-id", new Text("type"), Collections.emptyMap()); searchHit.sourceRef(source).score(42); searchHit.version(22); + searchHit.setSeqNo(1); + searchHit.setPrimaryTerm(2); SearchDocument document = DocumentAdapters.from(searchHit); @@ -160,5 +218,9 @@ public void shouldAdaptSearchResponseSource() { assertThat(document.getVersion()).isEqualTo(22); assertThat(document.getScore()).isBetween(42f, 42f); assertThat(document.get("field")).isEqualTo("value"); + assertThat(document.hasSeqNo()).isTrue(); + assertThat(document.getSeqNo()).isEqualTo(1); + assertThat(document.hasPrimaryTerm()).isTrue(); + assertThat(document.getPrimaryTerm()).isEqualTo(2); } } diff --git a/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchExceptionTranslatorTests.java b/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchExceptionTranslatorTests.java new file mode 100644 index 0000000000..4f2ec13395 --- /dev/null +++ b/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchExceptionTranslatorTests.java @@ -0,0 +1,61 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.elasticsearch.core; + +import static org.assertj.core.api.Assertions.*; + +import org.elasticsearch.ElasticsearchStatusException; +import org.elasticsearch.index.engine.VersionConflictEngineException; +import org.elasticsearch.index.shard.ShardId; +import org.elasticsearch.rest.RestStatus; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.OptimisticLockingFailureException; + +import java.util.UUID; + +/** + * @author Roman Puchkovskiy + */ +class ElasticsearchExceptionTranslatorTests { + private final ElasticsearchExceptionTranslator translator = new ElasticsearchExceptionTranslator(); + + @Test // DATAES-799 + void shouldConvertElasticsearchStatusExceptionWithSeqNoConflictToOptimisticLockingFailureException() { + ElasticsearchStatusException ex = new ElasticsearchStatusException( + "Elasticsearch exception [type=version_conflict_engine_exception, reason=[WPUUsXEB6uuA6j8_A7AB]: version conflict, required seqNo [34], primary term [16]. current document has seqNo [35] and primary term [16]]", + RestStatus.CONFLICT); + + DataAccessException translated = translator.translateExceptionIfPossible(ex); + + assertThat(translated).isInstanceOf(OptimisticLockingFailureException.class); + assertThat(translated.getMessage()).startsWith("Cannot index a document due to seq_no+primary_term conflict"); + assertThat(translated.getCause()).isSameAs(ex); + } + + @Test // DATAES-799 + void shouldConvertVersionConflictEngineExceptionWithSeqNoConflictToOptimisticLockingFailureException() { + VersionConflictEngineException ex = new VersionConflictEngineException( + new ShardId("index", "uuid", 1), "exception-id", + "Elasticsearch exception [type=version_conflict_engine_exception, reason=[WPUUsXEB6uuA6j8_A7AB]: version conflict, required seqNo [34], primary term [16]. current document has seqNo [35] and primary term [16]]"); + + DataAccessException translated = translator.translateExceptionIfPossible(ex); + + assertThat(translated).isInstanceOf(OptimisticLockingFailureException.class); + assertThat(translated.getMessage()).startsWith("Cannot index a document due to seq_no+primary_term conflict"); + assertThat(translated.getCause()).isSameAs(ex); + } +} \ No newline at end of file diff --git a/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplateTests.java b/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplateTests.java index 3e1c0696f7..e5eed540e2 100755 --- a/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplateTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplateTests.java @@ -15,6 +15,7 @@ */ package org.springframework.data.elasticsearch.core; +import static java.util.Collections.*; import static org.apache.commons.lang.RandomStringUtils.*; import static org.assertj.core.api.Assertions.*; import static org.elasticsearch.index.query.QueryBuilders.*; @@ -58,6 +59,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.annotation.Id; import org.springframework.data.annotation.Version; import org.springframework.data.domain.PageRequest; @@ -100,6 +102,7 @@ * @author Martin Choraine * @author Farid Azaza * @author Gyula Attila Csorogi + * @author Roman Puchkovskiy */ public abstract class ElasticsearchTemplateTests { @@ -3067,6 +3070,131 @@ void shouldDoExistsWithIndexCoordinates() { assertThat(operations.exists("42", index)).isTrue(); } + @Test // DATAES-799 + void getShouldReturnSeqNoPrimaryTerm() { + OptimisticEntity original = new OptimisticEntity(); + original.setMessage("It's fine"); + OptimisticEntity saved = operations.save(original); + + OptimisticEntity retrieved = operations.get(saved.getId(), OptimisticEntity.class); + + assertThatSeqNoPrimaryTermIsFilled(retrieved); + } + + private void assertThatSeqNoPrimaryTermIsFilled(OptimisticEntity retrieved) { + assertThat(retrieved.seqNoPrimaryTerm).isNotNull(); + assertThat(retrieved.seqNoPrimaryTerm.getSequenceNumber()).isNotNull(); + assertThat(retrieved.seqNoPrimaryTerm.getSequenceNumber()).isNotNegative(); + assertThat(retrieved.seqNoPrimaryTerm.getPrimaryTerm()).isNotNull(); + assertThat(retrieved.seqNoPrimaryTerm.getPrimaryTerm()).isPositive(); + } + + @Test // DATAES-799 + void multigetShouldReturnSeqNoPrimaryTerm() { + OptimisticEntity original = new OptimisticEntity(); + original.setMessage("It's fine"); + OptimisticEntity saved = operations.save(original); + operations.refresh(OptimisticEntity.class); + + List retrievedList = operations.multiGet(queryForOne(saved.getId()), OptimisticEntity.class, + operations.getIndexCoordinatesFor(OptimisticEntity.class)); + OptimisticEntity retrieved = retrievedList.get(0); + + assertThatSeqNoPrimaryTermIsFilled(retrieved); + } + + private Query queryForOne(String id) { + return new NativeSearchQueryBuilder().withIds(singletonList(id)).build(); + } + + @Test // DATAES-799 + void searchShouldReturnSeqNoPrimaryTerm() { + OptimisticEntity original = new OptimisticEntity(); + original.setMessage("It's fine"); + OptimisticEntity saved = operations.save(original); + operations.refresh(OptimisticEntity.class); + + SearchHits retrievedHits = operations.search(queryForOne(saved.getId()), OptimisticEntity.class); + OptimisticEntity retrieved = retrievedHits.getSearchHit(0).getContent(); + + assertThatSeqNoPrimaryTermIsFilled(retrieved); + } + + @Test // DATAES-799 + void multiSearchShouldReturnSeqNoPrimaryTerm() { + OptimisticEntity original = new OptimisticEntity(); + original.setMessage("It's fine"); + OptimisticEntity saved = operations.save(original); + operations.refresh(OptimisticEntity.class); + + List queries = singletonList(queryForOne(saved.getId())); + List> retrievedHits = operations.multiSearch(queries, + OptimisticEntity.class, operations.getIndexCoordinatesFor(OptimisticEntity.class)); + OptimisticEntity retrieved = retrievedHits.get(0).getSearchHit(0).getContent(); + + assertThatSeqNoPrimaryTermIsFilled(retrieved); + } + + @Test // DATAES-799 + void searchForStreamShouldReturnSeqNoPrimaryTerm() { + OptimisticEntity original = new OptimisticEntity(); + original.setMessage("It's fine"); + OptimisticEntity saved = operations.save(original); + operations.refresh(OptimisticEntity.class); + + SearchHitsIterator retrievedHits = operations.searchForStream(queryForOne(saved.getId()), + OptimisticEntity.class); + OptimisticEntity retrieved = retrievedHits.next().getContent(); + + assertThatSeqNoPrimaryTermIsFilled(retrieved); + } + + @Test // DATAES-799 + void shouldThrowOptimisticLockingFailureExceptionWhenConcurrentUpdateOccursOnEntityWithSeqNoPrimaryTermProperty() { + OptimisticEntity original = new OptimisticEntity(); + original.setMessage("It's fine"); + OptimisticEntity saved = operations.save(original); + + OptimisticEntity forEdit1 = operations.get(saved.getId(), OptimisticEntity.class); + OptimisticEntity forEdit2 = operations.get(saved.getId(), OptimisticEntity.class); + + forEdit1.setMessage("It'll be ok"); + operations.save(forEdit1); + + forEdit2.setMessage("It'll be great"); + assertThatThrownBy(() -> operations.save(forEdit2)) + .isInstanceOf(OptimisticLockingFailureException.class); + } + + @Test // DATAES-799 + void shouldThrowOptimisticLockingFailureExceptionWhenConcurrentUpdateOccursOnVersionedEntityWithSeqNoPrimaryTermProperty() { + OptimisticAndVersionedEntity original = new OptimisticAndVersionedEntity(); + original.setMessage("It's fine"); + OptimisticAndVersionedEntity saved = operations.save(original); + + OptimisticAndVersionedEntity forEdit1 = operations.get(saved.getId(), OptimisticAndVersionedEntity.class); + OptimisticAndVersionedEntity forEdit2 = operations.get(saved.getId(), OptimisticAndVersionedEntity.class); + + forEdit1.setMessage("It'll be ok"); + operations.save(forEdit1); + + forEdit2.setMessage("It'll be great"); + assertThatThrownBy(() -> operations.save(forEdit2)) + .isInstanceOf(OptimisticLockingFailureException.class); + } + + @Test // DATAES-799 + void shouldAllowFullReplaceOfEntityWithBothSeqNoPrimaryTermAndVersion() { + OptimisticAndVersionedEntity original = new OptimisticAndVersionedEntity(); + original.setMessage("It's fine"); + OptimisticAndVersionedEntity saved = operations.save(original); + + OptimisticAndVersionedEntity forEdit = operations.get(saved.getId(), OptimisticAndVersionedEntity.class); + + forEdit.setMessage("It'll be ok"); + operations.save(forEdit); + } + protected RequestFactory getRequestFactory() { return ((AbstractElasticsearchTemplate) operations).getRequestFactory(); } @@ -3230,4 +3358,21 @@ static class HighlightEntity { @Id private String id; private String message; } + + @Data + @Document(indexName = "test-index-optimistic-entity-template") + static class OptimisticEntity { + @Id private String id; + private String message; + private SeqNoPrimaryTerm seqNoPrimaryTerm; + } + + @Data + @Document(indexName = "test-index-optimistic-and-versioned-entity-template") + static class OptimisticAndVersionedEntity { + @Id private String id; + private String message; + private SeqNoPrimaryTerm seqNoPrimaryTerm; + @Version private Long version; + } } diff --git a/src/test/java/org/springframework/data/elasticsearch/core/ReactiveElasticsearchTemplateTests.java b/src/test/java/org/springframework/data/elasticsearch/core/ReactiveElasticsearchTemplateTests.java index 90d9833d86..e78e93853d 100644 --- a/src/test/java/org/springframework/data/elasticsearch/core/ReactiveElasticsearchTemplateTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/ReactiveElasticsearchTemplateTests.java @@ -15,6 +15,7 @@ */ package org.springframework.data.elasticsearch.core; +import static java.util.Collections.*; import static org.assertj.core.api.Assertions.*; import static org.elasticsearch.index.query.QueryBuilders.*; import static org.springframework.data.elasticsearch.annotations.FieldType.*; @@ -39,6 +40,7 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; +import org.elasticsearch.index.query.IdsQueryBuilder; import org.elasticsearch.search.aggregations.AggregationBuilders; import org.elasticsearch.search.aggregations.bucket.terms.ParsedStringTerms; import org.elasticsearch.search.sort.FieldSortBuilder; @@ -47,6 +49,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.annotation.Id; import org.springframework.data.annotation.Version; import org.springframework.data.domain.PageRequest; @@ -59,14 +62,7 @@ import org.springframework.data.elasticsearch.annotations.Score; import org.springframework.data.elasticsearch.client.reactive.ReactiveElasticsearchClient; import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates; -import org.springframework.data.elasticsearch.core.query.Criteria; -import org.springframework.data.elasticsearch.core.query.CriteriaQuery; -import org.springframework.data.elasticsearch.core.query.IndexQuery; -import org.springframework.data.elasticsearch.core.query.IndexQueryBuilder; -import org.springframework.data.elasticsearch.core.query.NativeSearchQuery; -import org.springframework.data.elasticsearch.core.query.NativeSearchQueryBuilder; -import org.springframework.data.elasticsearch.core.query.StringQuery; -import org.springframework.data.elasticsearch.core.query.UpdateQuery; +import org.springframework.data.elasticsearch.core.query.*; import org.springframework.data.elasticsearch.junit.junit4.ElasticsearchVersion; import org.springframework.data.elasticsearch.junit.jupiter.SpringIntegrationTest; import org.springframework.util.StringUtils; @@ -81,6 +77,7 @@ * @author Martin Choraine * @author Aleksei Arsenev * @author Russell Parry + * @author Roman Puchkovskiy */ @SpringIntegrationTest public class ReactiveElasticsearchTemplateTests { @@ -855,6 +852,115 @@ void shouldReturnEmptyFluxOnSaveAllWithEmptyInput() { .verifyComplete(); } + @Test // DATAES-799 + void getShouldReturnSeqNoPrimaryTerm() { + OptimisticEntity original = new OptimisticEntity(); + original.setMessage("It's fine"); + OptimisticEntity saved = template.save(original).block(); + + template.get(saved.getId(), OptimisticEntity.class) + .as(StepVerifier::create) + .assertNext(this::assertThatSeqNoPrimaryTermIsFilled) + .verifyComplete(); + } + + private void assertThatSeqNoPrimaryTermIsFilled(OptimisticEntity retrieved) { + assertThat(retrieved.seqNoPrimaryTerm).isNotNull(); + assertThat(retrieved.seqNoPrimaryTerm.getSequenceNumber()).isNotNull(); + assertThat(retrieved.seqNoPrimaryTerm.getSequenceNumber()).isNotNegative(); + assertThat(retrieved.seqNoPrimaryTerm.getPrimaryTerm()).isNotNull(); + assertThat(retrieved.seqNoPrimaryTerm.getPrimaryTerm()).isPositive(); + } + + @Test // DATAES-799 + void multiGetShouldReturnSeqNoPrimaryTerm() { + OptimisticEntity original = new OptimisticEntity(); + original.setMessage("It's fine"); + OptimisticEntity saved = template.save(original).block(); + + template.multiGet(multiGetQueryForOne(saved.getId()), OptimisticEntity.class, template.getIndexCoordinatesFor(OptimisticEntity.class)) + .as(StepVerifier::create) + .assertNext(this::assertThatSeqNoPrimaryTermIsFilled) + .verifyComplete(); + } + + private Query multiGetQueryForOne(String id) { + return new NativeSearchQueryBuilder().withIds(singletonList(id)).build(); + } + + @Test // DATAES-799 + void searchShouldReturnSeqNoPrimaryTerm() { + OptimisticEntity original = new OptimisticEntity(); + original.setMessage("It's fine"); + OptimisticEntity saved = template.save(original).block(); + restTemplate.refresh(OptimisticEntity.class); + + template.search(searchQueryForOne(saved.getId()), OptimisticEntity.class, template.getIndexCoordinatesFor(OptimisticEntity.class)) + .map(SearchHit::getContent) + .as(StepVerifier::create) + .assertNext(this::assertThatSeqNoPrimaryTermIsFilled) + .verifyComplete(); + } + + private Query searchQueryForOne(String id) { + return new NativeSearchQueryBuilder() + .withFilter(new IdsQueryBuilder().addIds(id)) + .build(); + } + + @Test // DATAES-799 + void shouldThrowOptimisticLockingFailureExceptionWhenConcurrentUpdateOccursOnEntityWithSeqNoPrimaryTermProperty() { + OptimisticEntity original = new OptimisticEntity(); + original.setMessage("It's fine"); + OptimisticEntity saved = template.save(original).block(); + + OptimisticEntity forEdit1 = template.get(saved.getId(), OptimisticEntity.class).block(); + OptimisticEntity forEdit2 = template.get(saved.getId(), OptimisticEntity.class).block(); + + forEdit1.setMessage("It'll be ok"); + template.save(forEdit1).block(); + + forEdit2.setMessage("It'll be great"); + template.save(forEdit2) + .as(StepVerifier::create) + .expectError(OptimisticLockingFailureException.class) + .verify(); + } + + @Test // DATAES-799 + void shouldThrowOptimisticLockingFailureExceptionWhenConcurrentUpdateOccursOnVersionedEntityWithSeqNoPrimaryTermProperty() { + OptimisticAndVersionedEntity original = new OptimisticAndVersionedEntity(); + original.setMessage("It's fine"); + OptimisticAndVersionedEntity saved = template.save(original).block(); + + OptimisticAndVersionedEntity forEdit1 = template.get(saved.getId(), OptimisticAndVersionedEntity.class).block(); + OptimisticAndVersionedEntity forEdit2 = template.get(saved.getId(), OptimisticAndVersionedEntity.class).block(); + + forEdit1.setMessage("It'll be ok"); + template.save(forEdit1).block(); + + forEdit2.setMessage("It'll be great"); + template.save(forEdit2) + .as(StepVerifier::create) + .expectError(OptimisticLockingFailureException.class) + .verify(); + } + + @Test // DATAES-799 + void shouldAllowFullReplaceOfEntityWithBothSeqNoPrimaryTermAndVersion() { + OptimisticAndVersionedEntity original = new OptimisticAndVersionedEntity(); + original.setMessage("It's fine"); + OptimisticAndVersionedEntity saved = template.save(original).block(); + + OptimisticAndVersionedEntity forEdit = template.get(saved.getId(), OptimisticAndVersionedEntity.class).block(); + + forEdit.setMessage("It'll be ok"); + template.save(forEdit) + .as(StepVerifier::create) + .expectNextCount(1) + .verifyComplete(); + } + @Data @Document(indexName = "marvel") static class Person { @@ -928,4 +1034,21 @@ static class SampleEntity { @Version private Long version; @Score private float score; } + + @Data + @Document(indexName = "test-index-reactive-optimistic-entity-template") + static class OptimisticEntity { + @Id private String id; + private String message; + private SeqNoPrimaryTerm seqNoPrimaryTerm; + } + + @Data + @Document(indexName = "test-index-reactive-optimistic-and-versioned-entity-template") + static class OptimisticAndVersionedEntity { + @Id private String id; + private String message; + private SeqNoPrimaryTerm seqNoPrimaryTerm; + @Version private Long version; + } } diff --git a/src/test/java/org/springframework/data/elasticsearch/core/RequestFactoryTests.java b/src/test/java/org/springframework/data/elasticsearch/core/RequestFactoryTests.java index f7d0bb6daa..ec4f319e69 100644 --- a/src/test/java/org/springframework/data/elasticsearch/core/RequestFactoryTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/RequestFactoryTests.java @@ -20,8 +20,12 @@ import static org.mockito.Mockito.*; import static org.skyscreamer.jsonassert.JSONAssert.*; -import java.util.Collections; +import java.util.Arrays; +import java.util.HashSet; +import org.elasticsearch.action.index.IndexAction; +import org.elasticsearch.action.index.IndexRequest; +import org.elasticsearch.action.index.IndexRequestBuilder; import org.elasticsearch.action.search.SearchAction; import org.elasticsearch.action.search.SearchRequest; import org.elasticsearch.action.search.SearchRequestBuilder; @@ -44,12 +48,15 @@ import org.springframework.data.elasticsearch.core.query.Criteria; import org.springframework.data.elasticsearch.core.query.CriteriaQuery; import org.springframework.data.elasticsearch.core.query.GeoDistanceOrder; +import org.springframework.data.elasticsearch.core.query.IndexQuery; import org.springframework.data.elasticsearch.core.query.NativeSearchQueryBuilder; import org.springframework.data.elasticsearch.core.query.Query; +import org.springframework.data.elasticsearch.core.query.SeqNoPrimaryTerm; import org.springframework.lang.Nullable; /** * @author Peter-Josef Meisch + * @author Roman Puchkovskiy */ @ExtendWith(MockitoExtension.class) class RequestFactoryTests { @@ -62,7 +69,7 @@ class RequestFactoryTests { @BeforeAll static void setUpAll() { SimpleElasticsearchMappingContext mappingContext = new SimpleElasticsearchMappingContext(); - mappingContext.setInitialEntitySet(Collections.singleton(Person.class)); + mappingContext.setInitialEntitySet(new HashSet<>(Arrays.asList(Person.class, EntityWithSeqNoPrimaryTerm.class))); mappingContext.afterPropertiesSet(); converter = new MappingElasticsearchConverter(mappingContext, new GenericConversionService()); @@ -153,9 +160,103 @@ void shouldAddMaxQueryWindowForUnpagedToRequestBuilder() { assertThat(searchRequestBuilder.request().source().size()).isEqualTo(RequestFactory.INDEX_MAX_RESULT_WINDOW); } + @Test // DATAES-799 + void shouldIncludeSeqNoAndPrimaryTermFromIndexQueryToIndexRequest() { + IndexQuery query = new IndexQuery(); + query.setObject(new Person()); + query.setSeqNo(1L); + query.setPrimaryTerm(2L); + + IndexRequest request = requestFactory.indexRequest(query, IndexCoordinates.of("persons")); + + assertThat(request.ifSeqNo()).isEqualTo(1L); + assertThat(request.ifPrimaryTerm()).isEqualTo(2L); + } + + @Test // DATAES-799 + void shouldIncludeSeqNoAndPrimaryTermFromIndexQueryToIndexRequestBuilder() { + when(client.prepareIndex(anyString(), anyString())) + .thenReturn(new IndexRequestBuilder(client, IndexAction.INSTANCE)); + + IndexQuery query = new IndexQuery(); + query.setObject(new Person()); + query.setSeqNo(1L); + query.setPrimaryTerm(2L); + + IndexRequestBuilder builder = requestFactory.indexRequestBuilder(client, query, IndexCoordinates.of("persons")); + + assertThat(builder.request().ifSeqNo()).isEqualTo(1L); + assertThat(builder.request().ifPrimaryTerm()).isEqualTo(2L); + } + + @Test // DATAES-799 + void shouldNotRequestSeqNoAndPrimaryTermViaSearchRequestWhenEntityClassDoesNotContainSeqNoPrimaryTermProperty() { + Query query = new NativeSearchQueryBuilder().build(); + + SearchRequest request = requestFactory.searchRequest(query, Person.class, IndexCoordinates.of("persons")); + + assertThat(request.source().seqNoAndPrimaryTerm()).isNull(); + } + + @Test // DATAES-799 + void shouldRequestSeqNoAndPrimaryTermViaSearchRequestWhenEntityClassContainsSeqNoPrimaryTermProperty() { + Query query = new NativeSearchQueryBuilder().build(); + + SearchRequest request = requestFactory.searchRequest(query, EntityWithSeqNoPrimaryTerm.class, + IndexCoordinates.of("seqNoPrimaryTerm")); + + assertThat(request.source().seqNoAndPrimaryTerm()).isTrue(); + } + + @Test // DATAES-799 + void shouldNotRequestSeqNoAndPrimaryTermViaSearchRequestWhenEntityClassIsNull() { + Query query = new NativeSearchQueryBuilder().build(); + + SearchRequest request = requestFactory.searchRequest(query, null, IndexCoordinates.of("persons")); + + assertThat(request.source().seqNoAndPrimaryTerm()).isNull(); + } + + @Test // DATAES-799 + void shouldNotRequestSeqNoAndPrimaryTermViaSearchRequestBuilderWhenEntityClassDoesNotContainSeqNoPrimaryTermProperty() { + when(client.prepareSearch(any())).thenReturn(new SearchRequestBuilder(client, SearchAction.INSTANCE)); + Query query = new NativeSearchQueryBuilder().build(); + + SearchRequestBuilder builder = requestFactory.searchRequestBuilder(client, query, Person.class, + IndexCoordinates.of("persons")); + + assertThat(builder.request().source().seqNoAndPrimaryTerm()).isNull(); + } + + @Test // DATAES-799 + void shouldRequestSeqNoAndPrimaryTermViaSearchRequestBuilderWhenEntityClassContainsSeqNoPrimaryTermProperty() { + when(client.prepareSearch(any())).thenReturn(new SearchRequestBuilder(client, SearchAction.INSTANCE)); + Query query = new NativeSearchQueryBuilder().build(); + + SearchRequestBuilder builder = requestFactory.searchRequestBuilder(client, query, + EntityWithSeqNoPrimaryTerm.class, IndexCoordinates.of("seqNoPrimaryTerm")); + + assertThat(builder.request().source().seqNoAndPrimaryTerm()).isTrue(); + } + + @Test // DATAES-799 + void shouldNotRequestSeqNoAndPrimaryTermViaSearchRequestBuilderWhenEntityClassIsNull() { + when(client.prepareSearch(any())).thenReturn(new SearchRequestBuilder(client, SearchAction.INSTANCE)); + Query query = new NativeSearchQueryBuilder().build(); + + SearchRequestBuilder builder = requestFactory.searchRequestBuilder(client, query, null, + IndexCoordinates.of("persons")); + + assertThat(builder.request().source().seqNoAndPrimaryTerm()).isNull(); + } + static class Person { @Nullable @Id String id; @Nullable @Field(name = "last-name") String lastName; @Nullable @Field(name = "current-location") GeoPoint location; } + + static class EntityWithSeqNoPrimaryTerm { + @Nullable private SeqNoPrimaryTerm seqNoPrimaryTerm; + } } diff --git a/src/test/java/org/springframework/data/elasticsearch/core/convert/MappingElasticsearchConverterUnitTests.java b/src/test/java/org/springframework/data/elasticsearch/core/convert/MappingElasticsearchConverterUnitTests.java index 1347f98800..45aac4f9eb 100644 --- a/src/test/java/org/springframework/data/elasticsearch/core/convert/MappingElasticsearchConverterUnitTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/convert/MappingElasticsearchConverterUnitTests.java @@ -15,6 +15,7 @@ */ package org.springframework.data.elasticsearch.core.convert; +import static java.util.Collections.*; import static org.assertj.core.api.Assertions.*; import static org.skyscreamer.jsonassert.JSONAssert.*; @@ -55,6 +56,7 @@ import org.springframework.data.elasticsearch.core.document.Document; import org.springframework.data.elasticsearch.core.geo.GeoPoint; import org.springframework.data.elasticsearch.core.mapping.SimpleElasticsearchMappingContext; +import org.springframework.data.elasticsearch.core.query.SeqNoPrimaryTerm; import org.springframework.data.geo.Box; import org.springframework.data.geo.Circle; import org.springframework.data.geo.Point; @@ -69,6 +71,7 @@ * @author Mark Paluch * @author Peter-Josef Meisch * @author Konrad Kurdej + * @author Roman Puchkovskiy */ public class MappingElasticsearchConverterUnitTests { @@ -695,6 +698,26 @@ void readGenericListWithMaps() { assertThat(wrapper.getSchemaLessObject()).isEqualTo(mapWithSimpleList); } + @Test // DATAES-799 + void shouldNotWriteSeqNoPrimaryTermProperty() { + EntityWithSeqNoPrimaryTerm entity = new EntityWithSeqNoPrimaryTerm(); + entity.seqNoPrimaryTerm = new SeqNoPrimaryTerm(1L, 2L); + Document document = Document.create(); + + mappingElasticsearchConverter.write(entity, document); + + assertThat(document).doesNotContainKey("seqNoPrimaryTerm"); + } + + @Test // DATAES-799 + void shouldNotReadSeqNoPrimaryTermProperty() { + Document document = Document.create().append("seqNoPrimaryTerm", emptyMap()); + + EntityWithSeqNoPrimaryTerm entity = mappingElasticsearchConverter.read(EntityWithSeqNoPrimaryTerm.class, document); + + assertThat(entity.seqNoPrimaryTerm).isNull(); + } + private String pointTemplate(String name, Point point) { return String.format(Locale.ENGLISH, "\"%s\":{\"lat\":%.1f,\"lon\":%.1f}", name, point.getX(), point.getY()); } @@ -901,4 +924,11 @@ static class SchemaLessObjectWrapper { private Map schemaLessObject; } + + @Data + @org.springframework.data.elasticsearch.annotations.Document(indexName = "test-index-entity-with-seq-no-primary-term-mapper") + static class EntityWithSeqNoPrimaryTerm { + + @Nullable private SeqNoPrimaryTerm seqNoPrimaryTerm; + } } diff --git a/src/test/java/org/springframework/data/elasticsearch/core/index/MappingBuilderTests.java b/src/test/java/org/springframework/data/elasticsearch/core/index/MappingBuilderTests.java index f652044def..5045aa726b 100644 --- a/src/test/java/org/springframework/data/elasticsearch/core/index/MappingBuilderTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/index/MappingBuilderTests.java @@ -20,10 +20,12 @@ import static org.elasticsearch.index.query.QueryBuilders.*; import static org.skyscreamer.jsonassert.JSONAssert.*; import static org.springframework.data.elasticsearch.annotations.FieldType.*; +import static org.springframework.data.elasticsearch.annotations.FieldType.Object; import static org.springframework.data.elasticsearch.utils.IndexBuilder.*; import lombok.AllArgsConstructor; import lombok.Builder; +import lombok.Data; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -60,6 +62,7 @@ import org.springframework.data.elasticsearch.core.query.IndexQuery; import org.springframework.data.elasticsearch.core.query.NativeSearchQuery; import org.springframework.data.elasticsearch.core.query.NativeSearchQueryBuilder; +import org.springframework.data.elasticsearch.core.query.SeqNoPrimaryTerm; import org.springframework.data.elasticsearch.junit.jupiter.ElasticsearchTemplateConfiguration; import org.springframework.data.elasticsearch.junit.jupiter.SpringIntegrationTest; import org.springframework.data.geo.Box; @@ -79,6 +82,7 @@ * @author Sascha Woo * @author Peter-Josef Meisch * @author Xiao Yu + * @author Roman Puchkovskiy */ @SpringIntegrationTest @ContextConfiguration(classes = { ElasticsearchTemplateConfiguration.class }) @@ -572,6 +576,13 @@ void shouldWriteCompletionContextInfo() throws JSONException { assertEquals(expected, mapping, false); } + @Test // DATAES-799 + void shouldNotIncludeSeqNoPrimaryTermPropertyInMappingEvenWhenAnnotatedWithField() { + String propertyMapping = getMappingBuilder().buildPropertyMapping(EntityWithSeqNoPrimaryTerm.class); + + assertThat(propertyMapping).doesNotContain("seqNoPrimaryTerm"); + } + /** * @author Xiao Yu */ @@ -1052,4 +1063,11 @@ static class CompletionDocument { @CompletionField(contexts = { @CompletionContext(name = "location", type = ContextMapping.Type.GEO, path = "proppath") }) private Completion suggest; } + + @Data + @Document(indexName = "test-index-entity-with-seq-no-primary-term-mapping-builder") + static class EntityWithSeqNoPrimaryTerm { + + @Field(type = Object) private SeqNoPrimaryTerm seqNoPrimaryTerm; + } } diff --git a/src/test/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentEntityTests.java b/src/test/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentEntityTests.java index 8ca2276152..ddb5ea7c81 100644 --- a/src/test/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentEntityTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentEntityTests.java @@ -22,6 +22,7 @@ import org.springframework.data.annotation.Version; import org.springframework.data.elasticsearch.annotations.Field; import org.springframework.data.elasticsearch.annotations.Score; +import org.springframework.data.elasticsearch.core.query.SeqNoPrimaryTerm; import org.springframework.data.mapping.MappingException; import org.springframework.data.mapping.model.Property; import org.springframework.data.mapping.model.SimpleTypeHolder; @@ -36,6 +37,7 @@ * @author Mark Paluch * @author Oliver Gierke * @author Peter-Josef Meisch + * @author Roman Puchkovskiy */ public class SimpleElasticsearchPersistentEntityTests { @@ -95,6 +97,52 @@ void shouldFindPropertiesByMappedName() { assertThat(persistentProperty.getFieldName()).isEqualTo("renamed-field"); } + @Test // DATAES-799 + void shouldReportThatThereIsNoSeqNoPrimaryTermPropertyWhenThereIsNoSuchProperty() { + TypeInformation typeInformation = ClassTypeInformation.from(EntityWithoutSeqNoPrimaryTerm.class); + SimpleElasticsearchPersistentEntity entity = new SimpleElasticsearchPersistentEntity<>( + typeInformation); + + assertThat(entity.hasSeqNoPrimaryTermProperty()).isFalse(); + } + + @Test // DATAES-799 + void shouldReportThatThereIsSeqNoPrimaryTermPropertyWhenThereIsSuchProperty() { + TypeInformation typeInformation = ClassTypeInformation.from(EntityWithSeqNoPrimaryTerm.class); + SimpleElasticsearchPersistentEntity entity = new SimpleElasticsearchPersistentEntity<>( + typeInformation); + + entity.addPersistentProperty(createProperty(entity, "seqNoPrimaryTerm")); + + assertThat(entity.hasSeqNoPrimaryTermProperty()).isTrue(); + } + + @Test // DATAES-799 + void shouldReturnSeqNoPrimaryTermPropertyWhenThereIsSuchProperty() { + TypeInformation typeInformation = ClassTypeInformation.from(EntityWithSeqNoPrimaryTerm.class); + SimpleElasticsearchPersistentEntity entity = new SimpleElasticsearchPersistentEntity<>( + typeInformation); + entity.addPersistentProperty(createProperty(entity, "seqNoPrimaryTerm")); + EntityWithSeqNoPrimaryTerm instance = new EntityWithSeqNoPrimaryTerm(); + SeqNoPrimaryTerm seqNoPrimaryTerm = new SeqNoPrimaryTerm(1, 2); + + ElasticsearchPersistentProperty property = entity.getSeqNoPrimaryTermProperty(); + entity.getPropertyAccessor(instance).setProperty(property, seqNoPrimaryTerm); + + assertThat(instance.seqNoPrimaryTerm).isSameAs(seqNoPrimaryTerm); + } + + @Test // DATAES-799 + void shouldNotAllowMoreThanOneSeqNoPrimaryTermProperties() { + TypeInformation typeInformation = ClassTypeInformation.from(EntityWithSeqNoPrimaryTerm.class); + SimpleElasticsearchPersistentEntity entity = new SimpleElasticsearchPersistentEntity<>( + typeInformation); + entity.addPersistentProperty(createProperty(entity, "seqNoPrimaryTerm")); + + assertThatThrownBy(() -> entity.addPersistentProperty(createProperty(entity, "seqNoPrimaryTerm2"))) + .isInstanceOf(MappingException.class); + } + private static SimpleElasticsearchPersistentProperty createProperty(SimpleElasticsearchPersistentEntity entity, String field) { @@ -153,4 +201,12 @@ private static class FieldNameEntity { @Nullable @Id private String id; @Nullable @Field(name = "renamed-field") private String renamedField; } + + private static class EntityWithoutSeqNoPrimaryTerm { + } + + private static class EntityWithSeqNoPrimaryTerm { + private SeqNoPrimaryTerm seqNoPrimaryTerm; + private SeqNoPrimaryTerm seqNoPrimaryTerm2; + } } diff --git a/src/test/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentPropertyUnitTests.java b/src/test/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentPropertyUnitTests.java index fa195c87ca..182fb75cd7 100644 --- a/src/test/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentPropertyUnitTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentPropertyUnitTests.java @@ -23,13 +23,13 @@ import java.time.ZonedDateTime; import java.util.Date; import java.util.GregorianCalendar; -import java.util.TimeZone; import org.junit.jupiter.api.Test; import org.springframework.data.elasticsearch.annotations.DateFormat; import org.springframework.data.elasticsearch.annotations.Field; import org.springframework.data.elasticsearch.annotations.FieldType; import org.springframework.data.elasticsearch.annotations.Score; +import org.springframework.data.elasticsearch.core.query.SeqNoPrimaryTerm; import org.springframework.data.mapping.MappingException; import org.springframework.lang.Nullable; @@ -38,6 +38,7 @@ * * @author Oliver Gierke * @author Peter-Josef Meisch + * @author Roman Puchkovskiy */ public class SimpleElasticsearchPersistentPropertyUnitTests { @@ -126,7 +127,7 @@ void shouldConvertFromLegacyDate() { assertThat(converted).isEqualTo("20200419T194400.000Z"); } - @Test // DATES-792 + @Test // DATAES-792 void shouldConvertToLegacyDate() { SimpleElasticsearchPersistentEntity persistentEntity = context.getRequiredPersistentEntity(DatesProperty.class); ElasticsearchPersistentProperty persistentProperty = persistentEntity.getRequiredPersistentProperty("legacyDate"); @@ -140,6 +141,38 @@ void shouldConvertToLegacyDate() { assertThat(converted).isEqualTo(legacyDate); } + @Test // DATAES-799 + void shouldReportSeqNoPrimaryTermPropertyWhenTheTypeIsSeqNoPrimaryTerm() { + SimpleElasticsearchPersistentEntity entity = context.getRequiredPersistentEntity(SeqNoPrimaryTermProperty.class); + ElasticsearchPersistentProperty seqNoProperty = entity.getRequiredPersistentProperty("seqNoPrimaryTerm"); + + assertThat(seqNoProperty.isSeqNoPrimaryTermProperty()).isTrue(); + } + + @Test // DATAES-799 + void shouldNotReportSeqNoPrimaryTermPropertyWhenTheTypeIsNotSeqNoPrimaryTerm() { + SimpleElasticsearchPersistentEntity entity = context.getRequiredPersistentEntity(SeqNoPrimaryTermProperty.class); + ElasticsearchPersistentProperty stringProperty = entity.getRequiredPersistentProperty("string"); + + assertThat(stringProperty.isSeqNoPrimaryTermProperty()).isFalse(); + } + + @Test // DATAES-799 + void seqNoPrimaryTermPropertyShouldNotBeWritable() { + SimpleElasticsearchPersistentEntity entity = context.getRequiredPersistentEntity(SeqNoPrimaryTermProperty.class); + ElasticsearchPersistentProperty seqNoProperty = entity.getRequiredPersistentProperty("seqNoPrimaryTerm"); + + assertThat(seqNoProperty.isWritable()).isFalse(); + } + + @Test // DATAES-799 + void seqNoPrimaryTermPropertyShouldNotBeReadable() { + SimpleElasticsearchPersistentEntity entity = context.getRequiredPersistentEntity(SeqNoPrimaryTermProperty.class); + ElasticsearchPersistentProperty seqNoProperty = entity.getRequiredPersistentProperty("seqNoPrimaryTerm"); + + assertThat(seqNoProperty.isReadable()).isFalse(); + } + static class InvalidScoreProperty { @Nullable @Score String scoreProperty; } @@ -157,4 +190,9 @@ static class DatesProperty { @Nullable @Field(type = FieldType.Date, format = DateFormat.basic_date_time) LocalDateTime localDateTime; @Nullable @Field(type = FieldType.Date, format = DateFormat.basic_date_time) Date legacyDate; } + + static class SeqNoPrimaryTermProperty { + SeqNoPrimaryTerm seqNoPrimaryTerm; + String string; + } } diff --git a/src/test/java/org/springframework/data/elasticsearch/core/query/SeqNoPrimaryTermTests.java b/src/test/java/org/springframework/data/elasticsearch/core/query/SeqNoPrimaryTermTests.java new file mode 100644 index 0000000000..6f31ba6a4f --- /dev/null +++ b/src/test/java/org/springframework/data/elasticsearch/core/query/SeqNoPrimaryTermTests.java @@ -0,0 +1,52 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.elasticsearch.core.query; + +import static org.assertj.core.api.Assertions.*; + +import org.elasticsearch.index.seqno.SequenceNumbers; +import org.junit.jupiter.api.Test; + +/** + * @author Roman Puchkovskiy + */ +class SeqNoPrimaryTermTests { + @Test + void shouldConstructInstanceWithAssignedSeqNoAndPrimaryTerm() { + SeqNoPrimaryTerm instance = new SeqNoPrimaryTerm(1, 2); + + assertThat(instance.getSequenceNumber()).isEqualTo(1); + assertThat(instance.getPrimaryTerm()).isEqualTo(2); + } + + @Test + void shouldThrowAnExceptionWhenTryingToConstructWithUnassignedSeqNo() { + assertThatThrownBy(() -> new SeqNoPrimaryTerm(SequenceNumbers.UNASSIGNED_SEQ_NO, 2)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void shouldThrowAnExceptionWhenTryingToConstructWithSeqNoForNoOpsPerformed() { + assertThatThrownBy(() -> new SeqNoPrimaryTerm(SequenceNumbers.NO_OPS_PERFORMED, 2)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void shouldThrowAnExceptionWhenTryingToConstructWithUnassignedPrimaryTerm() { + assertThatThrownBy(() -> new SeqNoPrimaryTerm(1, SequenceNumbers.UNASSIGNED_PRIMARY_TERM)) + .isInstanceOf(IllegalArgumentException.class); + } +} \ No newline at end of file