Skip to content

Commit d5ba929

Browse files
committed
✨ Handling partial dates in a better manner: also supporting month only and different format
1 parent aa7c1ac commit d5ba929

4 files changed

Lines changed: 122 additions & 42 deletions

File tree

sormas-backend/src/main/java/de/symeda/sormas/backend/patch/DataPatcherImpl.java

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
package de.symeda.sormas.backend.patch;
22

3-
import java.util.*;
3+
import java.util.AbstractMap;
4+
import java.util.HashMap;
5+
import java.util.LinkedHashMap;
6+
import java.util.List;
7+
import java.util.Map;
8+
import java.util.Objects;
9+
import java.util.Optional;
410
import java.util.function.Function;
511
import java.util.function.Predicate;
612
import java.util.function.Supplier;
@@ -10,6 +16,7 @@
1016
import javax.ejb.EJB;
1117
import javax.enterprise.context.ApplicationScoped;
1218
import javax.inject.Inject;
19+
import javax.transaction.Transactional;
1320

1421
import org.apache.commons.lang3.StringUtils;
1522
import org.jetbrains.annotations.NotNull;
@@ -22,7 +29,15 @@
2229
import de.symeda.sormas.api.customizablefield.CustomizableFieldValueDto;
2330
import de.symeda.sormas.api.externalmessage.survey.PatchDictionary;
2431
import de.symeda.sormas.api.externalmessage.survey.PatchField;
25-
import de.symeda.sormas.api.patch.*;
32+
import de.symeda.sormas.api.patch.CaseDataPatchRequest;
33+
import de.symeda.sormas.api.patch.DataPatchFailure;
34+
import de.symeda.sormas.api.patch.DataPatchFailureCause;
35+
import de.symeda.sormas.api.patch.DataPatchResponse;
36+
import de.symeda.sormas.api.patch.DataPatcher;
37+
import de.symeda.sormas.api.patch.DataReplacementStrategy;
38+
import de.symeda.sormas.api.patch.EmptyValueBehavior;
39+
import de.symeda.sormas.api.patch.PlainSinglePatchResult;
40+
import de.symeda.sormas.api.patch.SinglePatchResult;
2641
import de.symeda.sormas.api.patch.mapping.FieldCustomMapper;
2742
import de.symeda.sormas.api.patch.mapping.FieldPatchRequest;
2843
import de.symeda.sormas.api.patch.mapping.ValueMappingResult;
@@ -32,7 +47,11 @@
3247
import de.symeda.sormas.backend.common.ConfigFacadeEjb;
3348
import de.symeda.sormas.backend.feature.FeatureConfigurationFacadeEjb;
3449
import de.symeda.sormas.backend.json.ObjectMapperProvider;
35-
import de.symeda.sormas.backend.patch.customizablefield.*;
50+
import de.symeda.sormas.backend.patch.customizablefield.CustomizableContextIndexKey;
51+
import de.symeda.sormas.backend.patch.customizablefield.CustomizableFieldContextPatchMapping;
52+
import de.symeda.sormas.backend.patch.customizablefield.CustomizableFieldDataPatchRequest;
53+
import de.symeda.sormas.backend.patch.customizablefield.CustomizableFieldDataPatcher;
54+
import de.symeda.sormas.backend.patch.customizablefield.CustomizableFieldSinglePatchingResult;
3655
import de.symeda.sormas.backend.patch.mapping.FieldCustomMapperRegistry;
3756
import de.symeda.sormas.backend.patch.mapping.PatchEqualityCheckersRegistry;
3857
import de.symeda.sormas.backend.patch.mapping.ValueMapperRegistry;
@@ -88,6 +107,9 @@ public DataPatcherImpl(
88107
this.configFacade = configFacade;
89108
}
90109

