Skip to content

Commit

Permalink
refactor: dates validation and formatting (#1177)
Browse files Browse the repository at this point in the history
* refactor: dates validation and formatting

* chore: modifier and typos

* refactor: inner class factory and result to record

* refactor: oopsies

* test: unit test for iso 8601 formatter

* refactor: optional instead of null

* chore: fix typo in javadoc

* refactor: compile year pattern only once
  • Loading branch information
nsenave authored Dec 20, 2024
1 parent b0c3ee5 commit 472458f
Show file tree
Hide file tree
Showing 11 changed files with 387 additions and 105 deletions.
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ java {

allprojects {
group = "fr.insee.eno"
version = "3.30.1-SNAPSHOT.1"
version = "3.30.1-SNAPSHOT.2"
}

subprojects {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package fr.insee.eno.core.exceptions.business;

public class InvalidValueException extends RuntimeException {

public InvalidValueException(String message) {
super(message);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package fr.insee.eno.core.exceptions.business;

public class RequiredPropertyException extends RuntimeException {

public RequiredPropertyException(String message) {
super(message);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package fr.insee.eno.core.i18n.date;

import fr.insee.eno.core.parameter.EnoParameters;
import lombok.NonNull;

import java.util.regex.Pattern;

/**
* Interface to validate and format dates.
*/
public interface DateFormatter {

class Factory {

private Factory() {}

public static DateFormatter forLanguage(EnoParameters.Language language) {
if (EnoParameters.Language.FR.equals(language))
return new Iso8601ToFrench();
// By default, return the implementation that returns date values in ISO-8601 format
return new Iso8601Formatter();
}
}

record Result(
boolean isValid,
String value,
String errorMessage) {

public static Result success(String value) {
return new Result(true, value, null);
}

public static Result failure(String errorMessage) {
return new Result(false, null, errorMessage);
}
}

Pattern yearDatePattern = Pattern.compile("\\d{4}");

default Result convertYearDate(@NonNull String date) {
if (date.length() != 4 || !yearDatePattern.matcher(date).matches())
return Result.failure(errorMessage(date, "YYYY"));
return Result.success(date);
}

Result convertYearMontDate(@NonNull String date);

Result convertYearMontDayDate(@NonNull String date);

default String errorMessage(String date, String format) {
return "Date '" + date + "' is invalid or doesn't match format '" + format + "'.";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package fr.insee.eno.core.i18n.date;

import lombok.NonNull;

import java.time.LocalDate;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;

public class Iso8601Formatter implements DateFormatter {

public Result convertYearMontDate(@NonNull String date) {
try {
return Result.success(
YearMonth.parse(date).format(DateTimeFormatter.ofPattern("yyyy-MM")));
} catch (Exception e) {
return Result.failure(errorMessage(date, "YYYY-MM"));
}
}

public Result convertYearMontDayDate(@NonNull String date) {
try {
return Result.success(
LocalDate.parse(date).format(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
} catch (Exception e) {
return Result.failure(errorMessage(date, "YYYY-MM-DD"));
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package fr.insee.eno.core.i18n.date;

import lombok.NonNull;

import java.time.LocalDate;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;

public class Iso8601ToFrench implements DateFormatter {

public Result convertYearMontDate(@NonNull String date) {
try {
return Result.success(
YearMonth.parse(date).format(DateTimeFormatter.ofPattern("MM/yyyy")));
} catch (Exception e) {
return Result.failure(errorMessage(date, "YYYY-MM"));
}
}

public Result convertYearMontDayDate(@NonNull String date) {
try {
return Result.success(
LocalDate.parse(date).format(DateTimeFormatter.ofPattern("dd/MM/yyyy")));
} catch (Exception e) {
return Result.failure(errorMessage(date, "YYYY-MM-DD"));
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
@Context(format = Format.LUNATIC, type = Datepicker.class)
public class DateQuestion extends SingleResponseQuestion {

public static final String YEAR_FORMAT = "YYYY";
public static final String YEAR_MONTH_FORMAT = "YYYY-MM";
public static final String YEAR_MONTH_DAY_FORMAT = "YYYY-MM-DD";

/**
* Minimum date value allowed.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
package fr.insee.eno.core.processing.out.steps.lunatic;

import fr.insee.eno.core.exceptions.business.InvalidValueException;
import fr.insee.eno.core.exceptions.business.RequiredPropertyException;
import fr.insee.eno.core.i18n.date.DateFormatter;
import fr.insee.eno.core.model.question.DateQuestion;
import fr.insee.eno.core.parameter.EnoParameters;
import fr.insee.eno.core.processing.ProcessingStep;
import fr.insee.lunatic.model.flat.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
Expand All @@ -21,6 +23,18 @@
*/
@Slf4j
public class LunaticAddControlFormat implements ProcessingStep<Questionnaire> {

private final DateFormatter dateFormatter;

/** Using french as default value for date formatting. */
public LunaticAddControlFormat() {
dateFormatter = DateFormatter.Factory.forLanguage(EnoParameters.Language.FR);
}

public LunaticAddControlFormat(EnoParameters.Language language) {
dateFormatter = DateFormatter.Factory.forLanguage(language);
}

/**
*
* @param lunaticQuestionnaire lunatic questionnaire to be processed.
Expand Down Expand Up @@ -94,8 +108,10 @@ private List<ControlType> getFormatControlsForBodyCells(List<BodyCell> bodyCells
return controls;
}

// TODO: internationalization of the generated messages

/**
* Create controls for a input number component
* Create controls for an input number component
* @param number input number to process
*/
private void createFormatControlsForInputNumber(InputNumber number) {
Expand Down Expand Up @@ -162,26 +178,16 @@ private void createFormatControlsForDatepicker(Datepicker datepicker) {
// of the first control is displayed.
}

private static final Logger logger = LoggerFactory.getLogger(LunaticAddControlFormat.class);

String formatDateToFrench(String date) {

if (date == null || date.isEmpty()) {
logger.warn("Date nulle ou vide reçue. Elle sera ignorée.");
return null;
}
if (date.matches("\\d{4}")) {
return date;
} else if (date.matches("\\d{4}-\\d{2}")) {
YearMonth parsedDate = YearMonth.parse(date);
return parsedDate.format(DateTimeFormatter.ofPattern("MM-yyyy"));
} else if (date.matches("\\d{4}-\\d{2}-\\d{2}")) {
LocalDate parsedDate = LocalDate.parse(date);
return parsedDate.format(DateTimeFormatter.ofPattern("dd-MM-yyyy"));
} else {
logger.warn("Format de date non pris en charge. Attendu : AAAA, AAAA-MM, ou AAAA-MM-JJ. Reçu : {}. La date sera ignorée.", date);
return null;
}
private Optional<DateFormatter.Result> validateAndConvertDate(String date, @NonNull String format) {
if (date == null)
return Optional.empty(); // The min/max date properties can eventually be null (not 'required' in Pogues)
DateFormatter.Result result = switch (format) {
case DateQuestion.YEAR_MONTH_DAY_FORMAT -> dateFormatter.convertYearMontDayDate(date);
case DateQuestion.YEAR_MONTH_FORMAT -> dateFormatter.convertYearMontDate(date);
case DateQuestion.YEAR_FORMAT -> dateFormatter.convertYearDate(date);
default -> throw new InvalidValueException("Date format '" + format + "' is invalid.");
};
return Optional.of(result);
}

/**
Expand All @@ -192,20 +198,28 @@ String formatDateToFrench(String date) {
* @param format format string
* @param responseName date picker response attribute
*/
private Optional<ControlType> getFormatControlFromDatepickerAttributes(String id, String minValue, String maxValue, String format, String responseName) {
if (format == null)
throw new RequiredPropertyException("Format is missing in date question '" + id + "'");

Optional<ControlType> getFormatControlFromDatepickerAttributes(String id, String minValue, String maxValue, String format, String responseName) {
String controlIdPrefix = id + "-format-date";

String formattedMinValue = minValue != null ? formatDateToFrench(minValue) : null;
String formattedMaxValue = maxValue != null ? formatDateToFrench(maxValue) : null;

if (minValue == null && maxValue == null) {
logger.warn("Aucune contrainte de date définie pour l'id : {}.", id);
return Optional.empty();
Optional<DateFormatter.Result> formattedMinValue = validateAndConvertDate(minValue, format);
if (formattedMinValue.isPresent() && !formattedMinValue.get().isValid()) {
String message = "Invalid value for min date of question '" + id + "': " + formattedMinValue.get().errorMessage();
log.error(message);
throw new InvalidValueException(message);
}
Optional<DateFormatter.Result> formattedMaxValue = validateAndConvertDate(maxValue, format);
if (formattedMaxValue.isPresent() && !formattedMaxValue.get().isValid()) {
// Due to a bug in the Pogues -> DDI transformation, an invalid max date doesn't throw an exception for now
log.warn("Invalid value for max date of question '" + id + "': " + maxValue);
}

if (minValue != null && maxValue != null) {
boolean generateMin = formattedMinValue.isPresent(); // always valid when reached
boolean generateMax = formattedMaxValue.isPresent() && formattedMaxValue.get().isValid(); // can be invalid

if (generateMin && generateMax) {
String controlExpression = String.format(
"not(not(isnull(%s)) and " +
"(cast(%s, date, \"%s\")<cast(\"%s\", date, \"%s\") or " +
Expand All @@ -214,31 +228,31 @@ Optional<ControlType> getFormatControlFromDatepickerAttributes(String id, String
);
String controlErrorMessage = String.format(
"\"La date saisie doit être comprise entre %s et %s.\"",
formattedMinValue, formattedMaxValue
formattedMinValue.get().value(), formattedMaxValue.get().value()
);
return Optional.of(createFormatControl(controlIdPrefix + "-borne-inf-sup", controlExpression, controlErrorMessage));
}

if (minValue == null && maxValue != null) {
if (generateMax) {
String controlExpression = String.format(
"not(not(isnull(%s)) and (cast(%s, date, \"%s\")>cast(\"%s\", date, \"%s\")))",
responseName, responseName, format, maxValue, format
);
String controlErrorMessage = String.format(
"\"La date saisie doit être antérieure à %s.\"",
formattedMaxValue
formattedMaxValue.get().value()
);
return Optional.of(createFormatControl(controlIdPrefix + "-borne-sup", controlExpression, controlErrorMessage));
}

if (minValue != null && maxValue == null) {
if (generateMin) {
String controlExpression = String.format(
"not(not(isnull(%s)) and (cast(%s, date, \"%s\")<cast(\"%s\", date, \"%s\")))",
responseName, responseName, format, minValue, format
);
String controlErrorMessage = String.format(
"\"La date saisie doit être postérieure à %s.\"",
formattedMinValue
formattedMinValue.get().value()
);
return Optional.of(createFormatControl(controlIdPrefix + "-borne-inf", controlExpression, controlErrorMessage));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package fr.insee.eno.core.i18n;

import fr.insee.eno.core.i18n.date.DateFormatter;
import fr.insee.eno.core.i18n.date.Iso8601ToFrench;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class Iso8601ToFrenchTest {

@Test
void testConvertYearDate_ValidYear() {
DateFormatter.Result result = new Iso8601ToFrench().convertYearDate("2023");
assertTrue(result.isValid());
assertEquals("2023", result.value());
}

@Test
void testConvertYearDate_InvalidYearLength() {
DateFormatter.Result result = new Iso8601ToFrench().convertYearDate("23");
assertFalse(result.isValid());
assertEquals("Date '23' is invalid or doesn't match format 'YYYY'.", result.errorMessage());
}

@Test
void testConvertYearDate_InvalidCharacters() {
DateFormatter.Result result = new Iso8601ToFrench().convertYearDate("abcd");
assertFalse(result.isValid());
assertEquals("Date 'abcd' is invalid or doesn't match format 'YYYY'.", result.errorMessage());
}

@Test
void testConvertYearMonthDate_ValidInput() {
DateFormatter.Result result = new Iso8601ToFrench().convertYearMontDate("2023-12");
assertTrue(result.isValid());
assertEquals("12/2023", result.value());
}

@Test
void testConvertYearMonthDate_InvalidFormat() {
DateFormatter.Result result = new Iso8601ToFrench().convertYearMontDate("2023/12");
assertFalse(result.isValid());
assertEquals("Date '2023/12' is invalid or doesn't match format 'YYYY-MM'.", result.errorMessage());
}

@Test
void testConvertYearMonthDate_ExtraCharacters() {
DateFormatter.Result result = new Iso8601ToFrench().convertYearMontDate("2023-12-01");
assertFalse(result.isValid());
assertEquals("Date '2023-12-01' is invalid or doesn't match format 'YYYY-MM'.", result.errorMessage());
}

@Test
void testConvertYearMonthDayDate_ValidInput() {
DateFormatter.Result result = new Iso8601ToFrench().convertYearMontDayDate("2023-12-16");
assertTrue(result.isValid());
assertEquals("16/12/2023", result.value());
}

@Test
void testConvertYearMonthDayDate_InvalidFormat() {
DateFormatter.Result result = new Iso8601ToFrench().convertYearMontDayDate("2023/12/16");
assertFalse(result.isValid());
assertEquals("Date '2023/12/16' is invalid or doesn't match format 'YYYY-MM-DD'.", result.errorMessage());
}

@Test
void testConvertYearMonthDayDate_InvalidDate() {
DateFormatter.Result result = new Iso8601ToFrench().convertYearMontDayDate("2023-02-30");
assertFalse(result.isValid());
assertEquals("Date '2023-02-30' is invalid or doesn't match format 'YYYY-MM-DD'.", result.errorMessage());
}

}
Loading

0 comments on commit 472458f

Please sign in to comment.