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