110+
@Transactional(value = Transactional.TxType.REQUIRES_NEW,
111+
rollbackOn = {
112+
Exception.class })
91113
@Override
92114
public DataPatchResponse patch(CaseDataPatchRequest request) {
93115
logger.debug("patch: [{}]", request);

sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/impl/valuemapper/DatePatchMapper.java

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
import java.text.SimpleDateFormat;
55
import java.time.LocalDate;
66
import java.time.LocalDateTime;
7+
import java.time.YearMonth;
78
import java.time.ZoneId;
9+
import java.time.format.DateTimeFormatter;
10+
import java.time.format.DateTimeParseException;
811
import java.util.Arrays;
912
import java.util.Date;
1013
import java.util.List;
@@ -32,9 +35,14 @@ public class DatePatchMapper implements ValuePatchMapper {
3235
"yyyy-MM-dd'T'HH:mm:ssZ", // 2025-12-17T14:30:00+0100
3336
"yyyy-MM-dd'T'HH:mm:ss", // 2025-12-17T14:30:00
3437
"yyyy-MM-dd'T'HH:mm", // 2025-12-17T14:30
35-
"yyyy-MM-dd" // 2025-12-17
38+
"yyyy-MM-dd", // 2025-12-17,
39+
"dd/MM/yyyy" // 17-12-2025,
3640
);
3741

42+
private static final String YEAR_ONLY_REGEX = "\\d{4}";
43+
44+
private static final String MONTH_YEAR_ONLY_REGEX = "\\d{2}/\\d{4}";
45+
3846
@Override
3947
public Set<Class<?>> getSupportedTypes() {
4048
return SUPPORTED_TYPES;
@@ -44,9 +52,6 @@ public Set<Class<?>> getSupportedTypes() {
4452
@SuppressWarnings("unchecked")
4553
public <T> ValueMappingResult<T> map(ValuePatchRequest<T> request) {
4654
Object value = request.getValue();
47-
if (value == null) {
48-
return ValueMappingResult.withData(null);
49-
}
5055

5156
String str = value.toString().trim();
5257

@@ -65,9 +70,9 @@ public <T> ValueMappingResult<T> map(ValuePatchRequest<T> request) {
6570

6671
if (request.isAllowFallbackValues()) {
6772
logger.debug("Edge case to allow years only in lenient manner");
68-
ValueMappingResult<T> yearOnlyResult = tryParseYearOnly(str, targetType);
69-
if (yearOnlyResult != null) {
70-
return yearOnlyResult;
73+
ValueMappingResult<T> partialDate = tryParsePartialDate(str, targetType);
74+
if (partialDate != null) {
75+
return partialDate;
7176
}
7277
}
7378

@@ -81,18 +86,31 @@ public <T> ValueMappingResult<T> map(ValuePatchRequest<T> request) {
8186
* Returns {@code null} when the input is not a 4-digit year pattern.
8287
*/
8388
@SuppressWarnings("unchecked")
84-
private <T> ValueMappingResult<T> tryParseYearOnly(String str, Class<?> targetType) {
85-
if (!str.matches("\\d{4}")) {
86-
return null;
87-
}
88-
try {
89-
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
90-
sdf.setLenient(false);
91-
Date january1st = sdf.parse(str + "-01-01");
92-
return ValueMappingResult.withData((T) toTargetType(january1st, targetType));
93-
} catch (ParseException e) {
89+
private <T> ValueMappingResult<T> tryParsePartialDate(String str, Class<?> targetType) {
90+
if (str.matches(YEAR_ONLY_REGEX)) {
91+
try {
92+
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
93+
sdf.setLenient(false);
94+
Date january1st = sdf.parse(str + "-01-01");
95+
return ValueMappingResult.withData((T) toTargetType(january1st, targetType));
96+
} catch (ParseException e) {
97+
return null;
98+
}
99+
} else if (str.matches(MONTH_YEAR_ONLY_REGEX)) {
100+
try {
101+
YearMonth ym = YearMonth.parse(str, DateTimeFormatter.ofPattern("MM/uuuu"));
102+
LocalDate ld = ym.atDay(1);
103+
104+
Date legacyDate = Date.from(ld.atStartOfDay(ZoneId.systemDefault()).toInstant());
105+
106+
return ValueMappingResult.withData((T) toTargetType(legacyDate, targetType));
107+
} catch (DateTimeParseException e) {
108+
return null;
109+
}
110+
} else {
94111
return null;
95112
}
113+
96114
}
97115

98116
private Object toTargetType(Date parsed, Class<?> targetType) {

sormas-backend/src/test/java/de/symeda/sormas/backend/patch/mapping/impl/valuemapper/DateMapperTest.java

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,6 @@ void map_valueAsNonStringObject_usesToString() throws Exception {
8383
assertEquals(expected, actual);
8484
}
8585

86-
@Test
87-
void map_invalidFormat_throwsIllegalArgumentException() {
88-
// EXECUTE & CHECK
89-
assertEquals(DataPatchFailureCause.INVALID_VALUE_TYPE, victim.map("15/06/2024", Date.class).getDataPatchFailureCause());
90-
}
91-
9286
@Test
9387
void map_lenientOff_invalidDay_throwsIllegalArgumentException() {
9488
// EXECUTE & CHECK
@@ -127,12 +121,6 @@ void map_localDate_fromDateTimeString_returnsDatePart() {
127121
assertEquals(LocalDate.of(2024, 6, 15), victim.map("2024-06-15T14:30:00", LocalDate.class).getData());
128122
}
129123

130-
@Test
131-
void map_localDate_invalidFormat() {
132-
// EXECUTE & CHECK
133-
assertEquals(DataPatchFailureCause.INVALID_VALUE_TYPE, victim.map("15/06/2024", LocalDate.class).getDataPatchFailureCause());
134-
}
135-
136124
@Test
137125
void map_localDate_invalidDay() {
138126
// EXECUTE & CHECK
@@ -159,12 +147,6 @@ void map_localDateTime_fromDateOnlyString_returnsMidnight() {
159147
assertEquals(LocalDateTime.of(2024, 6, 15, 0, 0, 0), victim.map("2024-06-15", LocalDateTime.class).getData());
160148
}
161149

162-
@Test
163-
void map_localDateTime_invalidFormat() {
164-
// EXECUTE & CHECK
165-
assertEquals(DataPatchFailureCause.INVALID_VALUE_TYPE, victim.map("15/06/2024", LocalDateTime.class).getDataPatchFailureCause());
166-
}
167-
168150
@Test
169151
void map_localDateTime_invalidDay() {
170152
// EXECUTE & CHECK
@@ -217,4 +199,47 @@ void map_partialYear_fiveDigits_returnsInvalidValueType() {
217199
// EXECUTE & CHECK — 5-digit strings are not valid year-only input
218200
assertEquals(DataPatchFailureCause.INVALID_VALUE_TYPE, victim.map("20241", Date.class).getDataPatchFailureCause());
219201
}
202+
203+
@Test
204+
void map_dayMonthYear_dayWithinJanuary_parsesAsDayOfYear() throws Exception {
205+
// PREPARE
206+
String input = "15/01/2024";
207+
Date expected = new SimpleDateFormat("yyyy-MM-dd").parse("2024-01-15");
208+
209+
// EXECUTE
210+
Date actual = victim.map(input, Date.class).getData();
211+
212+
// CHECK
213+
assertEquals(expected, actual);
214+
}
215+
216+
@Test
217+
void map_monthYear_validDate_returnsFirstDayOfMonth() throws Exception {
218+
// PREPARE — no day field is set, so Calendar defaults it to the 1st
219+
Date expected = new SimpleDateFormat("yyyy-MM-dd").parse("2024-06-01");
220+
221+
// EXECUTE
222+
Date actual = victim.map("06/2024", Date.class).getData();
223+
224+
// CHECK
225+
assertEquals(expected, actual);
226+
}
227+
228+
@Test
229+
void map_monthYear_localDate_returnsFirstDayOfMonth() {
230+
// EXECUTE & CHECK
231+
assertEquals(LocalDate.of(2024, 6, 1), victim.map("06/2024", LocalDate.class).getData());
232+
}
233+
234+
@Test
235+
void map_monthYear_localDateTime_returnsMidnightFirstDayOfMonth() {
236+
// EXECUTE & CHECK
237+
assertEquals(LocalDateTime.of(2024, 6, 1, 0, 0, 0), victim.map("06/2024", LocalDateTime.class).getData());
238+
}
239+
240+
@Test
241+
void map_monthYear_invalidMonth_returnsInvalidValueType() {
242+
// EXECUTE & CHECK — month 13 does not exist
243+
assertEquals(DataPatchFailureCause.INVALID_VALUE_TYPE, victim.map("13/2024", Date.class).getDataPatchFailureCause());
244+
}
220245
}

sormas-backend/src/test/java/de/symeda/sormas/patch/DataPatcherImplTest.java

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,12 @@
2727
import de.symeda.sormas.api.caze.Vaccine;
2828
import de.symeda.sormas.api.customizableenum.CustomizableEnumTranslation;
2929
import de.symeda.sormas.api.customizableenum.CustomizableEnumType;
30-
import de.symeda.sormas.api.customizablefield.*;
30+
import de.symeda.sormas.api.customizablefield.CustomizableFieldContext;
31+
import de.symeda.sormas.api.customizablefield.CustomizableFieldGroup;
32+
import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto;
33+
import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataFacade;
34+
import de.symeda.sormas.api.customizablefield.CustomizableFieldType;
35+
import de.symeda.sormas.api.customizablefield.CustomizableFieldValueDto;
3136
import de.symeda.sormas.api.exposure.ExposureDto;
3237
import de.symeda.sormas.api.exposure.ExposureType;
3338
import de.symeda.sormas.api.externalmessage.survey.PatchDictionary;
@@ -40,8 +45,19 @@
4045
import de.symeda.sormas.api.infrastructure.country.CountryDto;
4146
import de.symeda.sormas.api.infrastructure.country.CountryReferenceDto;
4247
import de.symeda.sormas.api.infrastructure.facility.FacilityDto;
43-
import de.symeda.sormas.api.patch.*;
44-
import de.symeda.sormas.api.person.*;
48+
import de.symeda.sormas.api.patch.CaseDataPatchRequest;
49+
import de.symeda.sormas.api.patch.DataPatchFailure;
50+
import de.symeda.sormas.api.patch.DataPatchFailureCause;
51+
import de.symeda.sormas.api.patch.DataPatchResponse;
52+
import de.symeda.sormas.api.patch.DataPatcher;
53+
import de.symeda.sormas.api.patch.DataReplacementStrategy;
54+
import de.symeda.sormas.api.patch.EmptyValueBehavior;
55+
import de.symeda.sormas.api.person.OccupationType;
56+
import de.symeda.sormas.api.person.PersonContactDetailDto;
57+
import de.symeda.sormas.api.person.PersonContactDetailType;
58+
import de.symeda.sormas.api.person.PersonDto;
59+
import de.symeda.sormas.api.person.PhoneNumberType;
60+
import de.symeda.sormas.api.person.Sex;
4561
import de.symeda.sormas.api.symptoms.SymptomState;
4662
import de.symeda.sormas.api.utils.YesNoUnknown;
4763
import de.symeda.sormas.api.vaccination.VaccinationDto;
@@ -258,7 +274,6 @@ void patch_invalidMultiFieldFormat() {
258274
() -> Assertions.assertEquals(expectedFailures, response.getFailures()));
259275
}
260276

261-
// the value is properly resolved and set into the person, but when fetching it again it's not there anymore.
262277
@Test
263278
void patch_customizableEnu_default_enum() {
264279
// PREPARE

0 commit comments

Comments
 (0)