From 7e76cb00f34efc363b823437adbf8fe2bcb21e1d Mon Sep 17 00:00:00 2001 From: Michael Minella Date: Thu, 2 Apr 2020 13:18:50 -0500 Subject: [PATCH] Initial commit of Single Step Spring Batch Job Starter This commit adds a new starter for creating a single step Spring Batch job. It allows users to configure a FlatFileItemReader and a FlatFileItemWriter as well as the job and step with just properties. --- pom.xml | 1 + .../pom.xml | 49 ++ .../FlatFileItemReaderAutoConfiguration.java | 141 +++++ .../FlatFileItemReaderProperties.java | 363 ++++++++++++ .../FlatFileItemWriterAutoConfiguration.java | 155 +++++ .../FlatFileItemWriterProperties.java | 381 +++++++++++++ .../batch/autoconfigure/RangeConverter.java | 60 ++ .../SingleStepJobAutoConfiguration.java | 99 ++++ .../SingleStepJobProperties.java | 84 +++ .../spring-configuration-metadata.json | 229 ++++++++ .../main/resources/META-INF/spring.factories | 4 + ...tFileItemReaderAutoConfigurationTests.java | 469 +++++++++++++++ ...tFileItemWriterAutoConfigurationTests.java | 532 ++++++++++++++++++ .../autoconfigure/RangeConverterTests.java | 71 +++ .../SingleStepJobAutoConfigurationTests.java | 162 ++++++ .../src/test/resources/test.txt | 8 + .../src/test/resources/testUTF16.csv | Bin 0 -> 550 bytes .../src/test/resources/testUTF8.csv | 12 + .../src/test/resources/writerTestUTF16.txt | Bin 0 -> 26 bytes 19 files changed, 2820 insertions(+) create mode 100644 spring-cloud-starter-single-step-batch-job/pom.xml create mode 100644 spring-cloud-starter-single-step-batch-job/src/main/java/org/springframework/cloud/task/batch/autoconfigure/FlatFileItemReaderAutoConfiguration.java create mode 100644 spring-cloud-starter-single-step-batch-job/src/main/java/org/springframework/cloud/task/batch/autoconfigure/FlatFileItemReaderProperties.java create mode 100644 spring-cloud-starter-single-step-batch-job/src/main/java/org/springframework/cloud/task/batch/autoconfigure/FlatFileItemWriterAutoConfiguration.java create mode 100644 spring-cloud-starter-single-step-batch-job/src/main/java/org/springframework/cloud/task/batch/autoconfigure/FlatFileItemWriterProperties.java create mode 100644 spring-cloud-starter-single-step-batch-job/src/main/java/org/springframework/cloud/task/batch/autoconfigure/RangeConverter.java create mode 100644 spring-cloud-starter-single-step-batch-job/src/main/java/org/springframework/cloud/task/batch/autoconfigure/SingleStepJobAutoConfiguration.java create mode 100644 spring-cloud-starter-single-step-batch-job/src/main/java/org/springframework/cloud/task/batch/autoconfigure/SingleStepJobProperties.java create mode 100644 spring-cloud-starter-single-step-batch-job/src/main/resources/META-INF/spring-configuration-metadata.json create mode 100644 spring-cloud-starter-single-step-batch-job/src/main/resources/META-INF/spring.factories create mode 100644 spring-cloud-starter-single-step-batch-job/src/test/java/org/springframework/cloud/task/batch/autoconfigure/FlatFileItemReaderAutoConfigurationTests.java create mode 100644 spring-cloud-starter-single-step-batch-job/src/test/java/org/springframework/cloud/task/batch/autoconfigure/FlatFileItemWriterAutoConfigurationTests.java create mode 100644 spring-cloud-starter-single-step-batch-job/src/test/java/org/springframework/cloud/task/batch/autoconfigure/RangeConverterTests.java create mode 100644 spring-cloud-starter-single-step-batch-job/src/test/java/org/springframework/cloud/task/batch/autoconfigure/SingleStepJobAutoConfigurationTests.java create mode 100644 spring-cloud-starter-single-step-batch-job/src/test/resources/test.txt create mode 100644 spring-cloud-starter-single-step-batch-job/src/test/resources/testUTF16.csv create mode 100644 spring-cloud-starter-single-step-batch-job/src/test/resources/testUTF8.csv create mode 100644 spring-cloud-starter-single-step-batch-job/src/test/resources/writerTestUTF16.txt diff --git a/pom.xml b/pom.xml index 71315db3..8d5fb14a 100755 --- a/pom.xml +++ b/pom.xml @@ -106,6 +106,7 @@ spring-cloud-task-samples spring-cloud-task-integration-tests spring-cloud-task-docs + spring-cloud-starter-single-step-batch-job diff --git a/spring-cloud-starter-single-step-batch-job/pom.xml b/spring-cloud-starter-single-step-batch-job/pom.xml new file mode 100644 index 00000000..113ecf55 --- /dev/null +++ b/spring-cloud-starter-single-step-batch-job/pom.xml @@ -0,0 +1,49 @@ + + + + spring-cloud-task-parent + org.springframework.cloud + 2.3.0.BUILD-SNAPSHOT + + 4.0.0 + + spring-cloud-starter-single-step-batch-job + + + + org.springframework.boot + spring-boot-starter-batch + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.springframework.boot + spring-boot-configuration-processor + true + ${spring-boot.version} + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.batch + spring-batch-test + test + + + org.hsqldb + hsqldb + 2.4.0 + test + + + + diff --git a/spring-cloud-starter-single-step-batch-job/src/main/java/org/springframework/cloud/task/batch/autoconfigure/FlatFileItemReaderAutoConfiguration.java b/spring-cloud-starter-single-step-batch-job/src/main/java/org/springframework/cloud/task/batch/autoconfigure/FlatFileItemReaderAutoConfiguration.java new file mode 100644 index 00000000..c600ad19 --- /dev/null +++ b/spring-cloud-starter-single-step-batch-job/src/main/java/org/springframework/cloud/task/batch/autoconfigure/FlatFileItemReaderAutoConfiguration.java @@ -0,0 +1,141 @@ +/* + * Copyright 2019-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.cloud.task.batch.autoconfigure; + +import java.util.Map; + +import org.springframework.batch.item.file.FlatFileItemReader; +import org.springframework.batch.item.file.LineCallbackHandler; +import org.springframework.batch.item.file.LineMapper; +import org.springframework.batch.item.file.builder.FlatFileItemReaderBuilder; +import org.springframework.batch.item.file.mapping.FieldSetMapper; +import org.springframework.batch.item.file.separator.RecordSeparatorPolicy; +import org.springframework.batch.item.file.transform.FieldSet; +import org.springframework.batch.item.file.transform.LineTokenizer; +import org.springframework.batch.item.file.transform.Range; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Autconfiguration for a {@code FlatFileItemReader}. + * + * @author Michael Minella + * @since 2.3 + */ +@Configuration +@EnableConfigurationProperties(FlatFileItemReaderProperties.class) +@AutoConfigureAfter(BatchAutoConfiguration.class) +public class FlatFileItemReaderAutoConfiguration { + + private final FlatFileItemReaderProperties properties; + + @Autowired(required = false) + private LineTokenizer lineTokenizer; + + @Autowired(required = false) + private FieldSetMapper> fieldSetMapper; + + @Autowired(required = false) + private LineMapper> lineMapper; + + @Autowired(required = false) + private LineCallbackHandler skippedLinesCallback; + + @Autowired(required = false) + private RecordSeparatorPolicy recordSeparatorPolicy; + + public FlatFileItemReaderAutoConfiguration(FlatFileItemReaderProperties properties) { + this.properties = properties; + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = "spring.batch.job.flatfilereader", name = "name") + public FlatFileItemReader> itemReader() { + FlatFileItemReaderBuilder> mapFlatFileItemReaderBuilder = new FlatFileItemReaderBuilder>() + .name(this.properties.getName()).resource(this.properties.getResource()) + .saveState(this.properties.isSaveState()) + .maxItemCount(this.properties.getMaxItemCount()) + .currentItemCount(this.properties.getCurrentItemCount()) + .strict(this.properties.isStrict()) + .encoding(this.properties.getEncoding()) + .linesToSkip(this.properties.getLinesToSkip()) + .comments(this.properties.getComments() + .toArray(new String[this.properties.getComments().size()])); + + if (this.lineTokenizer != null) { + mapFlatFileItemReaderBuilder.lineTokenizer(this.lineTokenizer); + } + + if (this.recordSeparatorPolicy != null) { + mapFlatFileItemReaderBuilder + .recordSeparatorPolicy(this.recordSeparatorPolicy); + } + + if (this.fieldSetMapper != null) { + mapFlatFileItemReaderBuilder.fieldSetMapper(this.fieldSetMapper); + } + + if (this.lineMapper != null) { + mapFlatFileItemReaderBuilder.lineMapper(this.lineMapper); + } + + if (this.skippedLinesCallback != null) { + mapFlatFileItemReaderBuilder.skippedLinesCallback(skippedLinesCallback); + } + + if (this.properties.isDelimited()) { + mapFlatFileItemReaderBuilder.delimited() + .quoteCharacter(this.properties.getQuoteCharacter()) + .delimiter(this.properties.getDelimiter()) + .includedFields( + this.properties.getIncludedFields().toArray(new Integer[0])) + .names(this.properties.getNames()) + .beanMapperStrict(this.properties.isParsingStrict()) + .fieldSetMapper(new MapFieldSetMapper()); + } + else if (this.properties.isFixedLength()) { + mapFlatFileItemReaderBuilder.fixedLength() + .columns(this.properties.getRanges().toArray(new Range[0])) + .names(this.properties.getNames()) + .fieldSetMapper(new MapFieldSetMapper()) + .beanMapperStrict(this.properties.isParsingStrict()); + } + + return mapFlatFileItemReaderBuilder.build(); + } + + /** + * A {@link FieldSetMapper} that takes a {@code FieldSet} and returns the + * {@code Map} of its contents. + */ + public static class MapFieldSetMapper implements FieldSetMapper> { + + @Override + public Map mapFieldSet(FieldSet fieldSet) { + return fieldSet.getProperties(); + } + + } + +} diff --git a/spring-cloud-starter-single-step-batch-job/src/main/java/org/springframework/cloud/task/batch/autoconfigure/FlatFileItemReaderProperties.java b/spring-cloud-starter-single-step-batch-job/src/main/java/org/springframework/cloud/task/batch/autoconfigure/FlatFileItemReaderProperties.java new file mode 100644 index 00000000..e817f369 --- /dev/null +++ b/spring-cloud-starter-single-step-batch-job/src/main/java/org/springframework/cloud/task/batch/autoconfigure/FlatFileItemReaderProperties.java @@ -0,0 +1,363 @@ +/* + * Copyright 2019-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.cloud.task.batch.autoconfigure; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.batch.item.file.FlatFileItemReader; +import org.springframework.batch.item.file.transform.DelimitedLineTokenizer; +import org.springframework.batch.item.file.transform.Range; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.core.io.Resource; + +/** + * Properties to configure a {@code FlatFileItemReader}. + * + * @author Michael Minella + * @since 2.3 + */ +@ConfigurationProperties(prefix = "spring.batch.job.flatfilereader") +public class FlatFileItemReaderProperties { + + private boolean saveState = true; + + private String name; + + private int maxItemCount = Integer.MAX_VALUE; + + private int currentItemCount = 0; + + private List comments = new ArrayList<>(); + + private Resource resource; + + private boolean strict = true; + + private String encoding = FlatFileItemReader.DEFAULT_CHARSET; + + private int linesToSkip = 0; + + private boolean delimited = false; + + private String delimiter = DelimitedLineTokenizer.DELIMITER_COMMA; + + private char quoteCharacter = DelimitedLineTokenizer.DEFAULT_QUOTE_CHARACTER; + + private List includedFields = new ArrayList<>(); + + private boolean fixedLength = false; + + private List ranges = new ArrayList<>(); + + private String[] names; + + private boolean parsingStrict = true; + + /** + * Returns the configured value of if the state of the reader will be persisted. + * @return true if the state will be persisted + */ + public boolean isSaveState() { + return this.saveState; + } + + /** + * Configure if the state of the + * {@link org.springframework.batch.item.ItemStreamSupport} should be persisted within + * the {@link org.springframework.batch.item.ExecutionContext} for restart purposes. + * @param saveState defaults to true + */ + public void setSaveState(boolean saveState) { + this.saveState = saveState; + } + + /** + * Returns the configured value of the name used to calculate {@code ExecutionContext} + * keys. + * @return the name + */ + public String getName() { + return this.name; + } + + /** + * The name used to calculate the key within the + * {@link org.springframework.batch.item.ExecutionContext}. Required if + * {@link #setSaveState} is set to true. + * @param name name of the reader instance + * @see org.springframework.batch.item.ItemStreamSupport#setName(String) + */ + public void setName(String name) { + this.name = name; + } + + /** + * The maximum number of items to be read. + * @return the configured number of items, defaults to Integer.MAX_VALUE + */ + public int getMaxItemCount() { + return this.maxItemCount; + } + + /** + * Configure the max number of items to be read. + * @param maxItemCount the max items to be read + * @see org.springframework.batch.item.support.AbstractItemCountingItemStreamItemReader#setMaxItemCount(int) + */ + public void setMaxItemCount(int maxItemCount) { + this.maxItemCount = maxItemCount; + } + + /** + * Provides the index of the current item. + * @return item index + */ + public int getCurrentItemCount() { + return this.currentItemCount; + } + + /** + * Index for the current item. Also used on restarts to indicate where to start from. + * @param currentItemCount current index + * @see org.springframework.batch.item.support.AbstractItemCountingItemStreamItemReader#setCurrentItemCount(int) + */ + public void setCurrentItemCount(int currentItemCount) { + this.currentItemCount = currentItemCount; + } + + /** + * List of {@code String} values used to indicate what records are comments. + * @return list of comment indicators + */ + public List getComments() { + return this.comments; + } + + /** + * Takes a list of {@code String} elements used to indicate what records are comments. + * @param comments strings used to indicate commented lines + */ + public void setComments(List comments) { + this.comments = comments; + } + + /** + * The input file for the {@code FlatFileItemReader}. + * @return a Resource + */ + public Resource getResource() { + return this.resource; + } + + /** + * The {@link Resource} to be used as input. + * @param resource the input to the reader. + * @see FlatFileItemReader#setResource(Resource) + */ + public void setResource(Resource resource) { + this.resource = resource; + } + + /** + * Returns true if a missing input file is considered an error. + * @return true if the input file is required. + */ + public boolean isStrict() { + return this.strict; + } + + /** + * Configure if the reader should be in strict mode (require the input + * {@link Resource} to exist). + * @param strict true if the input file is required to exist. + * @see FlatFileItemReader#setStrict(boolean) + */ + public void setStrict(boolean strict) { + this.strict = strict; + } + + /** + * Returns the encoding for the input file. Defaults to + * {@code FlatFileItemReader#DEFAULT_CHARSET}. + * @return the configured encoding + */ + public String getEncoding() { + return this.encoding; + } + + /** + * Configure the encoding used by the reader to read the input source. Default value + * is {@link FlatFileItemReader#DEFAULT_CHARSET}. + * @param encoding to use to read the input source. + * @see FlatFileItemReader#setEncoding(String) + */ + public void setEncoding(String encoding) { + this.encoding = encoding; + } + + /** + * Number of lines to skip when reading the input file. + * @return number of lines + */ + public int getLinesToSkip() { + return this.linesToSkip; + } + + /** + * The number of lines to skip at the beginning of reading the file. + * @param linesToSkip number of lines to be skipped. + * @see FlatFileItemReader#setLinesToSkip(int) + */ + public void setLinesToSkip(int linesToSkip) { + this.linesToSkip = linesToSkip; + } + + /** + * Indicates if the input file is a delimited file or not. + * @return true if the file is delimited + */ + public boolean isDelimited() { + return this.delimited; + } + + /** + * Indicates that a {@link DelimitedLineTokenizer} should be used to parse each line. + * @param delimited true if the file is a delimited file + */ + public void setDelimited(boolean delimited) { + this.delimited = delimited; + } + + /** + * The {@code String} used to divide the record into fields. + * @return the delimiter + */ + public String getDelimiter() { + return this.delimiter; + } + + /** + * Define the delimiter for the file. + * @param delimiter String used as a delimiter between fields. + * @see DelimitedLineTokenizer#setDelimiter(String) + */ + public void setDelimiter(String delimiter) { + this.delimiter = delimiter; + } + + /** + * The char used to indicate that a field is quoted. + * @return the quote char + */ + public char getQuoteCharacter() { + return this.quoteCharacter; + } + + /** + * Define the character used to quote fields. + * @param quoteCharacter char used to define quoted fields + * @see DelimitedLineTokenizer#setQuoteCharacter(char) + */ + public void setQuoteCharacter(char quoteCharacter) { + this.quoteCharacter = quoteCharacter; + } + + /** + * A {@code List} of indices indicating what fields to include. + * @return list of indices + */ + public List getIncludedFields() { + return this.includedFields; + } + + /** + * A list of indices of the fields within a delimited file to be included. + * @param includedFields indices of the fields + * @see DelimitedLineTokenizer#setIncludedFields(int[]) + */ + public void setIncludedFields(List includedFields) { + this.includedFields = includedFields; + } + + /** + * Indicates that a file contains records with fixed length columns. + * @return true if the file is parsed using column indices + */ + public boolean isFixedLength() { + return this.fixedLength; + } + + /** + * Indicates that a + * {@link org.springframework.batch.item.file.transform.FixedLengthTokenizer} should + * be used to parse the records in the file. + * @param fixedLength true if the records should be tokenized by column index + */ + public void setFixedLength(boolean fixedLength) { + this.fixedLength = fixedLength; + } + + /** + * The column ranges to be used to parsed a fixed width file. + * @return a list of {@link Range} instances + */ + public List getRanges() { + return this.ranges; + } + + /** + * Column ranges for each field. + * @param ranges list of ranges in start-end format (end is optional) + */ + public void setRanges(List ranges) { + this.ranges = ranges; + } + + /** + * Names of each column. + * @return names + */ + public String[] getNames() { + return this.names; + } + + /** + * The names of the fields to be parsed from the file. + * @param names names of fields + */ + public void setNames(String[] names) { + this.names = names; + } + + /** + * Indicates if the number of tokens must match the number of configured fields. + * @return true if they must match + */ + public boolean isParsingStrict() { + return this.parsingStrict; + } + + /** + * Indicates if the number of tokens must match the number of configured fields. + * @param parsingStrict true if they must match + */ + public void setParsingStrict(boolean parsingStrict) { + this.parsingStrict = parsingStrict; + } + +} diff --git a/spring-cloud-starter-single-step-batch-job/src/main/java/org/springframework/cloud/task/batch/autoconfigure/FlatFileItemWriterAutoConfiguration.java b/spring-cloud-starter-single-step-batch-job/src/main/java/org/springframework/cloud/task/batch/autoconfigure/FlatFileItemWriterAutoConfiguration.java new file mode 100644 index 00000000..f21dabde --- /dev/null +++ b/spring-cloud-starter-single-step-batch-job/src/main/java/org/springframework/cloud/task/batch/autoconfigure/FlatFileItemWriterAutoConfiguration.java @@ -0,0 +1,155 @@ +/* + * Copyright 2019-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.cloud.task.batch.autoconfigure; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.springframework.batch.item.file.FlatFileFooterCallback; +import org.springframework.batch.item.file.FlatFileHeaderCallback; +import org.springframework.batch.item.file.FlatFileItemWriter; +import org.springframework.batch.item.file.builder.FlatFileItemWriterBuilder; +import org.springframework.batch.item.file.transform.FieldExtractor; +import org.springframework.batch.item.file.transform.LineAggregator; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Autoconfiguration for a {@code FlatFileItemWriter}. + * + * @author Michael Minella + * @since 2.3 + */ +@Configuration +@EnableConfigurationProperties(FlatFileItemWriterProperties.class) +@AutoConfigureAfter(BatchAutoConfiguration.class) +public class FlatFileItemWriterAutoConfiguration { + + private FlatFileItemWriterProperties properties; + + @Autowired(required = false) + private LineAggregator> lineAggregator; + + @Autowired(required = false) + private FieldExtractor> fieldExtractor; + + @Autowired(required = false) + private FlatFileHeaderCallback headerCallback; + + @Autowired(required = false) + private FlatFileFooterCallback footerCallback; + + public FlatFileItemWriterAutoConfiguration(FlatFileItemWriterProperties properties) { + this.properties = properties; + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = "spring.batch.job.flatfilewriter", name = "name") + public FlatFileItemWriter> itemWriter() { + + if (this.properties.isDelimited() && this.properties.isFormatted()) { + throw new IllegalStateException( + "An output file must be either delimited or formatted or a custom " + + "LineAggregator must be provided. Your current configuration specifies both delimited and formatted"); + } + else if ((this.properties.isFormatted() || this.properties.isDelimited()) + && this.lineAggregator != null) { + throw new IllegalStateException("A LineAggregator must be configured if the " + + "output is not formatted or delimited"); + } + + FlatFileItemWriterBuilder> builder = new FlatFileItemWriterBuilder>() + .name(this.properties.getName()).resource(this.properties.getResource()) + .append(this.properties.isAppend()) + .encoding(this.properties.getEncoding()) + .forceSync(this.properties.isForceSync()) + .lineSeparator(this.properties.getLineSeparator()) + .saveState(this.properties.isSaveState()) + .shouldDeleteIfEmpty(this.properties.isShouldDeleteIfEmpty()) + .shouldDeleteIfExists(this.properties.isShouldDeleteIfExists()) + .transactional(this.properties.isTransactional()) + .headerCallback(this.headerCallback).footerCallback(this.footerCallback); + + if (this.properties.isDelimited()) { + FlatFileItemWriterBuilder.DelimitedBuilder> delimitedBuilder = builder + .delimited().delimiter(this.properties.getDelimiter()); + + if (this.fieldExtractor != null) { + delimitedBuilder.fieldExtractor(this.fieldExtractor); + } + else { + delimitedBuilder.fieldExtractor( + new MapFieldExtractor(this.properties.getNames())); + } + } + else if (this.properties.isFormatted()) { + FlatFileItemWriterBuilder.FormattedBuilder> formattedBuilder = builder + .formatted().format(this.properties.getFormat()) + .locale(this.properties.getLocale()) + .maximumLength(this.properties.getMaximumLength()) + .minimumLength(this.properties.getMinimumLength()); + + if (this.fieldExtractor != null) { + formattedBuilder.fieldExtractor(this.fieldExtractor); + } + else { + formattedBuilder.fieldExtractor( + new MapFieldExtractor(this.properties.getNames())); + } + } + else if (this.lineAggregator != null) { + builder.lineAggregator(this.lineAggregator); + } + + return builder.build(); + } + + /** + * A {@code FieldExtractor} that converts a {@code Map} to the ordered + * {@code Object[]} required to populate an output record. + */ + public static class MapFieldExtractor implements FieldExtractor> { + + private String[] names; + + public MapFieldExtractor(String[] names) { + this.names = names; + } + + @Override + public Object[] extract(Map item) { + + List fields = new ArrayList<>(item.size()); + + for (String name : this.names) { + fields.add(item.get(name)); + } + + return fields.toArray(); + } + + } + +} diff --git a/spring-cloud-starter-single-step-batch-job/src/main/java/org/springframework/cloud/task/batch/autoconfigure/FlatFileItemWriterProperties.java b/spring-cloud-starter-single-step-batch-job/src/main/java/org/springframework/cloud/task/batch/autoconfigure/FlatFileItemWriterProperties.java new file mode 100644 index 00000000..68db2d86 --- /dev/null +++ b/spring-cloud-starter-single-step-batch-job/src/main/java/org/springframework/cloud/task/batch/autoconfigure/FlatFileItemWriterProperties.java @@ -0,0 +1,381 @@ +/* + * Copyright 2019-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.cloud.task.batch.autoconfigure; + +import java.util.Locale; + +import org.springframework.batch.item.file.FlatFileItemWriter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.core.io.Resource; + +/** + * Properties for configuring a {@code FlatFileItemWriter}. + * + * @author Michael Minella + * @since 2.3 + */ +@ConfigurationProperties(prefix = "spring.batch.job.flatfilewriter") +public class FlatFileItemWriterProperties { + + private Resource resource; + + private boolean delimited; + + private boolean formatted; + + private String format; + + private Locale locale = Locale.getDefault(); + + private int maximumLength = 0; + + private int minimumLength = 0; + + private String delimiter = ","; + + private String encoding = FlatFileItemWriter.DEFAULT_CHARSET; + + private boolean forceSync = false; + + private String[] names; + + private boolean append = false; + + private String lineSeparator = FlatFileItemWriter.DEFAULT_LINE_SEPARATOR; + + private String name; + + private boolean saveState = true; + + private boolean shouldDeleteIfEmpty = false; + + private boolean shouldDeleteIfExists = true; + + private boolean transactional = FlatFileItemWriter.DEFAULT_TRANSACTIONAL; + + /** + * Returns the configured value of if the state of the reader will be persisted. + * @return true if the state will be persisted + */ + public boolean isSaveState() { + return this.saveState; + } + + /** + * Configure if the state of the + * {@link org.springframework.batch.item.ItemStreamSupport} should be persisted within + * the {@link org.springframework.batch.item.ExecutionContext} for restart purposes. + * @param saveState defaults to true + */ + public void setSaveState(boolean saveState) { + this.saveState = saveState; + } + + /** + * Returns the configured value of the name used to calculate {@code ExecutionContext} + * keys. + * @return the name + */ + public String getName() { + return this.name; + } + + /** + * The name used to calculate the key within the + * {@link org.springframework.batch.item.ExecutionContext}. Required if + * {@link #setSaveState} is set to true. + * @param name name of the reader instance + * @see org.springframework.batch.item.ItemStreamSupport#setName(String) + */ + public void setName(String name) { + this.name = name; + } + + /** + * The output file for the {@code FlatFileItemWriter}. + * @return a {@code Resource} + */ + public Resource getResource() { + return this.resource; + } + + /** + * The {@link Resource} to be used as output. + * @param resource the input to the reader. + * @see FlatFileItemWriter#setResource + */ + public void setResource(Resource resource) { + this.resource = resource; + } + + /** + * Indicates of the output will be delimited by a configured string (, by default). + * @return true if the output file is a delimited file + */ + public boolean isDelimited() { + return delimited; + } + + /** + * Configure the use of the {@code DelimitedLineAggregator} to generate the output per + * item. + * @param delimited indicator if the file will be delimited or not + */ + public void setDelimited(boolean delimited) { + this.delimited = delimited; + } + + /** + * . When a file is delimited, this {@code String} will be used as the delimiter + * between fields + * @return delimiter + */ + public String getDelimiter() { + return delimiter; + } + + /** + * Configure the {@code String} used to delimit the fields in the output file. + * @param delimiter {@code String} used to delimit the fields of the output file. + */ + public void setDelimiter(String delimiter) { + this.delimiter = delimiter; + } + + /** + * Names of the fields to be extracted into the output. + * @return An array of field names + */ + public String[] getNames() { + return names; + } + + /** + * Provide an ordered array of field names used to generate the output of a file. + * @param names An array of field names + */ + public void setNames(String[] names) { + this.names = names; + } + + /** + * True if an output file is found and should be added onto instead of + * replaced/deleted. False by default. + * @return appending indicator + */ + public boolean isAppend() { + return append; + } + + /** + * Configure if the output file is found if it should be appended to. Defaults to + * false. + * @param append true if the output file should be appended onto if found. + */ + public void setAppend(boolean append) { + this.append = append; + } + + /** + * Indicates that the output file will use String formatting to generate the output. + * @return true if the file will contain formatted records + */ + public boolean isFormatted() { + return formatted; + } + + /** + * Indicates to use a {@code FormatterLineAggregator} to generate the output per item. + * @param formatted true if the output should be formatted via the + * {@code FormatterLineAggregator} + */ + public void setFormatted(boolean formatted) { + this.formatted = formatted; + } + + /** + * File encoding for the output file. + * @return the configured encoding for the output file (Defaults to + * {@code FlatFileItemWriter.DEFAULT_CHARSET}) + */ + public String getEncoding() { + return encoding; + } + + /** + * Configure encoding of the output file. + * @param encoding output encoding + */ + public void setEncoding(String encoding) { + this.encoding = encoding; + } + + /** + * A flag indicating that changes should be force-synced to disk on flush. Defaults to + * false. + * @return The current instance of the builder. + */ + public boolean isForceSync() { + return forceSync; + } + + /** + * A flag indicating that changes should be force-synced to disk on flush. Defaults to + * false. + * @param forceSync value to set the flag to + */ + public void setForceSync(boolean forceSync) { + this.forceSync = forceSync; + } + + /** + * String used to separate lines in output. Defaults to the System property + * line.separator. + * @return the separator string + */ + public String getLineSeparator() { + return lineSeparator; + } + + /** + * Configure the {@code String} used to separate each line. + * @param lineSeparator defaults to System's line.separator property + */ + public void setLineSeparator(String lineSeparator) { + this.lineSeparator = lineSeparator; + } + + /** + * Indicates if the output file should be deleted if no output was written to it. + * Defaults to false. + * @return true if a file that is empty at the end of the step should be deleted. + */ + public boolean isShouldDeleteIfEmpty() { + return shouldDeleteIfEmpty; + } + + /** + * Configure if an empty output file should be deleted once the step is complete. + * Defaults to false. + * @param shouldDeleteIfEmpty true if the file should be deleted if no items have been + * written to it. + */ + public void setShouldDeleteIfEmpty(boolean shouldDeleteIfEmpty) { + this.shouldDeleteIfEmpty = shouldDeleteIfEmpty; + } + + /** + * Indicates if an existing output file should be deleted on startup. Defaults to + * true. + * @return if an existing output file should be deleted. + */ + public boolean isShouldDeleteIfExists() { + return shouldDeleteIfExists; + } + + /** + * Configures if an existing output file should be deleted on the start of the step. + * Defaults to true. + * @param shouldDeleteIfExists if true and an output file of a previous run is found, + * it will be deleted. + */ + public void setShouldDeleteIfExists(boolean shouldDeleteIfExists) { + this.shouldDeleteIfExists = shouldDeleteIfExists; + } + + /** + * Indicates if flushing the buffer should be delayed while a transaction is active. + * Defaults to true. + * @return flag indicating if flushing should be delayed during a transaction + */ + public boolean isTransactional() { + return transactional; + } + + /** + * Configure if output should not be flushed to disk during an active transaction. + * @param transactional defaults to true + */ + public void setTransactional(boolean transactional) { + this.transactional = transactional; + } + + /** + * Format used with the {@code FormatterLineAggregator}. + * @return the format for each item's output. + */ + public String getFormat() { + return format; + } + + /** + * Configure the format the {@code FormatterLineAggregator} will use for each item. + * @param format the format for each item's output. + */ + public void setFormat(String format) { + this.format = format; + } + + /** + * The {@code Locale} used when generating the output file. + * @return configured {@code Locale}. Defaults to {@code Locale.getDefault()} + */ + public Locale getLocale() { + return locale; + } + + /** + * Configure the {@code Locale} to use when generating the output. + * @param locale the configured {@code Locale} + */ + public void setLocale(Locale locale) { + this.locale = locale; + } + + /** + * The longest a record is allowed to be. If 0, the maximum is unlimited. + * @return the max record length allowed + */ + public int getMaximumLength() { + return maximumLength; + } + + /** + * Configure the maximum record length. If 0, the size is unbounded. + * @param maximumLength the maximum record length allowed. + */ + public void setMaximumLength(int maximumLength) { + this.maximumLength = maximumLength; + } + + /** + * The minimum record length. + * @return the minimum record length allowed. + */ + public int getMinimumLength() { + return minimumLength; + } + + /** + * Configure the minimum record length. + * @param minimumLength the minimum record length. + */ + public void setMinimumLength(int minimumLength) { + this.minimumLength = minimumLength; + } + +} diff --git a/spring-cloud-starter-single-step-batch-job/src/main/java/org/springframework/cloud/task/batch/autoconfigure/RangeConverter.java b/spring-cloud-starter-single-step-batch-job/src/main/java/org/springframework/cloud/task/batch/autoconfigure/RangeConverter.java new file mode 100644 index 00000000..13aa5b04 --- /dev/null +++ b/spring-cloud-starter-single-step-batch-job/src/main/java/org/springframework/cloud/task/batch/autoconfigure/RangeConverter.java @@ -0,0 +1,60 @@ +/* + * Copyright 2020-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.cloud.task.batch.autoconfigure; + +import org.springframework.batch.item.file.transform.Range; +import org.springframework.boot.context.properties.ConfigurationPropertiesBinding; +import org.springframework.core.convert.converter.Converter; +import org.springframework.stereotype.Component; + +/** + * Converter for taking properties of format {@code start-end} or {@code start} (where + * start and end are both integers) and converting them into {@link Range} instances for + * configuring a {@link org.springframework.batch.item.file.FlatFileItemReader}. + * + * @author Michael Minella + * @since 2.3 + */ +@Component +@ConfigurationPropertiesBinding +public class RangeConverter implements Converter { + + @Override + public Range convert(String source) { + if (source == null) { + return null; + } + + String[] columns = source.split("-"); + + if (columns.length == 1) { + int start = Integer.parseInt(columns[0]); + return new Range(start); + } + else if (columns.length == 2) { + int start = Integer.parseInt(columns[0]); + int end = Integer.parseInt(columns[1]); + return new Range(start, end); + } + else { + throw new IllegalArgumentException(String.format( + "%s is in an illegal format. Ranges must be specified as startIndex-endIndex", + source)); + } + } + +} diff --git a/spring-cloud-starter-single-step-batch-job/src/main/java/org/springframework/cloud/task/batch/autoconfigure/SingleStepJobAutoConfiguration.java b/spring-cloud-starter-single-step-batch-job/src/main/java/org/springframework/cloud/task/batch/autoconfigure/SingleStepJobAutoConfiguration.java new file mode 100644 index 00000000..10423abe --- /dev/null +++ b/spring-cloud-starter-single-step-batch-job/src/main/java/org/springframework/cloud/task/batch/autoconfigure/SingleStepJobAutoConfiguration.java @@ -0,0 +1,99 @@ +/* + * Copyright 2019-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.cloud.task.batch.autoconfigure; + +import java.util.Map; + +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobBuilderFactory; +import org.springframework.batch.core.configuration.annotation.StepBuilderFactory; +import org.springframework.batch.core.step.builder.SimpleStepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.Assert; + +/** + * Autoconfiguration to create a single step Spring Batch Job. + * + * @author Michael Minella + * @since 2.3 + */ +@Configuration +@EnableConfigurationProperties(SingleStepJobProperties.class) +@AutoConfigureAfter(BatchAutoConfiguration.class) +public class SingleStepJobAutoConfiguration { + + private JobBuilderFactory jobBuilderFactory; + + private StepBuilderFactory stepBuilderFactory; + + private SingleStepJobProperties properties; + + @Autowired(required = false) + private ItemProcessor, Map> itemProcessor; + + public SingleStepJobAutoConfiguration(JobBuilderFactory jobBuilderFactory, + StepBuilderFactory stepBuilderFactory, SingleStepJobProperties properties, + ApplicationContext context) { + + validateProperties(properties); + + this.jobBuilderFactory = jobBuilderFactory; + this.stepBuilderFactory = stepBuilderFactory; + this.properties = properties; + } + + private void validateProperties(SingleStepJobProperties properties) { + Assert.hasText(properties.getJobName(), "A job name is required"); + Assert.hasText(properties.getStepName(), "A step name is required"); + Assert.notNull(properties.getChunkSize(), "A chunk size is required"); + Assert.isTrue(properties.getChunkSize() > 0, + "A chunk size greater than zero is required"); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = "spring.batch.job", name = "jobName") + public Job job(ItemReader> itemReader, + ItemWriter> itemWriter) { + + SimpleStepBuilder, Map> stepBuilder = this.stepBuilderFactory + .get(this.properties.getStepName()) + ., Map>chunk( + this.properties.getChunkSize()) + .reader(itemReader); + + stepBuilder.processor(this.itemProcessor); + + Step step = stepBuilder.writer(itemWriter).build(); + + return this.jobBuilderFactory.get(this.properties.getJobName()).start(step) + .build(); + } + +} diff --git a/spring-cloud-starter-single-step-batch-job/src/main/java/org/springframework/cloud/task/batch/autoconfigure/SingleStepJobProperties.java b/spring-cloud-starter-single-step-batch-job/src/main/java/org/springframework/cloud/task/batch/autoconfigure/SingleStepJobProperties.java new file mode 100644 index 00000000..a8021c34 --- /dev/null +++ b/spring-cloud-starter-single-step-batch-job/src/main/java/org/springframework/cloud/task/batch/autoconfigure/SingleStepJobProperties.java @@ -0,0 +1,84 @@ +/* + * Copyright 2019-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.cloud.task.batch.autoconfigure; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Properties to configure the step and job level properties for a single step job. + * + * @author Michael Minella + * @since 2.3 + */ +@ConfigurationProperties(prefix = "spring.batch.job") +public class SingleStepJobProperties { + + private String stepName; + + private Integer chunkSize; + + private String jobName; + + /** + * Name of the step in the single step job. + * @return name + */ + public String getStepName() { + return stepName; + } + + /** + * Set the name of the step. + * @param stepName name + */ + public void setStepName(String stepName) { + this.stepName = stepName; + } + + /** + * The number of items to process per transaction/chunk. + * @return number of items + */ + public Integer getChunkSize() { + return chunkSize; + } + + /** + * Set the number of items within a transaction/chunk. + * @param chunkSize number of items + */ + public void setChunkSize(Integer chunkSize) { + this.chunkSize = chunkSize; + } + + /** + * The name of the job. + * @return name + */ + public String getJobName() { + return jobName; + } + + /** + * Set the name of the job. + * @param jobName name + */ + public void setJobName(String jobName) { + this.jobName = jobName; + } + +} diff --git a/spring-cloud-starter-single-step-batch-job/src/main/resources/META-INF/spring-configuration-metadata.json b/spring-cloud-starter-single-step-batch-job/src/main/resources/META-INF/spring-configuration-metadata.json new file mode 100644 index 00000000..c7c099c9 --- /dev/null +++ b/spring-cloud-starter-single-step-batch-job/src/main/resources/META-INF/spring-configuration-metadata.json @@ -0,0 +1,229 @@ +{ + "groups": [ + { + "name": "spring.batch.job", + "type": "org.springframework.cloud.task.batch.autoconfigure.SingleStepJobProperties", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.SingleStepJobProperties" + }, + { + "name": "spring.batch.job.flatfilereader", + "type": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemReaderProperties", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemReaderProperties" + }, + { + "name": "spring.batch.job.flatfilewriter", + "type": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemWriterProperties", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemWriterProperties" + } + ], + "properties": [ + { + "name": "spring.batch.job.chunk-size", + "type": "java.lang.Integer", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.SingleStepJobProperties" + }, + { + "name": "spring.batch.job.flatfilereader.comments", + "type": "java.util.List", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemReaderProperties" + }, + { + "name": "spring.batch.job.flatfilereader.current-item-count", + "type": "java.lang.Integer", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemReaderProperties", + "defaultValue": 0 + }, + { + "name": "spring.batch.job.flatfilereader.delimited", + "type": "java.lang.Boolean", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemReaderProperties", + "defaultValue": false + }, + { + "name": "spring.batch.job.flatfilereader.delimiter", + "type": "java.lang.String", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemReaderProperties" + }, + { + "name": "spring.batch.job.flatfilereader.encoding", + "type": "java.lang.String", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemReaderProperties" + }, + { + "name": "spring.batch.job.flatfilereader.fixed-length", + "type": "java.lang.Boolean", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemReaderProperties", + "defaultValue": false + }, + { + "name": "spring.batch.job.flatfilereader.included-fields", + "type": "java.util.List", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemReaderProperties" + }, + { + "name": "spring.batch.job.flatfilereader.lines-to-skip", + "type": "java.lang.Integer", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemReaderProperties", + "defaultValue": 0 + }, + { + "name": "spring.batch.job.flatfilereader.max-item-count", + "type": "java.lang.Integer", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemReaderProperties" + }, + { + "name": "spring.batch.job.flatfilereader.name", + "type": "java.lang.String", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemReaderProperties" + }, + { + "name": "spring.batch.job.flatfilereader.names", + "type": "java.lang.String[]", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemReaderProperties" + }, + { + "name": "spring.batch.job.flatfilereader.parsing-strict", + "type": "java.lang.Boolean", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemReaderProperties", + "defaultValue": true + }, + { + "name": "spring.batch.job.flatfilereader.quote-character", + "type": "java.lang.Character", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemReaderProperties" + }, + { + "name": "spring.batch.job.flatfilereader.ranges", + "type": "java.util.List", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemReaderProperties" + }, + { + "name": "spring.batch.job.flatfilereader.resource", + "type": "org.springframework.core.io.Resource", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemReaderProperties" + }, + { + "name": "spring.batch.job.flatfilereader.save-state", + "type": "java.lang.Boolean", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemReaderProperties", + "defaultValue": true + }, + { + "name": "spring.batch.job.flatfilereader.strict", + "type": "java.lang.Boolean", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemReaderProperties", + "defaultValue": true + }, + { + "name": "spring.batch.job.flatfilewriter.append", + "type": "java.lang.Boolean", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemWriterProperties", + "defaultValue": false + }, + { + "name": "spring.batch.job.flatfilewriter.delimited", + "type": "java.lang.Boolean", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemWriterProperties", + "defaultValue": false + }, + { + "name": "spring.batch.job.flatfilewriter.delimiter", + "type": "java.lang.String", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemWriterProperties", + "defaultValue": "," + }, + { + "name": "spring.batch.job.flatfilewriter.encoding", + "type": "java.lang.String", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemWriterProperties" + }, + { + "name": "spring.batch.job.flatfilewriter.force-sync", + "type": "java.lang.Boolean", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemWriterProperties", + "defaultValue": false + }, + { + "name": "spring.batch.job.flatfilewriter.format", + "type": "java.lang.String", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemWriterProperties" + }, + { + "name": "spring.batch.job.flatfilewriter.formatted", + "type": "java.lang.Boolean", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemWriterProperties", + "defaultValue": false + }, + { + "name": "spring.batch.job.flatfilewriter.line-separator", + "type": "java.lang.String", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemWriterProperties" + }, + { + "name": "spring.batch.job.flatfilewriter.locale", + "type": "java.util.Locale", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemWriterProperties" + }, + { + "name": "spring.batch.job.flatfilewriter.maximum-length", + "type": "java.lang.Integer", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemWriterProperties", + "defaultValue": 0 + }, + { + "name": "spring.batch.job.flatfilewriter.minimum-length", + "type": "java.lang.Integer", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemWriterProperties", + "defaultValue": 0 + }, + { + "name": "spring.batch.job.flatfilewriter.name", + "type": "java.lang.String", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemWriterProperties" + }, + { + "name": "spring.batch.job.flatfilewriter.names", + "type": "java.lang.String[]", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemWriterProperties" + }, + { + "name": "spring.batch.job.flatfilewriter.resource", + "type": "org.springframework.core.io.Resource", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemWriterProperties" + }, + { + "name": "spring.batch.job.flatfilewriter.save-state", + "type": "java.lang.Boolean", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemWriterProperties", + "defaultValue": true + }, + { + "name": "spring.batch.job.flatfilewriter.should-delete-if-empty", + "type": "java.lang.Boolean", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemWriterProperties", + "defaultValue": false + }, + { + "name": "spring.batch.job.flatfilewriter.should-delete-if-exists", + "type": "java.lang.Boolean", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemWriterProperties", + "defaultValue": true + }, + { + "name": "spring.batch.job.flatfilewriter.transactional", + "type": "java.lang.Boolean", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.FlatFileItemWriterProperties" + }, + { + "name": "spring.batch.job.job-name", + "type": "java.lang.String", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.SingleStepJobProperties" + }, + { + "name": "spring.batch.job.step-name", + "type": "java.lang.String", + "sourceType": "org.springframework.cloud.task.batch.autoconfigure.SingleStepJobProperties" + } + ], + "hints": [] +} diff --git a/spring-cloud-starter-single-step-batch-job/src/main/resources/META-INF/spring.factories b/spring-cloud-starter-single-step-batch-job/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000..c9917bf4 --- /dev/null +++ b/spring-cloud-starter-single-step-batch-job/src/main/resources/META-INF/spring.factories @@ -0,0 +1,4 @@ +org.springframework.boot.autoconfigure.EnableAutoConfiguration=org.springframework.cloud.task.batch.autoconfigure.FlatFileItemReaderAutoConfiguration,\ + org.springframework.cloud.task.batch.autoconfigure.RangeConverter,\ + org.springframework.cloud.task.batch.autoconfigure.SingleStepJobAutoConfiguration,\ + org.springframework.cloud.task.batch.autoconfigure.FlatFileItemWriterAutoConfiguration diff --git a/spring-cloud-starter-single-step-batch-job/src/test/java/org/springframework/cloud/task/batch/autoconfigure/FlatFileItemReaderAutoConfigurationTests.java b/spring-cloud-starter-single-step-batch-job/src/test/java/org/springframework/cloud/task/batch/autoconfigure/FlatFileItemReaderAutoConfigurationTests.java new file mode 100644 index 00000000..cde17ad5 --- /dev/null +++ b/spring-cloud-starter-single-step-batch-job/src/test/java/org/springframework/cloud/task/batch/autoconfigure/FlatFileItemReaderAutoConfigurationTests.java @@ -0,0 +1,469 @@ +/* + * Copyright 2020-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.cloud.task.batch.autoconfigure; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Test; + +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; +import org.springframework.batch.core.configuration.annotation.JobBuilderFactory; +import org.springframework.batch.core.configuration.annotation.StepBuilderFactory; +import org.springframework.batch.core.explore.JobExplorer; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.batch.item.file.FlatFileItemReader; +import org.springframework.batch.item.file.LineCallbackHandler; +import org.springframework.batch.item.file.LineMapper; +import org.springframework.batch.item.file.mapping.FieldSetMapper; +import org.springframework.batch.item.file.separator.RecordSeparatorPolicy; +import org.springframework.batch.item.file.transform.DefaultFieldSet; +import org.springframework.batch.item.file.transform.LineTokenizer; +import org.springframework.batch.item.support.ListItemWriter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration; +import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Michael Minella + */ +public class FlatFileItemReaderAutoConfigurationTests { + + /** + * Contents of the file to be read (included here because it's UTF-16): + * + *
+	 * 1@2@3@4@5@six
+	 * # This should be ignored
+	 * 7@8@9@10@11@twelve
+	 * $ So should this
+	 * 13@14@15@16@17@eighteen
+	 * 19@20@21@22@23@%twenty four%
+	 * 15@26@27@28@29@thirty
+	 * 31@32@33@34@35@thirty six
+	 * 37@38@39@40@41@forty two
+	 * 43@44@45@46@47@forty eight
+	 * 49@50@51@52@53@fifty four
+	 * 55@56@57@58@59@sixty
+	 * 
+ */ + @Test + public void testFullDelimitedConfiguration() { + ApplicationContextRunner applicationContextRunner = new ApplicationContextRunner() + .withUserConfiguration(JobConfiguration.class) + .withConfiguration( + AutoConfigurations.of(PropertyPlaceholderAutoConfiguration.class, + BatchAutoConfiguration.class, + SingleStepJobAutoConfiguration.class, + FlatFileItemReaderAutoConfiguration.class)) + .withPropertyValues("spring.batch.job.jobName=job", + "spring.batch.job.stepName=step1", "spring.batch.job.chunkSize=5", + "spring.batch.job.flatfilereader.savestate=true", + "spring.batch.job.flatfilereader.name=fullDelimitedConfiguration", + "spring.batch.job.flatfilereader.maxItemCount=5", + "spring.batch.job.flatfilereader.currentItemCount=2", + "spring.batch.job.flatfilereader.comments=#,$", + "spring.batch.job.flatfilereader.resource=/testUTF16.csv", + "spring.batch.job.flatfilereader.strict=true", + "spring.batch.job.flatfilereader.encoding=UTF-16", + "spring.batch.job.flatfilereader.linesToSkip=1", + "spring.batch.job.flatfilereader.delimited=true", + "spring.batch.job.flatfilereader.delimiter=@", + "spring.batch.job.flatfilereader.quoteCharacter=%", + "spring.batch.job.flatfilereader.includedFields=1,3,5", + "spring.batch.job.flatfilereader.names=foo,bar,baz", + "spring.batch.job.flatfilereader.parsingStrict=false"); + + applicationContextRunner.run((context) -> { + JobLauncher jobLauncher = context.getBean(JobLauncher.class); + + Job job = context.getBean(Job.class); + + ListItemWriter itemWriter = context.getBean(ListItemWriter.class); + + JobExecution jobExecution = jobLauncher.run(job, new JobParameters()); + + JobExplorer jobExplorer = context.getBean(JobExplorer.class); + + while (jobExplorer.getJobExecution(jobExecution.getJobId()).isRunning()) { + Thread.sleep(1000); + } + + List writtenItems = itemWriter.getWrittenItems(); + + assertThat(writtenItems.size()).isEqualTo(3); + assertThat(((Map) writtenItems.get(0)).get("foo")).isEqualTo("20"); + assertThat(((Map) writtenItems.get(0)).get("bar")).isEqualTo("22"); + assertThat(((Map) writtenItems.get(0)).get("baz")).isEqualTo("twenty four"); + assertThat(((Map) writtenItems.get(1)).get("foo")).isEqualTo("26"); + assertThat(((Map) writtenItems.get(1)).get("bar")).isEqualTo("28"); + assertThat(((Map) writtenItems.get(1)).get("baz")).isEqualTo("thirty"); + assertThat(((Map) writtenItems.get(2)).get("foo")).isEqualTo("32"); + assertThat(((Map) writtenItems.get(2)).get("bar")).isEqualTo("34"); + assertThat(((Map) writtenItems.get(2)).get("baz")).isEqualTo("thirty six"); + }); + } + + @Test + public void testFixedWidthConfiguration() { + ApplicationContextRunner applicationContextRunner = new ApplicationContextRunner() + .withUserConfiguration(JobConfiguration.class) + .withConfiguration(AutoConfigurations.of( + PropertyPlaceholderAutoConfiguration.class, + BatchAutoConfiguration.class, + SingleStepJobAutoConfiguration.class, + FlatFileItemReaderAutoConfiguration.class, RangeConverter.class)) + .withPropertyValues("spring.batch.job.jobName=job", + "spring.batch.job.stepName=step1", "spring.batch.job.chunkSize=5", + "spring.batch.job.flatfilereader.savestate=true", + "spring.batch.job.flatfilereader.name=fixedWidthConfiguration", + "spring.batch.job.flatfilereader.comments=#,$", + "spring.batch.job.flatfilereader.resource=/test.txt", + "spring.batch.job.flatfilereader.strict=true", + "spring.batch.job.flatfilereader.fixedLength=true", + "spring.batch.job.flatfilereader.ranges=3-4,7-8,11", + "spring.batch.job.flatfilereader.names=foo,bar,baz", + "spring.batch.job.flatfilereader.parsingStrict=false"); + + applicationContextRunner.run((context) -> { + JobLauncher jobLauncher = context.getBean(JobLauncher.class); + + Job job = context.getBean(Job.class); + + ListItemWriter itemWriter = context.getBean(ListItemWriter.class); + + JobExecution jobExecution = jobLauncher.run(job, new JobParameters()); + + JobExplorer jobExplorer = context.getBean(JobExplorer.class); + + while (jobExplorer.getJobExecution(jobExecution.getJobId()).isRunning()) { + Thread.sleep(1000); + } + + List writtenItems = itemWriter.getWrittenItems(); + + assertThat(writtenItems.size()).isEqualTo(6); + assertThat(((Map) writtenItems.get(0)).get("foo")).isEqualTo("2"); + assertThat(((Map) writtenItems.get(0)).get("bar")).isEqualTo("4"); + assertThat(((Map) writtenItems.get(0)).get("baz")).isEqualTo("six"); + assertThat(((Map) writtenItems.get(1)).get("foo")).isEqualTo("8"); + assertThat(((Map) writtenItems.get(1)).get("bar")).isEqualTo("10"); + assertThat(((Map) writtenItems.get(1)).get("baz")).isEqualTo("twelve"); + assertThat(((Map) writtenItems.get(2)).get("foo")).isEqualTo("14"); + assertThat(((Map) writtenItems.get(2)).get("bar")).isEqualTo("16"); + assertThat(((Map) writtenItems.get(2)).get("baz")).isEqualTo("eighteen"); + assertThat(((Map) writtenItems.get(3)).get("foo")).isEqualTo("20"); + assertThat(((Map) writtenItems.get(3)).get("bar")).isEqualTo("22"); + assertThat(((Map) writtenItems.get(3)).get("baz")).isEqualTo("twenty four"); + assertThat(((Map) writtenItems.get(4)).get("foo")).isEqualTo("26"); + assertThat(((Map) writtenItems.get(4)).get("bar")).isEqualTo("28"); + assertThat(((Map) writtenItems.get(4)).get("baz")).isEqualTo("thirty"); + assertThat(((Map) writtenItems.get(5)).get("foo")).isEqualTo("32"); + assertThat(((Map) writtenItems.get(5)).get("bar")).isEqualTo("34"); + assertThat(((Map) writtenItems.get(5)).get("baz")).isEqualTo("thirty six"); + }); + } + + @Test + public void testCustomLineMapper() { + ApplicationContextRunner applicationContextRunner = new ApplicationContextRunner() + .withUserConfiguration(CustomLineMapperConfiguration.class) + .withConfiguration( + AutoConfigurations.of(PropertyPlaceholderAutoConfiguration.class, + BatchAutoConfiguration.class, + SingleStepJobAutoConfiguration.class, + FlatFileItemReaderAutoConfiguration.class)) + .withPropertyValues("spring.batch.job.jobName=job", + "spring.batch.job.stepName=step1", "spring.batch.job.chunkSize=5", + "spring.batch.job.flatfilereader.name=fixedWidthConfiguration", + "spring.batch.job.flatfilereader.resource=/test.txt", + "spring.batch.job.flatfilereader.strict=true"); + + applicationContextRunner.run((context) -> { + JobLauncher jobLauncher = context.getBean(JobLauncher.class); + + Job job = context.getBean(Job.class); + + ListItemWriter itemWriter = context.getBean(ListItemWriter.class); + + JobExecution jobExecution = jobLauncher.run(job, new JobParameters()); + + JobExplorer jobExplorer = context.getBean(JobExplorer.class); + + while (jobExplorer.getJobExecution(jobExecution.getJobId()).isRunning()) { + Thread.sleep(1000); + } + + List writtenItems = itemWriter.getWrittenItems(); + + assertThat(writtenItems.size()).isEqualTo(8); + }); + } + + /** + * This test requires an input file with an even number of records + */ + @Test + public void testCustomRecordSeparatorAndSkippedLines() { + ApplicationContextRunner applicationContextRunner = new ApplicationContextRunner() + .withUserConfiguration( + RecordSeparatorAndSkippedLinesJobConfiguration.class) + .withConfiguration(AutoConfigurations.of( + PropertyPlaceholderAutoConfiguration.class, + BatchAutoConfiguration.class, + SingleStepJobAutoConfiguration.class, + FlatFileItemReaderAutoConfiguration.class, RangeConverter.class)) + .withPropertyValues("spring.batch.job.jobName=job", + "spring.batch.job.stepName=step1", "spring.batch.job.chunkSize=5", + "spring.batch.job.flatfilereader.name=fixedWidthConfiguration", + "spring.batch.job.flatfilereader.resource=/test.txt", + "spring.batch.job.flatfilereader.linesToSkip=2", + "spring.batch.job.flatfilereader.fixedLength=true", + "spring.batch.job.flatfilereader.ranges=3-4,7-8,11", + "spring.batch.job.flatfilereader.names=foo,bar,baz", + "spring.batch.job.flatfilereader.strict=true"); + + applicationContextRunner.run((context) -> { + JobLauncher jobLauncher = context.getBean(JobLauncher.class); + + Job job = context.getBean(Job.class); + + ListItemWriter itemWriter = context.getBean(ListItemWriter.class); + + JobExecution jobExecution = jobLauncher.run(job, new JobParameters()); + + JobExplorer jobExplorer = context.getBean(JobExplorer.class); + + while (jobExplorer.getJobExecution(jobExecution.getJobId()).isRunning()) { + Thread.sleep(1000); + } + + ListLineCallbackHandler callbackHandler = context + .getBean(ListLineCallbackHandler.class); + + assertThat(callbackHandler.getLines().size()).isEqualTo(2); + + List writtenItems = itemWriter.getWrittenItems(); + + assertThat(writtenItems.size()).isEqualTo(2); + }); + } + + @Test + public void testCustomMapping() { + ApplicationContextRunner applicationContextRunner = new ApplicationContextRunner() + .withUserConfiguration(CustomMappingConfiguration.class) + .withConfiguration(AutoConfigurations.of( + PropertyPlaceholderAutoConfiguration.class, + BatchAutoConfiguration.class, + SingleStepJobAutoConfiguration.class, + FlatFileItemReaderAutoConfiguration.class, RangeConverter.class)) + .withPropertyValues("spring.batch.job.jobName=job", + "spring.batch.job.stepName=step1", "spring.batch.job.chunkSize=5", + "spring.batch.job.flatfilereader.name=fixedWidthConfiguration", + "spring.batch.job.flatfilereader.resource=/test.txt", + "spring.batch.job.flatfilereader.maxItemCount=1", + "spring.batch.job.flatfilereader.strict=true"); + + applicationContextRunner.run((context) -> { + JobLauncher jobLauncher = context.getBean(JobLauncher.class); + + Job job = context.getBean(Job.class); + + ListItemWriter itemWriter = context.getBean(ListItemWriter.class); + + JobExecution jobExecution = jobLauncher.run(job, new JobParameters()); + + JobExplorer jobExplorer = context.getBean(JobExplorer.class); + + while (jobExplorer.getJobExecution(jobExecution.getJobId()).isRunning()) { + Thread.sleep(1000); + } + + List> writtenItems = itemWriter.getWrittenItems(); + + assertThat(writtenItems.size()).isEqualTo(1); + assertThat(writtenItems.get(0).get("one")).isEqualTo("1 2 3"); + assertThat(writtenItems.get(0).get("two")).isEqualTo("4 5 six"); + }); + } + + @EnableBatchProcessing + @Configuration + public static class CustomMappingConfiguration { + + @Autowired + private JobBuilderFactory jobBuilderFactory; + + @Autowired + private StepBuilderFactory stepBuilderFactory; + + @Autowired + private FlatFileItemReader itemReader; + + @Bean + public ListItemWriter itemWriter() { + return new ListItemWriter<>(); + } + + @Bean + public LineTokenizer lineTokenizer() { + return line -> new DefaultFieldSet( + new String[] { line.substring(0, 5), line.substring(6) }, + new String[] { "one", "two" }); + } + + @Bean + public FieldSetMapper> fieldSetMapper() { + return fieldSet -> fieldSet.getProperties(); + } + + } + + @EnableBatchProcessing + @Configuration + public static class JobConfiguration { + + @Autowired + private JobBuilderFactory jobBuilderFactory; + + @Autowired + private StepBuilderFactory stepBuilderFactory; + + @Autowired + private FlatFileItemReader itemReader; + + @Bean + public ListItemWriter itemWriter() { + return new ListItemWriter<>(); + } + + } + + @EnableBatchProcessing + @Configuration + public static class RecordSeparatorAndSkippedLinesJobConfiguration { + + @Autowired + private JobBuilderFactory jobBuilderFactory; + + @Autowired + private StepBuilderFactory stepBuilderFactory; + + @Autowired + private FlatFileItemReader itemReader; + + @Bean + public RecordSeparatorPolicy recordSeparatorPolicy() { + return new RecordSeparatorPolicy() { + @Override + public boolean isEndOfRecord(String record) { + + boolean endOfRecord = false; + + int index = record.indexOf('\n'); + + if (index > 0 && record.length() > index + 1) { + endOfRecord = true; + } + + return endOfRecord; + } + + @Override + public String postProcess(String record) { + return record; + } + + @Override + public String preProcess(String record) { + return record + '\n'; + } + }; + } + + @Bean + public LineCallbackHandler lineCallbackHandler() { + return new ListLineCallbackHandler(); + } + + @Bean + public ListItemWriter itemWriter() { + return new ListItemWriter<>(); + } + + } + + @EnableBatchProcessing + @Configuration + public static class CustomLineMapperConfiguration { + + @Autowired + private JobBuilderFactory jobBuilderFactory; + + @Autowired + private StepBuilderFactory stepBuilderFactory; + + @Autowired + private FlatFileItemReader itemReader; + + @Bean + public LineMapper> lineMapper() { + return (line, lineNumber) -> { + Map item = new HashMap<>(1); + + item.put("line", line); + item.put("lineNumber", lineNumber); + + return item; + }; + } + + @Bean + public ListItemWriter itemWriter() { + return new ListItemWriter<>(); + } + + } + + public static class ListLineCallbackHandler implements LineCallbackHandler { + + private List lines = new ArrayList<>(); + + @Override + public void handleLine(String line) { + lines.add(line); + } + + public List getLines() { + return lines; + } + + } + +} diff --git a/spring-cloud-starter-single-step-batch-job/src/test/java/org/springframework/cloud/task/batch/autoconfigure/FlatFileItemWriterAutoConfigurationTests.java b/spring-cloud-starter-single-step-batch-job/src/test/java/org/springframework/cloud/task/batch/autoconfigure/FlatFileItemWriterAutoConfigurationTests.java new file mode 100644 index 00000000..148198bc --- /dev/null +++ b/spring-cloud-starter-single-step-batch-job/src/test/java/org/springframework/cloud/task/batch/autoconfigure/FlatFileItemWriterAutoConfigurationTests.java @@ -0,0 +1,532 @@ +/* + * Copyright 2020-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.cloud.task.batch.autoconfigure; + +import java.io.File; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; +import org.springframework.batch.core.explore.JobExplorer; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.batch.item.file.FlatFileFooterCallback; +import org.springframework.batch.item.file.FlatFileHeaderCallback; +import org.springframework.batch.item.file.FlatFileItemWriter; +import org.springframework.batch.item.file.transform.FieldExtractor; +import org.springframework.batch.item.file.transform.LineAggregator; +import org.springframework.batch.item.file.transform.PassThroughLineAggregator; +import org.springframework.batch.item.support.ListItemReader; +import org.springframework.batch.test.AssertFile; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration; +import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.FileSystemResource; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.util.FileCopyUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +/** + * @author Michael Minella + */ +public class FlatFileItemWriterAutoConfigurationTests { + + private File outputFile; + + @Before + public void setUp() throws Exception { + this.outputFile = File.createTempFile("flatfile-config-test-output", ".tmp"); + } + + @After + public void tearDown() { + this.outputFile.delete(); + } + + @Test + public void testValidation() { + + FlatFileItemWriterProperties properties = new FlatFileItemWriterProperties(); + + properties.setFormatted(true); + properties.setDelimited(true); + + FlatFileItemWriterAutoConfiguration configuration = new FlatFileItemWriterAutoConfiguration( + properties); + + try { + configuration.itemWriter(); + } + catch (IllegalStateException ise) { + assertThat(ise.getMessage()).isEqualTo( + "An output file must be either delimited or formatted or a custom " + + "LineAggregator must be provided. Your current configuration specifies both delimited and formatted"); + } + catch (Exception e) { + fail("Incorrect exception thrown", e); + } + + properties.setFormatted(true); + properties.setDelimited(false); + + ReflectionTestUtils.setField(configuration, "lineAggregator", + new PassThroughLineAggregator<>()); + + try { + configuration.itemWriter(); + } + catch (IllegalStateException ise) { + assertThat(ise.getMessage()) + .isEqualTo("A LineAggregator must be configured if the " + + "output is not formatted or delimited"); + } + catch (Exception e) { + fail("Incorrect exception thrown", e); + } + + properties.setFormatted(false); + properties.setDelimited(true); + + try { + configuration.itemWriter(); + } + catch (IllegalStateException ise) { + assertThat(ise.getMessage()) + .isEqualTo("A LineAggregator must be configured if the " + + "output is not formatted or delimited"); + } + catch (Exception e) { + fail("Incorrect exception thrown", e); + } + } + + @Test + public void testDelimitedFileGeneration() { + ApplicationContextRunner applicationContextRunner = new ApplicationContextRunner() + .withUserConfiguration(DelimitedJobConfiguration.class) + .withConfiguration( + AutoConfigurations.of(PropertyPlaceholderAutoConfiguration.class, + BatchAutoConfiguration.class, + SingleStepJobAutoConfiguration.class, + FlatFileItemWriterAutoConfiguration.class)) + .withPropertyValues("spring.batch.job.jobName=job", + "spring.batch.job.stepName=step1", "spring.batch.job.chunkSize=5", + "spring.batch.job.flatfilewriter.name=fooWriter", + String.format( + "spring.batch.job.flatfilewriter.resource=file://%s", + this.outputFile.getAbsolutePath()), + "spring.batch.job.flatfilewriter.encoding=UTF-16", + "spring.batch.job.flatfilewriter.saveState=false", + "spring.batch.job.flatfilewriter.shouldDeleteIfEmpty=true", + "spring.batch.job.flatfilewriter.delimited=true", + "spring.batch.job.flatfilewriter.names=item", + "spring.batch.job.flatfilewriter.append=true", + "spring.batch.job.flatfilewriter.forceSync=true", + "spring.batch.job.flatfilewriter.shouldDeleteIfExists=false", + "spring.batch.job.flatfilewriter.transactional=false"); + + applicationContextRunner.run((context) -> { + JobLauncher jobLauncher = context.getBean(JobLauncher.class); + + Job job = context.getBean(Job.class); + + JobExecution jobExecution = jobLauncher.run(job, new JobParameters()); + + JobExplorer jobExplorer = context.getBean(JobExplorer.class); + + while (jobExplorer.getJobExecution(jobExecution.getJobId()).isRunning()) { + Thread.sleep(1000); + } + + FlatFileItemWriter writer = context.getBean(FlatFileItemWriter.class); + + AssertFile.assertLineCount(3, this.outputFile); + AssertFile.assertFileEquals(new ClassPathResource("writerTestUTF16.txt"), + new FileSystemResource(this.outputFile)); + + assertThat((Boolean) ReflectionTestUtils.getField(writer, "saveState")) + .isFalse(); + assertThat((Boolean) ReflectionTestUtils.getField(writer, "append")).isTrue(); + assertThat((Boolean) ReflectionTestUtils.getField(writer, "forceSync")) + .isTrue(); + assertThat((Boolean) ReflectionTestUtils.getField(writer, + "shouldDeleteIfExists")).isFalse(); + assertThat((Boolean) ReflectionTestUtils.getField(writer, "transactional")) + .isFalse(); + }); + } + + @Test + public void testFormattedFileGeneration() { + ApplicationContextRunner applicationContextRunner = new ApplicationContextRunner() + .withUserConfiguration(FormattedJobConfiguration.class) + .withConfiguration( + AutoConfigurations.of(PropertyPlaceholderAutoConfiguration.class, + BatchAutoConfiguration.class, + SingleStepJobAutoConfiguration.class, + FlatFileItemWriterAutoConfiguration.class)) + .withPropertyValues("spring.batch.job.jobName=job", + "spring.batch.job.stepName=step1", "spring.batch.job.chunkSize=2", + "spring.batch.job.flatfilewriter.name=fooWriter", + String.format( + "spring.batch.job.flatfilewriter.resource=file://%s", + this.outputFile.getAbsolutePath()), + "spring.batch.job.flatfilewriter.encoding=UTF-8", + "spring.batch.job.flatfilewriter.formatted=true", + "spring.batch.job.flatfilewriter.names=item", + "spring.batch.job.flatfilewriter.format=item = %s", + "spring.batch.job.flatfilewriter.minimumLength=8", + "spring.batch.job.flatfilewriter.maximumLength=10"); + + applicationContextRunner.run((context) -> { + JobLauncher jobLauncher = context.getBean(JobLauncher.class); + + Job job = context.getBean(Job.class); + + JobExecution jobExecution = jobLauncher.run(job, new JobParameters()); + + JobExplorer jobExplorer = context.getBean(JobExplorer.class); + + while (jobExplorer.getJobExecution(jobExecution.getJobId()).isRunning()) { + Thread.sleep(1000); + } + + AssertFile.assertLineCount(2, this.outputFile); + + String results = FileCopyUtils.copyToString(new InputStreamReader( + new FileSystemResource(this.outputFile).getInputStream())); + assertThat(results).isEqualTo("item = foo\nitem = bar\n"); + }); + } + + @Test + public void testFormattedFieldExtractorFileGeneration() { + ApplicationContextRunner applicationContextRunner = new ApplicationContextRunner() + .withUserConfiguration(FormattedFieldExtractorJobConfiguration.class) + .withConfiguration( + AutoConfigurations.of(PropertyPlaceholderAutoConfiguration.class, + BatchAutoConfiguration.class, + SingleStepJobAutoConfiguration.class, + FlatFileItemWriterAutoConfiguration.class)) + .withPropertyValues("spring.batch.job.jobName=job", + "spring.batch.job.stepName=step1", "spring.batch.job.chunkSize=2", + "spring.batch.job.flatfilewriter.name=fooWriter", + String.format( + "spring.batch.job.flatfilewriter.resource=file://%s", + this.outputFile.getAbsolutePath()), + "spring.batch.job.flatfilewriter.encoding=UTF-8", + "spring.batch.job.flatfilewriter.formatted=true", + "spring.batch.job.flatfilewriter.names=item", + "spring.batch.job.flatfilewriter.format=item = %s"); + + applicationContextRunner.run((context) -> { + JobLauncher jobLauncher = context.getBean(JobLauncher.class); + + Job job = context.getBean(Job.class); + + JobExecution jobExecution = jobLauncher.run(job, new JobParameters()); + + JobExplorer jobExplorer = context.getBean(JobExplorer.class); + + while (jobExplorer.getJobExecution(jobExecution.getJobId()).isRunning()) { + Thread.sleep(1000); + } + + AssertFile.assertLineCount(3, this.outputFile); + + String results = FileCopyUtils.copyToString(new InputStreamReader( + new FileSystemResource(this.outputFile).getInputStream())); + assertThat(results).isEqualTo("item = f\nitem = b\nitem = b\n"); + }); + } + + @Test + public void testFieldExtractorFileGeneration() { + ApplicationContextRunner applicationContextRunner = new ApplicationContextRunner() + .withUserConfiguration(FieldExtractorConfiguration.class) + .withConfiguration( + AutoConfigurations.of(PropertyPlaceholderAutoConfiguration.class, + BatchAutoConfiguration.class, + SingleStepJobAutoConfiguration.class, + FlatFileItemWriterAutoConfiguration.class)) + .withPropertyValues("spring.batch.job.jobName=job", + "spring.batch.job.stepName=step1", "spring.batch.job.chunkSize=5", + "spring.batch.job.flatfilewriter.name=fooWriter", + String.format( + "spring.batch.job.flatfilewriter.resource=file://%s", + this.outputFile.getAbsolutePath()), + "spring.batch.job.flatfilewriter.encoding=UTF-8", + "spring.batch.job.flatfilewriter.delimited=true"); + + applicationContextRunner.run((context) -> { + JobLauncher jobLauncher = context.getBean(JobLauncher.class); + + Job job = context.getBean(Job.class); + + JobExecution jobExecution = jobLauncher.run(job, new JobParameters()); + + JobExplorer jobExplorer = context.getBean(JobExplorer.class); + + while (jobExplorer.getJobExecution(jobExecution.getJobId()).isRunning()) { + Thread.sleep(1000); + } + + AssertFile.assertLineCount(3, this.outputFile); + + String results = FileCopyUtils.copyToString(new InputStreamReader( + new FileSystemResource(this.outputFile).getInputStream())); + assertThat(results).isEqualTo("f\nb\nb\n"); + }); + } + + @Test + public void testCustomLineAggregatorFileGeneration() { + ApplicationContextRunner applicationContextRunner = new ApplicationContextRunner() + .withUserConfiguration(LineAggregatorConfiguration.class) + .withConfiguration( + AutoConfigurations.of(PropertyPlaceholderAutoConfiguration.class, + BatchAutoConfiguration.class, + SingleStepJobAutoConfiguration.class, + FlatFileItemWriterAutoConfiguration.class)) + .withPropertyValues("spring.batch.job.jobName=job", + "spring.batch.job.stepName=step1", "spring.batch.job.chunkSize=5", + "spring.batch.job.flatfilewriter.name=fooWriter", + String.format( + "spring.batch.job.flatfilewriter.resource=file://%s", + this.outputFile.getAbsolutePath()), + "spring.batch.job.flatfilewriter.encoding=UTF-8"); + + applicationContextRunner.run((context) -> { + JobLauncher jobLauncher = context.getBean(JobLauncher.class); + + Job job = context.getBean(Job.class); + + JobExecution jobExecution = jobLauncher.run(job, new JobParameters()); + + JobExplorer jobExplorer = context.getBean(JobExplorer.class); + + while (jobExplorer.getJobExecution(jobExecution.getJobId()).isRunning()) { + Thread.sleep(1000); + } + + AssertFile.assertLineCount(3, this.outputFile); + + String results = FileCopyUtils.copyToString(new InputStreamReader( + new FileSystemResource(this.outputFile).getInputStream())); + assertThat(results).isEqualTo("{item=foo}\n{item=bar}\n{item=baz}\n"); + }); + } + + @Test + public void testHeaderFooterFileGeneration() { + ApplicationContextRunner applicationContextRunner = new ApplicationContextRunner() + .withUserConfiguration(HeaderFooterConfiguration.class) + .withConfiguration( + AutoConfigurations.of(PropertyPlaceholderAutoConfiguration.class, + BatchAutoConfiguration.class, + SingleStepJobAutoConfiguration.class, + FlatFileItemWriterAutoConfiguration.class)) + .withPropertyValues("spring.batch.job.jobName=job", + "spring.batch.job.stepName=step1", "spring.batch.job.chunkSize=5", + "spring.batch.job.flatfilewriter.name=fooWriter", + String.format( + "spring.batch.job.flatfilewriter.resource=file://%s", + this.outputFile.getAbsolutePath()), + "spring.batch.job.flatfilewriter.encoding=UTF-8", + "spring.batch.job.flatfilewriter.delimited=true", + "spring.batch.job.flatfilewriter.names=item"); + + applicationContextRunner.run((context) -> { + JobLauncher jobLauncher = context.getBean(JobLauncher.class); + + Job job = context.getBean(Job.class); + + JobExecution jobExecution = jobLauncher.run(job, new JobParameters()); + + JobExplorer jobExplorer = context.getBean(JobExplorer.class); + + while (jobExplorer.getJobExecution(jobExecution.getJobId()).isRunning()) { + Thread.sleep(1000); + } + + AssertFile.assertLineCount(5, this.outputFile); + + String results = FileCopyUtils.copyToString(new InputStreamReader( + new FileSystemResource(this.outputFile).getInputStream())); + assertThat(results).isEqualTo("header\nfoo\nbar\nbaz\nfooter"); + }); + } + + @Configuration + @EnableBatchProcessing + public static class DelimitedJobConfiguration { + + @Bean + public ListItemReader> itemReader() { + + List> items = new ArrayList<>(3); + + items.add(Collections.singletonMap("item", "foo")); + items.add(Collections.singletonMap("item", "bar")); + items.add(Collections.singletonMap("item", "baz")); + + return new ListItemReader<>(items); + } + + } + + @Configuration + @EnableBatchProcessing + public static class LineAggregatorConfiguration { + + @Bean + public ListItemReader> itemReader() { + + List> items = new ArrayList<>(3); + + items.add(Collections.singletonMap("item", "foo")); + items.add(Collections.singletonMap("item", "bar")); + items.add(Collections.singletonMap("item", "baz")); + + return new ListItemReader<>(items); + } + + @Bean + public LineAggregator> lineAggregator() { + return new PassThroughLineAggregator<>(); + } + + } + + @Configuration + @EnableBatchProcessing + public static class HeaderFooterConfiguration { + + @Bean + public ListItemReader> itemReader() { + + List> items = new ArrayList<>(3); + + items.add(Collections.singletonMap("item", "foo")); + items.add(Collections.singletonMap("item", "bar")); + items.add(Collections.singletonMap("item", "baz")); + + return new ListItemReader<>(items); + } + + @Bean + public FlatFileHeaderCallback headerCallback() { + return writer -> writer.append("header"); + } + + @Bean + public FlatFileFooterCallback footerCallback() { + return writer -> writer.append("footer"); + } + + } + + @Configuration + @EnableBatchProcessing + public static class FieldExtractorConfiguration { + + @Bean + public ListItemReader> itemReader() { + + List> items = new ArrayList<>(3); + + items.add(Collections.singletonMap("item", "foo")); + items.add(Collections.singletonMap("item", "bar")); + items.add(Collections.singletonMap("item", "baz")); + + return new ListItemReader<>(items); + } + + @Bean + public FieldExtractor> lineAggregator() { + return item -> { + List fields = new ArrayList<>(1); + + fields.add(((String) item.get("item")).substring(0, 1)); + return fields.toArray(); + }; + } + + } + + @Configuration + @EnableBatchProcessing + public static class FormattedJobConfiguration { + + @Bean + public ListItemReader> itemReader() { + + List> items = new ArrayList<>(3); + + items.add(Collections.singletonMap("item", "foo")); + items.add(Collections.singletonMap("item", "bar")); + items.add(Collections.singletonMap("item", "tooLong")); + + return new ListItemReader<>(items); + } + + } + + @Configuration + @EnableBatchProcessing + public static class FormattedFieldExtractorJobConfiguration { + + @Bean + public FieldExtractor> lineAggregator() { + return item -> { + List fields = new ArrayList<>(1); + + fields.add(((String) item.get("item")).substring(0, 1)); + return fields.toArray(); + }; + } + + @Bean + public ListItemReader> itemReader() { + + List> items = new ArrayList<>(3); + + items.add(Collections.singletonMap("item", "foo")); + items.add(Collections.singletonMap("item", "bar")); + items.add(Collections.singletonMap("item", "baz")); + + return new ListItemReader<>(items); + } + + } + +} diff --git a/spring-cloud-starter-single-step-batch-job/src/test/java/org/springframework/cloud/task/batch/autoconfigure/RangeConverterTests.java b/spring-cloud-starter-single-step-batch-job/src/test/java/org/springframework/cloud/task/batch/autoconfigure/RangeConverterTests.java new file mode 100644 index 00000000..ee2692ac --- /dev/null +++ b/spring-cloud-starter-single-step-batch-job/src/test/java/org/springframework/cloud/task/batch/autoconfigure/RangeConverterTests.java @@ -0,0 +1,71 @@ +/* + * Copyright 2020-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.cloud.task.batch.autoconfigure; + +import org.junit.Test; + +import org.springframework.batch.item.file.transform.Range; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Michael Minella + */ +public class RangeConverterTests { + + @Test + public void testNullInput() { + RangeConverter converter = new RangeConverter(); + + assertThat(converter.convert(null)).isNull(); + } + + @Test + public void testStartValueOnly() { + RangeConverter converter = new RangeConverter(); + + Range range = converter.convert("5"); + + assertThat(range.getMin()).isEqualTo(5); + assertThat(range.getMax()).isEqualTo(Integer.MAX_VALUE); + } + + @Test + public void testStartAndEndValue() { + RangeConverter converter = new RangeConverter(); + + Range range = converter.convert("5-25"); + + assertThat(range.getMin()).isEqualTo(5); + assertThat(range.getMax()).isEqualTo(25); + } + + @Test(expected = NumberFormatException.class) + public void testIllegalValue() { + RangeConverter converter = new RangeConverter(); + + converter.convert("invalid"); + } + + @Test(expected = IllegalArgumentException.class) + public void testTooManyValues() { + RangeConverter converter = new RangeConverter(); + + converter.convert("1-2-3-4"); + } + +} diff --git a/spring-cloud-starter-single-step-batch-job/src/test/java/org/springframework/cloud/task/batch/autoconfigure/SingleStepJobAutoConfigurationTests.java b/spring-cloud-starter-single-step-batch-job/src/test/java/org/springframework/cloud/task/batch/autoconfigure/SingleStepJobAutoConfigurationTests.java new file mode 100644 index 00000000..8c844c30 --- /dev/null +++ b/spring-cloud-starter-single-step-batch-job/src/test/java/org/springframework/cloud/task/batch/autoconfigure/SingleStepJobAutoConfigurationTests.java @@ -0,0 +1,162 @@ +/* + * Copyright 2020-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.cloud.task.batch.autoconfigure; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.junit.Test; + +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; +import org.springframework.batch.core.explore.JobExplorer; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.batch.item.support.ListItemReader; +import org.springframework.batch.item.support.ListItemWriter; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration; +import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +/** + * @author Michael Minella + */ +public class SingleStepJobAutoConfigurationTests { + + @Test + public void testInvalidProperties() { + SingleStepJobProperties properties = new SingleStepJobProperties(); + + try { + new SingleStepJobAutoConfiguration(null, null, properties, null); + } + catch (IllegalArgumentException iae) { + assertThat(iae.getMessage()).isEqualTo("A job name is required"); + } + catch (Throwable t) { + fail("wrong exception was thrown", t); + } + + properties.setJobName("job"); + + try { + new SingleStepJobAutoConfiguration(null, null, properties, null); + } + catch (IllegalArgumentException iae) { + assertThat(iae.getMessage()).isEqualTo("A step name is required"); + } + catch (Throwable t) { + fail("wrong exception was thrown", t); + } + + properties.setStepName("step"); + + try { + new SingleStepJobAutoConfiguration(null, null, properties, null); + } + catch (IllegalArgumentException iae) { + assertThat(iae.getMessage()).isEqualTo("A chunk size is required"); + } + catch (Throwable t) { + fail("wrong exception was thrown", t); + } + + properties.setChunkSize(-5); + + try { + new SingleStepJobAutoConfiguration(null, null, properties, null); + } + catch (IllegalArgumentException iae) { + assertThat(iae.getMessage()) + .isEqualTo("A chunk size greater than zero is required"); + } + catch (Throwable t) { + fail("wrong exception was thrown", t); + } + + properties.setChunkSize(5); + } + + @Test + public void testSimpleConfiguration() { + ApplicationContextRunner applicationContextRunner = new ApplicationContextRunner() + .withUserConfiguration(SimpleConfiguration.class) + .withConfiguration( + AutoConfigurations.of(PropertyPlaceholderAutoConfiguration.class, + BatchAutoConfiguration.class, + SingleStepJobAutoConfiguration.class)) + .withPropertyValues("spring.batch.job.jobName=job", + "spring.batch.job.stepName=step1", + "spring.batch.job.chunkSize=5"); + + applicationContextRunner.run((context) -> { + JobLauncher jobLauncher = context.getBean(JobLauncher.class); + + Job job = context.getBean(Job.class); + + ListItemWriter itemWriter = context.getBean(ListItemWriter.class); + + JobExecution jobExecution = jobLauncher.run(job, new JobParameters()); + + JobExplorer jobExplorer = context.getBean(JobExplorer.class); + + while (jobExplorer.getJobExecution(jobExecution.getJobId()).isRunning()) { + Thread.sleep(1000); + } + + List> writtenItems = itemWriter.getWrittenItems(); + + assertThat(writtenItems.size()).isEqualTo(3); + + assertThat(writtenItems.get(0).get("item")).isEqualTo("foo"); + assertThat(writtenItems.get(1).get("item")).isEqualTo("bar"); + assertThat(writtenItems.get(2).get("item")).isEqualTo("baz"); + }); + } + + @EnableBatchProcessing + @Configuration + public static class SimpleConfiguration { + + @Bean + public ListItemReader> itemReader() { + List> items = new ArrayList<>(3); + + items.add(Collections.singletonMap("item", "foo")); + items.add(Collections.singletonMap("item", "bar")); + items.add(Collections.singletonMap("item", "baz")); + + return new ListItemReader<>(items); + } + + @Bean + public ListItemWriter> itemWriter() { + return new ListItemWriter<>(); + } + + } + +} diff --git a/spring-cloud-starter-single-step-batch-job/src/test/resources/test.txt b/spring-cloud-starter-single-step-batch-job/src/test/resources/test.txt new file mode 100644 index 00000000..bb0a96b4 --- /dev/null +++ b/spring-cloud-starter-single-step-batch-job/src/test/resources/test.txt @@ -0,0 +1,8 @@ +1 2 3 4 5 six +# This should be ignored +7 8 9 1011twelve +$ So should this +1314151617eighteen +1920212223twenty four +1526272829thirty +3132333435thirty six diff --git a/spring-cloud-starter-single-step-batch-job/src/test/resources/testUTF16.csv b/spring-cloud-starter-single-step-batch-job/src/test/resources/testUTF16.csv new file mode 100644 index 0000000000000000000000000000000000000000..92ce17a494a65d54ed54f0f6a2f48d5ce872984f GIT binary patch literal 550 zcmZ9IS!x3@5Jc-+r!XY`kG3p-xkGY-;F;K9#xSv^3AGS=IPQaSsQ*bjxwII9=VAQ1AVuq4*n(+GaU;Zi%*d$QWZGsOaICLUw0kJ zF>8%75*O$N|Ahnv;^JsVm&^)sg