MonthDeserializer.java

package tools.jackson.databind.ext.javatime.deser;

import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;

import tools.jackson.core.*;
import tools.jackson.databind.BeanProperty;
import tools.jackson.databind.DeserializationContext;
import tools.jackson.databind.MapperFeature;
import tools.jackson.databind.cfg.DateTimeFeature;

import com.fasterxml.jackson.annotation.JsonFormat;

/**
 * Deserializer for Java 8 temporal {@link Month}s.
 *<p>
 * Note that unlike most other date/time types {@link Month} is also an {@link Enum}:
 * because of this, case-insensitive matching of textual values (like {@code "January"})
 * is enabled by either date/time-specific
 * {@link MapperFeature#ACCEPT_CASE_INSENSITIVE_VALUES} or Enum-specific
 * {@link MapperFeature#ACCEPT_CASE_INSENSITIVE_ENUMS}; and may also be overridden
 * per-property with
 * {@link JsonFormat.Feature#ACCEPT_CASE_INSENSITIVE_VALUES}.
 */
public class MonthDeserializer extends JSR310DateTimeDeserializerBase<Month>
{
    // @since 3.1
    private final static Map<String, Month> BY_NAME_LOOKUP = Arrays.stream(Month.values())
            .collect(Collectors.toUnmodifiableMap(Month::name, Function.identity()));

    public static final MonthDeserializer INSTANCE = new MonthDeserializer();

    /**
     * Property-level override for {@link JsonFormat.Feature#ACCEPT_CASE_INSENSITIVE_VALUES}.
     */
    protected final Boolean _caseInsensitiveValues;

    /**
     * NOTE: only {@code public} so that use via annotations (see [modules-java8#202])
     * is possible
     */
    public MonthDeserializer() {
        this(null);
    }

    public MonthDeserializer(DateTimeFormatter formatter) {
        super(Month.class, formatter);
        _caseInsensitiveValues = null;
    }

    protected MonthDeserializer(MonthDeserializer base, Boolean leniency) {
        super(base, leniency);
        _caseInsensitiveValues = base._caseInsensitiveValues;
    }

    protected MonthDeserializer(MonthDeserializer base,
            Boolean leniency, DateTimeFormatter formatter, JsonFormat.Shape shape) {
        this(base, leniency, formatter, shape, base._caseInsensitiveValues);
    }

    protected MonthDeserializer(MonthDeserializer base,
            Boolean leniency, DateTimeFormatter formatter, JsonFormat.Shape shape,
            Boolean caseInsensitiveValues) {
        super(base, leniency, formatter, shape);
        _caseInsensitiveValues = caseInsensitiveValues;
    }

    @Override
    protected MonthDeserializer withLeniency(Boolean leniency) {
        return new MonthDeserializer(this, leniency);
    }

    @Override
    protected MonthDeserializer withDateFormat(DateTimeFormatter dtf) {
        return new MonthDeserializer(this, _isLenient, dtf, _shape);
    }

    protected MonthDeserializer withCaseInsensitiveValues(Boolean state) {
        if (Objects.equals(_caseInsensitiveValues, state)) {
            return this;
        }
        return new MonthDeserializer(this, _isLenient, _formatter, _shape, state);
    }

    @Override
    protected JSR310DateTimeDeserializerBase<?> _withFormatOverrides(DeserializationContext ctxt,
            BeanProperty property, JsonFormat.Value formatOverrides)
    {
        MonthDeserializer deser = (MonthDeserializer) super._withFormatOverrides(ctxt,
                property, formatOverrides);
        Boolean caseInsensitive = formatOverrides.getFeature(
                JsonFormat.Feature.ACCEPT_CASE_INSENSITIVE_VALUES);
        // Only override if explicitly specified: `null` means "no setting", must not
        // clear a value possibly set earlier
        if (caseInsensitive != null) {
            deser = deser.withCaseInsensitiveValues(caseInsensitive);
        }
        return deser;
    }

