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

Add 1-based Month[De]serializer enabled with JavaTimeFeature.ONE_BASED_MONTHS option #292

Merged
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ public enum JavaTimeFeature implements JacksonFeature
* stringified numbers are always accepted as timestamps regardless of
* this feature.
*/
ALWAYS_ALLOW_STRINGIFIED_DATE_TIMESTAMPS(false)
ALWAYS_ALLOW_STRINGIFIED_DATE_TIMESTAMPS(false),

ONE_BASED_MONTHS(false)
cowtowncoder marked this conversation as resolved.
Show resolved Hide resolved
;

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ public void setupModule(SetupContext context) {
desers.addDeserializer(ZoneOffset.class, JSR310StringParsableDeserializer.ZONE_OFFSET);

context.addDeserializers(desers);

boolean oneBasedMonth = _features.isEnabled(JavaTimeFeature.ONE_BASED_MONTHS);
context.addBeanDeserializerModifier(new JavaTimeDeserializerModifier(oneBasedMonth));

// 20-Nov-2023, tatu: [modules-java8#288]: someone may have directly
// added entries, need to add for backwards compatibility
if (_deserializers != null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.fasterxml.jackson.datatype.jsr310.deser;

import java.io.IOException;
import java.time.Month;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.BeanDescription;
import com.fasterxml.jackson.databind.DeserializationConfig;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.deser.BeanDeserializerModifier;
import com.fasterxml.jackson.databind.deser.std.DelegatingDeserializer;
import com.fasterxml.jackson.databind.exc.InvalidFormatException;

public class JavaTimeDeserializerModifier extends BeanDeserializerModifier {
private final boolean _oneBaseMonths;

public JavaTimeDeserializerModifier() {
Copy link
Member

Choose a reason for hiding this comment

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

Probably not needed?

this(false);
}

public JavaTimeDeserializerModifier(boolean oneBaseMonths) {
_oneBaseMonths = oneBaseMonths;
}

@Override
public JsonDeserializer<?> modifyEnumDeserializer(DeserializationConfig config, JavaType type, BeanDescription beanDesc, JsonDeserializer<?> defaultDeserializer) {
if (type.hasRawClass(Month.class)) {
return new MonthDeserializer((JsonDeserializer<Enum>) defaultDeserializer, _oneBaseMonths);
Copy link
Member

Choose a reason for hiding this comment

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

Shouldn't this only need to be returned if _oneBasedMonths is true? That might simplify handling a bit when there are fewer checks.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@cowtowncoder - it's mostly about the naming and meaning of the classes. If we do it like this, perhaps it's better to rename the deserializer from MonthDeserializer to OneBasedMonthDeserializer and make it always updated the value returned by the delegate (without checking the _oneBasedMonths flag -- and move the check here).
What do you think?

Copy link
Member

Choose a reason for hiding this comment

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

Yes, I think that makes sense -- I like explicit naming.

}
return defaultDeserializer;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.fasterxml.jackson.datatype.jsr310.deser;

import java.io.IOException;
import java.time.Month;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.deser.std.DelegatingDeserializer;
import com.fasterxml.jackson.databind.exc.InvalidFormatException;

Copy link
Member

Choose a reason for hiding this comment

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

Add @since 2.17 in Javadoc here too

public class MonthDeserializer extends DelegatingDeserializer {
private final boolean _enableOneBaseMonths;

public MonthDeserializer(JsonDeserializer<Enum> defaultDeserializer, boolean oneBaseMonths) {
super(defaultDeserializer);
_enableOneBaseMonths = oneBaseMonths;
}

@Override
public Object deserialize(JsonParser parser, DeserializationContext context) throws IOException {
Month zeroBaseMonth = (Month) getDelegatee().deserialize(parser, context);
if (!_enableOneBaseMonths || zeroBaseMonth == null) {
Copy link
Member

Choose a reason for hiding this comment

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

I think I would actually change this so that if ! _enableOneBaseMonths, or current token not JsonToken.VALUE_NUMBER_INT (and I guess "Stringified" number), just call delegate.deserialize() and return value you get.

This is based on understanding that, I think, String name like "FEBRUARY" should NOT be changed to JANUARY? At least to me 0- vs 1-based logic only applies to numeric (and stringified numbers) case, not to textual.

return zeroBaseMonth;
}

JsonToken token = parser.currentToken();
if (token == JsonToken.VALUE_NUMBER_INT || _isNumberAsString(parser.getText(), token)) {
if (zeroBaseMonth == Month.JANUARY) {
throw new InvalidFormatException(parser, "Month.JANUARY value not allowed for 1-based Month.", zeroBaseMonth, Month.class);
} else {
return zeroBaseMonth.minus(1);
}
}
return zeroBaseMonth;
}

private boolean _isNumberAsString(String text, JsonToken token) throws IOException {
String regex = "[\"']?\\d{1,2}[\"']?";
Copy link
Member

Choose a reason for hiding this comment

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

Ok, couple of things: parser.getText() will return contents, so no quotes around it, can leave those out.

And make sure to create pre-compiled Pattern as static member of class: otherwise RegEx state machine is re-create for every call... and that's pretty expensive (and more importantly easily avoidable :) ).

return token == JsonToken.VALUE_STRING && text.matches(regex);
}

@Override
protected JsonDeserializer<?> newDelegatingInstance(JsonDeserializer<?> newDelegatee) {
return new MonthDeserializer((JsonDeserializer<Enum>) newDelegatee, _enableOneBaseMonths);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
package com.fasterxml.jackson.datatype.jsr310.deser;



import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectReader;
import com.fasterxml.jackson.databind.exc.InvalidFormatException;
import com.fasterxml.jackson.databind.exc.MismatchedInputException;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.datatype.jsr310.MockObjectConfiguration;
import com.fasterxml.jackson.datatype.jsr310.ModuleTestBase;

import org.junit.Test;
import org.junit.function.ThrowingRunnable;

import java.io.IOException;
import java.time.Month;
import java.time.format.DateTimeParseException;
import java.time.temporal.TemporalAccessor;
import java.util.Map;

import static java.lang.String.format;
import static org.junit.Assert.*;

public class MonthDeserTest extends ModuleTestBase
{
static class Wrapper {
@JsonFormat(pattern="MM")
public Month value;

public Wrapper(Month v) { value = v; }
}

@Test
public void testDeserializationAsString01_feature() throws Exception
{
ObjectReader READER = JsonMapper.builder()
.addModule(new JavaTimeModule().enable(JavaTimeFeature.ONE_BASED_MONTHS))
.build()
.readerFor(Month.class);

assertEquals(Month.JANUARY, READER.readValue("\"01\""));
}

@Test
public void testDeserializationAsString01_zeroBased() throws Exception
{
assertEquals(Month.FEBRUARY, readerForZeroBased().readValue("\"01\""));
}


@Test
public void testDeserializationAsString02_oneBased() throws Exception
{
assertEquals(Month.JANUARY, readerForOneBased().readValue("\"JANUARY\""));
}

@Test
public void testDeserializationAsString02_zeroBased() throws Exception
{
assertEquals(Month.JANUARY, readerForZeroBased().readValue("\"JANUARY\""));
}

@Test
public void testBadDeserializationAsString01() {
assertError(
() -> readerForOneBased().readValue("\"notamonth\""),
InvalidFormatException.class,
"Cannot deserialize value of type `java.time.Month` from String \"notamonth\": not one of the values accepted for Enum class: [OCTOBER, SEPTEMBER, JUNE, MARCH, MAY, APRIL, JULY, JANUARY, FEBRUARY, DECEMBER, AUGUST, NOVEMBER]"
);
}

static void assertError(ThrowingRunnable codeToRun, Class<? extends Throwable> expectedException, String expectedMessage) {
try {
codeToRun.run();
fail(format("Expecting %s, but nothing was thrown!", expectedException.getName()));
} catch (Throwable actualException) {
if (!expectedException.isInstance(actualException)) {
fail(format("Expecting exception of type %s, but %s was thrown instead", expectedException.getName(), actualException.getClass().getName()));
}
if (actualException.getMessage() == null || !actualException.getMessage().contains(expectedMessage)) {
fail(format("Expecting exception with message containing:'%s', but the actual error message was:'%s'", expectedMessage, actualException.getMessage()));
}
}
}


@Test
public void testDeserialization01_zeroBased() throws Exception
{
assertEquals(Month.FEBRUARY, readerForZeroBased().readValue("1"));
}

@Test
public void testDeserialization01_oneBased() throws Exception
{
assertEquals(Month.JANUARY, readerForOneBased().readValue("1"));
}

@Test
public void testDeserialization02_zeroBased() throws Exception
{
assertEquals(Month.SEPTEMBER, readerForZeroBased().readValue("\"08\""));
}

@Test
public void testDeserialization02_oneBased() throws Exception
{
assertEquals(Month.AUGUST, readerForOneBased().readValue("\"08\""));
}

@Test
public void testDeserializationWithTypeInfo01_feature() throws Exception
{
ObjectMapper MAPPER = new ObjectMapper()
.registerModule(new JavaTimeModule().enable(JavaTimeFeature.ONE_BASED_MONTHS));
MAPPER.addMixIn(TemporalAccessor.class, MockObjectConfiguration.class);

TemporalAccessor value = MAPPER.readValue("[\"java.time.Month\",11]", TemporalAccessor.class);
assertEquals(Month.NOVEMBER, value);
}

@Test
public void testDeserializationWithTypeInfo01_default() throws Exception
{
ObjectMapper MAPPER = new ObjectMapper();
MAPPER.addMixIn(TemporalAccessor.class, MockObjectConfiguration.class);

TemporalAccessor value = MAPPER.readValue("[\"java.time.Month\",\"11\"]", TemporalAccessor.class);
assertEquals(Month.DECEMBER, value);
}


@Test
public void _testDeserializationWithTypeInfo01_default() throws Exception
{
ObjectMapper mapper = new ObjectMapper()
.registerModule(new JavaTimeModule());
mapper.addMixIn(TemporalAccessor.class, MockObjectConfiguration.class);

TemporalAccessor value = mapper.readValue("[\"java.time.Month\",\"11\"]", TemporalAccessor.class);
assertEquals(Month.DECEMBER, value);
}

@Test
public void _testDeserializationWithTypeInfo01_feature() throws Exception
{
ObjectMapper mapper = new ObjectMapper()
.registerModule(new JavaTimeModule().enable(JavaTimeFeature.ONE_BASED_MONTHS));
mapper.addMixIn(TemporalAccessor.class, MockObjectConfiguration.class);

TemporalAccessor value = mapper.readValue("[\"java.time.Month\", 11]", TemporalAccessor.class);
assertEquals(Month.NOVEMBER, value);
}

@Test
public void testFormatAnnotation() throws Exception
{
ObjectMapper MAPPER = newMapper();
String json = newMapper().writeValueAsString(new Wrapper(Month.DECEMBER));
assertEquals("{\"value\":\"12\"}", json);

Wrapper output = MAPPER.readValue(json, Wrapper.class);
assertEquals(new Wrapper(Month.of(12)).value, output.value);
}

/*
/**********************************************************
/* Tests for empty string handling
/**********************************************************
*/

// minor changes in 2.12
@Test
public void testDeserializeFromEmptyString() throws Exception
{
// First: by default, lenient, so empty String fine
TypeReference<Map<String, Month>> MAP_TYPE_REF = new TypeReference<Map<String, Month>>() { };
ObjectReader objectReader = newMapper().registerModule(new JavaTimeModule())
cowtowncoder marked this conversation as resolved.
Show resolved Hide resolved
.readerFor(MAP_TYPE_REF);

Map<String, Month> map = objectReader.readValue("{\"month\":null}");
assertNull(map.get("month"));

Map<String, Month> map2 = objectReader.readValue("{\"month\":\"\"}");
assertNotNull(map2);

// But can make strict:
ObjectMapper strictMapper = mapperBuilder()
.addModule(new JavaTimeModule())
.build();
strictMapper.configOverride(Month.class)
.setFormat(JsonFormat.Value.forLeniency(false));

try {
strictMapper.readerFor(MAP_TYPE_REF).readValue("{\"date\":\"\"}");
fail("Should not pass");
} catch (MismatchedInputException e) {
verifyException(e, "not allowed because 'strict' mode set for");
}
}

private void expectFailure(String aposJson) throws Throwable {
cowtowncoder marked this conversation as resolved.
Show resolved Hide resolved
try {
newMapper().registerModule(new JavaTimeModule())
.readerFor(Month.class)
.readValue(aposJson);
fail("expected DateTimeParseException");
} catch (JsonProcessingException e) {
if (e.getCause() == null) {
throw e;
}
if (!(e.getCause() instanceof DateTimeParseException)) {
throw e.getCause();
}
} catch (IOException e) {
throw e;
}
}

private ObjectReader readerForZeroBased() {
return JsonMapper.builder()
.addModule(new JavaTimeModule().disable(JavaTimeFeature.ONE_BASED_MONTHS))
.build()
.readerFor(Month.class);
}

private ObjectReader readerForOneBased() {
return JsonMapper.builder()
.addModule(new JavaTimeModule().enable(JavaTimeFeature.ONE_BASED_MONTHS))
.build()
.readerFor(Month.class);
}

}
Loading