From 92ab99d38dc434cb44359d85aff5961cb2b35204 Mon Sep 17 00:00:00 2001 From: LeeHyungGeol Date: Sat, 4 Oct 2025 17:32:09 +0900 Subject: [PATCH 1/3] Fix RecordFieldExtractor to honor names() configuration in FlatFileItemWriterBuilder Signed-off-by: LeeHyungGeol --- .../builder/FlatFileItemWriterBuilder.java | 13 +- .../FlatFileItemWriterBuilderTests.java | 139 +++++++++++++++++- 2 files changed, 146 insertions(+), 6 deletions(-) diff --git a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/file/builder/FlatFileItemWriterBuilder.java b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/file/builder/FlatFileItemWriterBuilder.java index 2df89027ad..f2674d9eb1 100644 --- a/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/file/builder/FlatFileItemWriterBuilder.java +++ b/spring-batch-infrastructure/src/main/java/org/springframework/batch/item/file/builder/FlatFileItemWriterBuilder.java @@ -47,6 +47,7 @@ * @author Mahmoud Ben Hassine * @author Drummond Dawson * @author Stefano Cordio + * @author Hyunggeol Lee * @since 4.0 * @see FlatFileItemWriter */ @@ -394,7 +395,11 @@ public FormatterLineAggregator build() { if (this.fieldExtractor == null) { if (this.sourceType != null && this.sourceType.isRecord()) { - this.fieldExtractor = new RecordFieldExtractor<>(this.sourceType); + RecordFieldExtractor recordFieldExtractor = new RecordFieldExtractor<>(this.sourceType); + if (!this.names.isEmpty()) { + recordFieldExtractor.setNames(this.names.toArray(new String[0])); + } + this.fieldExtractor = recordFieldExtractor; } else { BeanWrapperFieldExtractor beanWrapperFieldExtractor = new BeanWrapperFieldExtractor<>(); @@ -511,7 +516,11 @@ public DelimitedLineAggregator build() { if (this.fieldExtractor == null) { if (this.sourceType != null && this.sourceType.isRecord()) { - this.fieldExtractor = new RecordFieldExtractor<>(this.sourceType); + RecordFieldExtractor recordFieldExtractor = new RecordFieldExtractor<>(this.sourceType); + if (!this.names.isEmpty()) { + recordFieldExtractor.setNames(this.names.toArray(new String[0])); + } + this.fieldExtractor = recordFieldExtractor; } else { BeanWrapperFieldExtractor beanWrapperFieldExtractor = new BeanWrapperFieldExtractor<>(); diff --git a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/builder/FlatFileItemWriterBuilderTests.java b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/builder/FlatFileItemWriterBuilderTests.java index ad8083c4d2..66f63147b9 100644 --- a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/builder/FlatFileItemWriterBuilderTests.java +++ b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/builder/FlatFileItemWriterBuilderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2023 the original author or authors. + * Copyright 2016-2025 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. @@ -20,6 +20,7 @@ import java.io.IOException; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; +import java.util.Arrays; import org.junit.jupiter.api.Test; @@ -48,6 +49,7 @@ * @author Mahmoud Ben Hassine * @author Drummond Dawson * @author Glenn Renfro + * @author Hyunggeol Lee */ class FlatFileItemWriterBuilderTests { @@ -98,7 +100,7 @@ void test() throws Exception { writer.close(); assertEquals("HEADER$Foo{first=1, second=2, third='3'}$Foo{first=4, second=5, third='6'}$FOOTER", - readLine("UTF-16LE", output)); + readLine("UTF-16LE", output)); } @Test @@ -219,7 +221,7 @@ void testDelimitedOutputWithCustomFieldExtractor() throws Exception { .lineSeparator("$") .delimited() .delimiter(" ") - .fieldExtractor(item -> new Object[] { item.getFirst(), item.getThird() }) + .fieldExtractor(item -> new Object[]{item.getFirst(), item.getThird()}) .encoding("UTF-16LE") .headerCallback(writer1 -> writer1.append("HEADER")) .footerCallback(writer12 -> writer12.append("FOOTER")) @@ -273,7 +275,7 @@ void testFormattedOutputWithCustomFieldExtractor() throws Exception { .lineSeparator("$") .formatted() .format("%3s%3s") - .fieldExtractor(item -> new Object[] { item.getFirst(), item.getThird() }) + .fieldExtractor(item -> new Object[]{item.getFirst(), item.getThird()}) .encoding("UTF-16LE") .headerCallback(writer1 -> writer1.append("HEADER")) .footerCallback(writer12 -> writer12.append("FOOTER")) @@ -485,6 +487,135 @@ void testSetupFormatterLineAggregatorWithNoItemType() throws IOException { assertInstanceOf(BeanWrapperFieldExtractor.class, fieldExtractor); } + // Issue #4916: RecordFieldExtractor should honor names() configuration + @Test + void testDelimitedWithRecordAndSelectedFields() throws IOException { + // 이유: DelimitedBuilder가 names() 설정을 RecordFieldExtractor에 전달하는지 확인 + // 핵심 버그 수정 검증 테스트 + + WritableResource output = new FileSystemResource(File.createTempFile("delimited-selected", "csv")); + record Person(int id, String name, String email, int age) {} + + FlatFileItemWriter writer = new FlatFileItemWriterBuilder() + .name("personWriter") + .resource(output) + .delimited() + .delimiter(",") + .sourceType(Person.class) + .names("name", "age") // 특정 필드만 선택 + .build(); + + // 타입 및 설정 검증 + Object lineAggregator = ReflectionTestUtils.getField(writer, "lineAggregator"); + assertNotNull(lineAggregator); + assertInstanceOf(DelimitedLineAggregator.class, lineAggregator); + + Object fieldExtractor = ReflectionTestUtils.getField(lineAggregator, "fieldExtractor"); + assertNotNull(fieldExtractor); + assertInstanceOf(RecordFieldExtractor.class, fieldExtractor); + + // 핵심: names 설정이 전달되었는지 확인 + Object names = ReflectionTestUtils.getField(fieldExtractor, "names"); + assertEquals(Arrays.asList("name", "age"), names); + } + + @Test + void testDelimitedWithRecordFieldReordering() throws IOException { + // 이유: 필드 순서 변경이 제대로 동작하는지 확인 + // names()의 순서가 RecordFieldExtractor에 전달되는지 검증 + + WritableResource output = new FileSystemResource(File.createTempFile("delimited-reorder", "csv")); + record Employee(int id, String firstName, String lastName, String dept) {} + + FlatFileItemWriter writer = new FlatFileItemWriterBuilder() + .name("employeeWriter") + .resource(output) + .delimited() + .delimiter("|") + .sourceType(Employee.class) + .names("lastName", "firstName", "id") // 순서 변경 + dept 제외 + .build(); + + Object lineAggregator = ReflectionTestUtils.getField(writer, "lineAggregator"); + assertNotNull(lineAggregator); + assertInstanceOf(DelimitedLineAggregator.class, lineAggregator); + + // delimiter 설정 확인 + Object delimiter = ReflectionTestUtils.getField(lineAggregator, "delimiter"); + assertEquals("|", delimiter); + + Object fieldExtractor = ReflectionTestUtils.getField(lineAggregator, "fieldExtractor"); + assertNotNull(fieldExtractor); + assertInstanceOf(RecordFieldExtractor.class, fieldExtractor); + + // 순서 변경 및 필드 제외 확인 + Object names = ReflectionTestUtils.getField(fieldExtractor, "names"); + assertEquals(Arrays.asList("lastName", "firstName", "id"), names); + } + + @Test + void testFormattedWithRecordAndSelectedFields() throws IOException { + // 이유: FormattedBuilder도 names() 설정을 RecordFieldExtractor에 전달하는지 확인 + // FormattedBuilder가 DelimitedBuilder와 동일하게 동작하는지 검증 + + WritableResource output = new FileSystemResource(File.createTempFile("formatted-selected", "txt")); + record Person(int id, String name, String email) {} + + FlatFileItemWriter writer = new FlatFileItemWriterBuilder() + .name("personWriter") + .resource(output) + .formatted() + .format("%-10s%3d") + .sourceType(Person.class) + .names("name", "id") // email 제외, 순서 변경 + .build(); + + Object lineAggregator = ReflectionTestUtils.getField(writer, "lineAggregator"); + assertNotNull(lineAggregator); + assertInstanceOf(FormatterLineAggregator.class, lineAggregator); + + // format 설정 확인 + Object format = ReflectionTestUtils.getField(lineAggregator, "format"); + assertEquals("%-10s%3d", format); + + Object fieldExtractor = ReflectionTestUtils.getField(lineAggregator, "fieldExtractor"); + assertNotNull(fieldExtractor); + assertInstanceOf(RecordFieldExtractor.class, fieldExtractor); + + // FormattedBuilder도 names 설정을 전달하는지 확인 + Object names = ReflectionTestUtils.getField(fieldExtractor, "names"); + assertEquals(Arrays.asList("name", "id"), names); + } + + @Test + void testDelimitedWithRecordAllFields() throws IOException { + // 이유: names()를 지정하지 않았을 때 모든 필드가 사용되는지 확인 + // 기본 동작 검증 (backward compatibility) + + WritableResource output = new FileSystemResource(File.createTempFile("delimited-all", "csv")); + record Product(String code, String name, double price) {} + + FlatFileItemWriter writer = new FlatFileItemWriterBuilder() + .name("productWriter") + .resource(output) + .delimited() + .sourceType(Product.class) + .names("code", "name", "price") // 모든 필드 명시 + .build(); + + Object lineAggregator = ReflectionTestUtils.getField(writer, "lineAggregator"); + assertNotNull(lineAggregator); + assertInstanceOf(DelimitedLineAggregator.class, lineAggregator); + + Object fieldExtractor = ReflectionTestUtils.getField(lineAggregator, "fieldExtractor"); + assertNotNull(fieldExtractor); + assertInstanceOf(RecordFieldExtractor.class, fieldExtractor); + + // 모든 필드가 포함되었는지 확인 + Object names = ReflectionTestUtils.getField(fieldExtractor, "names"); + assertEquals(Arrays.asList("code", "name", "price"), names); + } + private void validateBuilderFlags(FlatFileItemWriter writer, String encoding) { assertFalse((Boolean) ReflectionTestUtils.getField(writer, "saveState")); assertTrue((Boolean) ReflectionTestUtils.getField(writer, "append")); From d89083b40970a2d35990992b3cd9cb89c1e34fb9 Mon Sep 17 00:00:00 2001 From: LeeHyungGeol Date: Sat, 4 Oct 2025 17:46:05 +0900 Subject: [PATCH 2/3] Fix spring-javaformat Signed-off-by: LeeHyungGeol --- .../FlatFileItemWriterBuilderTests.java | 70 ++++++++----------- 1 file changed, 31 insertions(+), 39 deletions(-) diff --git a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/builder/FlatFileItemWriterBuilderTests.java b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/builder/FlatFileItemWriterBuilderTests.java index 66f63147b9..063fd5e3f6 100644 --- a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/builder/FlatFileItemWriterBuilderTests.java +++ b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/builder/FlatFileItemWriterBuilderTests.java @@ -100,7 +100,7 @@ void test() throws Exception { writer.close(); assertEquals("HEADER$Foo{first=1, second=2, third='3'}$Foo{first=4, second=5, third='6'}$FOOTER", - readLine("UTF-16LE", output)); + readLine("UTF-16LE", output)); } @Test @@ -221,7 +221,7 @@ void testDelimitedOutputWithCustomFieldExtractor() throws Exception { .lineSeparator("$") .delimited() .delimiter(" ") - .fieldExtractor(item -> new Object[]{item.getFirst(), item.getThird()}) + .fieldExtractor(item -> new Object[] { item.getFirst(), item.getThird() }) .encoding("UTF-16LE") .headerCallback(writer1 -> writer1.append("HEADER")) .footerCallback(writer12 -> writer12.append("FOOTER")) @@ -275,7 +275,7 @@ void testFormattedOutputWithCustomFieldExtractor() throws Exception { .lineSeparator("$") .formatted() .format("%3s%3s") - .fieldExtractor(item -> new Object[]{item.getFirst(), item.getThird()}) + .fieldExtractor(item -> new Object[] { item.getFirst(), item.getThird() }) .encoding("UTF-16LE") .headerCallback(writer1 -> writer1.append("HEADER")) .footerCallback(writer12 -> writer12.append("FOOTER")) @@ -487,25 +487,23 @@ void testSetupFormatterLineAggregatorWithNoItemType() throws IOException { assertInstanceOf(BeanWrapperFieldExtractor.class, fieldExtractor); } - // Issue #4916: RecordFieldExtractor should honor names() configuration @Test void testDelimitedWithRecordAndSelectedFields() throws IOException { - // 이유: DelimitedBuilder가 names() 설정을 RecordFieldExtractor에 전달하는지 확인 - // 핵심 버그 수정 검증 테스트 - + // given WritableResource output = new FileSystemResource(File.createTempFile("delimited-selected", "csv")); - record Person(int id, String name, String email, int age) {} + record Person(int id, String name, String email, int age) { + } - FlatFileItemWriter writer = new FlatFileItemWriterBuilder() - .name("personWriter") + // when + FlatFileItemWriter writer = new FlatFileItemWriterBuilder().name("personWriter") .resource(output) .delimited() .delimiter(",") .sourceType(Person.class) - .names("name", "age") // 특정 필드만 선택 + .names("name", "age") .build(); - // 타입 및 설정 검증 + // then Object lineAggregator = ReflectionTestUtils.getField(writer, "lineAggregator"); assertNotNull(lineAggregator); assertInstanceOf(DelimitedLineAggregator.class, lineAggregator); @@ -514,33 +512,31 @@ record Person(int id, String name, String email, int age) {} assertNotNull(fieldExtractor); assertInstanceOf(RecordFieldExtractor.class, fieldExtractor); - // 핵심: names 설정이 전달되었는지 확인 Object names = ReflectionTestUtils.getField(fieldExtractor, "names"); assertEquals(Arrays.asList("name", "age"), names); } @Test void testDelimitedWithRecordFieldReordering() throws IOException { - // 이유: 필드 순서 변경이 제대로 동작하는지 확인 - // names()의 순서가 RecordFieldExtractor에 전달되는지 검증 - + // given WritableResource output = new FileSystemResource(File.createTempFile("delimited-reorder", "csv")); - record Employee(int id, String firstName, String lastName, String dept) {} + record Employee(int id, String firstName, String lastName, String dept) { + } - FlatFileItemWriter writer = new FlatFileItemWriterBuilder() - .name("employeeWriter") + // when + FlatFileItemWriter writer = new FlatFileItemWriterBuilder().name("employeeWriter") .resource(output) .delimited() .delimiter("|") .sourceType(Employee.class) - .names("lastName", "firstName", "id") // 순서 변경 + dept 제외 + .names("lastName", "firstName", "id") .build(); + // then Object lineAggregator = ReflectionTestUtils.getField(writer, "lineAggregator"); assertNotNull(lineAggregator); assertInstanceOf(DelimitedLineAggregator.class, lineAggregator); - // delimiter 설정 확인 Object delimiter = ReflectionTestUtils.getField(lineAggregator, "delimiter"); assertEquals("|", delimiter); @@ -548,33 +544,31 @@ record Employee(int id, String firstName, String lastName, String dept) {} assertNotNull(fieldExtractor); assertInstanceOf(RecordFieldExtractor.class, fieldExtractor); - // 순서 변경 및 필드 제외 확인 Object names = ReflectionTestUtils.getField(fieldExtractor, "names"); assertEquals(Arrays.asList("lastName", "firstName", "id"), names); } @Test void testFormattedWithRecordAndSelectedFields() throws IOException { - // 이유: FormattedBuilder도 names() 설정을 RecordFieldExtractor에 전달하는지 확인 - // FormattedBuilder가 DelimitedBuilder와 동일하게 동작하는지 검증 - + // given WritableResource output = new FileSystemResource(File.createTempFile("formatted-selected", "txt")); - record Person(int id, String name, String email) {} + record Person(int id, String name, String email) { + } - FlatFileItemWriter writer = new FlatFileItemWriterBuilder() - .name("personWriter") + // when + FlatFileItemWriter writer = new FlatFileItemWriterBuilder().name("personWriter") .resource(output) .formatted() .format("%-10s%3d") .sourceType(Person.class) - .names("name", "id") // email 제외, 순서 변경 + .names("name", "id") .build(); + // then Object lineAggregator = ReflectionTestUtils.getField(writer, "lineAggregator"); assertNotNull(lineAggregator); assertInstanceOf(FormatterLineAggregator.class, lineAggregator); - // format 설정 확인 Object format = ReflectionTestUtils.getField(lineAggregator, "format"); assertEquals("%-10s%3d", format); @@ -582,27 +576,26 @@ record Person(int id, String name, String email) {} assertNotNull(fieldExtractor); assertInstanceOf(RecordFieldExtractor.class, fieldExtractor); - // FormattedBuilder도 names 설정을 전달하는지 확인 Object names = ReflectionTestUtils.getField(fieldExtractor, "names"); assertEquals(Arrays.asList("name", "id"), names); } @Test void testDelimitedWithRecordAllFields() throws IOException { - // 이유: names()를 지정하지 않았을 때 모든 필드가 사용되는지 확인 - // 기본 동작 검증 (backward compatibility) - + // given WritableResource output = new FileSystemResource(File.createTempFile("delimited-all", "csv")); - record Product(String code, String name, double price) {} + record Product(String code, String name, double price) { + } - FlatFileItemWriter writer = new FlatFileItemWriterBuilder() - .name("productWriter") + // when + FlatFileItemWriter writer = new FlatFileItemWriterBuilder().name("productWriter") .resource(output) .delimited() .sourceType(Product.class) - .names("code", "name", "price") // 모든 필드 명시 + .names("code", "name", "price") .build(); + // then Object lineAggregator = ReflectionTestUtils.getField(writer, "lineAggregator"); assertNotNull(lineAggregator); assertInstanceOf(DelimitedLineAggregator.class, lineAggregator); @@ -611,7 +604,6 @@ record Product(String code, String name, double price) {} assertNotNull(fieldExtractor); assertInstanceOf(RecordFieldExtractor.class, fieldExtractor); - // 모든 필드가 포함되었는지 확인 Object names = ReflectionTestUtils.getField(fieldExtractor, "names"); assertEquals(Arrays.asList("code", "name", "price"), names); } From fada615120e8d67f2a481df6bd83c8fdf7049357 Mon Sep 17 00:00:00 2001 From: LeeHyungGeol Date: Mon, 6 Oct 2025 20:32:53 +0900 Subject: [PATCH 3/3] Add comprehensive tests for FormattedBuilder with Record field selection and reordering Signed-off-by: LeeHyungGeol --- .../FlatFileItemWriterBuilderTests.java | 72 +++++++++++++++++-- 1 file changed, 68 insertions(+), 4 deletions(-) diff --git a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/builder/FlatFileItemWriterBuilderTests.java b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/builder/FlatFileItemWriterBuilderTests.java index 063fd5e3f6..1492f9f6b8 100644 --- a/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/builder/FlatFileItemWriterBuilderTests.java +++ b/spring-batch-infrastructure/src/test/java/org/springframework/batch/item/file/builder/FlatFileItemWriterBuilderTests.java @@ -548,6 +548,34 @@ record Employee(int id, String firstName, String lastName, String dept) { assertEquals(Arrays.asList("lastName", "firstName", "id"), names); } + @Test + void testDelimitedWithRecordAllFields() throws IOException { + // given + WritableResource output = new FileSystemResource(File.createTempFile("delimited-all", "csv")); + record Product(String code, String name, double price) { + } + + // when + FlatFileItemWriter writer = new FlatFileItemWriterBuilder().name("productWriter") + .resource(output) + .delimited() + .sourceType(Product.class) + .names("code", "name", "price") + .build(); + + // then + Object lineAggregator = ReflectionTestUtils.getField(writer, "lineAggregator"); + assertNotNull(lineAggregator); + assertInstanceOf(DelimitedLineAggregator.class, lineAggregator); + + Object fieldExtractor = ReflectionTestUtils.getField(lineAggregator, "fieldExtractor"); + assertNotNull(fieldExtractor); + assertInstanceOf(RecordFieldExtractor.class, fieldExtractor); + + Object names = ReflectionTestUtils.getField(fieldExtractor, "names"); + assertEquals(Arrays.asList("code", "name", "price"), names); + } + @Test void testFormattedWithRecordAndSelectedFields() throws IOException { // given @@ -581,16 +609,49 @@ record Person(int id, String name, String email) { } @Test - void testDelimitedWithRecordAllFields() throws IOException { + void testFormattedWithRecordFieldReordering() throws IOException { // given - WritableResource output = new FileSystemResource(File.createTempFile("delimited-all", "csv")); + WritableResource output = new FileSystemResource(File.createTempFile("formatted-reorder", "txt")); + record Employee(int id, String firstName, String lastName, String dept) { + } + + // when + FlatFileItemWriter writer = new FlatFileItemWriterBuilder().name("employeeWriter") + .resource(output) + .formatted() + .format("%-15s%-15s%5d") + .sourceType(Employee.class) + .names("lastName", "firstName", "id") + .build(); + + // then + Object lineAggregator = ReflectionTestUtils.getField(writer, "lineAggregator"); + assertNotNull(lineAggregator); + assertInstanceOf(FormatterLineAggregator.class, lineAggregator); + + Object format = ReflectionTestUtils.getField(lineAggregator, "format"); + assertEquals("%-15s%-15s%5d", format); + + Object fieldExtractor = ReflectionTestUtils.getField(lineAggregator, "fieldExtractor"); + assertNotNull(fieldExtractor); + assertInstanceOf(RecordFieldExtractor.class, fieldExtractor); + + Object names = ReflectionTestUtils.getField(fieldExtractor, "names"); + assertEquals(Arrays.asList("lastName", "firstName", "id"), names); + } + + @Test + void testFormattedWithRecordAllFields() throws IOException { + // given + WritableResource output = new FileSystemResource(File.createTempFile("formatted-all", "txt")); record Product(String code, String name, double price) { } // when FlatFileItemWriter writer = new FlatFileItemWriterBuilder().name("productWriter") .resource(output) - .delimited() + .formatted() + .format("%-10s%-20s%10.2f") .sourceType(Product.class) .names("code", "name", "price") .build(); @@ -598,7 +659,10 @@ record Product(String code, String name, double price) { // then Object lineAggregator = ReflectionTestUtils.getField(writer, "lineAggregator"); assertNotNull(lineAggregator); - assertInstanceOf(DelimitedLineAggregator.class, lineAggregator); + assertInstanceOf(FormatterLineAggregator.class, lineAggregator); + + Object format = ReflectionTestUtils.getField(lineAggregator, "format"); + assertEquals("%-10s%-20s%10.2f", format); Object fieldExtractor = ReflectionTestUtils.getField(lineAggregator, "fieldExtractor"); assertNotNull(fieldExtractor);