From d4e20e87b8240db996eb04a028d9b0b157066ba9 Mon Sep 17 00:00:00 2001 From: Andrey Nosov <107930557+swankygawk@users.noreply.github.com> Date: Sun, 28 Sep 2025 21:30:59 +0200 Subject: [PATCH 1/7] chore: update `.gitignore`, add `datafaker` and `assertj` dependencies --- .gitignore | 10 +++++----- pom.xml | 13 +++++++++++++ src/main/java/org/writer/Writable.java | 2 -- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 5ff6309..479420e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,10 +4,8 @@ target/ !**/src/test/**/target/ ### IntelliJ IDEA ### -.idea/modules.xml -.idea/jarRepositories.xml -.idea/compiler.xml -.idea/libraries/ +.idea +.run *.iws *.iml *.ipr @@ -35,4 +33,6 @@ build/ .vscode/ ### Mac OS ### -.DS_Store \ No newline at end of file +.DS_Store + +output \ No newline at end of file diff --git a/pom.xml b/pom.xml index effc1bd..657c052 100644 --- a/pom.xml +++ b/pom.xml @@ -15,11 +15,18 @@ + + net.datafaker + datafaker + 2.5.1 + + org.projectlombok lombok 1.18.34 provided + org.junit.jupiter @@ -27,6 +34,12 @@ 5.8.1 test + + org.assertj + assertj-core + 3.27.6 + test + \ No newline at end of file diff --git a/src/main/java/org/writer/Writable.java b/src/main/java/org/writer/Writable.java index 9f60c45..d764200 100644 --- a/src/main/java/org/writer/Writable.java +++ b/src/main/java/org/writer/Writable.java @@ -3,7 +3,5 @@ import java.util.List; public interface Writable { - void writeToFile(List data, String fileName); - } From 33711dba2a057bb60d1fab29de1a4aa8cce302e7 Mon Sep 17 00:00:00 2001 From: Andrey Nosov <107930557+swankygawk@users.noreply.github.com> Date: Mon, 29 Sep 2025 16:22:03 +0200 Subject: [PATCH 2/7] feat: implement `CsvEntity` and `CsvColumn` annotations --- .../java/org/writer/annotation/CsvColumn.java | 30 +++++++++++++++++++ .../java/org/writer/annotation/CsvEntity.java | 16 ++++++++++ 2 files changed, 46 insertions(+) create mode 100644 src/main/java/org/writer/annotation/CsvColumn.java create mode 100644 src/main/java/org/writer/annotation/CsvEntity.java diff --git a/src/main/java/org/writer/annotation/CsvColumn.java b/src/main/java/org/writer/annotation/CsvColumn.java new file mode 100644 index 0000000..c318de5 --- /dev/null +++ b/src/main/java/org/writer/annotation/CsvColumn.java @@ -0,0 +1,30 @@ +package org.writer.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Помечает поле класса для включения в CSV-файл. + * + * @see CsvEntity + */ +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +public @interface CsvColumn { + /** + * Название столбца в заголовке CSV-файла. + * + * @return название столбца + */ + String name(); + + /** + * Порядок столбца в CSV-файле (слева направо), начиная с 1. + * Чем меньше число, тем левее столбец. + * + * @return порядковый номер столбца + */ + int order(); +} diff --git a/src/main/java/org/writer/annotation/CsvEntity.java b/src/main/java/org/writer/annotation/CsvEntity.java new file mode 100644 index 0000000..420ff9b --- /dev/null +++ b/src/main/java/org/writer/annotation/CsvEntity.java @@ -0,0 +1,16 @@ +package org.writer.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Помечает класс, экземпляры которого можно экспортировать в CSV. + * + * @see CsvColumn + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface CsvEntity { +} From df58283aa547778eef39fa0b3c91aa3ffe34cc8d Mon Sep 17 00:00:00 2001 From: Andrey Nosov <107930557+swankygawk@users.noreply.github.com> Date: Mon, 29 Sep 2025 16:24:44 +0200 Subject: [PATCH 3/7] feat: implement `Writable` interface in `CsvWriterImpl` --- src/main/java/org/writer/Writable.java | 7 - .../java/org/writer/writer/CsvWriterImpl.java | 192 ++++++++++++++++++ src/main/java/org/writer/writer/Writable.java | 25 +++ 3 files changed, 217 insertions(+), 7 deletions(-) delete mode 100644 src/main/java/org/writer/Writable.java create mode 100644 src/main/java/org/writer/writer/CsvWriterImpl.java create mode 100644 src/main/java/org/writer/writer/Writable.java diff --git a/src/main/java/org/writer/Writable.java b/src/main/java/org/writer/Writable.java deleted file mode 100644 index d764200..0000000 --- a/src/main/java/org/writer/Writable.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.writer; - -import java.util.List; - -public interface Writable { - void writeToFile(List data, String fileName); -} diff --git a/src/main/java/org/writer/writer/CsvWriterImpl.java b/src/main/java/org/writer/writer/CsvWriterImpl.java new file mode 100644 index 0000000..dbc82b8 --- /dev/null +++ b/src/main/java/org/writer/writer/CsvWriterImpl.java @@ -0,0 +1,192 @@ +package org.writer.writer; + +import org.writer.annotation.CsvColumn; +import org.writer.annotation.CsvEntity; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Реализация интерфейса {@link Writable}, которая сохраняет + * список объектов в файл в формате CSV, используя аннотации и рефлексию. + */ +public class CsvWriterImpl implements Writable { + private static final String CSV_DELIMITER = ","; + private static final String COLLECTION_DELIMITER = ";"; + private static final String KEY_VALUE_DELIMITER = ":"; + + /** + * Записывает данные в CSV-файл. Первой строкой всегда будет заголовок, + * сформированный из аннотаций {@link CsvColumn}. + * + * @param data список объектов для записи. Класс объектов должен быть помечен + * аннотацией {@link CsvEntity}. + * @param fileName имя файла, в который будут сохранены данные. + * + * @throws IllegalArgumentException если список data:

