diff --git a/.codacy.yml b/.codacy.yml new file mode 100644 index 0000000..e206dc4 --- /dev/null +++ b/.codacy.yml @@ -0,0 +1,3 @@ +exclude_paths: + - 'src/test/java/com/microsoft/azure/spring/data/cosmosdb/domain/Person.java' + - 'src/test/java/com/microsoft/azure/spring/data/cosmosdb/repository/support/DocumentDbEntityInformationUnitTest.java' \ No newline at end of file diff --git a/HowToContribute.md b/HowToContribute.md index 7688776..ea1355c 100644 --- a/HowToContribute.md +++ b/HowToContribute.md @@ -15,7 +15,7 @@ mvnw clean install ``` ## Test -There're 3 profiles: `dev`, `integration-test-azure` and `integration-test-emulator`. Default profile is `dev`. Profile `integration-test-azure` will trigger integration test execution against Azure Cosmos DB. Profile `integration-test-emulator` will trigger integration test execution against [Azure Cosmos DB Emulator](https://docs.microsoft.com/en-us/azure/cosmos-db/local-emulator), you need to follow the link to setup emualator before test execution. +There're 3 profiles: `dev`, `integration-test-azure` and `integration-test-emulator`. Default profile is `dev`. Profile `integration-test-azure` will trigger integration test execution against Azure Cosmos DB. Profile `integration-test-emulator` will trigger integration test execution against [Azure Cosmos DB Emulator](https://docs.microsoft.com/en-us/azure/cosmos-db/local-emulator), you need to follow the link to setup emulator before test execution. - Run unit tests ```bash diff --git a/src/main/java/com/microsoft/azure/spring/data/cosmosdb/common/Memoizer.java b/src/main/java/com/microsoft/azure/spring/data/cosmosdb/common/Memoizer.java new file mode 100644 index 0000000..966506d --- /dev/null +++ b/src/main/java/com/microsoft/azure/spring/data/cosmosdb/common/Memoizer.java @@ -0,0 +1,31 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for + * license information. + */ +package com.microsoft.azure.spring.data.cosmosdb.common; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +/** + * Memoize function computation results + * @author Domenico Sibilio + * + */ +public class Memoizer { + + private final Map cache = new ConcurrentHashMap<>(); + + private Memoizer() {} + + public static Function memoize(Function function) { + return new Memoizer().internalMemoize(function); + } + + private Function internalMemoize(Function function) { + return input -> cache.computeIfAbsent(input, function); + } + +} diff --git a/src/main/java/com/microsoft/azure/spring/data/cosmosdb/core/DocumentDbOperations.java b/src/main/java/com/microsoft/azure/spring/data/cosmosdb/core/DocumentDbOperations.java index 02d4771..f19b264 100644 --- a/src/main/java/com/microsoft/azure/spring/data/cosmosdb/core/DocumentDbOperations.java +++ b/src/main/java/com/microsoft/azure/spring/data/cosmosdb/core/DocumentDbOperations.java @@ -11,6 +11,7 @@ import com.microsoft.azure.documentdb.PartitionKey; import com.microsoft.azure.spring.data.cosmosdb.core.convert.MappingDocumentDbConverter; import com.microsoft.azure.spring.data.cosmosdb.core.query.DocumentQuery; import com.microsoft.azure.spring.data.cosmosdb.repository.support.DocumentDbEntityInformation; + import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -20,7 +21,7 @@ public interface DocumentDbOperations { String getCollectionName(Class entityClass); - DocumentCollection createCollectionIfNotExists(DocumentDbEntityInformation information); + DocumentCollection createCollectionIfNotExists(DocumentDbEntityInformation information); List findAll(Class entityClass); @@ -38,7 +39,7 @@ public interface DocumentDbOperations { void upsert(String collectionName, T object, PartitionKey partitionKey); - void deleteById(String collectionName, Object id, PartitionKey partitionKey); + void deleteById(String collectionName, Object id, PartitionKey partitionKey); void deleteAll(String collectionName, Class domainClass); diff --git a/src/main/java/com/microsoft/azure/spring/data/cosmosdb/core/DocumentDbTemplate.java b/src/main/java/com/microsoft/azure/spring/data/cosmosdb/core/DocumentDbTemplate.java index fdf31bb..5ec26b9 100644 --- a/src/main/java/com/microsoft/azure/spring/data/cosmosdb/core/DocumentDbTemplate.java +++ b/src/main/java/com/microsoft/azure/spring/data/cosmosdb/core/DocumentDbTemplate.java @@ -6,6 +6,8 @@ package com.microsoft.azure.spring.data.cosmosdb.core; +import com.azure.data.cosmos.AccessCondition; +import com.azure.data.cosmos.AccessConditionType; import com.azure.data.cosmos.CosmosClient; import com.azure.data.cosmos.CosmosContainerResponse; import com.azure.data.cosmos.CosmosItemProperties; @@ -17,6 +19,7 @@ import com.azure.data.cosmos.SqlQuerySpec; import com.microsoft.azure.documentdb.DocumentCollection; import com.microsoft.azure.documentdb.PartitionKey; import com.microsoft.azure.spring.data.cosmosdb.CosmosDbFactory; +import com.microsoft.azure.spring.data.cosmosdb.common.Memoizer; import com.microsoft.azure.spring.data.cosmosdb.core.convert.MappingDocumentDbConverter; import com.microsoft.azure.spring.data.cosmosdb.core.generator.CountQueryGenerator; import com.microsoft.azure.spring.data.cosmosdb.core.generator.FindQuerySpecGenerator; @@ -27,6 +30,9 @@ import com.microsoft.azure.spring.data.cosmosdb.core.query.DocumentQuery; import com.microsoft.azure.spring.data.cosmosdb.exception.DocumentDBAccessException; import com.microsoft.azure.spring.data.cosmosdb.repository.support.DocumentDbEntityInformation; import lombok.extern.slf4j.Slf4j; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + import org.springframework.beans.BeansException; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; @@ -36,15 +42,19 @@ import org.springframework.data.domain.Pageable; import org.springframework.lang.NonNull; import org.springframework.util.Assert; import org.springframework.util.StringUtils; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; import java.util.ArrayList; import java.util.Collections; import java.util.Iterator; import java.util.List; +import java.util.function.Function; import java.util.stream.Collectors; +/** + * + * @author Domenico Sibilio + * + */ @Slf4j public class DocumentDbTemplate implements DocumentDbOperations, ApplicationContextAware { @@ -54,10 +64,12 @@ public class DocumentDbTemplate implements DocumentDbOperations, ApplicationCont private final String databaseName; private final CosmosClient cosmosClient; + private Function, DocumentDbEntityInformation> entityInfoCreator = + Memoizer.memoize(this::getDocumentDbEntityInformation); public DocumentDbTemplate(CosmosDbFactory cosmosDbFactory, - MappingDocumentDbConverter mappingDocumentDbConverter, - String dbName) { + MappingDocumentDbConverter mappingDocumentDbConverter, + String dbName) { Assert.notNull(cosmosDbFactory, "CosmosDbFactory must not be null!"); Assert.notNull(mappingDocumentDbConverter, "MappingDocumentDbConverter must not be null!"); @@ -92,10 +104,10 @@ public class DocumentDbTemplate implements DocumentDbOperations, ApplicationCont final Class domainClass = (Class) objectToSave.getClass(); final CosmosItemResponse response = cosmosClient.getDatabase(this.databaseName) - .getContainer(collectionName) - .createItem(originalItem, options) - .onErrorResume(Mono::error) - .block(); + .getContainer(collectionName) + .createItem(originalItem, options) + .onErrorResume(Mono::error) + .block(); if (response == null) { throw new DocumentDBAccessException("Failed to insert item"); @@ -125,16 +137,16 @@ public class DocumentDbTemplate implements DocumentDbOperations, ApplicationCont final FeedOptions options = new FeedOptions(); options.enableCrossPartitionQuery(true); return cosmosClient - .getDatabase(databaseName) - .getContainer(collectionName) - .queryItems(query, options) - .flatMap(cosmosItemFeedResponse -> Mono.justOrEmpty(cosmosItemFeedResponse - .results() - .stream() - .map(cosmosItem -> mappingDocumentDbConverter.read(domainClass, cosmosItem)) - .findFirst())) - .onErrorResume(Mono::error) - .blockFirst(); + .getDatabase(databaseName) + .getContainer(collectionName) + .queryItems(query, options) + .flatMap(cosmosItemFeedResponse -> Mono.justOrEmpty(cosmosItemFeedResponse + .results() + .stream() + .map(cosmosItem -> mappingDocumentDbConverter.read(domainClass, cosmosItem)) + .findFirst())) + .onErrorResume(Mono::error) + .blockFirst(); } catch (Exception e) { throw new DocumentDBAccessException("findById exception", e); @@ -158,12 +170,13 @@ public class DocumentDbTemplate implements DocumentDbOperations, ApplicationCont final CosmosItemRequestOptions options = new CosmosItemRequestOptions(); options.partitionKey(toCosmosPartitionKey(partitionKey)); + applyVersioning(object.getClass(), originalItem, options); final CosmosItemResponse cosmosItemResponse = cosmosClient.getDatabase(this.databaseName) - .getContainer(collectionName) - .upsertItem(originalItem, options) - .onErrorResume(Mono::error) - .block(); + .getContainer(collectionName) + .upsertItem(originalItem, options) + .onErrorResume(Mono::error) + .block(); if (cosmosItemResponse == null) { throw new DocumentDBAccessException("Failed to upsert item"); @@ -187,8 +200,8 @@ public class DocumentDbTemplate implements DocumentDbOperations, ApplicationCont final List documents = findDocuments(query, domainClass, collectionName); return documents.stream() - .map(d -> getConverter().read(domainClass, d)) - .collect(Collectors.toList()); + .map(d -> getConverter().read(domainClass, d)) + .collect(Collectors.toList()); } public void deleteAll(@NonNull String collectionName, @NonNull Class domainClass) { @@ -206,26 +219,26 @@ public class DocumentDbTemplate implements DocumentDbOperations, ApplicationCont cosmosClient.getDatabase(this.databaseName).getContainer(collectionName).delete().block(); } catch (Exception e) { throw new DocumentDBAccessException("failed to delete collection: " + collectionName, - e); + e); } } public String getCollectionName(Class domainClass) { Assert.notNull(domainClass, "domainClass should not be null"); - return new DocumentDbEntityInformation<>(domainClass).getCollectionName(); + return entityInfoCreator.apply(domainClass).getCollectionName(); } @Override - public DocumentCollection createCollectionIfNotExists(@NonNull DocumentDbEntityInformation information) { + public DocumentCollection createCollectionIfNotExists(@NonNull DocumentDbEntityInformation information) { final CosmosContainerResponse response = cosmosClient - .createDatabaseIfNotExists(this.databaseName) - .flatMap(cosmosDatabaseResponse -> cosmosDatabaseResponse - .database() - .createContainerIfNotExists(information.getCollectionName(), - "/" + information.getPartitionKeyFieldName()) - .map(cosmosContainerResponse -> cosmosContainerResponse)) - .block(); + .createDatabaseIfNotExists(this.databaseName) + .flatMap(cosmosDatabaseResponse -> cosmosDatabaseResponse + .database() + .createContainerIfNotExists(information.getCollectionName(), + "/" + information.getPartitionKeyFieldName()) + .map(cosmosContainerResponse -> cosmosContainerResponse)) + .block(); if (response == null) { throw new DocumentDBAccessException("Failed to create collection"); } @@ -246,12 +259,12 @@ public class DocumentDbTemplate implements DocumentDbOperations, ApplicationCont final CosmosItemRequestOptions options = new CosmosItemRequestOptions(); options.partitionKey(pk); cosmosClient.getDatabase(this.databaseName) - .getContainer(collectionName) - .getItem(id.toString(), partitionKey) - .delete(options) - .onErrorResume(Mono::error) - .then() - .block(); + .getContainer(collectionName) + .getItem(id.toString(), partitionKey) + .delete(options) + .onErrorResume(Mono::error) + .then() + .block(); } catch (Exception e) { throw new DocumentDBAccessException("deleteById exception", e); } @@ -275,9 +288,9 @@ public class DocumentDbTemplate implements DocumentDbOperations, ApplicationCont try { return findDocuments(query, domainClass, collectionName) - .stream() + .stream() .map(cosmosItemProperties -> toDomainObject(domainClass, cosmosItemProperties)) - .collect(Collectors.toList()); + .collect(Collectors.toList()); } catch (Exception e) { throw new DocumentDBAccessException("Failed to execute find operation from " + collectionName, e); } @@ -300,7 +313,7 @@ public class DocumentDbTemplate implements DocumentDbOperations, ApplicationCont */ @Override public List delete(@NonNull DocumentQuery query, @NonNull Class domainClass, - @NonNull String collectionName) { + @NonNull String collectionName) { Assert.notNull(query, "DocumentQuery should not be null."); Assert.notNull(domainClass, "domainClass should not be null."); Assert.hasText(collectionName, "collection should not be null, empty or only whitespaces"); @@ -308,11 +321,11 @@ public class DocumentDbTemplate implements DocumentDbOperations, ApplicationCont final List results = findDocuments(query, domainClass, collectionName); final List partitionKeyName = getPartitionKeyNames(domainClass); - results.forEach(d -> deleteDocument(d, partitionKeyName, collectionName)); + results.forEach(d -> deleteDocument(d, partitionKeyName, collectionName, domainClass)); return results.stream() - .map(d -> getConverter().read(domainClass, d)) - .collect(Collectors.toList()); + .map(d -> getConverter().read(domainClass, d)) + .collect(Collectors.toList()); } @Override @@ -341,11 +354,11 @@ public class DocumentDbTemplate implements DocumentDbOperations, ApplicationCont final SqlQuerySpec sqlQuerySpec = new FindQuerySpecGenerator().generateCosmos(query); final FeedResponse feedResponse = - cosmosClient.getDatabase(this.databaseName) - .getContainer(collectionName) - .queryItems(sqlQuerySpec, feedOptions) - .next() - .block(); + cosmosClient.getDatabase(this.databaseName) + .getContainer(collectionName) + .queryItems(sqlQuerySpec, feedOptions) + .next() + .block(); if (feedResponse == null) { throw new DocumentDBAccessException("Failed to query documents"); @@ -366,9 +379,9 @@ public class DocumentDbTemplate implements DocumentDbOperations, ApplicationCont } final DocumentDbPageRequest pageRequest = DocumentDbPageRequest.of(pageable.getPageNumber(), - pageable.getPageSize(), - feedResponse.continuationToken(), - query.getSort()); + pageable.getPageSize(), + feedResponse.continuationToken(), + query.getSort()); return new PageImpl<>(result, pageRequest, count(query, domainClass, collectionName)); } @@ -391,7 +404,7 @@ public class DocumentDbTemplate implements DocumentDbOperations, ApplicationCont Assert.hasText(collectionName, "collectionName should not be empty"); final boolean isCrossPartitionQuery = - query.isCrossPartitionQuery(getPartitionKeyNames(domainClass)); + query.isCrossPartitionQuery(getPartitionKeyNames(domainClass)); final Long count = getCountValue(query, isCrossPartitionQuery, collectionName); if (count == null) { throw new DocumentDBAccessException("Failed to get count for collectionName: " + collectionName); @@ -411,10 +424,10 @@ public class DocumentDbTemplate implements DocumentDbOperations, ApplicationCont options.enableCrossPartitionQuery(isCrossPartitionQuery); return executeQuery(querySpec, containerName, options) - .onErrorResume(this::databaseAccessExceptionHandler) - .next() - .map(r -> r.results().get(0).getLong(COUNT_VALUE_KEY)) - .block(); + .onErrorResume(this::databaseAccessExceptionHandler) + .next() + .map(r -> r.results().get(0).getLong(COUNT_VALUE_KEY)) + .block(); } private Mono databaseAccessExceptionHandler(Throwable e) { @@ -422,15 +435,14 @@ public class DocumentDbTemplate implements DocumentDbOperations, ApplicationCont } private Flux> executeQuery(SqlQuerySpec sqlQuerySpec, String collectionName, - FeedOptions options) { + FeedOptions options) { return cosmosClient.getDatabase(this.databaseName) - .getContainer(collectionName) - .queryItems(sqlQuerySpec, options); + .getContainer(collectionName) + .queryItems(sqlQuerySpec, options); } - @SuppressWarnings("unchecked") private List getPartitionKeyNames(Class domainClass) { - final DocumentDbEntityInformation entityInfo = new DocumentDbEntityInformation(domainClass); + final DocumentDbEntityInformation entityInfo = entityInfoCreator.apply(domainClass); if (entityInfo.getPartitionKeyFieldName() == null) { return new ArrayList<>(); @@ -454,25 +466,26 @@ public class DocumentDbTemplate implements DocumentDbOperations, ApplicationCont } private List findDocuments(@NonNull DocumentQuery query, - @NonNull Class domainClass, - @NonNull String containerName) { + @NonNull Class domainClass, + @NonNull String containerName) { final SqlQuerySpec sqlQuerySpec = new FindQuerySpecGenerator().generateCosmos(query); final boolean isCrossPartitionQuery = - query.isCrossPartitionQuery(getPartitionKeyNames(domainClass)); + query.isCrossPartitionQuery(getPartitionKeyNames(domainClass)); final FeedOptions feedOptions = new FeedOptions(); feedOptions.enableCrossPartitionQuery(isCrossPartitionQuery); return cosmosClient - .getDatabase(this.databaseName) - .getContainer(containerName) - .queryItems(sqlQuerySpec, feedOptions) - .flatMap(cosmosItemFeedResponse -> Flux.fromIterable(cosmosItemFeedResponse.results())) - .collectList() - .block(); + .getDatabase(this.databaseName) + .getContainer(containerName) + .queryItems(sqlQuerySpec, feedOptions) + .flatMap(cosmosItemFeedResponse -> Flux.fromIterable(cosmosItemFeedResponse.results())) + .collectList() + .block(); } private CosmosItemResponse deleteDocument(@NonNull CosmosItemProperties cosmosItemProperties, - @NonNull List partitionKeyNames, - String containerName) { + @NonNull List partitionKeyNames, + String containerName, + @NonNull Class domainClass) { Assert.isTrue(partitionKeyNames.size() <= 1, "Only one Partition is supported."); PartitionKey partitionKey = null; @@ -488,16 +501,34 @@ public class DocumentDbTemplate implements DocumentDbOperations, ApplicationCont } final CosmosItemRequestOptions options = new CosmosItemRequestOptions(pk); + applyVersioning(domainClass, cosmosItemProperties, options); return cosmosClient - .getDatabase(this.databaseName) - .getContainer(containerName) - .getItem(cosmosItemProperties.id(), partitionKey) - .delete(options) - .block(); + .getDatabase(this.databaseName) + .getContainer(containerName) + .getItem(cosmosItemProperties.id(), partitionKey) + .delete(options) + .block(); } private T toDomainObject(@NonNull Class domainClass, CosmosItemProperties cosmosItemProperties) { return mappingDocumentDbConverter.read(domainClass, cosmosItemProperties); } + + private void applyVersioning(Class domainClass, + CosmosItemProperties cosmosItemProperties, + CosmosItemRequestOptions options) { + + if (entityInfoCreator.apply(domainClass).isVersioned()) { + final AccessCondition accessCondition = new AccessCondition(); + accessCondition.type(AccessConditionType.IF_MATCH); + accessCondition.condition(cosmosItemProperties.etag()); + options.accessCondition(accessCondition); + } + } + + private DocumentDbEntityInformation getDocumentDbEntityInformation(Class domainClass) { + return new DocumentDbEntityInformation<>(domainClass); + } + } diff --git a/src/main/java/com/microsoft/azure/spring/data/cosmosdb/repository/support/DocumentDbEntityInformation.java b/src/main/java/com/microsoft/azure/spring/data/cosmosdb/repository/support/DocumentDbEntityInformation.java index a2463e5..070e27d 100644 --- a/src/main/java/com/microsoft/azure/spring/data/cosmosdb/repository/support/DocumentDbEntityInformation.java +++ b/src/main/java/com/microsoft/azure/spring/data/cosmosdb/repository/support/DocumentDbEntityInformation.java @@ -17,6 +17,7 @@ import com.microsoft.azure.spring.data.cosmosdb.core.mapping.PartitionKey; import org.apache.commons.lang3.reflect.FieldUtils; import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.Version; import org.springframework.data.repository.core.support.AbstractEntityInformation; import org.springframework.lang.NonNull; import org.springframework.util.ReflectionUtils; @@ -31,12 +32,14 @@ import java.util.List; public class DocumentDbEntityInformation extends AbstractEntityInformation { + private static final String ETAG = "_etag"; private Field id; private Field partitionKeyField; private String collectionName; private Integer requestUnit; private Integer timeToLive; private IndexingPolicy indexingPolicy; + private boolean isVersioned; public DocumentDbEntityInformation(Class domainClass) { super(domainClass); @@ -53,6 +56,7 @@ public class DocumentDbEntityInformation extends AbstractEntityInformatio this.requestUnit = getRequestUnit(domainClass); this.timeToLive = getTimeToLive(domainClass); this.indexingPolicy = getIndexingPolicy(domainClass); + this.isVersioned = getIsVersioned(domainClass); } @SuppressWarnings("unchecked") @@ -86,6 +90,10 @@ public class DocumentDbEntityInformation extends AbstractEntityInformatio return this.indexingPolicy; } + public boolean isVersioned() { + return isVersioned; + } + public String getPartitionKeyFieldName() { if (partitionKeyField == null) { return null; @@ -239,5 +247,13 @@ public class DocumentDbEntityInformation extends AbstractEntityInformatio return pathsCollection; } + + private boolean getIsVersioned(Class domainClass) { + final Field findField = ReflectionUtils.findField(domainClass, ETAG); + return findField != null + && findField.getType() == String.class + && findField.isAnnotationPresent(Version.class); + } + } diff --git a/src/test/java/com/microsoft/azure/spring/data/cosmosdb/common/MemoizerUnitTest.java b/src/test/java/com/microsoft/azure/spring/data/cosmosdb/common/MemoizerUnitTest.java new file mode 100644 index 0000000..d9b467a --- /dev/null +++ b/src/test/java/com/microsoft/azure/spring/data/cosmosdb/common/MemoizerUnitTest.java @@ -0,0 +1,57 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for + * license information. + */ +package com.microsoft.azure.spring.data.cosmosdb.common; + +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import java.util.stream.IntStream; + +/** + * + * @author Domenico Sibilio + * + */ +public class MemoizerUnitTest { + private static final String KEY = "key_1"; + private static final Map countMap = new HashMap<>(); + private static final Function memoizedFunction = + Memoizer.memoize(MemoizerUnitTest::incrCount); + + @Before + public void setUp() { + countMap.put(KEY, new AtomicInteger(0)); + } + + @Test + public void testMemoizedFunctionShouldBeCalledOnlyOnce() { + IntStream + .range(0, 10) + .forEach(number -> memoizedFunction.apply(KEY)); + + assertEquals(1, countMap.get(KEY).get()); + } + + @Test + public void testDifferentMemoizersShouldNotShareTheSameCache() { + IntStream + .range(0, 10) + .forEach(number -> Memoizer.memoize(MemoizerUnitTest::incrCount).apply(KEY)); + + assertEquals(10, countMap.get(KEY).get()); + } + + private static int incrCount(String key) { + return countMap.get(key).incrementAndGet(); + } + +} diff --git a/src/test/java/com/microsoft/azure/spring/data/cosmosdb/core/DocumentDbTemplateIT.java b/src/test/java/com/microsoft/azure/spring/data/cosmosdb/core/DocumentDbTemplateIT.java index 7152ee7..86b1fcd 100644 --- a/src/test/java/com/microsoft/azure/spring/data/cosmosdb/core/DocumentDbTemplateIT.java +++ b/src/test/java/com/microsoft/azure/spring/data/cosmosdb/core/DocumentDbTemplateIT.java @@ -6,6 +6,7 @@ package com.microsoft.azure.spring.data.cosmosdb.core; +import com.azure.data.cosmos.CosmosClientException; import com.microsoft.azure.documentdb.PartitionKey; import com.microsoft.azure.spring.data.cosmosdb.CosmosDbFactory; import com.microsoft.azure.spring.data.cosmosdb.config.DocumentDBConfig; @@ -23,6 +24,7 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.domain.EntityScanner; @@ -34,29 +36,48 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import static com.microsoft.azure.spring.data.cosmosdb.common.PageTestUtils.validateLastPage; +import static com.microsoft.azure.spring.data.cosmosdb.common.PageTestUtils.validateNonLastPage; +import static com.microsoft.azure.spring.data.cosmosdb.common.TestConstants.ADDRESSES; +import static com.microsoft.azure.spring.data.cosmosdb.common.TestConstants.DB_NAME; +import static com.microsoft.azure.spring.data.cosmosdb.common.TestConstants.FIRST_NAME; +import static com.microsoft.azure.spring.data.cosmosdb.common.TestConstants.HOBBIES; +import static com.microsoft.azure.spring.data.cosmosdb.common.TestConstants.ID_1; +import static com.microsoft.azure.spring.data.cosmosdb.common.TestConstants.ID_2; +import static com.microsoft.azure.spring.data.cosmosdb.common.TestConstants.ID_3; +import static com.microsoft.azure.spring.data.cosmosdb.common.TestConstants.LAST_NAME; +import static com.microsoft.azure.spring.data.cosmosdb.common.TestConstants.NEW_FIRST_NAME; +import static com.microsoft.azure.spring.data.cosmosdb.common.TestConstants.NEW_LAST_NAME; +import static com.microsoft.azure.spring.data.cosmosdb.common.TestConstants.NOT_EXIST_ID; +import static com.microsoft.azure.spring.data.cosmosdb.common.TestConstants.PAGE_SIZE_1; +import static com.microsoft.azure.spring.data.cosmosdb.common.TestConstants.PAGE_SIZE_2; +import static com.microsoft.azure.spring.data.cosmosdb.common.TestConstants.PAGE_SIZE_3; +import static com.microsoft.azure.spring.data.cosmosdb.common.TestConstants.UPDATED_FIRST_NAME; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + import java.util.Collections; import java.util.List; import java.util.UUID; -import static com.microsoft.azure.spring.data.cosmosdb.common.PageTestUtils.validateLastPage; -import static com.microsoft.azure.spring.data.cosmosdb.common.PageTestUtils.validateNonLastPage; -import static com.microsoft.azure.spring.data.cosmosdb.common.TestConstants.*; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.Assert.assertEquals; - @RunWith(SpringJUnit4ClassRunner.class) @PropertySource(value = { "classpath:application.properties" }) public class DocumentDbTemplateIT { private static final Person TEST_PERSON = new Person(ID_1, FIRST_NAME, LAST_NAME, HOBBIES, - ADDRESSES); + ADDRESSES); private static final Person TEST_PERSON_2 = new Person(ID_2, - NEW_FIRST_NAME, - NEW_LAST_NAME, HOBBIES, ADDRESSES); + NEW_FIRST_NAME, + NEW_LAST_NAME, HOBBIES, ADDRESSES); private static final Person TEST_PERSON_3 = new Person(ID_3, - NEW_FIRST_NAME, - NEW_LAST_NAME, HOBBIES, ADDRESSES); + NEW_FIRST_NAME, + NEW_LAST_NAME, HOBBIES, ADDRESSES); + + private static final String PRECONDITION_IS_NOT_MET = "is not met"; + + private static final String WRONG_ETAG = "WRONG_ETAG"; @Value("${cosmosdb.uri}") private String documentDbUri; @@ -68,6 +89,8 @@ public class DocumentDbTemplateIT { private static String collectionName; private static boolean initialized; + + private Person insertedPerson; @Autowired private ApplicationContext applicationContext; @@ -76,7 +99,7 @@ public class DocumentDbTemplateIT { public void setup() throws ClassNotFoundException { if (!initialized) { final DocumentDBConfig dbConfig = DocumentDBConfig.builder(documentDbUri, - documentDbKey, DB_NAME).build(); + documentDbKey, DB_NAME).build(); final CosmosDbFactory cosmosDbFactory = new CosmosDbFactory(dbConfig); final DocumentDbMappingContext mappingContext = new DocumentDbMappingContext(); @@ -86,12 +109,13 @@ public class DocumentDbTemplateIT { mappingContext.setInitialEntitySet(new EntityScanner(this.applicationContext).scan(Persistent.class)); final MappingDocumentDbConverter dbConverter = - new MappingDocumentDbConverter(mappingContext, null); + new MappingDocumentDbConverter(mappingContext, null); dbTemplate = new DocumentDbTemplate(cosmosDbFactory, dbConverter, DB_NAME); dbTemplate.createCollectionIfNotExists(personInfo); initialized = true; } - dbTemplate.insert(Person.class.getSimpleName(), TEST_PERSON, null); + + insertedPerson = dbTemplate.insert(Person.class.getSimpleName(), TEST_PERSON, null); } @After @@ -102,7 +126,7 @@ public class DocumentDbTemplateIT { @Test(expected = DocumentDBAccessException.class) public void testInsertDuplicateId() { dbTemplate.insert(Person.class.getSimpleName(), TEST_PERSON, - new PartitionKey(personInfo.getPartitionKeyFieldValue(TEST_PERSON))); + new PartitionKey(personInfo.getPartitionKeyFieldValue(TEST_PERSON))); } @Test @@ -115,20 +139,20 @@ public class DocumentDbTemplateIT { @Test public void testFindById() { final Person result = dbTemplate.findById(Person.class.getSimpleName(), - TEST_PERSON.getId(), Person.class); + TEST_PERSON.getId(), Person.class); assertEquals(result, TEST_PERSON); final Person nullResult = dbTemplate.findById(Person.class.getSimpleName(), - NOT_EXIST_ID, Person.class); + NOT_EXIST_ID, Person.class); assertThat(nullResult).isNull(); } @Test public void testFindByMultiIds() { dbTemplate.insert(TEST_PERSON_2, - new PartitionKey(personInfo.getPartitionKeyFieldValue(TEST_PERSON_2))); + new PartitionKey(personInfo.getPartitionKeyFieldValue(TEST_PERSON_2))); dbTemplate.insert(TEST_PERSON_3, - new PartitionKey(personInfo.getPartitionKeyFieldValue(TEST_PERSON_3))); + new PartitionKey(personInfo.getPartitionKeyFieldValue(TEST_PERSON_3))); final List ids = Lists.newArrayList(ID_1, ID_2, ID_3); final List result = dbTemplate.findByIds(ids, Person.class, collectionName); @@ -142,14 +166,14 @@ public class DocumentDbTemplateIT { public void testUpsertNewDocument() { // Delete first as was inserted in setup dbTemplate.deleteById(Person.class.getSimpleName(), TEST_PERSON.getId(), - new PartitionKey(personInfo.getPartitionKeyFieldValue(TEST_PERSON))); + new PartitionKey(personInfo.getPartitionKeyFieldValue(TEST_PERSON))); final String firstName = NEW_FIRST_NAME + "_" + UUID.randomUUID().toString(); final Person newPerson = new Person(TEST_PERSON.getId(), firstName, - NEW_FIRST_NAME, null, null); + NEW_FIRST_NAME, null, null); dbTemplate.upsert(Person.class.getSimpleName(), newPerson, - new PartitionKey(personInfo.getPartitionKeyFieldValue(newPerson))); + new PartitionKey(personInfo.getPartitionKeyFieldValue(newPerson))); final List result = dbTemplate.findAll(Person.class); @@ -160,24 +184,47 @@ public class DocumentDbTemplateIT { @Test public void testUpdate() { final Person updated = new Person(TEST_PERSON.getId(), UPDATED_FIRST_NAME, - TEST_PERSON.getLastName(), TEST_PERSON.getHobbies(), - TEST_PERSON.getShippingAddresses()); - dbTemplate.upsert(Person.class.getSimpleName(), updated, - new PartitionKey(personInfo.getPartitionKeyFieldValue(updated))); + TEST_PERSON.getLastName(), TEST_PERSON.getHobbies(), TEST_PERSON.getShippingAddresses()); + updated.set_etag(insertedPerson.get_etag()); + + dbTemplate.upsert(Person.class.getSimpleName(), updated, null); final Person result = dbTemplate.findById(Person.class.getSimpleName(), - updated.getId(), Person.class); + updated.getId(), Person.class); assertEquals(result, updated); } + @Test + public void testOptimisticLockWhenUpdatingWithWrongEtag() { + final Person updated = new Person(TEST_PERSON.getId(), UPDATED_FIRST_NAME, + TEST_PERSON.getLastName(), TEST_PERSON.getHobbies(), TEST_PERSON.getShippingAddresses()); + updated.set_etag(WRONG_ETAG); + + try { + dbTemplate.upsert(Person.class.getSimpleName(), updated, null); + } catch (DocumentDBAccessException e) { + assertThat(e.getCause()).isNotNull(); + final Throwable cosmosClientException = e.getCause().getCause(); + assertThat(cosmosClientException).isInstanceOf(CosmosClientException.class); + assertThat(cosmosClientException.getMessage()).contains(PRECONDITION_IS_NOT_MET); + + final Person unmodifiedPerson = dbTemplate.findById(Person.class.getSimpleName(), + TEST_PERSON.getId(), Person.class); + assertThat(unmodifiedPerson.getFirstName()).isEqualTo(insertedPerson.getFirstName()); + return; + } + + fail(); + } + @Test public void testDeleteById() { dbTemplate.insert(TEST_PERSON_2, null); assertThat(dbTemplate.findAll(Person.class).size()).isEqualTo(2); dbTemplate.deleteById(Person.class.getSimpleName(), TEST_PERSON.getId(), - new PartitionKey(personInfo.getPartitionKeyFieldValue(TEST_PERSON))); + new PartitionKey(personInfo.getPartitionKeyFieldValue(TEST_PERSON))); final List result = dbTemplate.findAll(Person.class); assertThat(result.size()).isEqualTo(1); @@ -190,7 +237,7 @@ public class DocumentDbTemplateIT { assertThat(prevCount).isEqualTo(1); dbTemplate.insert(TEST_PERSON_2, - new PartitionKey(personInfo.getPartitionKeyFieldValue(TEST_PERSON_2))); + new PartitionKey(personInfo.getPartitionKeyFieldValue(TEST_PERSON_2))); final long newCount = dbTemplate.count(collectionName); assertThat(newCount).isEqualTo(2); @@ -199,10 +246,10 @@ public class DocumentDbTemplateIT { @Test public void testCountByQuery() { dbTemplate.insert(TEST_PERSON_2, - new PartitionKey(personInfo.getPartitionKeyFieldValue(TEST_PERSON_2))); + new PartitionKey(personInfo.getPartitionKeyFieldValue(TEST_PERSON_2))); final Criteria criteria = Criteria.getInstance(CriteriaType.IS_EQUAL, "firstName", - Collections.singletonList(TEST_PERSON_2.getFirstName())); + Collections.singletonList(TEST_PERSON_2.getFirstName())); final DocumentQuery query = new DocumentQuery(criteria); final long count = dbTemplate.count(query, Person.class, collectionName); @@ -212,7 +259,7 @@ public class DocumentDbTemplateIT { @Test public void testFindAllPageableMultiPages() { dbTemplate.insert(TEST_PERSON_2, - new PartitionKey(personInfo.getPartitionKeyFieldValue(TEST_PERSON_2))); + new PartitionKey(personInfo.getPartitionKeyFieldValue(TEST_PERSON_2))); final DocumentDbPageRequest pageRequest = new DocumentDbPageRequest(0, PAGE_SIZE_1, null); final Page page1 = dbTemplate.findAll(pageRequest, Person.class, collectionName); @@ -221,7 +268,7 @@ public class DocumentDbTemplateIT { validateNonLastPage(page1, PAGE_SIZE_1); final Page page2 = dbTemplate.findAll(page1.getPageable(), Person.class, - collectionName); + collectionName); assertThat(page2.getContent().size()).isEqualTo(1); validateLastPage(page2, PAGE_SIZE_1); } @@ -229,10 +276,10 @@ public class DocumentDbTemplateIT { @Test public void testPaginationQuery() { dbTemplate.insert(TEST_PERSON_2, - new PartitionKey(personInfo.getPartitionKeyFieldValue(TEST_PERSON_2))); + new PartitionKey(personInfo.getPartitionKeyFieldValue(TEST_PERSON_2))); final Criteria criteria = Criteria.getInstance(CriteriaType.IS_EQUAL, "firstName", - Collections.singletonList(FIRST_NAME)); + Collections.singletonList(FIRST_NAME)); final PageRequest pageRequest = new DocumentDbPageRequest(0, PAGE_SIZE_2, null); final DocumentQuery query = new DocumentQuery(criteria).with(pageRequest); @@ -244,9 +291,9 @@ public class DocumentDbTemplateIT { @Test public void testFindAllWithPageableAndSort() { dbTemplate.insert(TEST_PERSON_2, - new PartitionKey(personInfo.getPartitionKeyFieldValue(TEST_PERSON_2))); + new PartitionKey(personInfo.getPartitionKeyFieldValue(TEST_PERSON_2))); dbTemplate.insert(TEST_PERSON_3, - new PartitionKey(personInfo.getPartitionKeyFieldValue(TEST_PERSON_3))); + new PartitionKey(personInfo.getPartitionKeyFieldValue(TEST_PERSON_3))); final Sort sort = new Sort(Sort.Direction.DESC, "firstName"); final PageRequest pageRequest = DocumentDbPageRequest.of(0, PAGE_SIZE_3, null, sort); @@ -268,19 +315,19 @@ public class DocumentDbTemplateIT { final Person testPerson5 = new Person("id_5", "fred", NEW_LAST_NAME, HOBBIES, ADDRESSES); dbTemplate.insert(TEST_PERSON_2, - new PartitionKey(personInfo.getPartitionKeyFieldValue(TEST_PERSON_2))); + new PartitionKey(personInfo.getPartitionKeyFieldValue(TEST_PERSON_2))); dbTemplate.insert(TEST_PERSON_3, - new PartitionKey(personInfo.getPartitionKeyFieldValue(TEST_PERSON_3))); + new PartitionKey(personInfo.getPartitionKeyFieldValue(TEST_PERSON_3))); dbTemplate.insert(testPerson4, - new PartitionKey(personInfo.getPartitionKeyFieldValue(testPerson4))); + new PartitionKey(personInfo.getPartitionKeyFieldValue(testPerson4))); dbTemplate.insert(testPerson5, - new PartitionKey(personInfo.getPartitionKeyFieldValue(testPerson5))); + new PartitionKey(personInfo.getPartitionKeyFieldValue(testPerson5))); final Sort sort = new Sort(Sort.Direction.ASC, "firstName"); final PageRequest pageRequest = DocumentDbPageRequest.of(0, PAGE_SIZE_3, null, sort); final Page firstPage = dbTemplate.findAll(pageRequest, Person.class, - collectionName); + collectionName); assertThat(firstPage.getContent().size()).isEqualTo(3); validateNonLastPage(firstPage, PAGE_SIZE_3); @@ -291,7 +338,7 @@ public class DocumentDbTemplateIT { assertThat(firstPageResults.get(2).getFirstName()).isEqualTo(testPerson5.getFirstName()); final Page secondPage = dbTemplate.findAll(firstPage.getPageable(), Person.class, - collectionName); + collectionName); assertThat(secondPage.getContent().size()).isEqualTo(2); validateLastPage(secondPage, PAGE_SIZE_3); diff --git a/src/test/java/com/microsoft/azure/spring/data/cosmosdb/core/DocumentDbTemplateIllegalTest.java b/src/test/java/com/microsoft/azure/spring/data/cosmosdb/core/DocumentDbTemplateIllegalTest.java index 1c24e17..c54a2a0 100644 --- a/src/test/java/com/microsoft/azure/spring/data/cosmosdb/core/DocumentDbTemplateIllegalTest.java +++ b/src/test/java/com/microsoft/azure/spring/data/cosmosdb/core/DocumentDbTemplateIllegalTest.java @@ -16,14 +16,15 @@ import org.junit.runner.RunWith; import org.mockito.Answers; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; + import org.springframework.util.Assert; +import static com.microsoft.azure.spring.data.cosmosdb.core.query.CriteriaType.IS_EQUAL; + import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.Arrays; -import static com.microsoft.azure.spring.data.cosmosdb.core.query.CriteriaType.IS_EQUAL; - @RunWith(MockitoJUnitRunner.class) public class DocumentDbTemplateIllegalTest { private static final String NULL_STR = null; @@ -83,9 +84,7 @@ public class DocumentDbTemplateIllegalTest { public void findByIdIllegalArgsShouldFail() throws NoSuchMethodException { final Method method = dbTemplateClass.getDeclaredMethod("findById", Object.class, Class.class); - checkIllegalArgument(method, null, Person.class); - checkIllegalArgument(method, EMPTY_STR, Person.class); - checkIllegalArgument(method, WHITESPACES_STR, Person.class); + checkIllegalArgument(method, DUMMY_ID, null); } @Test diff --git a/src/test/java/com/microsoft/azure/spring/data/cosmosdb/domain/Person.java b/src/test/java/com/microsoft/azure/spring/data/cosmosdb/domain/Person.java index 4509feb..b9f5cea 100644 --- a/src/test/java/com/microsoft/azure/spring/data/cosmosdb/domain/Person.java +++ b/src/test/java/com/microsoft/azure/spring/data/cosmosdb/domain/Person.java @@ -6,16 +6,20 @@ package com.microsoft.azure.spring.data.cosmosdb.domain; +import java.util.List; + import com.microsoft.azure.spring.data.cosmosdb.common.TestConstants; import com.microsoft.azure.spring.data.cosmosdb.core.mapping.DocumentIndexingPolicy; import com.microsoft.azure.spring.data.cosmosdb.core.mapping.PartitionKey; -import lombok.AllArgsConstructor; import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; -import java.util.List; +import org.springframework.data.annotation.Version; @Data -@AllArgsConstructor +@EqualsAndHashCode(exclude = "_etag") +@NoArgsConstructor @DocumentIndexingPolicy(includePaths = TestConstants.ORDER_BY_STRING_PATH) public class Person { private String id; @@ -25,4 +29,14 @@ public class Person { private String lastName; private List hobbies; private List
shippingAddresses; + @Version + private String _etag; + + public Person(String id, String firstName, String lastName, List hobbies, List
shippingAddresses) { + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + this.hobbies = hobbies; + this.shippingAddresses = shippingAddresses; + } } diff --git a/src/test/java/com/microsoft/azure/spring/data/cosmosdb/repository/support/DocumentDbEntityInformationUnitTest.java b/src/test/java/com/microsoft/azure/spring/data/cosmosdb/repository/support/DocumentDbEntityInformationUnitTest.java index 632d2e7..739f9d0 100644 --- a/src/test/java/com/microsoft/azure/spring/data/cosmosdb/repository/support/DocumentDbEntityInformationUnitTest.java +++ b/src/test/java/com/microsoft/azure/spring/data/cosmosdb/repository/support/DocumentDbEntityInformationUnitTest.java @@ -5,16 +5,20 @@ */ package com.microsoft.azure.spring.data.cosmosdb.repository.support; +import java.util.List; + import com.microsoft.azure.spring.data.cosmosdb.common.TestConstants; import com.microsoft.azure.spring.data.cosmosdb.core.mapping.Document; import com.microsoft.azure.spring.data.cosmosdb.core.mapping.PartitionKey; import com.microsoft.azure.spring.data.cosmosdb.domain.Address; import com.microsoft.azure.spring.data.cosmosdb.domain.Person; +import com.microsoft.azure.spring.data.cosmosdb.domain.Student; +import lombok.Data; import org.junit.Test; -import java.util.List; +import org.springframework.data.annotation.Version; -import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.*; public class DocumentDbEntityInformationUnitTest { private static final String ID = "entity_info_test_id"; @@ -54,8 +58,8 @@ public class DocumentDbEntityInformationUnitTest { @Test public void testCustomCollectionName() { - final DocumentDbEntityInformation entityInformation = - new DocumentDbEntityInformation(Volunteer.class); + final DocumentDbEntityInformation entityInformation = + new DocumentDbEntityInformation(VersionedVolunteer.class); final String collectionName = entityInformation.getCollectionName(); assertThat(collectionName).isEqualTo("testCollection"); @@ -87,6 +91,42 @@ public class DocumentDbEntityInformationUnitTest { final String partitionKeyName = entityInformation.getPartitionKeyFieldName(); assertThat(partitionKeyName).isEqualTo("vol_name"); } + + @Test + public void testVersionedEntity() { + final DocumentDbEntityInformation entityInformation = + new DocumentDbEntityInformation(VersionedVolunteer.class); + + final boolean isVersioned = entityInformation.isVersioned(); + assertThat(isVersioned).isTrue(); + } + + @Test + public void testEntityShouldNotBeVersionedWithWrongType() { + final DocumentDbEntityInformation entityInformation = + new DocumentDbEntityInformation(WrongVersionType.class); + + final boolean isVersioned = entityInformation.isVersioned(); + assertThat(isVersioned).isFalse(); + } + + @Test + public void testEntityShouldNotBeVersionedWithoutAnnotationOnEtag() { + final DocumentDbEntityInformation entityInformation = + new DocumentDbEntityInformation(VersionOnWrongField.class); + + final boolean isVersioned = entityInformation.isVersioned(); + assertThat(isVersioned).isFalse(); + } + + @Test + public void testNonVersionedEntity() { + final DocumentDbEntityInformation entityInformation = + new DocumentDbEntityInformation(Student.class); + + final boolean isVersioned = entityInformation.isVersioned(); + assertThat(isVersioned).isFalse(); + } @Document(collection = "testCollection") class Volunteer { @@ -139,4 +179,30 @@ public class DocumentDbEntityInformationUnitTest { this.name = name; } } + + @Data + @Document(collection = "testCollection") + class VersionedVolunteer { + private String id; + private String name; + @Version + private String _etag; + } + + @Data + @Document + class WrongVersionType { + private String id; + private String name; + private long _etag; + } + + @Data + @Document + class VersionOnWrongField { + private String id; + @Version + private String name; + private String _etag; + } }