diff --git a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/data/builder/RepositoryItemReaderBuilder.java b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/data/builder/RepositoryItemReaderBuilder.java new file mode 100644 index 000000000..3552ca8e4 --- /dev/null +++ b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/data/builder/RepositoryItemReaderBuilder.java @@ -0,0 +1,242 @@ +/* + * Copyright 2017 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 + * + * http://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.batch.item.data.builder; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import org.springframework.batch.item.builder.AbstractItemCountingItemStreamItemReaderBuilder; +import org.springframework.batch.item.data.RepositoryItemReader; +import org.springframework.cglib.proxy.Enhancer; +import org.springframework.cglib.proxy.MethodInterceptor; +import org.springframework.cglib.proxy.MethodProxy; +import org.springframework.data.domain.Sort; +import org.springframework.data.repository.PagingAndSortingRepository; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * A builder implementation for the {@link RepositoryItemReader}. + * + * @author Glenn Renfro + * @since 4.0 + * @see RepositoryItemReader + */ + +public class RepositoryItemReaderBuilder + extends AbstractItemCountingItemStreamItemReaderBuilder> { + + private PagingAndSortingRepository repository; + + private Map sorts; + + private List arguments; + + private int pageSize = 10; + + private String methodName; + + private RepositoryMethodReference repositoryMethodReference; + + /** + * Arguments to be passed to the data providing method. + * + * @param arguments list of method arguments to be passed to the repository. + * @return The current instance of the builder. + * @see RepositoryItemReader#setArguments(List) + */ + public RepositoryItemReaderBuilder arguments(List arguments) { + this.arguments = arguments; + + return this; + } + + /** + * Provides ordering of the results so that order is maintained between paged queries. + * + * @param sorts the fields to sort by and the directions. + * @return The current instance of the builder. + * @see RepositoryItemReader#setSort(Map) + */ + public RepositoryItemReaderBuilder sorts(Map sorts) { + this.sorts = sorts; + + return this; + } + + /** + * Establish the pageSize for the generated RepositoryItemReader. + * + * @param pageSize The number of items to retrieve per page. + * @return The current instance of the builder. + * @see RepositoryItemReader#setPageSize(int) + */ + public RepositoryItemReaderBuilder pageSize(int pageSize) { + this.pageSize = pageSize; + + return this; + } + + /** + * The {@link org.springframework.data.repository.PagingAndSortingRepository} + * implementation used to read input from. + * + * @param repository underlying repository for input to be read from. + * @return The current instance of the builder. + * @see RepositoryItemReader#setRepository(PagingAndSortingRepository) + */ + public RepositoryItemReaderBuilder repository(PagingAndSortingRepository repository) { + this.repository = repository; + + return this; + } + + /** + * Specifies what method on the repository to call. This method must take + * {@link org.springframework.data.domain.Pageable} as the last argument. + * + * @param methodName name of the method to invoke. + * @return The current instance of the builder. + * @see RepositoryItemReader#setMethodName(String) + */ + public RepositoryItemReaderBuilder methodName(String methodName) { + this.methodName = methodName; + + return this; + } + + /** + * Specifies a repository and the type-safe method to call for the reader. This method + * must take {@link org.springframework.data.domain.Pageable} as the last + * argument. This method can be used in place of {@link #methodName(String)}, + * {@link #arguments(List)} and {@link #repository(PagingAndSortingRepository)}. The + * repository that is used by the repositoryMethodReference must be non-final. + * + * @param repositoryMethodReference of the used to get a repository and type-safe + * method for use by the reader. + * @return The current instance of the builder. + * @see RepositoryItemReader#setMethodName(String) + * @see RepositoryItemReader#setRepository(PagingAndSortingRepository) + * + */ + public RepositoryItemReaderBuilder repository(RepositoryMethodReference repositoryMethodReference) { + this.repositoryMethodReference = repositoryMethodReference; + + return this; + } + + /** + * Builds the {@link RepositoryItemReader}. + * + * @return a {@link RepositoryItemReader} + */ + public RepositoryItemReader build() { + if (this.repositoryMethodReference != null) { + this.methodName = this.repositoryMethodReference.getMethodName(); + this.repository = this.repositoryMethodReference.getRepository(); + this.arguments = this.repositoryMethodReference.getArguments(); + } + + Assert.notNull(this.sorts, "sorts map is required."); + Assert.notNull(this.repository, "repository is required."); + Assert.hasText(this.methodName, "methodName is required."); + if (this.saveState) { + Assert.state(StringUtils.hasText(this.name), "A name is required when saveState is set to true."); + } + + RepositoryItemReader reader = new RepositoryItemReader<>(); + reader.setArguments(this.arguments); + reader.setRepository(this.repository); + reader.setMethodName(this.methodName); + reader.setPageSize(this.pageSize); + reader.setCurrentItemCount(this.currentItemCount); + reader.setMaxItemCount(this.maxItemCount); + reader.setSaveState(this.saveState); + reader.setSort(this.sorts); + reader.setName(this.name); + return reader; + } + + /** + * Establishes a proxy that will capture a the Repository and the associated + * methodName that will be used by the reader. + * @param The type of repository that will be used by the reader. + */ + public static class RepositoryMethodReference { + private RepositoryMethodIterceptor repositoryInvocationHandler; + + private PagingAndSortingRepository repository; + + public RepositoryMethodReference(PagingAndSortingRepository repository) { + this.repository = repository; + this.repositoryInvocationHandler = new RepositoryMethodIterceptor(); + } + + /** + * The proxy returned prevents actual method execution and is only used to gather, + * information about the method. + * @return T is a proxy of the object passed in in the constructor + */ + public T methodIs() { + Enhancer enhancer = new Enhancer(); + enhancer.setSuperclass(this.repository.getClass()); + enhancer.setCallback(this.repositoryInvocationHandler); + return (T) enhancer.create(); + } + + public PagingAndSortingRepository getRepository() { + return this.repository; + } + + public String getMethodName() { + return this.repositoryInvocationHandler.getMethodName(); + } + + public List getArguments() { + return this.repositoryInvocationHandler.getArguments(); + } + } + + private static class RepositoryMethodIterceptor implements MethodInterceptor { + private String methodName; + + private List arguments; + + @Override + public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable { + this.methodName = method.getName(); + if (objects != null && objects.length > 1) { + arguments = new ArrayList<>(Arrays.asList(objects)); + // remove last entry because that will be provided by the + // RepositoryItemReader + arguments.remove(objects.length - 1); + } + return null; + } + + public String getMethodName() { + return this.methodName; + } + + public List getArguments() { + return arguments; + } + } +} diff --git a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/data/builder/RepositoryItemReaderBuilderTests.java b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/data/builder/RepositoryItemReaderBuilderTests.java new file mode 100644 index 000000000..f0c7efaaa --- /dev/null +++ b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/data/builder/RepositoryItemReaderBuilderTests.java @@ -0,0 +1,241 @@ +/* + * Copyright 2017 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 + * + * http://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.batch.item.data.builder; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import org.springframework.batch.item.data.RepositoryItemReader; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.repository.PagingAndSortingRepository; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.when; + +/** + * @author Glenn Renfro + */ +public class RepositoryItemReaderBuilderTests { + + private static final String ARG1 = "foo"; + private static final String ARG2 = "bar"; + private static final String ARG3 = "baz"; + + public static final String TEST_CONTENT = "FOOBAR"; + + @Mock + private TestRepository repository; + + @Mock + private Page page; + + private Map sorts; + + private List testResult; + + private ArgumentCaptor pageRequestContainer; + + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + this.sorts = new HashMap(); + this.sorts.put("id", Sort.Direction.ASC); + this.pageRequestContainer = ArgumentCaptor.forClass(PageRequest.class); + + testResult = new ArrayList<>(); + testResult.add(TEST_CONTENT); + when(page.getContent()).thenReturn(testResult); + when(page.getSize()).thenReturn(5); + when(this.repository.foo(this.pageRequestContainer.capture())).thenReturn(this.page); + } + + @Test + public void testBasicRead() throws Exception { + RepositoryItemReader reader = new RepositoryItemReaderBuilder().repository(this.repository) + .sorts(this.sorts).maxItemCount(5).methodName("foo").name("bar").build(); + String result = (String) reader.read(); + assertEquals("Result returned from reader was not expected value.", TEST_CONTENT, result); + assertEquals("page size was not expected value.", 10, this.pageRequestContainer.getValue().getPageSize()); + } + + @Test + public void testRepositoryMethodReference() throws Exception { + RepositoryItemReaderBuilder.RepositoryMethodReference repositoryMethodReference = new RepositoryItemReaderBuilder.RepositoryMethodReference( + this.repository); + repositoryMethodReference.methodIs().foo(null); + RepositoryItemReader reader = new RepositoryItemReaderBuilder() + .repository(repositoryMethodReference) + .sorts(this.sorts) + .maxItemCount(5) + .name("bar").build(); + String result = (String) reader.read(); + assertEquals("Result returned from reader was not expected value.", TEST_CONTENT, result); + assertEquals("page size was not expected value.", 10, this.pageRequestContainer.getValue().getPageSize()); + } + + @Test + public void testRepositoryMethodReferenceWithArgs() throws Exception { + RepositoryItemReaderBuilder.RepositoryMethodReference repositoryMethodReference = new RepositoryItemReaderBuilder.RepositoryMethodReference( + this.repository); + repositoryMethodReference.methodIs().foo(ARG1, ARG2, ARG3, null); + RepositoryItemReader reader = new RepositoryItemReaderBuilder() + .repository(repositoryMethodReference) + .sorts(this.sorts) + .maxItemCount(5) + .name("bar").build(); + ArgumentCaptor arg1Captor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor arg2Captor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor arg3Captor = ArgumentCaptor.forClass(String.class); + when(this.repository.foo(arg1Captor.capture(), arg2Captor.capture(), arg3Captor.capture(), + this.pageRequestContainer.capture())).thenReturn(this.page); + + String result = (String) reader.read(); + assertEquals("Result returned from reader was not expected value.", TEST_CONTENT, result); + verifyMultiArgRead(arg1Captor, arg2Captor, arg3Captor, result); + } + + @Test + public void testCurrentItemCount() throws Exception { + RepositoryItemReader reader = new RepositoryItemReaderBuilder().repository(this.repository) + .sorts(this.sorts).currentItemCount(6).maxItemCount(5).methodName("foo").name("bar").build(); + assertNull("Result returned from reader was not null.", reader.read()); + } + + @Test + public void testPageSize() throws Exception { + RepositoryItemReader reader = new RepositoryItemReaderBuilder().repository(this.repository) + .sorts(this.sorts).maxItemCount(5).methodName("foo").name("bar").pageSize(2).build(); + reader.read(); + assertEquals("page size was not expected value.", 2, this.pageRequestContainer.getValue().getPageSize()); + } + + @Test + public void testNoMethodName() throws Exception { + try { + new RepositoryItemReaderBuilder().repository(this.repository).sorts(this.sorts).maxItemCount(10) + .build(); + + fail("IllegalArgumentException should have been thrown"); + } + catch (IllegalArgumentException iae) { + assertEquals("IllegalArgumentException message did not match the expected result.", + "methodName is required.", iae.getMessage()); + } + try { + new RepositoryItemReaderBuilder().repository(this.repository).sorts(this.sorts).methodName("") + .maxItemCount(5).build(); + + fail("IllegalArgumentException should have been thrown"); + } + catch (IllegalArgumentException iae) { + assertEquals("IllegalArgumentException message did not match the expected result.", + "methodName is required.", iae.getMessage()); + } + } + + @Test + public void testSaveState() throws Exception { + try { + new RepositoryItemReaderBuilder().repository(repository).methodName("foo").sorts(sorts) + .maxItemCount(5).build(); + + fail("IllegalArgumentException should have been thrown"); + } + catch (IllegalStateException ise) { + assertEquals("IllegalStateException name was not set when saveState was true.", + "A name is required when saveState is set to true.", ise.getMessage()); + } + // No IllegalStateException for a name that is not set, should not be thrown since + // saveState was false. + new RepositoryItemReaderBuilder().repository(repository).saveState(false).methodName("foo").sorts(sorts) + .maxItemCount(5).build(); + } + + @Test + public void testNullSort() throws Exception { + try { + new RepositoryItemReaderBuilder().repository(repository).methodName("foo") + .maxItemCount(5).build(); + + fail("IllegalArgumentException should have been thrown"); + } + catch (IllegalArgumentException iae) { + assertEquals("IllegalArgumentException sorts did not match the expected result.", "sorts map is required.", + iae.getMessage()); + } + } + + @Test + public void testNoRepository() throws Exception { + try { + new RepositoryItemReaderBuilder().sorts(this.sorts).maxItemCount(10).methodName("foo").build(); + + fail("IllegalArgumentException should have been thrown"); + } + catch (IllegalArgumentException iae) { + assertEquals("IllegalArgumentException message did not match the expected result.", + "repository is required.", iae.getMessage()); + } + } + + @Test + public void testArguments() throws Exception { + List args = new ArrayList<>(3); + args.add(ARG1); + args.add(ARG2); + args.add(ARG3); + ArgumentCaptor arg1Captor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor arg2Captor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor arg3Captor = ArgumentCaptor.forClass(String.class); + when(this.repository.foo(arg1Captor.capture(), arg2Captor.capture(), arg3Captor.capture(), + this.pageRequestContainer.capture())).thenReturn(this.page); + + RepositoryItemReader reader = new RepositoryItemReaderBuilder().repository(this.repository) + .sorts(this.sorts).maxItemCount(5).methodName("foo").name("bar").arguments(args).build(); + + String result = (String) reader.read(); + verifyMultiArgRead(arg1Captor, arg2Captor, arg3Captor, result); + } + + public static interface TestRepository extends PagingAndSortingRepository { + + public Object foo(PageRequest request); + + public Object foo(String arg1, String arg2, String arg3, PageRequest request); + } + + private void verifyMultiArgRead(ArgumentCaptor arg1Captor, ArgumentCaptor arg2Captor, ArgumentCaptor arg3Captor, String result) { + assertEquals("Result returned from reader was not expected value.", TEST_CONTENT, result); + assertEquals("ARG1 for calling method did not match expected result", ARG1, arg1Captor.getValue()); + assertEquals("ARG2 for calling method did not match expected result", ARG2, arg2Captor.getValue()); + assertEquals("ARG3 for calling method did not match expected result", ARG3, arg3Captor.getValue()); + assertEquals("Result Total Pages did not match expected result", 10, + this.pageRequestContainer.getValue().getPageSize()); + } +}