diff --git a/spring-data-geode/src/main/java/org/springframework/data/gemfire/support/ComposableRegionResolver.java b/spring-data-geode/src/main/java/org/springframework/data/gemfire/support/ComposableRegionResolver.java new file mode 100644 index 00000000..088bf07b --- /dev/null +++ b/spring-data-geode/src/main/java/org/springframework/data/gemfire/support/ComposableRegionResolver.java @@ -0,0 +1,168 @@ +/* + * 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.gemfire.support; + +import java.util.Arrays; +import java.util.function.Function; + +import org.apache.geode.cache.Region; +import org.apache.geode.cache.RegionDestroyedException; + +import org.springframework.data.gemfire.RegionResolver; +import org.springframework.data.gemfire.util.ArrayUtils; +import org.springframework.data.gemfire.util.CollectionUtils; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * {@link RegionResolver} implementation used to compose a collection of {@link RegionResolver RegionResolvers}. + * + * @author John Blum + * @see org.apache.geode.cache.Region + * @see org.springframework.data.gemfire.RegionResolver + * @see org.springframework.data.gemfire.support.AbstractCachingRegionResolver + * @since 2.3.0 + */ +public class ComposableRegionResolver extends AbstractCachingRegionResolver { + + /** + * Factory method used to compose an array of {@link RegionResolver RegionResolvers} in a composition. + * + * @param regionResolvers array of {@link RegionResolver RegionResolvers} to compose; may be {@literal null}. + * @return a composition from the array of {@link RegionResolver RegionResolvers}; may be {@literal null}. + * @see org.springframework.data.gemfire.RegionResolver + * @see #compose(Iterable) + */ + public static RegionResolver compose(@Nullable RegionResolver... regionResolvers) { + return compose(Arrays.asList(ArrayUtils.nullSafeArray(regionResolvers, RegionResolver.class))); + } + + /** + * Factory method used to compose an {@literal Iterable} collection of {@link RegionResolver RegionResolvers} + * in a composition. + * + * @param regionResolvers {@link Iterable} collection of {@link RegionResolver RegionResolvers} to compose; + * may be {@literal null}. + * @return a composition from the {@link Iterable} collection of {@link RegionResolver RegionResolvers}; + * may be {@literal null}. + * @see #compose(RegionResolver, RegionResolver) + * @see org.springframework.data.gemfire.RegionResolver + * @see java.lang.Iterable + */ + public static @Nullable RegionResolver compose(@Nullable Iterable regionResolvers) { + + RegionResolver current = null; + + for (RegionResolver regionResolver : CollectionUtils.nullSafeIterable(regionResolvers)) { + current = compose(current, regionResolver); + } + + return current; + } + + /** + * Composes two {@link RegionResolver RegionResolvers} in a composition. + * + * @param one first {@link RegionResolver} in the composition. + * @param two second {@link RegionResolver} in the composition. + * @return a {@link ComposableRegionResolver} composed of the {@link RegionResolver} arguments. + * Returns the first {@link RegionResolver} if the second is {@literal null}. + * Returns the second {@link RegionResolver} if the first is {@literal null}. + * Returns {@literal null} if both {@link RegionResolver} arguments are {@literal null}. + * @see #ComposableRegionResolver(RegionResolver, RegionResolver) + * @see org.springframework.data.gemfire.RegionResolver + */ + public static @Nullable RegionResolver compose(@Nullable RegionResolver one, @Nullable RegionResolver two) { + return one == null ? two : two == null ? one : new ComposableRegionResolver(one, two); + } + + private final RegionResolver regionResolverOne; + private final RegionResolver regionResolverTwo; + + /** + * Constructs a new instance of {@link ComposableRegionResolver} initialized and composed with + * the given {@link RegionResolver RegionResolvers} forming the composition. + * + * @param regionResolverOne first {@link RegionResolver} in the composition; must not be {@literal null}. + * @param regionResolverTwo second {@link RegionResolver} in the composition; must not be {@literal null}. + * @throws IllegalArgumentException if either {@link RegionResolver} argument is {@literal null}. + * @see org.springframework.data.gemfire.RegionResolver + */ + protected ComposableRegionResolver(@NonNull RegionResolver regionResolverOne, + @NonNull RegionResolver regionResolverTwo) { + + Assert.notNull(regionResolverOne, "RegionResolver 1 must not be null"); + Assert.notNull(regionResolverTwo, "RegionResolver 2 must not be null"); + + this.regionResolverOne = regionResolverOne; + this.regionResolverTwo = regionResolverTwo; + } + + /** + * Returns a reference to the first, non-null, configured {@link RegionResolver} in the composition. + * + * @return a reference to the first {@link RegionResolver} in the composition. + * @see org.springframework.data.gemfire.RegionResolver + */ + protected @NonNull RegionResolver getRegionResolverOne() { + return this.regionResolverOne; + } + + /** + * Returns a reference to the second, non-null, configured {@link RegionResolver} in the composition. + * + * @return a reference to the second {@link RegionResolver} in the composition. + * @see org.springframework.data.gemfire.RegionResolver + */ + protected @NonNull RegionResolver getRegionResolverTwo() { + return this.regionResolverTwo; + } + + /** + * Resolves the first {@literal non-null} reference to cache {@link Region} identified by + * the given {@link String name}, handling any {@link Exception Exceptions} throwing by + * the composed {@link RegionResolver RegionResolvers}, such as a {@link RegionDestroyedException}. + * + * This method may ultimately still result in a thrown {@link Exception}, but it will make a best effort to + * exhaustively consult all composed {@link RegionResolver RegionResolvers}. + * + * @param {@link Class type} of the {@link Region} key. + * @param {@link Class type} of the {@link Region} value. + * @param regionName {@link String name} of the {@link Region} to resolve. + * @return the first, resolved reference to a cache {@link Region} identified by the given {@link String name}. + * @see org.springframework.data.gemfire.RegionResolver#resolve(String) + * @see org.apache.geode.cache.Region + * @see java.util.function.Function + * @see #getRegionResolverOne() + * @see #getRegionResolverTwo() + */ + @Nullable @Override + protected Region doResolve(@Nullable String regionName) { + + Function> regionResolverFunction = getRegionResolverTwo()::resolve; + + try { + + Region region = getRegionResolverOne().resolve(regionName); + + return region != null ? region : regionResolverFunction.apply(regionName); + } + catch (Throwable ignore) { + return regionResolverFunction.apply(regionName); + } + } +} diff --git a/spring-data-geode/src/test/java/org/springframework/data/gemfire/support/ComposableRegionResolverUnitTests.java b/spring-data-geode/src/test/java/org/springframework/data/gemfire/support/ComposableRegionResolverUnitTests.java new file mode 100644 index 00000000..72466ac7 --- /dev/null +++ b/spring-data-geode/src/test/java/org/springframework/data/gemfire/support/ComposableRegionResolverUnitTests.java @@ -0,0 +1,257 @@ +/* + * 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.gemfire.support; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.function.Supplier; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +import org.apache.geode.cache.AttributesMutator; +import org.apache.geode.cache.Region; +import org.apache.geode.cache.RegionDestroyedException; + +import org.springframework.data.gemfire.RegionResolver; + +/** + * Unit Tests for {@link ComposableRegionResolver}. + * + * @author John Blum + * @see org.junit.Test + * @see org.mockito.Mockito + * @see org.mockito.junit.MockitoJUnitRunner + * @see org.apache.geode.cache.Region + * @see org.springframework.data.gemfire.RegionResolver + * @see org.springframework.data.gemfire.support.ComposableRegionResolver + * @since 2.3.0 + */ +@RunWith(MockitoJUnitRunner.class) +@SuppressWarnings("rawtypes") +public class ComposableRegionResolverUnitTests { + + @Mock + private Region mockRegion; + + @Mock(name = "MockRegionResolverOne") + private RegionResolver mockRegionResolverOne; + + @Mock(name = "MockRegionResolverTwo") + private RegionResolver mockRegionResolverTwo; + + @Mock(name = "MockRegionResolverThree") + private RegionResolver mockRegionResolverThree; + + @Before + public void setupMockRegion() { + + AttributesMutator mockAttributesMutator = mock(AttributesMutator.class); + + when(this.mockRegion.getAttributesMutator()).thenReturn(mockAttributesMutator); + when(mockAttributesMutator.getRegion()).thenReturn(this.mockRegion); + } + + @Test + public void constructComposableRegionResolver() { + + ComposableRegionResolver regionResolver = + new ComposableRegionResolver(this.mockRegionResolverOne, this.mockRegionResolverTwo); + + assertThat(regionResolver).isNotNull(); + assertThat(regionResolver.getRegionResolverOne()).isEqualTo(this.mockRegionResolverOne); + assertThat(regionResolver.getRegionResolverTwo()).isEqualTo(this.mockRegionResolverTwo); + } + + public void testConstructRegionResolverWithNullRegionResolver(Supplier regionResolverSupplier, + String expectedMessage) { + + try { + regionResolverSupplier.get(); + } + catch (IllegalArgumentException expected) { + + assertThat(expected).hasMessage(expectedMessage); + assertThat(expected).hasNoCause(); + + throw expected; + } + } + + @Test(expected = IllegalArgumentException.class) + public void constructComposableRegionResolverWithNullFirstArgument() { + testConstructRegionResolverWithNullRegionResolver( + () -> new ComposableRegionResolver(null, this.mockRegionResolverTwo), + "RegionResolver 1 must not be null"); + } + + @Test(expected = IllegalArgumentException.class) + public void constructComposableRegionResolverWithNullSecondArgument() { + testConstructRegionResolverWithNullRegionResolver( + () -> new ComposableRegionResolver(this.mockRegionResolverOne, null), + "RegionResolver 2 must not be null"); + } + + @Test + public void composeWithAllNullRegionResolvers() { + assertThat(ComposableRegionResolver.compose(null, null)).isNull(); + } + + @Test + public void composeWithSingleNullRegionResolver() { + + assertThat(ComposableRegionResolver.compose(this.mockRegionResolverOne, null)) + .isSameAs(this.mockRegionResolverOne); + + assertThat(ComposableRegionResolver.compose(null, this.mockRegionResolverTwo)) + .isSameAs(this.mockRegionResolverTwo); + } + + @Test + public void composeWithTwoNonNullRegionResolvers() { + + RegionResolver regionResolver = + ComposableRegionResolver.compose(this.mockRegionResolverOne, this.mockRegionResolverTwo); + + assertThat(regionResolver).isNotNull(); + assertThat(regionResolver).isInstanceOf(ComposableRegionResolver.class); + assertThat(((ComposableRegionResolver) regionResolver).getRegionResolverOne()).isEqualTo(this.mockRegionResolverOne); + assertThat(((ComposableRegionResolver) regionResolver).getRegionResolverTwo()).isEqualTo(this.mockRegionResolverTwo); + } + + @Test + public void composeArrayOfRegionResolvers() { + + RegionResolver regionResolver = ComposableRegionResolver + .compose(this.mockRegionResolverOne, this.mockRegionResolverTwo, this.mockRegionResolverThree); + + assertThat(regionResolver).isNotNull(); + assertThat(regionResolver).isInstanceOf(ComposableRegionResolver.class); + + RegionResolver regionResolverOne = ((ComposableRegionResolver) regionResolver).getRegionResolverOne(); + RegionResolver regionResolverTwo = ((ComposableRegionResolver) regionResolver).getRegionResolverTwo(); + + assertThat(regionResolverOne).isNotNull(); + assertThat(regionResolverOne).isInstanceOf(ComposableRegionResolver.class); + assertThat(((ComposableRegionResolver) regionResolverOne).getRegionResolverOne()).isEqualTo(this.mockRegionResolverOne); + assertThat(((ComposableRegionResolver) regionResolverOne).getRegionResolverTwo()).isEqualTo(this.mockRegionResolverTwo); + assertThat(regionResolverTwo).isEqualTo(this.mockRegionResolverThree); + } + + @Test + public void composeArrayWithNullAndNonNullRegionResolvers() { + + RegionResolver regionResolver = ComposableRegionResolver + .compose(null, this.mockRegionResolverOne, null, null, this.mockRegionResolverTwo); + + assertThat(regionResolver).isNotNull(); + assertThat(regionResolver).isInstanceOf(ComposableRegionResolver.class); + assertThat(((ComposableRegionResolver) regionResolver).getRegionResolverOne()).isEqualTo(this.mockRegionResolverOne); + assertThat(((ComposableRegionResolver) regionResolver).getRegionResolverTwo()).isEqualTo(this.mockRegionResolverTwo); + } + + @Test + public void composeArrayWithSingleRegionResolver() { + assertThat(ComposableRegionResolver.compose(this.mockRegionResolverOne)).isEqualTo(this.mockRegionResolverOne); + } + + @Test + public void composeEmptyArrayReturnsNull() { + assertThat(ComposableRegionResolver.compose()).isNull(); + } + + @Test + public void composeNullArrayIsNullSafeAndReturnsNull() { + assertThat(ComposableRegionResolver.compose((RegionResolver[]) null)).isNull(); + } + + @Test + public void composeNullIterableIsNullSafeAndReturnsNull() { + assertThat(ComposableRegionResolver.compose((Iterable) null)).isNull(); + } + + @Test + @SuppressWarnings("unchecked") + public void resolveReturnsRegionFromFirstRegionResolverInComposition() { + + RegionResolver regionResolver = ComposableRegionResolver + .compose(this.mockRegionResolverOne, this.mockRegionResolverTwo, this.mockRegionResolverThree); + + when(this.mockRegionResolverOne.resolve(anyString())).thenReturn(this.mockRegion); + + assertThat(regionResolver.resolve("MockRegion")).isEqualTo(this.mockRegion); + + verify(this.mockRegionResolverOne, times(1)).resolve(eq("MockRegion")); + verify(this.mockRegionResolverTwo, never()).resolve(anyString()); + verify(this.mockRegionResolverThree, never()).resolve(anyString()); + } + + @Test + @SuppressWarnings("unchecked") + public void resolveReturnsRegionFromLastRegionResolverInCompositionAndCachesResolvedRegion() { + + RegionResolver regionResolver = ComposableRegionResolver + .compose(this.mockRegionResolverOne, null, this.mockRegionResolverTwo, + this.mockRegionResolverThree, null, null); + + when(this.mockRegionResolverThree.resolve(anyString())).thenReturn(this.mockRegion); + + assertThat(regionResolver.resolve("MockRegion")).isEqualTo(this.mockRegion); + assertThat(regionResolver.resolve("MockRegion")).isEqualTo(this.mockRegion); + + InOrder inOrder = Mockito + .inOrder(this.mockRegionResolverOne, this.mockRegionResolverTwo, this.mockRegionResolverThree); + + inOrder.verify(this.mockRegionResolverOne, times(1)).resolve(eq("MockRegion")); + inOrder.verify(this.mockRegionResolverTwo, times(1)).resolve(eq("MockRegion")); + inOrder.verify(this.mockRegionResolverThree, times(1)).resolve(eq("MockRegion")); + } + + @Test + @SuppressWarnings("unchecked") + public void resolveReturnsRegionFromSecondRegionResolverInCompositionAndCachesResolvedRegion() { + + RegionResolver regionResolver = ComposableRegionResolver + .compose(this.mockRegionResolverOne, this.mockRegionResolverTwo, this.mockRegionResolverThree); + + when(this.mockRegionResolverOne.resolve(anyString())) + .thenThrow(new RegionDestroyedException("TEST", "/MockRegion")); + + when(this.mockRegionResolverTwo.resolve(anyString())).thenReturn(this.mockRegion); + + assertThat(regionResolver.resolve("MockRegion")).isEqualTo(this.mockRegion); + assertThat(regionResolver.resolve("MockRegion")).isEqualTo(this.mockRegion); + + InOrder inOrder = Mockito + .inOrder(this.mockRegionResolverOne, this.mockRegionResolverTwo, this.mockRegionResolverThree); + + inOrder.verify(this.mockRegionResolverOne, times(1)).resolve(eq("MockRegion")); + inOrder.verify(this.mockRegionResolverTwo, times(1)).resolve(eq("MockRegion")); + inOrder.verify(this.mockRegionResolverThree, never()).resolve(eq("MockRegion")); + } +}