diff --git a/pom.xml b/pom.xml index effc1bd..7fd6b43 100644 --- a/pom.xml +++ b/pom.xml @@ -23,10 +23,23 @@ org.junit.jupiter - junit-jupiter - 5.8.1 + junit-jupiter-api + 5.10.0 test + + + org.junit.jupiter + junit-jupiter-engine + 5.10.0 + test + + + + net.datafaker + datafaker + 2.0.2 + \ 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..825fc90 --- /dev/null +++ b/src/main/java/org/writer/CsvWriter.java @@ -0,0 +1,133 @@ +package org.writer; + +import org.writer.annotation.CsvColumn; +import org.writer.annotation.CsvSerializable; + +import java.io.BufferedWriter; +import java.io.FileWriter; +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Реализация интерфейса {@link Writable}, который сериализует список объектов в CSV файл. + *

+ * Данная реализация использует рефлексию, а также кастомные аннотации ({@link CsvSerializable}, + * {@link CsvColumn}) для валидации данных, подлежащих записи. + */ +public class CsvWriter implements Writable { + + /** + * Разделитель, используемый для отделения значений в строке CSV. + */ + private final static String SEPARATOR = ","; + + /** + * Разделитель, используемый для объединения элементов списка в одну ячейку CSV. + */ + private static final String LIST_ITEM_DELIM = ";"; + + /** + * Записывает список объектов в CSV файл. + *

+ * Сначала записывается строка заголовка, основанная на именах колонок, + * а затем для каждого объекта из списка записывается строка данных. + * + * @param data список объектов для записи. Класс этих объектов должен быть + * помечен аннотацией {@link CsvSerializable}. + * @param fileName имя файла, в который будут сохранены данные. + * @throws IllegalArgumentException если список данных null или пуст, или если + * класс объектов не помечен аннотацией {@link CsvSerializable}. + * @throws RuntimeException если возникает ошибка ввода-вывода при записи в файл. + */ + @Override + public void writeToFile(List data, String fileName) { + + if (data == null || data.isEmpty()) { + throw new IllegalArgumentException("Список данных не может быть пустым."); + } + + Class clazz = data.get(0).getClass(); + if (!clazz.isAnnotationPresent(CsvSerializable.class)) { + throw new IllegalArgumentException("Класс " + clazz.getSimpleName() + " не помечен аннотацией @CsvSerializable."); + } + + try (BufferedWriter writer = new BufferedWriter(new FileWriter(fileName))) { + List fields = getCsvFields(clazz); + + // Header + String header = fields.stream() + .map(this::getColumnName) + .collect(Collectors.joining(SEPARATOR)); + + writer.write(header); + writer.newLine(); + + // Body + String body = data.stream() + .map(object -> fields.stream() + .map(field -> getFieldValue(field, object)) + .collect(Collectors.joining(SEPARATOR))) + .collect(Collectors.joining(System.lineSeparator())); + + writer.write(body); + } + catch (IOException e) { + throw new RuntimeException("Ошибка записи в файл " + fileName, e); + } + } + + /** + * Сканирует класс на наличие полей, аннотированных {@link CsvColumn}, с помощью рефлексии. + * + * @param clazz класс для сканирования. + * @return список объектов {@link Field}, которые помечены для сериализации в CSV. + */ + private List getCsvFields(Class clazz){ + return Stream.of(clazz.getDeclaredFields()) + .filter(field -> field.isAnnotationPresent(CsvColumn.class)) + .collect(Collectors.toList()); + } + + /** + * Определяет имя колонки для заданного поля. + *

+ * В приоритете используется атрибут {@code name} из аннотации {@link CsvColumn}. + * Если он пуст, используется фактическое имя поля. + * + * @param field поле, для которого нужно получить имя колонки. + * @return имя колонки CSV. + */ + private String getColumnName(Field field) { + String name = field.getAnnotation(CsvColumn.class).name(); + return name.isEmpty() ? field.getName() : name; + } + + /** + * Извлекает строковое представление значения поля из заданного объекта. + *

+ * Предоставляет специальную обработку для полей типа {@link List}, + * объединяя их элементы в одну строку с помощью разделителя {@code LIST_ITEM_DELIM}. + * + * @param field поле, значение которого нужно извлечь. + * @param object экземпляр объекта, из которого извлекается значение. + * @return строковое представление значения поля. + * @throws RuntimeException если доступ к полю невозможен. + */ + private String getFieldValue(Field field, Object object) { + try { + field.setAccessible(true); + Object value = field.get(object); + if (value instanceof List) { + return ((List) value).stream() + .map(String::valueOf) + .collect(Collectors.joining(LIST_ITEM_DELIM)); + } + return String.valueOf(value); + } catch (IllegalAccessException e) { + throw new RuntimeException("Не удалось получить доступ к полю " + field.getName(), 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 index 40e8213..0fa7dec 100644 --- a/src/main/java/org/writer/Main.java +++ b/src/main/java/org/writer/Main.java @@ -1,7 +1,47 @@ 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.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + public class Main { public static void main(String[] args) { - System.out.println("Hello world!"); + + Faker faker = new Faker(); + Writable csvWriter = new CsvWriter(); + + + List people = IntStream.range(0, 10) + .mapToObj(i -> Person.builder() + .firstName(faker.name().firstName()) + .lastName(faker.name().lastName()) + .dayOfBirth(faker.number().numberBetween(1, 28)) + .monthOfBirth(Months.values()[faker.number().numberBetween(0, 11)]) + .yearOfBirth(faker.number().numberBetween(1990, 2010)) + .build()) + .collect(Collectors.toList()); + + csvWriter.writeToFile(people, "people_report.csv"); + System.out.println("Отчет по людям сохранен в people_report.csv"); + + + List students = IntStream.range(0, 5) + .mapToObj(i -> Student.builder() + .name(faker.name().fullName()) + .score(List.of( + String.valueOf(faker.number().numberBetween(1, 5)), + String.valueOf(faker.number().numberBetween(1, 5)), + String.valueOf(faker.number().numberBetween(1, 5)) + )) + .build()) + .collect(Collectors.toList()); + + csvWriter.writeToFile(students, "students_report.csv"); + System.out.println("Отчет по студентам сохранен в students_report.csv"); } } \ 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..33392ad --- /dev/null +++ b/src/main/java/org/writer/annotation/CsvColumn.java @@ -0,0 +1,19 @@ +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 файл как колонка + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface CsvColumn { + /** + * Имя колонки в CSV файле. Если не указано, то будет использовано имя поля. + * @return имя колонки + */ + String name() default ""; +} diff --git a/src/main/java/org/writer/annotation/CsvSerializable.java b/src/main/java/org/writer/annotation/CsvSerializable.java new file mode 100644 index 0000000..68e28c1 --- /dev/null +++ b/src/main/java/org/writer/annotation/CsvSerializable.java @@ -0,0 +1,15 @@ +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 формат. + *

CsvWriter будет обрабатывать только классы, помеченные этой аннотацией. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface CsvSerializable { +} diff --git a/src/main/java/org/writer/model/Person.java b/src/main/java/org/writer/model/Person.java index ba8576b..7067692 100644 --- a/src/main/java/org/writer/model/Person.java +++ b/src/main/java/org/writer/model/Person.java @@ -3,20 +3,27 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import org.writer.annotation.CsvColumn; +import org.writer.annotation.CsvSerializable; @Data @Builder @AllArgsConstructor +@CsvSerializable public class Person { + @CsvColumn(name = "Имя") private String firstName; + @CsvColumn(name = "Фамилия") private String lastName; + @CsvColumn(name = "День рождения") private int dayOfBirth; + @CsvColumn(name = "Месяц рождения") private Months monthOfBirth; + @CsvColumn(name = "Год рождения") 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..e71d99b 100644 --- a/src/main/java/org/writer/model/Student.java +++ b/src/main/java/org/writer/model/Student.java @@ -3,15 +3,20 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import org.writer.annotation.CsvColumn; +import org.writer.annotation.CsvSerializable; import java.util.List; @Data @Builder @AllArgsConstructor +@CsvSerializable public class Student { + @CsvColumn(name = "Имя") private String name; + @CsvColumn(name = "Оценки") 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..e9412e5 --- /dev/null +++ b/src/test/java/org/writer/CsvWriterTest.java @@ -0,0 +1,106 @@ +package org.writer; + +import net.datafaker.Faker; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +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.List; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.*; + +public class CsvWriterTest { + + private CsvWriter csvWriter; + private Faker faker; + + @TempDir + Path tempDir; + + @BeforeEach + void setUp() { + csvWriter = new CsvWriter(); + faker = new Faker(); + } + + @Test + void testWritePersonToFile() throws IOException { + List persons = IntStream.range(0, 9) + .mapToObj(i -> Person.builder() + .firstName(faker.name().firstName()) + .lastName(faker.name().lastName()) + .dayOfBirth(faker.number().numberBetween(1, 28)) + .monthOfBirth(Months.values()[faker.number().numberBetween(0, 11)]) + .yearOfBirth(faker.number().numberBetween(1985, 2005)) + .build()) + .toList(); + + Path filePath = tempDir.resolve("persons.csv"); + csvWriter.writeToFile(persons, filePath.toString()); + + assertTrue(Files.exists(filePath)); + + List lines = Files.readAllLines(filePath); + assertEquals(10, lines.size()); + assertEquals("Имя,Фамилия,День рождения,Месяц рождения,Год рождения", lines.get(0)); + + String[] firstPersonData = lines.get(1).split(","); + assertEquals(persons.get(0).getFirstName(), firstPersonData[0]); + assertEquals(persons.get(0).getLastName(), firstPersonData[1]); + } + + @Test + void testWriteStudentsToFile() throws IOException { + List students = IntStream.range(0, 4) + .mapToObj(i -> Student.builder() + .name(faker.name().fullName()) + .score(List.of( + String.valueOf(faker.number().numberBetween(1, 5)), + String.valueOf(faker.number().numberBetween(1, 5)) + )).build()) + .toList(); + + Path filePath = tempDir.resolve("students.csv"); + csvWriter.writeToFile(students, filePath.toString()); + + assertTrue(Files.exists(filePath)); + + List lines = Files.readAllLines(filePath); + assertEquals(5, lines.size()); + assertEquals("Имя,Оценки", lines.get(0)); + + String[] firstStudentData = lines.get(1).split(","); + assertEquals(students.get(0).getName(), firstStudentData[0]); + assertEquals(String.join(";", students.get(0).getScore()), firstStudentData[1]); + } + + @Test + void testWriteEmptyListDoesNothing() { + String fileName = "empty.csv"; + assertThrows(IllegalArgumentException.class, () -> csvWriter.writeToFile(List.of(), fileName)); + assertTrue(Files.notExists(Path.of(fileName))); + } + + @Test + void testWriteToFileThatNotCsvSerializable() { + + class UnannotatedClass { + private final String data; + + public UnannotatedClass(String data) { + this.data = data; + } + } + + List unannotatedList = List.of(new UnannotatedClass("some data")); + assertThrows(IllegalArgumentException.class, () -> csvWriter.writeToFile(unannotatedList, "should_fail.csv")); + } +}