Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: dates validation and formatting #1177

Merged
merged 8 commits into from
Dec 20, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,46 @@
package fr.insee.eno.core.i18n.date;

import lombok.Getter;
import lombok.NonNull;

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

@Getter
class Result {
FBibonne marked this conversation as resolved.
Show resolved Hide resolved
private final boolean valid;
private final String value;
private final String errorMessage;

private Result(boolean valid, String value, String errorMessage) {
this.valid = valid;
this.value = value;
this.errorMessage = 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);
}
}

default Result convertYearDate(@NonNull String date) {
FBibonne marked this conversation as resolved.
Show resolved Hide resolved
if (date.length() != 4 || !date.matches("\\d{4}"))
nsenave marked this conversation as resolved.
Show resolved Hide resolved
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,16 @@
package fr.insee.eno.core.i18n.date;

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

public class DateFormatterFactory {

private DateFormatterFactory() {}

public static DateFormatter forLanguage(EnoParameters.Language language) {
FBibonne marked this conversation as resolved.
Show resolved Hide resolved
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();
}

}
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 {
FBibonne marked this conversation as resolved.
Show resolved Hide resolved

public Result convertYearMontDate(@NonNull String date) {
try {
return Result.success(
YearMonth.parse(date).format(DateTimeFormatter.ofPattern("yyyy-MM")));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On peut écrire un test pour vérifier mais pour moi il suffit de renvoyer la chaîne telle qu'elle. Un match d'une regexp \d{4}-[01]\d devrait suffire pour isoler les cas d'erreur

} 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")));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idem

} 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,18 @@
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.i18n.date.DateFormatterFactory;
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 +24,18 @@
*/
@Slf4j
public class LunaticAddControlFormat implements ProcessingStep<Questionnaire> {

private final DateFormatter dateFormatter;

/** Using french as default value for date formatting. */
public LunaticAddControlFormat() {
dateFormatter = DateFormatterFactory.forLanguage(EnoParameters.Language.FR);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remplacer par this(EnoParameters.defaultLanguage()) ?

}

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

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

// TODO: internationalization of the generated messages

/**
* Create controls for a input number component
* @param number input number to process
Expand Down Expand Up @@ -162,26 +179,15 @@ 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 DateFormatter.Result validateAndConvertDate(String date, @NonNull String format) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Utiliser un enum pour le paramètre format pour s'extirper de l'influence de Lunatic model

if (date == null)
return null; // The min/max date properties can eventually be null (not 'required' in Pogues)
return 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.");
};
}

/**
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();
DateFormatter.Result formattedMinValue = validateAndConvertDate(minValue, format);
if (formattedMinValue != null && !formattedMinValue.isValid()) {
nsenave marked this conversation as resolved.
Show resolved Hide resolved
String message = "Invalid value for min date of question '" + id + "': " + formattedMinValue.getErrorMessage();
log.error(message);
throw new InvalidValueException(message);
}
DateFormatter.Result formattedMaxValue = validateAndConvertDate(maxValue, format);
if (formattedMaxValue != null && !formattedMaxValue.isValid()) {
nsenave marked this conversation as resolved.
Show resolved Hide resolved
// 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 != null && formattedMinValue.isValid();
nsenave marked this conversation as resolved.
Show resolved Hide resolved
boolean generateMax = formattedMaxValue != null && formattedMaxValue.isValid();
nsenave marked this conversation as resolved.
Show resolved Hide resolved

if (generateMin && generateMax) {
String controlExpression = String.format(
"not(not(isnull(%s)) and " +
nsenave marked this conversation as resolved.
Show resolved Hide resolved
"(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.getValue(), formattedMaxValue.getValue()
);
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.getValue()
);
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.getValue()
);
return Optional.of(createFormatControl(controlIdPrefix + "-borne-inf", controlExpression, controlErrorMessage));
}
Expand Down
Loading