diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..63b3a9a --- /dev/null +++ b/build.gradle @@ -0,0 +1,27 @@ +plugins { + id 'java-library' +} + +group = 'org.writer' +version = '1.0-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +repositories { + mavenCentral() +} + +dependencies { + compileOnly 'org.projectlombok:lombok:1.18.34' + annotationProcessor 'org.projectlombok:lombok:1.18.34' + + testImplementation 'org.junit.jupiter:junit-jupiter:5.8.1' +} + +test { + useJUnitPlatform() +} \ No newline at end of file diff --git a/pom.xml b/pom.xml deleted file mode 100644 index effc1bd..0000000 --- a/pom.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - 4.0.0 - - org.writer - csv - 1.0-SNAPSHOT - - - 17 - 17 - UTF-8 - - - - - org.projectlombok - lombok - 1.18.34 - provided - - - org.junit.jupiter - junit-jupiter - 5.8.1 - test - - - - \ No newline at end of file diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..30578a3 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'csv-writer-lib' \ 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/csv/CsvColumn.java b/src/main/java/org/writer/csv/CsvColumn.java new file mode 100644 index 0000000..c885de2 --- /dev/null +++ b/src/main/java/org/writer/csv/CsvColumn.java @@ -0,0 +1,21 @@ +package org.writer.csv; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a field to be included in CSV output with custom configuration. + *

Allows overriding column name, order, and delimiter for collections.

+ */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface CsvColumn { + + String name() default ""; + + int order() default Integer.MAX_VALUE; + + String collectionDelimiter() default ";"; +} diff --git a/src/main/java/org/writer/csv/CsvFile.java b/src/main/java/org/writer/csv/CsvFile.java new file mode 100644 index 0000000..497bad1 --- /dev/null +++ b/src/main/java/org/writer/csv/CsvFile.java @@ -0,0 +1,18 @@ +package org.writer.csv; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Defines global CSV configuration for a class. + *

Allows setting the delimiter between columns.

