diff --git a/.gitignore b/.gitignore
index 5ff6309..34da14f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,8 @@ target/
!**/src/main/**/target/
!**/src/test/**/target/
+.gradle
+
### IntelliJ IDEA ###
.idea/modules.xml
.idea/jarRepositories.xml
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..13566b8
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/encodings.xml b/.idea/encodings.xml
new file mode 100644
index 0000000..aa00ffa
--- /dev/null
+++ b/.idea/encodings.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
new file mode 100644
index 0000000..ce1c62c
--- /dev/null
+++ b/.idea/gradle.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000..e54ab36
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml
new file mode 100644
index 0000000..e5ee2d1
--- /dev/null
+++ b/.idea/material_theme_project_new.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..14e769f
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..a979813
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,31 @@
+plugins {
+ id "application"
+}
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ compileOnly("org.projectlombok:lombok:1.18.42")
+ annotationProcessor("org.projectlombok:lombok:1.18.42")
+
+ testCompileOnly("org.projectlombok:lombok:1.18.42")
+ testAnnotationProcessor("org.projectlombok:lombok:1.18.42")
+
+ testImplementation("org.junit.jupiter:junit-jupiter:5.8.1")
+ testImplementation("net.datafaker:datafaker:2.5.1")
+}
+
+tasks.test {
+ useJUnitPlatform()
+}
+
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(17)
+ }
+}
+
+group = "org.writer"
+version = "1.0.0"
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..1b33c55
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..ca025c8
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100644
index 0000000..23d15a9
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..db3a6ac
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
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/src/main/java/org/writer/CsvWriter.java b/src/main/java/org/writer/CsvWriter.java
new file mode 100644
index 0000000..8fdc596
--- /dev/null
+++ b/src/main/java/org/writer/CsvWriter.java
@@ -0,0 +1,165 @@
+package org.writer;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import org.writer.annotations.CollectionMappingStrategy;
+import org.writer.annotations.CsvInclude;
+import org.writer.annotations.EnumMappingStrategy;
+import org.writer.annotations.EscapeStrategy;
+import org.writer.exceptions.CsvMappingException;
+
+import java.io.*;
+import java.lang.reflect.Field;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+/**
+ * Класс позволяющий записать объекты в CSV-файл
+ *
+ * Запись производится с помощью метода {@link Writable#writeToFile(List, String)}.
+ * Перед записью в файл производится интроспектирование с целью определения структуры полей класса и определения
+ * стратегии маппинга и экранирования. Затем идет накопление значений прошедших маппинг, а лишь затем производится запись в файл.
+ *
+ * Таким образом для больших объемов данных данная реализация будет не сильно эффективна.
+ */
+public class CsvWriter implements Writable {
+
+ /**
+ * Класс позволяющий производить маппинг полей объекта в CSV-формат, применяя указанные стратегии
+ */
+ @AllArgsConstructor
+ private static final class MappingPipeline {
+ private final List introspectNodes;
+
+ private String mapEnum(Enum> enumValue, EnumMappingStrategy enumMappingStrategy) {
+ return enumMappingStrategy == EnumMappingStrategy.NAME ? enumValue.name() : Integer.toString(enumValue.ordinal());
+ }
+
+ private String mapObject(Object object, EscapeStrategy escapeStrategy) {
+ return escapeStrategy == EscapeStrategy.QUOTE_ESCAPING ? "\"" + object.toString() + "\"" : object.toString();
+ }
+
+ private String mapCollection(Collection> collection, CollectionMappingStrategy collectionMappingStrategy) {
+ return collectionMappingStrategy == CollectionMappingStrategy.STRINGIFY ? "\"" + collection.toString() + "\"" :
+ collection.stream().map(Object::toString).collect(Collectors.joining(","));
+ }
+
+ /**
+ * Формируем список замаппеных значений, переданного объекта
+ *
+ * @param object объект для маппинга
+ * @return список строк - замаппеных значений полей объекта
+ * @throws CsvMappingException в случаях если возникла непредвиденная ошибка, либо невозможно получить значение поля вследствие отсутствия доступа
+ */
+ public List map(Object object) {
+ Objects.requireNonNull(object);
+ introspectNodes.forEach(node -> node.getField().setAccessible(true));
+
+ try {
+ return introspectNodes.stream().map(introspectNode -> {
+ try {
+ CsvInclude fieldMappingData = introspectNode.getMappingData();
+ Class> fieldClass = introspectNode.field.getType();
+ if (fieldClass.isPrimitive()) {
+ return introspectNode.getField().get(object).toString();
+ } else if (fieldClass.isEnum()) {
+ return mapEnum((Enum>) introspectNode.getField().get(object), fieldMappingData.enumMapping().strategy());
+ } else if (introspectNode.isCollection()) {
+ return mapCollection((Collection>) introspectNode.getField().get(object), fieldMappingData.collectionMapping().strategy());
+ } else {
+ return mapObject(introspectNode.getField().get(object), fieldMappingData.escape().strategy());
+ }
+ } catch (IllegalAccessException illegalAccessException) {
+ throw new CsvMappingException(
+ String.format("Violating field \"%s\" access while mapping object %s", introspectNode.getField().getName(), object)
+ );
+ }
+ }).toList();
+ } catch (CsvMappingException csvMappingException) {
+ throw csvMappingException;
+ } catch (Exception exception) {
+ throw new CsvMappingException("Unexpected error occurred while mapping object", exception);
+ } finally {
+ introspectNodes.forEach(node -> node.getField().setAccessible(false));
+ }
+ }
+ }
+
+ /**
+ * Класс содержащий информацию о поле объекта
+ */
+ @AllArgsConstructor
+ @Getter
+ private static final class IntrospectNode {
+ private final Field field;
+ private final CsvInclude mappingData;
+ private final boolean isCollection;
+ }
+
+ /**
+ * Получаем всю необходимую информацию о полях класса, которые необходимо записать в CSV-файл, после чего
+ * создаем объект {@link MappingPipeline} для последующей обработки
+ *
+ * @param object объект структура полей которого будет интроспектирована
+ * @return объект {@link MappingPipeline} для маппинга
+ */
+ private MappingPipeline introspect(Object object) {
+ Objects.requireNonNull(object);
+ List introspectNodes = Arrays.stream(object.getClass().getDeclaredFields())
+ .filter(field -> field.isAnnotationPresent(CsvInclude.class))
+ .map(field -> new IntrospectNode(field, field.getAnnotation(CsvInclude.class), IntrospectingUtils.isCollection(field.getType())))
+ .toList();
+
+ return new MappingPipeline(introspectNodes);
+ }
+
+ /**
+ * Производит запись списка объектов в файл в CSV-формате. В данной реализации файл создается в директории откуда запущена программа.
+ * @param data список объектов для записи
+ * @param fileName название файла, автоматически расширение {@code .csv} не проставляется, таким образом для сохранения расширения
+ * вы должны указать его самостоятельно
+ * @throws CsvMappingException в случаях если возникла непредвиденная ошибка, либо невозможно получить значение поля вследствие отсутствия доступа,
+ * а также в случае попытки создания CSV-файла если такой файл уже существует
+ */
+ @Override
+ public void writeToFile(List> data, String fileName) {
+ if (Objects.requireNonNull(data).isEmpty()) {
+ throw new IllegalArgumentException("Expected non-empty data to write");
+ }
+
+ if (Objects.requireNonNull(fileName).isBlank()) {
+ throw new IllegalArgumentException("Blank or empty filename");
+ }
+
+ MappingPipeline pipeline = introspect(data.get(0));
+
+ // Складываем все замаппеные значения в список, конечно это не сильно эффективно по-сравнению другими реализациями,
+ // но данная реализация пока, что для упрощения
+ List> mappedData = new ArrayList<>();
+ data.forEach(value -> mappedData.add(pipeline.map(value)));
+
+
+ Path filePath = Paths.get(System.getProperty("user.dir"), fileName);
+ try (Writer writer = new FileWriter(Files.createFile(filePath).toFile())) {
+ mappedData.stream()
+ .map(mappedValues -> String.join(",", mappedValues))
+ .forEach(joinedValues -> {
+ try {
+ writer.write(joinedValues + "\n");
+ } catch (IOException internalIOException) {
+ throw new CsvMappingException("Unexpected error occurred while writing data to CSV file", internalIOException);
+ }
+ });
+ } catch (IOException ioException) {
+ throw new CsvMappingException("Error while creating file to writing data", ioException);
+ }
+ }
+
+}
diff --git a/src/main/java/org/writer/IntrospectingUtils.java b/src/main/java/org/writer/IntrospectingUtils.java
new file mode 100644
index 0000000..2e86feb
--- /dev/null
+++ b/src/main/java/org/writer/IntrospectingUtils.java
@@ -0,0 +1,30 @@
+package org.writer;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Objects;
+
+/**
+ * Утилитный класс содержащий методы для упрощения работы по интроспектированию
+ */
+final class IntrospectingUtils {
+
+ /**
+ * Позволяет определить реализует ли класс интерфейс {@link java.util.Collections}
+ * @param clazz класс, который необходимо проверить
+ * @return {@code true} - если класс реализует интерфейс {@link Collection}, иначе {@code false}
+ */
+ public static boolean isCollection(Class> clazz) {
+ Objects.requireNonNull(clazz);
+ if (clazz.equals(Objects.class)) {
+ return false;
+ }
+
+ if (clazz.equals(Collection.class)) {
+ return true;
+ }
+
+ return Arrays.stream(clazz.getInterfaces()).anyMatch(IntrospectingUtils::isCollection);
+ }
+
+}
diff --git a/src/main/java/org/writer/Main.java b/src/main/java/org/writer/Main.java
index 40e8213..29c6a32 100644
--- a/src/main/java/org/writer/Main.java
+++ b/src/main/java/org/writer/Main.java
@@ -1,7 +1,27 @@
package org.writer;
+import org.writer.model.Months;
+import org.writer.model.Person;
+import org.writer.model.Student;
+import java.util.List;
+
public class Main {
+
public static void main(String[] args) {
- System.out.println("Hello world!");
+ CsvWriter csvWriter = new CsvWriter();
+
+ List students = List.of(
+ new Student("Andrew", List.of("3.4", "4.5")),
+ new Student("Andrew", List.of("3.5", "4.1", "2.3"))
+ );
+
+ List persons = List.of(
+ new Person("Andrew", "Smith", 24, Months.MAY, 1956),
+ new Person("Liza", "Black", 31, Months.DECEMBER, 1992),
+ new Person("Kay", "Zoom", 1, Months.APRIL, 2001)
+ );
+
+ csvWriter.writeToFile(students, "students.csv");
+ csvWriter.writeToFile(persons, "people.csv");
}
}
\ No newline at end of file
diff --git a/src/main/java/org/writer/annotations/CollectionMapping.java b/src/main/java/org/writer/annotations/CollectionMapping.java
new file mode 100644
index 0000000..e9c1767
--- /dev/null
+++ b/src/main/java/org/writer/annotations/CollectionMapping.java
@@ -0,0 +1,18 @@
+package org.writer.annotations;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Аннотация позволяющая указать стратегию маппинга коллекций
+ * Стратегия по-умолчанию {@link CollectionMappingStrategy#STRINGIFY} - маппинг с помощью вызова {@code toString()}.
+ *
+ * Более подробную информацию о стратегиях маппинга смотри в {@link CollectionMappingStrategy}
+ */
+@Target(ElementType.FIELD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface CollectionMapping {
+ CollectionMappingStrategy strategy() default CollectionMappingStrategy.STRINGIFY;
+}
diff --git a/src/main/java/org/writer/annotations/CollectionMappingStrategy.java b/src/main/java/org/writer/annotations/CollectionMappingStrategy.java
new file mode 100644
index 0000000..2a36e60
--- /dev/null
+++ b/src/main/java/org/writer/annotations/CollectionMappingStrategy.java
@@ -0,0 +1,14 @@
+package org.writer.annotations;
+
+/**
+ * Определяет стратегию маппинга коллекций
+ *
+ * {@link CollectionMappingStrategy#STRINGIFY} - стратегия маппинга коллекции путем вызова метода {@code toString()} коллекции
+ *
+ * {@link CollectionMappingStrategy#ITEM_COLUMN} - стратегия маппинга "одно значение - одна колонка", то есть:
+ * {@code List.of(1,2,3,4)} будет замаппен следующим образом {@code 1,2,3,4}
+ */
+public enum CollectionMappingStrategy {
+ STRINGIFY,
+ ITEM_COLUMN
+}
diff --git a/src/main/java/org/writer/annotations/CsvInclude.java b/src/main/java/org/writer/annotations/CsvInclude.java
new file mode 100644
index 0000000..8814f9e
--- /dev/null
+++ b/src/main/java/org/writer/annotations/CsvInclude.java
@@ -0,0 +1,24 @@
+package org.writer.annotations;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Аннотация, которая определяет какие поля класса будут включены в CSV-файл.
+ * С помощью данной аннотации можно установить стратегии экранирования, маппинга перечислений, и маппинга коллекций
+ *
+ * {@link CsvInclude#escape} - устанавливает стратегию экранирования запятых, подробнее {@link Escape}
+ *
+ * {@link CsvInclude#enumMapping} - устанавливает стратегию маппинга перечислений, подробнее {@link EnumMapping}
+ *
+ * {@link CsvInclude#collectionMapping} - устанавливает стратегию маппинга коллекций, подробнее {@link CollectionMapping}
+ */
+@Target(ElementType.FIELD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface CsvInclude {
+ Escape escape() default @Escape(strategy = EscapeStrategy.COLUMN_ESCAPING);
+ EnumMapping enumMapping() default @EnumMapping(strategy = EnumMappingStrategy.NAME);
+ CollectionMapping collectionMapping() default @CollectionMapping(strategy = CollectionMappingStrategy.ITEM_COLUMN);
+}
diff --git a/src/main/java/org/writer/annotations/EnumMapping.java b/src/main/java/org/writer/annotations/EnumMapping.java
new file mode 100644
index 0000000..2ad1faf
--- /dev/null
+++ b/src/main/java/org/writer/annotations/EnumMapping.java
@@ -0,0 +1,18 @@
+package org.writer.annotations;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Аннотация позволяющая указать стратегию маппинга перечислений. Стратегия по-умолчанию
+ * {@link EnumMappingStrategy#NAME} - маппинг по имени.
+ *
+ * Более подробную информацию о стратегиях маппинга смотри в {@link EnumMappingStrategy}
+ */
+@Target(ElementType.FIELD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface EnumMapping {
+ EnumMappingStrategy strategy() default EnumMappingStrategy.NAME;
+}
diff --git a/src/main/java/org/writer/annotations/EnumMappingStrategy.java b/src/main/java/org/writer/annotations/EnumMappingStrategy.java
new file mode 100644
index 0000000..bc3f4b6
--- /dev/null
+++ b/src/main/java/org/writer/annotations/EnumMappingStrategy.java
@@ -0,0 +1,13 @@
+package org.writer.annotations;
+
+/**
+ * Определяет стратегию маппинга перечислений
+ *
+ * {@link EnumMappingStrategy#NAME} - стратегия маппинга по имени, {@code EnumExample.SOME_ENUM_VALUE} будет маппиться в {@code SOME_ENUM_VALUE}
+ *
+ * {@link EnumMappingStrategy#ORDINAL} - стратегия маппинга по ординалу значения перечисления
+ */
+public enum EnumMappingStrategy {
+ NAME,
+ ORDINAL
+}
diff --git a/src/main/java/org/writer/annotations/Escape.java b/src/main/java/org/writer/annotations/Escape.java
new file mode 100644
index 0000000..2fd563e
--- /dev/null
+++ b/src/main/java/org/writer/annotations/Escape.java
@@ -0,0 +1,18 @@
+package org.writer.annotations;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Аннотация позволяющая указать стратегию экранирования для полей, строковое представление которых содержит запятые.
+ * Примитивные типы не экранируются. Стратегия по-умолчанию {@link EscapeStrategy#QUOTE_ESCAPING} - экранирование с помощью кавычек.
+ *
+ * Более подробную информацию о стратегиях экранирования смотри в {@link EscapeStrategy}
+ */
+@Target(ElementType.FIELD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface Escape {
+ EscapeStrategy strategy() default EscapeStrategy.QUOTE_ESCAPING;
+}
diff --git a/src/main/java/org/writer/annotations/EscapeStrategy.java b/src/main/java/org/writer/annotations/EscapeStrategy.java
new file mode 100644
index 0000000..7256618
--- /dev/null
+++ b/src/main/java/org/writer/annotations/EscapeStrategy.java
@@ -0,0 +1,15 @@
+package org.writer.annotations;
+
+/**
+ * Определяет стратегию для экранирования полей, строковое представление которых содержит запятые
+ *
+ * {@link EscapeStrategy#QUOTE_ESCAPING} - стратегия экранирования, которая оборачивает значение в кавычки.
+ * Например: {@code fieldName,1} станет {@code "fieldName,1"}
+ *
+ * {@link EscapeStrategy#COLUMN_ESCAPING} - стратегия экранирования, которая записывает значения между запятыми в
+ * разные колонки. Семантически данная стратегия просто записывает данные "как есть"
+ */
+public enum EscapeStrategy {
+ QUOTE_ESCAPING,
+ COLUMN_ESCAPING
+}
diff --git a/src/main/java/org/writer/exceptions/CsvMappingException.java b/src/main/java/org/writer/exceptions/CsvMappingException.java
new file mode 100644
index 0000000..f05974f
--- /dev/null
+++ b/src/main/java/org/writer/exceptions/CsvMappingException.java
@@ -0,0 +1,15 @@
+package org.writer.exceptions;
+
+/**
+ * Исключение выбрасываемое в случае непредвиденной ошибки по время маппинга объектов в CSV-формат
+ */
+public class CsvMappingException extends RuntimeException {
+
+ public CsvMappingException(String message, Throwable throwable) {
+ super(message, throwable);
+ }
+
+ public CsvMappingException(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..592459c 100644
--- a/src/main/java/org/writer/model/Person.java
+++ b/src/main/java/org/writer/model/Person.java
@@ -3,20 +3,28 @@
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
+import org.writer.annotations.CsvInclude;
+import org.writer.annotations.EnumMapping;
+import org.writer.annotations.EnumMappingStrategy;
@Data
@Builder
@AllArgsConstructor
public class Person {
+ @CsvInclude
private String firstName;
+ @CsvInclude
private String lastName;
+ @CsvInclude
private int dayOfBirth;
+ @CsvInclude(enumMapping = @EnumMapping(strategy = EnumMappingStrategy.NAME))
private Months monthOfBirth;
+ @CsvInclude
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..b7d4cc4 100644
--- a/src/main/java/org/writer/model/Student.java
+++ b/src/main/java/org/writer/model/Student.java
@@ -3,6 +3,9 @@
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
+import org.writer.annotations.CollectionMapping;
+import org.writer.annotations.CollectionMappingStrategy;
+import org.writer.annotations.CsvInclude;
import java.util.List;
@@ -11,7 +14,9 @@
@AllArgsConstructor
public class Student {
+ @CsvInclude
private String name;
+ @CsvInclude(collectionMapping = @CollectionMapping(strategy = CollectionMappingStrategy.ITEM_COLUMN))
private List score;
}
\ No newline at end of file
diff --git a/src/test/java/org/writer/CsvWriterTests.java b/src/test/java/org/writer/CsvWriterTests.java
new file mode 100644
index 0000000..95d6618
--- /dev/null
+++ b/src/test/java/org/writer/CsvWriterTests.java
@@ -0,0 +1,253 @@
+package org.writer;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import net.datafaker.Faker;
+import net.datafaker.providers.base.Options;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.writer.annotations.CollectionMapping;
+import org.writer.annotations.CollectionMappingStrategy;
+import org.writer.annotations.CsvInclude;
+import org.writer.annotations.EnumMapping;
+import org.writer.annotations.EnumMappingStrategy;
+import org.writer.annotations.Escape;
+import org.writer.annotations.EscapeStrategy;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Scanner;
+import java.util.Set;
+import java.util.stream.IntStream;
+
+public class CsvWriterTests {
+
+ private static final String TEST_FILE_NAME = "testFileName.csv";
+
+ private List readCsv() {
+ List csvTable = new ArrayList<>();
+
+ try (Scanner scanner = new Scanner(Paths.get(System.getProperty("user.dir"), TEST_FILE_NAME).toFile())) {
+ while (scanner.hasNextLine()) {
+ csvTable.add(scanner.nextLine());
+ }
+ } catch (Exception e) {
+ throw new RuntimeException("Unexpected error", e);
+ }
+
+ return csvTable;
+ }
+
+ @AfterEach
+ public void afterEach() throws IOException {
+ Path testFilePath = Paths.get(System.getProperty("user.dir"), TEST_FILE_NAME);
+ Files.deleteIfExists(testFilePath);
+ }
+
+ @Test
+ public void invalidArguments_emptyData_shouldThrowException() {
+ Assertions.assertThrows(IllegalArgumentException.class, () -> new CsvWriter().writeToFile(List.of(), "file"));
+ }
+
+ @Test
+ public void invalidArguments_nullData_shouldThrowException() {
+ Assertions.assertThrows(NullPointerException.class, () -> new CsvWriter().writeToFile(null, "file"));
+ }
+
+ @Test
+ public void invalidArguments_blankFileName_shouldThrowException() {
+ Assertions.assertThrows(IllegalArgumentException.class, () -> new CsvWriter().writeToFile(List.of(new Object()), " "));
+ }
+
+ @Test
+ public void invalidArguments_nullFileName_shouldThrowException() {
+ Assertions.assertThrows(NullPointerException.class, () -> new CsvWriter().writeToFile(List.of(new Object()), null));
+ }
+
+ @Test
+ public void classWithFieldsOfPrimitiveTypes_defaultStrategies_shouldWriteToCsvFile() {
+
+ @AllArgsConstructor
+ @Getter
+ class PrimitiveClass {
+ @CsvInclude
+ private int value;
+ @CsvInclude
+ private double anotherValue;
+ }
+
+ PrimitiveClass primitiveClass = new PrimitiveClass(1, 1.2);
+ new CsvWriter().writeToFile(List.of(primitiveClass), TEST_FILE_NAME);
+
+ List csvTable = readCsv();
+
+ Assertions.assertEquals("1,1.2", csvTable.get(0));
+ }
+
+ @Test
+ public void classWithSimpleField_escapeStrategyQuoting_shouldWriteToCsvFile() {
+
+ @AllArgsConstructor
+ @Getter
+ class SampleClass {
+ @CsvInclude(escape = @Escape(strategy = EscapeStrategy.QUOTE_ESCAPING))
+ private String value;
+ }
+
+ SampleClass sampleClass = new SampleClass("some_value");
+ new CsvWriter().writeToFile(List.of(sampleClass), TEST_FILE_NAME);
+
+ List csvTable = readCsv();
+
+ Assertions.assertEquals("\"some_value\"", csvTable.get(0));
+ }
+
+ @Test
+ public void classWithEnumsValues_differentMappingStrategies_shouldWriteToCsvFile() {
+
+ enum FirstEnum {
+ FIRST_ENUM_VALUE
+ }
+
+ enum SecondEnum {
+ SECOND_ENUM_VALUE
+ }
+
+ @AllArgsConstructor
+ @Getter
+ class SampleClass {
+ @CsvInclude(enumMapping = @EnumMapping(strategy = EnumMappingStrategy.NAME))
+ private FirstEnum firstEnum;
+ @CsvInclude(enumMapping = @EnumMapping(strategy = EnumMappingStrategy.ORDINAL))
+ private SecondEnum secondEnum;
+ }
+
+ SampleClass sampleClass = new SampleClass(FirstEnum.FIRST_ENUM_VALUE, SecondEnum.SECOND_ENUM_VALUE);
+ new CsvWriter().writeToFile(List.of(sampleClass), TEST_FILE_NAME);
+
+ List csvTable = readCsv();
+
+ Assertions.assertEquals("FIRST_ENUM_VALUE,0", csvTable.get(0));
+ }
+
+ @Test
+ public void classWithObjectsValues_differentEscapeStrategies_shouldWriteToCsvFile() {
+
+ @AllArgsConstructor
+ class ColumnEscape {
+ private int value1;
+ private int value2;
+ private int value3;
+
+ @Override
+ public String toString() {
+ return value1 + "," + value2 + "," + value3;
+ }
+ }
+
+ @AllArgsConstructor
+ class QuoteEscape {
+ private int value1;
+ private int value2;
+
+ @Override
+ public String toString() {
+ return value1 + "," + value2;
+ }
+ }
+
+ @AllArgsConstructor
+ @Getter
+ class SampleClass {
+ @CsvInclude(escape = @Escape(strategy = EscapeStrategy.COLUMN_ESCAPING))
+ private ColumnEscape columnEscape;
+ @CsvInclude(escape = @Escape(strategy = EscapeStrategy.QUOTE_ESCAPING))
+ private QuoteEscape quoteEscape;
+ }
+
+ SampleClass sampleClass = new SampleClass(new ColumnEscape(1, 2, 3), new QuoteEscape(1, 2));
+ new CsvWriter().writeToFile(List.of(sampleClass), TEST_FILE_NAME);
+
+ List csvTable = readCsv();
+
+ Assertions.assertEquals("1,2,3,\"1,2\"", csvTable.get(0));
+ }
+
+ @Test
+ public void classWithCollections_differentMappingStrategies_shouldWriteToCsvFile() {
+
+ @AllArgsConstructor
+ @Getter
+ class SampleClass {
+ @CsvInclude(collectionMapping = @CollectionMapping(strategy = CollectionMappingStrategy.ITEM_COLUMN))
+ private List list;
+ @CsvInclude(collectionMapping = @CollectionMapping(strategy = CollectionMappingStrategy.STRINGIFY))
+ private List listOfInt;
+ }
+
+ SampleClass sampleClass = new SampleClass(List.of("1", "2", "some_value"), List.of(1, 2));
+ new CsvWriter().writeToFile(List.of(sampleClass), TEST_FILE_NAME);
+
+ List csvTable = readCsv();
+
+ Assertions.assertEquals("1,2,some_value,\"[1, 2]\"", csvTable.get(0));
+ }
+
+ @Test
+ public void largeSetOfFakeData_allTypesOfFieldAndMappingStrategies_shouldWriteToCsvFile() {
+
+ enum FakeEnum {
+ FIRST,
+ SECOND,
+ THIRD
+ }
+
+ @AllArgsConstructor
+ @Getter
+ class FakeData {
+ @CsvInclude
+ private final String name;
+ @CsvInclude(escape = @Escape(strategy = EscapeStrategy.QUOTE_ESCAPING))
+ private final String surname;
+ @CsvInclude(enumMapping = @EnumMapping(strategy = EnumMappingStrategy.NAME))
+ private final FakeEnum fakeEnumFirst;
+ @CsvInclude(enumMapping = @EnumMapping(strategy = EnumMappingStrategy.ORDINAL))
+ private final FakeEnum fakeEnumSecond;
+ @CsvInclude(collectionMapping = @CollectionMapping(strategy = CollectionMappingStrategy.ITEM_COLUMN))
+ private final List stringList;
+ @CsvInclude(collectionMapping = @CollectionMapping(strategy = CollectionMappingStrategy.STRINGIFY))
+ private final Collection integerCollection;
+ }
+
+ Faker faker = new Faker();
+ Options options = faker.options();
+ List fakeData = IntStream.range(0, 100)
+ .mapToObj(i -> new FakeData(
+ faker.name().firstName(), faker.name().lastName(),
+ options.option(FakeEnum.class), options.option(FakeEnum.class),
+ faker.collection(() -> faker.animal().name(), () -> faker.address().streetName()).generate(),
+ faker.collection(() -> faker.number().digit(), () -> faker.number().negative()).generate()
+ )).toList();
+
+ new CsvWriter().writeToFile(fakeData, TEST_FILE_NAME);
+ List csvTable = readCsv();
+
+ for (int i = 0; i < 100; i++) {
+ FakeData item = fakeData.get(i);
+ Assertions.assertEquals(
+ String.format(
+ "%s,\"%s\",%s,%s,%s,\"%s\"",
+ item.getName(), item.getSurname(), item.getFakeEnumFirst(),
+ item.getFakeEnumSecond().ordinal(), String.join(",", item.getStringList()),
+ item.getIntegerCollection().toString()
+ ), csvTable.get(i)
+ );
+ }
+ }
+
+}