    @Override
    public Month deserialize(JsonParser p, DeserializationContext ctxt)
        throws JacksonException
    {
        if (p.hasToken(JsonToken.VALUE_STRING)) {
            return _fromString(p, ctxt, p.getString());
        }
        // Support numeric scalar input
        if (p.hasToken(JsonToken.VALUE_NUMBER_INT)) {
            final int monthIndex = p.getIntValue();
            if (ctxt.isEnabled(DateTimeFeature.ONE_BASED_MONTHS)) {
                return _decode1BasedMonth(monthIndex, ctxt);
            }
            return _decode0BasedMonth(monthIndex, ctxt);
        }
        // 30-Sep-2020, tatu: New! "Scalar from Object" (mostly for XML)
        if (p.isExpectedStartObjectToken()) {
            final String str = ctxt.extractScalarFromObject(p, this, handledType());
            // 17-May-2025, tatu: [databind#4656] need to check for `null`
            if (str != null) {
                return _fromString(p, ctxt, str);
            }
            // fall through
        } else if (p.isExpectedStartArrayToken()) {
            // [databind#5957]: Delegate to standard array handling so empty arrays
            // and single-element unwrapping respect coercion / UNWRAP_SINGLE_VALUE_ARRAYS.
            return _deserializeFromArray(p, ctxt);
        } else if (p.hasToken(JsonToken.VALUE_EMBEDDED_OBJECT)) {
            return (Month) p.getEmbeddedObject();
        }
        return _handleUnexpectedToken(ctxt, p,
                JsonToken.VALUE_STRING, JsonToken.START_ARRAY);
    }

    protected Month _fromString(JsonParser p, DeserializationContext ctxt,
            String string0)
        throws JacksonException
    {
        String string = string0.trim();
        if (string.length() == 0) {
            // 22-Oct-2020, tatu: not sure if we should pass original (to distinguish
            //   b/w empty and blank); for now don't which will allow blanks to be
            //   handled like "regular" empty (same as pre-2.12)
            return _fromEmptyString(p, ctxt, string);
        }
        try {
            if (_formatter == null) {
                // First: try purely numeric input
                try {
                    int monthIndex = Integer.parseInt(string);
                    if (ctxt.isEnabled(DateTimeFeature.ONE_BASED_MONTHS)) {
                        return _decode1BasedMonth(monthIndex, ctxt);
                    }
                    return _decode0BasedMonth(monthIndex, ctxt);
                } catch (NumberFormatException nfe) {
                    // fall through ��� treat as textual month name
                }
                // Second: try textual input
                // Handle English month names such as "JANUARY" from the actual Month Enum names
                Month m = BY_NAME_LOOKUP.get(string);
                if (m != null) {
                    return m;
                }
                if (_acceptCaseInsensitiveNames(ctxt)) {
                    m = _findMonthIgnoreCase(string);
                    if (m != null) {
                        return m;
                    }
                }
                return (Month) ctxt.handleWeirdStringValue(handledType(), string, 
                        "not one of known `Month` values: %s",
                                Arrays.toString(Month.values()));
            }
            return Month.from(_formatter.parse(string));
        } catch (DateTimeException e) {
            return _handleDateTimeFormatException(ctxt, e, _formatter, string);
        } catch (NumberFormatException e) {
            throw ctxt.weirdStringException(string, handledType(),
                    "not a valid Month value");
        }
    }

    /**
     * Helper method for checking whether textual {@link Month} names (like {@code "JANUARY"})
     * may be matched case-insensitively: explicit per-property override, if any, takes
     * precedence over either of the two applicable global settings.
     */
    private boolean _acceptCaseInsensitiveNames(DeserializationContext ctxt) {
        if (_caseInsensitiveValues != null) {
            return _caseInsensitiveValues;
        }
        // [databind#6178]: `Month` is an `Enum`, so honor Enum-specific setting as well
        // as the date/time-specific one
        return _acceptCaseInsensitiveValues(ctxt, null)
                || ctxt.isEnabled(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS);
    }

    private Month _findMonthIgnoreCase(String key) {
        // Iterate over lookup Map (and not `Month.values()`, which allocates a new
        // array on every call); matching same way as case-insensitive `Enum` lookup
        // (see `CompactStringObjectMap.findCaseInsensitive()`)
        for (Map.Entry<String, Month> entry : BY_NAME_LOOKUP.entrySet()) {
            if (entry.getKey().equalsIgnoreCase(key)) {
                return entry.getValue();
            }
        }
        return null;
    }

    /**
     * Validate and convert a 1���based month number to {@link Month}.
     */
    private Month _decode1BasedMonth(int monthIndex, DeserializationContext ctxt)
        throws JacksonException
    {
        if (Month.JANUARY.getValue() <= monthIndex && monthIndex <= Month.DECEMBER.getValue()) {
            return Month.of(monthIndex);
        }
        return (Month) ctxt.handleWeirdNumberValue(handledType(),
                monthIndex, "month number outside 1-12 range for 1-based `Month`s");
    }

    /**
     * Validate and convert a 0���based month number to {@link Month}.
     */
    private Month _decode0BasedMonth(int monthIndex, DeserializationContext ctxt)
        throws JacksonException
    {
        if (monthIndex < 0 || monthIndex >= 12) { // invalid for 0���based
            return (Month) ctxt.handleWeirdNumberValue(handledType(),
                    monthIndex, "month number outside 0-11 range for 0-based `Month`s");
        }
        return Month.values()[monthIndex]; // 0���based mapping
    }
}