diff --git a/pom.xml b/pom.xml
index effc1bd..f6b823f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -21,12 +21,27 @@
1.18.34
provided
+
+ org.slf4j
+ slf4j-api
+ 2.0.13
+
org.junit.jupiter
junit-jupiter
5.8.1
test
+
+ net.datafaker
+ datafaker
+ 2.5.3
+
+
+ ch.qos.logback
+ logback-classic
+ 1.5.20
+
\ No newline at end of file
diff --git a/src/main/java/org/writer/CsvWriter.java b/src/main/java/org/writer/CsvWriter.java
new file mode 100644
index 0000000..871f52f
--- /dev/null
+++ b/src/main/java/org/writer/CsvWriter.java
@@ -0,0 +1,137 @@
+package org.writer;
+
+import lombok.extern.slf4j.Slf4j;
+import org.writer.annotation.CsvColumn;
+import org.writer.exception.CsvWriteException;
+
+import java.io.BufferedWriter;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.util.Comparator;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * Класс для записи объектов в CSV формат.
+ *
+ * Класс использует рефлексию: поля, помеченные аннотацией {@link CsvColumn},
+ * извлекаются, сортируются по порядку и записываются в файл как строки CSV.
+ * Поддерживается экранирование значений, содержащих запятые, кавычки или переносы строки.
+ *
+ */
+@Slf4j
+public class CsvWriter implements Writable {
+
+ /**
+ * Записывает список объектов в CSV-файл.
+ *
+ * @param data список объектов, которые необходимо сохранить
+ * @param fileName имя создаваемого файла (существующий будет перезаписан)
+ * @throws IllegalArgumentException если список пустой или null
+ * @throws CsvWriteException при ошибке ввода-вывода
+ */
+ @Override
+ public void writeToFile(List> data, String fileName) {
+ if (data == null || data.isEmpty()) {
+ log.error("Data list is empty");
+ throw new IllegalArgumentException("Data list is empty");
+ }
+
+ Class> clazz = data.get(0).getClass();
+ List csvFields = getCsvFields(clazz);
+
+ try (BufferedWriter writer = new BufferedWriter(new FileWriter(fileName))) {
+ writer.write(buildHeader(csvFields));
+ writer.newLine();
+
+ for (Object object : data) {
+ writer.write(buildRow(object, csvFields));
+ writer.newLine();
+ }
+
+ } catch (IOException e) {
+ log.error(e.getMessage());
+ throw new CsvWriteException("Failed to write CSV file: " + fileName);
+ }
+ }
+
+ /**
+ * Возвращает отсортированный список полей с аннотацией {@link CsvColumn}.
+ *
+ * @param clazz класс модели
+ * @return список отсортированных полей
+ */
+ private List getCsvFields(Class> clazz) {
+ return Stream.of(clazz.getDeclaredFields())
+ .filter(field -> field.isAnnotationPresent(CsvColumn.class))
+ .sorted(Comparator.comparingInt(field -> field.getAnnotation(CsvColumn.class).order()))
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Формирует строку заголовка CSV на основе имени столбца из аннотации {@link CsvColumn}.
+ *
+ * @param fields список полей
+ * @return строка заголовка
+ */
+ private String buildHeader(List fields) {
+ return fields.stream()
+ .map(field -> field.getAnnotation(CsvColumn.class).name())
+ .collect(Collectors.joining(","));
+ }
+
+ /**
+ * Формирует строку CSV, извлекая значения полей объекта.
+ *
+ * @param object объект модели
+ * @param fields список полей для экспорта
+ * @return строка в формате CSV
+ */
+ private String buildRow(Object object, List fields) {
+ return fields.stream()
+ .map(field -> {
+ try {
+ field.setAccessible(true);
+ return escapeCsvValue(convertValue(field.get(object)));
+ } catch (IllegalAccessException e) {
+ log.error(e.getMessage());
+ throw new CsvWriteException(e.getMessage());
+ }
+ })
+ .collect(Collectors.joining(","));
+ }
+
+ /**
+ * Преобразует значение поля в строку.
+ *
+ * @param value значение поля
+ * @return строковое представление
+ */
+ private String convertValue(Object value) {
+ if (value == null) {
+ return "";
+ }
+
+ if (value instanceof List> list) {
+ return list.toString();
+ }
+
+ return value.toString();
+ }
+
+ /**
+ * Экранирует значение для корректной записи в CSV.
+ *
+ * @param value исходное значение
+ * @return экранированное значение
+ */
+ private String escapeCsvValue(String value) {
+ if (value.contains(",") || value.contains("\"") || value.contains("\n")) {
+ value = value.replace("\"", "\"\"");
+ return "\"" + value + "\"";
+ }
+ return value.trim();
+ }
+}
diff --git a/src/main/java/org/writer/Main.java b/src/main/java/org/writer/Main.java
index 40e8213..ff825e2 100644
--- a/src/main/java/org/writer/Main.java
+++ b/src/main/java/org/writer/Main.java
@@ -1,7 +1,55 @@
package org.writer;
+import net.datafaker.Faker;
+import org.writer.model.Months;
+import org.writer.model.Person;
+import org.writer.model.Student;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
+
public class Main {
+
+ private static final String PERSON_FILE_NAME = "persons.csv";
+ private static final String STUDENT_FILE_NAME = "students.csv";
+
public static void main(String[] args) {
- System.out.println("Hello world!");
+ Writable csvWriter = new CsvWriter();
+ Faker faker = new Faker();
+ Random random = new Random();
+
+ List people = new ArrayList<>();
+ for (int i = 0; i < 10; i++) {
+ people.add(
+ Person.builder()
+ .firstName(faker.name().firstName())
+ .lastName(faker.name().lastName())
+ .dayOfBirth(faker.number().numberBetween(1, 28))
+ .monthOfBirth(Months.values()[random.nextInt(Months.values().length)])
+ .yearOfBirth(faker.number().numberBetween(1941, 2025))
+ .build()
+ );
+ }
+
+ csvWriter.writeToFile(people, PERSON_FILE_NAME);
+
+ List students = new ArrayList<>();
+ for (int i = 0; i < 10; i++) {
+ List scores = List.of(
+ String.valueOf(faker.number().numberBetween(1, 5)),
+ String.valueOf(faker.number().numberBetween(1, 5)),
+ String.valueOf(faker.number().numberBetween(1, 5))
+ );
+
+ students.add(
+ Student.builder()
+ .name(faker.name().fullName())
+ .score(scores)
+ .build()
+ );
+ }
+
+ csvWriter.writeToFile(students, STUDENT_FILE_NAME);
}
}
\ No newline at end of file
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..d0355ff
--- /dev/null
+++ b/src/main/java/org/writer/annotation/CsvColumn.java
@@ -0,0 +1,24 @@
+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.
+ */
+@Target(ElementType.FIELD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface CsvColumn {
+
+ /**
+ * Имя столбца в CSV.
+ */
+ String name() default "";
+
+ /**
+ * Порядок следования столбца в CSV.
+ */
+ int order() default 0;
+}
diff --git a/src/main/java/org/writer/exception/CsvWriteException.java b/src/main/java/org/writer/exception/CsvWriteException.java
new file mode 100644
index 0000000..e13202d
--- /dev/null
+++ b/src/main/java/org/writer/exception/CsvWriteException.java
@@ -0,0 +1,11 @@
+package org.writer.exception;
+
+/**
+ * Исключение, которое выбрасывается при ошибках записи CSV-файла.
+ */
+public class CsvWriteException extends RuntimeException {
+
+ public CsvWriteException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/org/writer/model/Person.java b/src/main/java/org/writer/model/Person.java
index ba8576b..0f68f3e 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;
@Data
@Builder
@AllArgsConstructor
public class Person {
+ @CsvColumn(name = "first_name", order = 1)
private String firstName;
+ @CsvColumn(name = "last_name", order = 2)
private String lastName;
+ @CsvColumn(name = "day_of_birth", order = 3)
private int dayOfBirth;
+ @CsvColumn(name = "month_of_birth", order = 4)
private Months monthOfBirth;
+ @CsvColumn(name = "year_of_birth", order = 5)
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..c614042 100644
--- a/src/main/java/org/writer/model/Student.java
+++ b/src/main/java/org/writer/model/Student.java
@@ -3,6 +3,7 @@
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
+import org.writer.annotation.CsvColumn;
import java.util.List;
@@ -11,7 +12,9 @@
@AllArgsConstructor
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
diff --git a/src/test/java/org/writer/CsvWriterTest.java b/src/test/java/org/writer/CsvWriterTest.java
new file mode 100644
index 0000000..94e4c00
--- /dev/null
+++ b/src/test/java/org/writer/CsvWriterTest.java
@@ -0,0 +1,103 @@
+package org.writer;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.writer.exception.CsvWriteException;
+import org.writer.model.Months;
+import org.writer.model.Person;
+import org.writer.model.Student;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CsvWriterTest {
+
+ private final CsvWriter writer = new CsvWriter();
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ void testWritePersonsCsv() throws IOException {
+ List people = new ArrayList<>();
+ people.add(Person.builder()
+ .firstName("Ivan")
+ .lastName("Ivanov")
+ .dayOfBirth(15)
+ .monthOfBirth(Months.JANUARY)
+ .yearOfBirth(2002)
+ .build());
+
+ Path testFile = tempDir.resolve("persons.csv");
+ writer.writeToFile(people, testFile.toString());
+
+ List lines = Files.readAllLines(testFile);
+ assertEquals(2, lines.size());
+ assertEquals("first_name,last_name,day_of_birth,month_of_birth,year_of_birth", lines.get(0));
+ assertEquals("Ivan,Ivanov,15,JANUARY,2002", lines.get(1));
+ }
+
+ @Test
+ void testEscapeSpecialCharacters() throws IOException {
+ List people = new ArrayList<>();
+ people.add(Person.builder()
+ .firstName("Alice, \"The Great\"")
+ .lastName("O'Connor")
+ .dayOfBirth(1)
+ .monthOfBirth(Months.MARCH)
+ .yearOfBirth(1985)
+ .build());
+
+ Path testFile = tempDir.resolve("special_chars.csv");
+ writer.writeToFile(people, testFile.toString());
+
+ List lines = Files.readAllLines(testFile);
+ assertEquals("\"Alice, \"\"The Great\"\"\",O'Connor,1,MARCH,1985", lines.get(1));
+ }
+
+ @Test
+ void testEmptyListThrowsException() {
+ List emptyList = new ArrayList<>();
+ Path testFile = tempDir.resolve("empty.csv");
+ assertThrows(IllegalArgumentException.class, () -> writer.writeToFile(emptyList, testFile.toString()));
+ }
+
+ @Test
+ void testListOfStudents() throws IOException {
+ List students = List.of(
+ Student.builder().name("Bob").score(List.of("5", "4", "3")).build(),
+ Student.builder().name("Charlie").score(List.of("3", "2", "4")).build()
+ );
+
+ Path testFile = tempDir.resolve("students.csv");
+ writer.writeToFile(students, testFile.toString());
+
+ List lines = Files.readAllLines(testFile);
+ assertEquals(3, lines.size());
+ assertEquals("name,score", lines.get(0));
+ assertTrue(lines.get(1).contains("Bob"));
+ assertTrue(lines.get(2).contains("Charlie"));
+ }
+
+ @Test
+ void testInvalidFilePathThrowsCsvWriteException() {
+ List people = List.of(
+ Person.builder()
+ .firstName("Test")
+ .lastName("User")
+ .dayOfBirth(1)
+ .monthOfBirth(Months.JANUARY)
+ .yearOfBirth(2000).build()
+ );
+
+ String invalidPath = "/invalid_path/test.csv";
+ assertThrows(CsvWriteException.class, () -> writer.writeToFile(people, invalidPath));
+ }
+}