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)); + } +}