+ * а) {@code null},

+ * б) {@code data.isEmpty()},

+ * в) содержит объекты, не помеченные аннотацией {@link CsvEntity};

+ * если строка fileName:

+ * а) {@code null},

+ * б) {@code fileName.isBlank()}. + * @throws RuntimeException в случае ошибки ввода-вывода при записи файла. + */ + @Override + public void writeToFile(final List data, final String fileName) { + if (data == null || data.isEmpty()) { + throw new IllegalArgumentException("data не должен быть isEmpty или null"); + } + + if (fileName == null || fileName.isBlank()) { + throw new IllegalArgumentException("fileName не должен быть isBlank или null"); + } + + Class csvEntityClass = data.get(0).getClass(); + if (!csvEntityClass.isAnnotationPresent(CsvEntity.class)) { + throw new IllegalArgumentException(csvEntityClass.getSimpleName() + " не имеет аннотации @CsvEntity"); + } + + List sortedFields = getSortedCsvFields(csvEntityClass); + + try (BufferedWriter writer = Files.newBufferedWriter(Path.of(fileName))) { + String header = generateHeader(sortedFields); + writer.write(header); + writer.newLine(); + + for (Object item : data) { + String row = convertObjectToCsvRow(item, sortedFields); + writer.write(row); + writer.newLine(); + } + } catch (IOException e) { + throw new RuntimeException("Ошибка во время записи в файл: " + fileName, e); + } + } + + /** + * Находит все поля, помеченные аннотацией {@link CsvColumn}, + * проверяет уникальность их параметров {@link CsvColumn#name()} и {@link CsvColumn#order()}, + * и сортирует их в соответствии с параметром {@code order}. + * + * @param csvEntityClass класс для сканирования. + * + * @return отсортированный список полей. + * + * @throws IllegalStateException если найдены дублирующиеся значения {@code name} или {@code order} + * в аннотациях {@link CsvColumn} в пределах класса. + */ + private List getSortedCsvFields(final Class csvEntityClass) { + List fields = Arrays.stream(csvEntityClass.getDeclaredFields()) + .filter(field -> field.isAnnotationPresent(CsvColumn.class)) + .toList(); + + List names = fields.stream() + .map(field -> field.getAnnotation(CsvColumn.class).name()) + .toList(); + if (new HashSet<>(names).size() < names.size()) { + throw new IllegalStateException("В классе " + csvEntityClass.getSimpleName() + " найдены дублирующиеся значения \"name\" в аннотациях @CsvColumn"); + } + + List orders = fields.stream() + .map(field -> field.getAnnotation(CsvColumn.class).order()) + .toList(); + if (new HashSet<>(orders).size() < orders.size()) { + throw new IllegalStateException("В классе " + csvEntityClass.getSimpleName() + " найдены дублирующиеся значения \"order\" в аннотациях @CsvColumn"); + } + + return fields.stream() + .sorted(Comparator.comparingInt(field -> field.getAnnotation(CsvColumn.class).order())) + .toList(); + } + + /** + * Генерирует строку заголовка CSV на основе сортированного списка полей. + * + * @param sortedFields отсортированный список полей. + * + * @return строка заголовка, например, "firstName,lastName,birthYear". + */ + private String generateHeader(final List sortedFields) { + return sortedFields.stream() + .map(field -> field.getAnnotation(CsvColumn.class).name()) + .map(this::escapeCsvValue) + .collect(Collectors.joining(CSV_DELIMITER)); + } + + /** + * Преобразует один объект в строку формата CSV. + * + * @param item объект для преобразования. + * @param sortedFields отсортированный список полей, значения которых нужно извлечь. + * + * @return строка в формате CSV, например, "John,Doe,1990". + * + * @throws RuntimeException если не удалось получить доступ к полю. + */ + private String convertObjectToCsvRow(final Object item, final List sortedFields) { + return sortedFields.stream() + .map(field -> { + try { + field.setAccessible(true); + Object value = field.get(item); + String stringValue = convertFieldValueToString(value); + return escapeCsvValue(stringValue); + } catch (IllegalAccessException e) { + throw new RuntimeException("Не удалось получить доступ к полю: " + field.getName(), e); + } + }) + .collect(Collectors.joining(CSV_DELIMITER)); + } + + /** + * Конвертирует значение поля в его строковое представление для CSV. + * Обрабатывает {@code Map}, разделяя ключ-значение {@code KEY_VALUE_DELIMITER}, + * и объединяя все пары {@code COLLECTION_DELIMITER}. + * Обрабатывает {@code Collection}, объединяя их элементы {@code COLLECTION_DELIMITER}. + * + * @param value значение поля. + * + * @return строковое представление значения. + */ + private String convertFieldValueToString(final Object value) { + if (value == null) { + return ""; + } + + if (value instanceof Map map) { + return map.entrySet().stream() + .map(entry -> entry.getKey().toString() + KEY_VALUE_DELIMITER + entry.getValue().toString()) + .collect(Collectors.joining(COLLECTION_DELIMITER)); + } + + if (value instanceof Collection collection) { + return collection.stream() + .map(String::valueOf) + .collect(Collectors.joining(COLLECTION_DELIMITER)); + } + + return value.toString(); + } + + /** + * Экранирует значение поля для CSV формата в соответствии с RFC 4180. + * + * @param value исходное строковое значение. + * + * @return экранированное значение, готовое к записи в CSV. + */ + private String escapeCsvValue(final String value) { + if (value.contains(CSV_DELIMITER) || value.contains("\"") || value.contains("\n")) { + String escapedValue = value.replace("\"", "\"\""); + return "\"" + escapedValue + "\""; + } + + return value; + } +} diff --git a/src/main/java/org/writer/writer/Writable.java b/src/main/java/org/writer/writer/Writable.java new file mode 100644 index 0000000..052426c --- /dev/null +++ b/src/main/java/org/writer/writer/Writable.java @@ -0,0 +1,25 @@ +package org.writer.writer; + +import java.util.List; + +/** + * Определяет контракт для классов, которые могут записывать + * списки объектов в файл в определённом формате. + */ +public interface Writable { + /** + * Записывает переданный список объектов в файл с указанным именем. + *

+ * Реализации этого метода должны чётко определять формат записи + * и требования к аннотациям и/или структуре объектов в списке {@code data}. + * + * @param data список объектов для записи. + * @param fileName полное имя файла, в который будут сохранены данные. + * + * @throws IllegalArgumentException если какой-либо из входных параметров невалиден. + * Например, если список {@code data} равен {@code null} или {@code data.isEmpty()}, + * или если {@code fileName} равен {@code null} или {@code fileName.isBlank()}. + * @throws RuntimeException если в процессе записи в файл возникает ошибка ввода-вывода или другая критическая ошибка. + */ + void writeToFile(List data, String fileName); +} From 3de7934ddd598172f97c5ec750061cd8c7ea6fdc Mon Sep 17 00:00:00 2001 From: Andrey Nosov <107930557+swankygawk@users.noreply.github.com> Date: Mon, 29 Sep 2025 16:26:28 +0200 Subject: [PATCH 4/7] feat: implement `TestDataGenerator` utility class --- .../org/writer/util/TestDataGenerator.java | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/main/java/org/writer/util/TestDataGenerator.java diff --git a/src/main/java/org/writer/util/TestDataGenerator.java b/src/main/java/org/writer/util/TestDataGenerator.java new file mode 100644 index 0000000..63972e2 --- /dev/null +++ b/src/main/java/org/writer/util/TestDataGenerator.java @@ -0,0 +1,89 @@ +package org.writer.util; + +import net.datafaker.Faker; +import org.writer.model.Months; +import org.writer.model.Person; +import org.writer.model.Student; + +import java.util.List; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +/** + * Вспомогательный класс для генерации тестовых данных + */ +public final class TestDataGenerator { + private static final Faker FAKER = new Faker(); + + private TestDataGenerator() {} + + /** + * Генерирует случайное int в пределах границ (включительно). + * + * @param min нижняя граница. + * @param max верхняя граница. + * + * @return случайное int. + */ + public static int generateRandomInt(final int min, final int max) { + return FAKER.number().numberBetween(min, max + 1); + } + + /** + * Генерирует список случайных объектов {@link Person}. + * + * @param count желаемое количество объектов. + * + * @return список сгенерированных объектов {@code Person}. + */ + public static List generatePersons(final int count) { + return IntStream.range(0, count) + .mapToObj(i -> Person.builder() + .firstName(FAKER.name().firstName()) + .lastName(FAKER.name().lastName()) + .dayOfBirth(FAKER.number().numberBetween(1, 28)) + .monthOfBirth(getRandomMonth()) + .yearOfBirth(FAKER.number().numberBetween(1925, 2025)) + .build()) + .toList(); + } + + /** + * Генерирует список случайных объектов {@link Student}. + * + * @param count желаемое количество объектов. + * + * @return список сгенерированных объектов {@code Student}. + */ + public static List generateStudents(final int count) { + return IntStream.range(0, count) + .mapToObj(i -> Student.builder() + .name(FAKER.name().fullName()) + .score(generateRandomScores(FAKER.number().numberBetween(2, 9))) + .build()) + .toList(); + } + + /** + * Вспомогательный метод для получения случайного месяца из enum {@link Months}. + * + * @return случайный месяц. + */ + private static Months getRandomMonth() { + Months[] months = Months.values(); + return months[FAKER.number().numberBetween(0, months.length - 1)]; + } + + /** + * Вспомогательный метод для генерации списка случайных оценок. + * + * @param count количество оценок. + * + * @return список оценок в виде строк. + */ + private static List generateRandomScores(final int count) { + return Stream.generate(() -> String.valueOf(FAKER.number().numberBetween(1, 6))) + .limit(count) + .toList(); + } +} From df735effda811b86425f1100e2cbb5a0c699b1ef Mon Sep 17 00:00:00 2001 From: Andrey Nosov <107930557+swankygawk@users.noreply.github.com> Date: Mon, 29 Sep 2025 16:28:38 +0200 Subject: [PATCH 5/7] feat: add usage examples --- src/main/java/org/writer/Application.java | 72 +++++++++++++++++++++ src/main/java/org/writer/Main.java | 7 -- src/main/java/org/writer/model/Person.java | 10 ++- src/main/java/org/writer/model/Student.java | 6 +- 4 files changed, 85 insertions(+), 10 deletions(-) create mode 100644 src/main/java/org/writer/Application.java delete mode 100644 src/main/java/org/writer/Main.java diff --git a/src/main/java/org/writer/Application.java b/src/main/java/org/writer/Application.java new file mode 100644 index 0000000..e895d2e --- /dev/null +++ b/src/main/java/org/writer/Application.java @@ -0,0 +1,72 @@ +package org.writer; + +import org.writer.model.Person; +import org.writer.model.Student; +import org.writer.util.TestDataGenerator; +import org.writer.writer.CsvWriterImpl; +import org.writer.writer.Writable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +public final class Application { + private static final String OUTPUT_DIR = "output"; + + public static void main(String[] args) { + System.out.println(); + + createOutputDir(); + + Writable csvWriter = new CsvWriterImpl(); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"); + + // Person + String personsTimestamp = LocalDateTime.now().format(formatter); + String personsFileName = OUTPUT_DIR + "/persons-" + personsTimestamp + ".csv"; + int personsCount = TestDataGenerator.generateRandomInt(2, 100); + + System.out.println("Генерация случайных Person..."); + List persons = TestDataGenerator.generatePersons(personsCount); + + System.out.println("Запись " + personsCount + " объектов Person в файл " + personsFileName + "..."); + csvWriter.writeToFile(persons, personsFileName); + System.out.println("Файл "+ personsFileName + " успешно создан"); + System.out.println("---------------------------------------------"); + + // Student + String studentsTimestamp = LocalDateTime.now().format(formatter); + String studentsFileName = OUTPUT_DIR + "/students-" + studentsTimestamp + ".csv"; + int studentsCount = TestDataGenerator.generateRandomInt(2, 100); + + System.out.println("Генерация случайных Student..."); + List students = TestDataGenerator.generateStudents(studentsCount); + + System.out.println("Запись " + studentsCount + " объектов Student в файл " + studentsFileName + "..."); + csvWriter.writeToFile(students, studentsFileName); + System.out.println("Файл "+ studentsFileName + " успешно создан"); + + System.out.println(); + } + + /** + * Создаёт директорию для вывода, если она ещё не существует. + * + * @throws RuntimeException если не удалось создать директорию. + */ + private static void createOutputDir() { + Path outputPath = Path.of(OUTPUT_DIR); + if (!Files.exists(outputPath)) { + try { + Files.createDirectories(outputPath); + System.out.println("Создана директория для вывода: " + OUTPUT_DIR); + System.out.println(); + } catch (IOException e) { + throw new RuntimeException("Не удалось создать директорию для вывода: " + OUTPUT_DIR, e); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/org/writer/Main.java b/src/main/java/org/writer/Main.java deleted file mode 100644 index 40e8213..0000000 --- a/src/main/java/org/writer/Main.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.writer; - -public class Main { - public static void main(String[] args) { - System.out.println("Hello world!"); - } -} \ No newline at end of file diff --git a/src/main/java/org/writer/model/Person.java b/src/main/java/org/writer/model/Person.java index ba8576b..cfe852d 100644 --- a/src/main/java/org/writer/model/Person.java +++ b/src/main/java/org/writer/model/Person.java @@ -3,20 +3,26 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import org.writer.annotation.CsvColumn; +import org.writer.annotation.CsvEntity; @Data @Builder @AllArgsConstructor +@CsvEntity public class Person { - + @CsvColumn(name = "firstName", order = 1) private String firstName; + @CsvColumn(name = "lastName", order = 2) private String lastName; + @CsvColumn(name = "birthDay", order = 5) private int dayOfBirth; + @CsvColumn(name = "birthMonth", order = 4) private Months monthOfBirth; + @CsvColumn(name = "birthYear", order = 3) private int yearOfBirth; - } diff --git a/src/main/java/org/writer/model/Student.java b/src/main/java/org/writer/model/Student.java index 2b549c4..fd01afa 100644 --- a/src/main/java/org/writer/model/Student.java +++ b/src/main/java/org/writer/model/Student.java @@ -3,15 +3,19 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import org.writer.annotation.CsvColumn; +import org.writer.annotation.CsvEntity; import java.util.List; @Data @Builder @AllArgsConstructor +@CsvEntity public class Student { - + @CsvColumn(name = "name", order = 1) private String name; + @CsvColumn(name = "score", order = 2) private List score; } \ No newline at end of file From d20fe5121f7a44e7d4eed54fa122d9f652f357f2 Mon Sep 17 00:00:00 2001 From: Andrey Nosov <107930557+swankygawk@users.noreply.github.com> Date: Mon, 29 Sep 2025 16:29:29 +0200 Subject: [PATCH 6/7] test: implement unit tests for `CsvWriterImpl` --- .../org/writer/writer/CsvWriterImplTest.java | 341 ++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 src/test/java/org/writer/writer/CsvWriterImplTest.java diff --git a/src/test/java/org/writer/writer/CsvWriterImplTest.java b/src/test/java/org/writer/writer/CsvWriterImplTest.java new file mode 100644 index 0000000..b5ffe83 --- /dev/null +++ b/src/test/java/org/writer/writer/CsvWriterImplTest.java @@ -0,0 +1,341 @@ +package org.writer.writer; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.writer.annotation.CsvColumn; +import org.writer.annotation.CsvEntity; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class CsvWriterImplTest { + private Writable csvWriter; + + @TempDir + Path tempDir; + + @BeforeEach + void setUp() { + csvWriter = new CsvWriterImpl(); + } + + @Test + @DisplayName("Должен выбросить исключение, если список data null") + void writeToFile_shouldThrowException_whenDataIsNull() { + // Arrange + String fileName = tempDir.resolve("test.csv").toString(); + + // Act & Assert + assertThatThrownBy(() -> csvWriter.writeToFile(null, fileName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("data не должен быть isEmpty или null"); + } + + @Test + @DisplayName("Должен выбросить исключение, если список data isEmpty") + void writeToFile_shouldThrowException_whenDataIsEmpty() { + // Arrange + List data = Collections.emptyList(); + String fileName = tempDir.resolve("test.csv").toString(); + + // Act & Assert + assertThatThrownBy(() -> csvWriter.writeToFile(data, fileName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("data не должен быть isEmpty или null"); + } + + @Test + @DisplayName("Должен выбросить исключение, если строка fileName null") + void writeToFile_shouldThrowException_whenFileNameIsNull() { + // Arrange + List data = List.of(1); + + // Act & Assert + assertThatThrownBy(() -> csvWriter.writeToFile(data, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("fileName не должен быть isBlank или null"); + } + + @Test + @DisplayName("Должен выбросить исключение, если строка fileName isBlank") + void writeToFile_shouldThrowException_whenFileNameIsBlank() { + // Arrange + List data = List.of(1); + String fileName = " "; + + // Act & Assert + assertThatThrownBy(() -> csvWriter.writeToFile(data, fileName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("fileName не должен быть isBlank или null"); + } + + @Test + @DisplayName("Должен выбросить исключение, если класс объектов в списке data не имеет аннотации @CsvEntity") + void writeToFile_shouldThrowException_whenClassIsNotCsvEntity() { + // Arrange + class InvalidModel {} + List invalidData = List.of(new InvalidModel()); + String fileName = tempDir.resolve("test.csv").toString(); + + // Act & Assert + assertThatThrownBy(() -> csvWriter.writeToFile(invalidData, fileName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(InvalidModel.class.getSimpleName() + " не имеет аннотации @CsvEntity"); + } + + @Test + @DisplayName("Должен выбросить исключение, если в @CsvColumn есть дублирующийся \"name\"") + void writeToFile_shouldThrowException_whenCsvColumnHasDuplicateName() { + // Arrange + @CsvEntity + @SuppressWarnings("unused") + class DuplicateNameModel { + @CsvColumn(name = "field", order = 1) + private Object field1; + + @CsvColumn(name = "field", order = 2) + private Object field2; + } + List data = List.of(new DuplicateNameModel()); + String fileName = tempDir.resolve("test.csv").toString(); + + // Act & Assert + assertThatThrownBy(() -> csvWriter.writeToFile(data, fileName)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("В классе " + DuplicateNameModel.class.getSimpleName() + " найдены дублирующиеся значения \"name\" в аннотациях @CsvColumn"); + } + + @Test + @DisplayName("Должен выбросить исключение, если в CsvColumn есть дублирующийся \"order\"") + void writeToFile_shouldThrowException_whenCsvColumnHasDuplicateOrder() { + // Arrange + @CsvEntity + @SuppressWarnings("unused") + class DuplicateOrderModel { + @CsvColumn(name = "field1", order = 1) + private Object field1; + + @CsvColumn(name = "field2", order = 1) + private Object field2; + } + List data = List.of(new DuplicateOrderModel()); + String fileName = tempDir.resolve("test.csv").toString(); + + // Act & Assert + assertThatThrownBy(() -> csvWriter.writeToFile(data, fileName)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("В классе " + DuplicateOrderModel.class.getSimpleName() + " найдены дублирующиеся значения \"order\" в аннотациях @CsvColumn"); + } + + @Test + @DisplayName("Должен корректно записывать простой список объектов в CSV-файл") + void writeToFile_shouldWriteSimpleObjectsCorrectly() throws IOException { + // Arrange + @Data + @AllArgsConstructor + @CsvEntity + class SimpleModel { + @CsvColumn(name = "User ID", order = 1) + private int id; + + @CsvColumn(name = "User Name", order = 2) + private String name; + } + List data = List.of( + new SimpleModel(101, "John Doe"), + new SimpleModel(202, "Jane Smith") + ); + Path filePath = tempDir.resolve("test.csv"); + + String expectedHeader = "User ID,User Name"; + String expectedRow1 = "101,John Doe"; + String expectedRow2 = "202,Jane Smith"; + + // Act + csvWriter.writeToFile(data, filePath.toString()); + + // Assert + assertThat(filePath).exists().isRegularFile(); + + List actualRows = Files.readAllLines(filePath); + assertThat(actualRows) + .hasSize(3) + .containsExactly( + expectedHeader, + expectedRow1, + expectedRow2 + ); + } + + @Test + @DisplayName("Должен корректно экранировать значение с запятой") + void writeToFile_shouldEscapeValueWithComma() throws IOException { + // Arrange + @Data + @AllArgsConstructor + @CsvEntity + class ModelWithCommaValue { + @CsvColumn(name = "commaField", order = 1) + private String commaField; + } + + List data = List.of( + new ModelWithCommaValue("this is a comma,value") + ); + Path filePath = tempDir.resolve("test.csv"); + + String expectedHeader = "commaField"; + String expectedRow = "\"this is a comma,value\""; + + // Act + csvWriter.writeToFile(data, filePath.toString()); + + // Assert + assertThat(filePath).exists().isRegularFile(); + + List actualRows = Files.readAllLines(filePath); + assertThat(actualRows) + .hasSize(2) + .containsExactly( + expectedHeader, + expectedRow + ); + } + + @Test + @DisplayName("Должен корректно экранировать значение с кавычками") + void writeToFile_shouldEscapeValueWithQuotes() throws IOException { + // Arrange + @Data + @AllArgsConstructor + @CsvEntity + class ModelWithQuotesValue { + @CsvColumn(name = "quotesField", order = 1) + private String quotesField; + } + + List data = List.of( + new ModelWithQuotesValue("this is a \"quotes\" value") + ); + Path filePath = tempDir.resolve("test.csv"); + + String expectedHeader = "quotesField"; + String expectedRow = "\"this is a \"\"quotes\"\" value\""; + + // Act + csvWriter.writeToFile(data, filePath.toString()); + + // Assert + assertThat(filePath).exists().isRegularFile(); + + List actualRows = Files.readAllLines(filePath); + assertThat(actualRows) + .hasSize(2) + .containsExactly( + expectedHeader, + expectedRow + ); + } + + @Test + @DisplayName("Должен корректно экранировать значение с новой строкой") + void writeToFile_shouldEscapeValueWithNewLine() throws IOException { + // Arrange + @Data + @AllArgsConstructor + @CsvEntity + class ModelWithNewLineValue { + @CsvColumn(name = "newLineField", order = 1) + private String newLineField; + } + + List data = List.of( + new ModelWithNewLineValue("this is a\nnew line value") + ); + Path filePath = tempDir.resolve("test.csv"); + + String expectedHeader = "newLineField"; + String expectedRow = "\"this is a\nnew line value\""; + String expectedFileContent = expectedHeader + System.lineSeparator() + expectedRow + System.lineSeparator(); + + // Act + csvWriter.writeToFile(data, filePath.toString()); + + // Assert + assertThat(filePath).exists().isRegularFile(); + + String actualFileContent = Files.readString(filePath); + assertThat(actualFileContent).isEqualTo(expectedFileContent); + } + + @Data + @Builder + @CsvEntity + private static class ComplexModel { + @CsvColumn(name = "nullField", order = 1) + private Object nullField; + + @CsvColumn(name = "list", order = 2) + private Collection list; + + @CsvColumn(name = "emptyCollection", order = 3) + private Collection emptyCollection; + + @CsvColumn(name = "map", order = 4) + private Map map; + + @CsvColumn(name = "emptyMap", order = 5) + private Map emptyMap; + } + + private List getComplexModels() { + Map map = new LinkedHashMap<>(); + map.put("key1", "value1"); + map.put("key2", "value2"); + + ComplexModel complexModel = ComplexModel.builder() + .nullField(null) + .list(List.of("listValue1", "listValue2")) + .emptyCollection(Collections.emptySet()) + .map(map) + .emptyMap(Collections.emptyMap()) + .build(); + + return List.of(complexModel); + } + + @Test + @DisplayName("Должен корректно обрабатывать null, Collection и Map") + void writeToFile_shouldCorrectlyHandleNullsCollectionsAndMaps() throws IOException { + // Arrange + List data = getComplexModels(); + Path filePath = tempDir.resolve("test.csv"); + + String expectedHeader = "nullField,list,emptyCollection,map,emptyMap"; + String expectedRow = ",listValue1;listValue2,,key1:value1;key2:value2,"; + + // Act + csvWriter.writeToFile(data, filePath.toString()); + + // Assert + assertThat(filePath).exists().isRegularFile(); + + List actualRows = Files.readAllLines(filePath); + assertThat(actualRows) + .hasSize(2) + .containsExactly( + expectedHeader, + expectedRow + ); + } +} From e631f37d92c16cbdd1e89605a35d6c17342a23ab Mon Sep 17 00:00:00 2001 From: Andrey Nosov <107930557+swankygawk@users.noreply.github.com> Date: Mon, 29 Sep 2025 16:29:55 +0200 Subject: [PATCH 7/7] chore: update READMEs --- README.md | 34 +++++++++++++++++----------------- TASKREADME.md | 20 ++++++++++++++++++++ 2 files changed, 37 insertions(+), 17 deletions(-) create mode 100644 TASKREADME.md diff --git a/README.md b/README.md index d6f44cb..3ba4bf4 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,20 @@ -# Практическое задание 1 # CSV Writer +## Prerequisites +- JDK 17+ +- Apache Maven 3.9+ -Мы пишем приложение, которое должно генерировать отчеты в разных форматах и нам хочется генерировать какой-нибудь отчёт в CSV, но проблема в том, что у нас нет такого инструмента. Поэтому мы решаем, что нам нужна библиотека для генерации CSV файлов. -Реализация может быть построена с помощью Reflection и аннотаций. +## Getting started +```bash +git clone https://github.com/swankygawk/CsvWriter.git +cd CsvWriter +``` -Папка model содержит классы, которые нужно сохранять в CSV. Классы можно изменять и добавлять новые. +## Launching demo +```bash +mvn compile exec:java -Dexec.mainClass="org.writer.Application" +``` +Generated CSV files will be located in the `output` directory -**Описание:**
-✅ Написать реализацию Writable, которая будет сохранять объекты в CSV формате в файл
-✅ Написать unit тесты
-✅ Написать JavaDoc
-✅ Для генерации тестовых данных можно использовать Datafaker
-✅ В main должно быть несколько примеров сохранения данных в файл - -**Как выполнить:** -1. Сделать форк этого проекта -2. Сделать ветку dev в своем проекте. Выполнять работу в ней -3. После выполнения работы открыть Pull Request в master -4. Назначить ментора ревьювером -5. Отправить ссылку на PR в тг чат ментору \ No newline at end of file +## Launching tests +```bash +mvn test +``` diff --git a/TASKREADME.md b/TASKREADME.md new file mode 100644 index 0000000..d6f44cb --- /dev/null +++ b/TASKREADME.md @@ -0,0 +1,20 @@ +# Практическое задание 1 # CSV Writer + +Мы пишем приложение, которое должно генерировать отчеты в разных форматах и нам хочется генерировать какой-нибудь отчёт в CSV, но проблема в том, что у нас нет такого инструмента. Поэтому мы решаем, что нам нужна библиотека для генерации CSV файлов. +Реализация может быть построена с помощью Reflection и аннотаций. + +Папка model содержит классы, которые нужно сохранять в CSV. Классы можно изменять и добавлять новые. + +**Описание:**
+✅ Написать реализацию Writable, которая будет сохранять объекты в CSV формате в файл
+✅ Написать unit тесты
+✅ Написать JavaDoc
+✅ Для генерации тестовых данных можно использовать Datafaker
+✅ В main должно быть несколько примеров сохранения данных в файл + +**Как выполнить:** +1. Сделать форк этого проекта +2. Сделать ветку dev в своем проекте. Выполнять работу в ней +3. После выполнения работы открыть Pull Request в master +4. Назначить ментора ревьювером +5. Отправить ссылку на PR в тг чат ментору \ No newline at end of file