+ */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface CsvFile { + + String columnDelimiter() default ","; + +} diff --git a/src/main/java/org/writer/csv/CsvIgnore.java b/src/main/java/org/writer/csv/CsvIgnore.java new file mode 100644 index 0000000..2665c41 --- /dev/null +++ b/src/main/java/org/writer/csv/CsvIgnore.java @@ -0,0 +1,14 @@ +package org.writer.csv; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Excludes a field from CSV serialization. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface CsvIgnore { +} diff --git a/src/main/java/org/writer/exception/CsvException.java b/src/main/java/org/writer/exception/CsvException.java new file mode 100644 index 0000000..070ff56 --- /dev/null +++ b/src/main/java/org/writer/exception/CsvException.java @@ -0,0 +1,7 @@ +package org.writer.exception; + +public class CsvException extends RuntimeException { + public CsvException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/org/writer/exception/CsvFieldAccessException.java b/src/main/java/org/writer/exception/CsvFieldAccessException.java new file mode 100644 index 0000000..98f8799 --- /dev/null +++ b/src/main/java/org/writer/exception/CsvFieldAccessException.java @@ -0,0 +1,7 @@ +package org.writer.exception; + +public class CsvFieldAccessException extends CsvException { + public CsvFieldAccessException(String fieldName, Throwable cause) { + super("Field access error: " + fieldName, cause); + } +} 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..c308bb3 --- /dev/null +++ b/src/main/java/org/writer/exception/CsvWriteException.java @@ -0,0 +1,7 @@ +package org.writer.exception; + +public class CsvWriteException extends CsvException { + public CsvWriteException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/org/writer/Writable.java b/src/main/java/org/writer/writer/Writable.java similarity index 80% rename from src/main/java/org/writer/Writable.java rename to src/main/java/org/writer/writer/Writable.java index 9f60c45..0f87ba1 100644 --- a/src/main/java/org/writer/Writable.java +++ b/src/main/java/org/writer/writer/Writable.java @@ -1,4 +1,4 @@ -package org.writer; +package org.writer.writer; import java.util.List; diff --git a/src/main/java/org/writer/writer/csv/CsvWriter.java b/src/main/java/org/writer/writer/csv/CsvWriter.java new file mode 100644 index 0000000..0713681 --- /dev/null +++ b/src/main/java/org/writer/writer/csv/CsvWriter.java @@ -0,0 +1,215 @@ +package org.writer.writer.csv; + +import org.writer.csv.CsvColumn; +import org.writer.csv.CsvFile; +import org.writer.csv.CsvIgnore; +import org.writer.exception.CsvFieldAccessException; +import org.writer.exception.CsvWriteException; +import org.writer.writer.Writable; + +import java.io.FileWriter; +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.*; +import java.util.logging.Logger; +import java.util.stream.Collectors; + + +/** + * Utility class for writing annotated Java objects into CSV files. + *

Supports field-level configuration via {@link CsvColumn} and class-level + * configuration via {@link CsvFile}. Fields annotated with {@link CsvIgnore} + * are excluded from output.

+ * + *

Collections and maps are serialized using custom delimiters. Primitive + * types and enums are converted via {@link Object#toString()}.

+ * + *

Throws {@link org.writer.exception.CsvWriteException} if writing fails, + * and {@link org.writer.exception.CsvFieldAccessException} if field access + * is not possible.

+ */ +public class CsvWriter implements Writable { + + private static final Logger logger = Logger.getLogger(CsvWriter.class.getName()); + + /** + * Writes a list of objects into a CSV file. + *

+ * The header row is generated from field names or {@link CsvColumn#name()}. + * Each object is serialized into a row according to field order. + * + * @param data list of objects to serialize + * @param fileName target file path + * @throws CsvWriteException if writing fails + */ + @Override + public void writeToFile(List data, String fileName) { + if (!validateInput(data)) return; + + Class clazz = data.get(0).getClass(); + List fields = prepareFields(clazz); + + CsvFile fileAnnotation = clazz.getAnnotation(CsvFile.class); + String columnDelimiter = (fileAnnotation != null) ? fileAnnotation.columnDelimiter() : getDefaultColumnDelimiter(); + + try (FileWriter writer = new FileWriter(fileName)) { + writer.append(buildHeader(fields, columnDelimiter)).append('\n'); + + for (Object obj : data) { + writer.append(buildRow(obj, fields, columnDelimiter)).append('\n'); + } + logger.info("CSV file created successfully: " + fileName); + + } catch (IOException e) { + logger.severe("Error writing CSV file: " + e.getMessage()); + throw new CsvWriteException("Error writing CSV file: " + fileName, e); + } + } + + /** + * Validates input list before serialization. + * + * @param data list of objects + * @return true if list is non-null and non-empty + */ + private boolean validateInput(List data) { + if (data == null || data.isEmpty()) { + logger.warning("No data to write"); + return false; + } + return true; + } + + /** + * Collects fields of the given class for serialization. + *

+ * Excludes fields annotated with {@link CsvIgnore} and sorts them + * according to {@link CsvColumn#order()}. + * + * @param clazz object class + * @return sorted list of accessible fields + */ + private List prepareFields(Class clazz) { + return Arrays.stream(clazz.getDeclaredFields()) + .filter(f -> f.getAnnotation(CsvIgnore.class) == null) + .peek(f -> f.setAccessible(true)) + .sorted(Comparator.comparingInt(f -> { + CsvColumn column = f.getAnnotation(CsvColumn.class); + return column != null ? column.order() : Integer.MAX_VALUE; + })) + .collect(Collectors.toList()); + } + + /** + * Builds the CSV header row. + * + * @param fields fields to include + * @param columnDelimiter delimiter between columns + * @return header row string + */ + private String buildHeader(List fields, String columnDelimiter) { + return fields.stream() + .map(this::getColumnName) + .collect(Collectors.joining(columnDelimiter)); + } + + /** + * Builds a CSV row for a single object. + * + * @param obj object to serialize + * @param fields fields to include + * @param columnDelimiter delimiter between columns + * @return row string + */ + private String buildRow(Object obj, List fields, String columnDelimiter) { + return fields.stream() + .map(f -> getFieldValueAsString(obj, f)) + .collect(Collectors.joining(columnDelimiter)); + } + + /** + * Resolves column name for a field. + *

+ * Uses {@link CsvColumn#name()} if present, otherwise defaults to field name. + * + * @param field field to resolve + * @return column name + */ + private String getColumnName(Field field) { + CsvColumn column = field.getAnnotation(CsvColumn.class); + return (column != null && !column.name().isEmpty()) ? column.name() : field.getName(); + } + + /** + * Retrieves string value of a field from an object. + *

+ * Supports collections and maps with custom delimiters. + * + * @param obj object containing the field + * @param field field to access + * @return string representation of field value + * @throws CsvFieldAccessException if field access fails + */ + private String getFieldValueAsString(Object obj, Field field) { + try { + Object value = field.get(obj); + if (value instanceof Collection) { + CsvColumn column = field.getAnnotation(CsvColumn.class); + String delimiter = column != null ? column.collectionDelimiter() : getDefaultCollectionDelimiter(); + return serialize((Collection) value, delimiter); + } + + if (value instanceof Map) { + CsvColumn column = field.getAnnotation(CsvColumn.class); + String delimiter = column != null ? column.collectionDelimiter() : getDefaultCollectionDelimiter(); + return serialize((Map) value, delimiter); + } + return value != null ? value.toString() : ""; + } catch (IllegalAccessException e) { + logger.severe("Error access to field: " + field.getName()); + throw new CsvFieldAccessException(field.getName(), e); + } + } + + /** + * Returns default collection delimiter from {@link CsvColumn}. + */ + private static String getDefaultCollectionDelimiter() { + return (String) Arrays.stream(CsvColumn.class.getDeclaredMethods()) + .filter(m -> m.getName().equals("collectionDelimiter")) + .findFirst().get() // мы уверены, что метод существует, но, возможно, лучше воспользоваться orElseThrow() для самодостаточности метода + .getDefaultValue(); + } + + /** + * Returns default column delimiter from {@link CsvFile}. + */ + private static String getDefaultColumnDelimiter() { + return (String) Arrays.stream(CsvFile.class.getDeclaredMethods()) + .filter(m -> m.getName().equals("columnDelimiter")) + .findFirst().get() // мы уверены, что метод существует, но, возможно, лучше воспользоваться orElseThrow() для самодостаточности метода + .getDefaultValue(); + } + + /** + * Converts a {@link Collection} into a string joined by the given delimiter. + * Null elements are represented as empty strings. + */ + private String serialize(Collection collection, String delimiter) { + return collection.stream() + .map(e -> e == null ? "" : e.toString()) + .collect(Collectors.joining(delimiter)); + } + + /** + * Converts a {@link Map} into a string of key=value pairs joined by the given delimiter. + * Null keys or values are represented as empty strings. + */ + private String serialize(Map map, String delimiter) { + return map.entrySet().stream() + .map(e -> new StringBuilder().append(e.getKey() == null + ? "" + : e.getKey()).append("=").append(e.getValue() == null ? "" : e.getValue()).toString()) + .collect(Collectors.joining(delimiter)); + } +} diff --git a/src/test/java/org/writter/CsvWriterTest.java b/src/test/java/org/writter/CsvWriterTest.java new file mode 100644 index 0000000..9260850 --- /dev/null +++ b/src/test/java/org/writter/CsvWriterTest.java @@ -0,0 +1,65 @@ +package org.writter; + + +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.writer.csv.CsvWriter; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class CsvWriterTest { + + @TempDir + Path tempDir; + + @Test + void testWriteValidData() throws IOException { + CsvWriter writer = new CsvWriter(); + List people = List.of( + Person.builder() + .firstName("Иван") + .lastName("Иванов") + .dayOfBirth(12) + .monthOfBirth(Months.JANUARY) + .yearOfBirth(1997) + .build(), + Person.builder() + .firstName("Мария") + .lastName("Петрова") + .dayOfBirth(5) + .monthOfBirth(Months.MARCH) + .yearOfBirth(1997) + .build() + ); + + Path file = tempDir.resolve("people.csv"); + writer.writeToFile(people, file.toString()); + + List lines = Files.readAllLines(file); + + assertEquals("firstName,lastName,dayOfBirth,monthOfBirth,yearOfBirth", lines.get(0)); + + assertEquals("Иван,Иванов,12,JANUARY,1997", lines.get(1)); + assertEquals("Мария,Петрова,5,MARCH,1997", lines.get(2)); + } + + @Test + void testWriteEmptyList() { + CsvWriter writer = new CsvWriter(); + assertDoesNotThrow(() -> writer.writeToFile(List.of(), "empty.csv")); + } + + @Test + void testWriteNullList() { + CsvWriter writer = new CsvWriter(); + assertDoesNotThrow(() -> writer.writeToFile(null, "null.csv")); + } +}