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

Feature request: Enum (de)serialization in conjunction with JsonFormat.Shape.NUMBER_INT #3580

Open
kistlers opened this issue Aug 23, 2022 · 12 comments
Labels
enum Related to handling of Enum values

Comments

@kistlers
Copy link

kistlers commented Aug 23, 2022

Is your feature request related to a problem? Please describe.
Not a problem, as there is a relatively clear and simple way to achieve the same, albeit slightly more limited, functionality. See below:

Describe the solution you'd like
I would like to see the functionality of the @JsonProperty annotation on enum values be extended to output the value as integers (and potentially other types) in the JSON output (and also deserialized with the same logic). I imagine a @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT) annotation on the property in the POJO would define the output format of the enum value given to @JsonProperty as a string. Hence, see the example below.

Usage example

The following example is taken from StackOverflow, an answer by user SomethingSomething:

public enum State {

    @JsonProperty("0")
    OFF,

    @JsonProperty("1")
    ON,

    @JsonProperty("2")
    UNKNOWN
}

The enum State in the following POJO would be serialized as {"state": "1"}, all good so far.

public record Pojo(
	@JsonProperty("state") State state
) {}

However, consider the following case:

public record Pojo(
    @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT) @JsonProperty("state") State state
) {}

I would like to see this case result in JSON such as {"state": 1}, with the value being a number (int in that case) instead of a string.

Additional context

I know I can achieve the same functionality using @JsonValue and @JsonCreator annotations:

public enum State {

    OFF(0),

    ON(1),

    UNKNOWN(2);

    private final int value;

    State(final int value) {
        this.value = value;
    }
    
    @JsonValue
    public int getValue() {
        return value;
    }
    
    @JsonCreator
    public State forValue(final int value) {
        return Arrays.stream(values())
                .filter(v -> v.value == value)
                .findFirst()
                .orElseThrow();
    }
}

The proposed feature would allow us to shortcut these methods. Before Jackson supported the @JsonProperty annotation on enum values, the above was also the way to (de)serialize enums to other strings, this feature would extend the functionality of the @JsonProperty annotation to integers and potentially other types.

I originally posted this idea in the Ideas section of the main Jackson repo, where cowtowncoder suggested filing a feature request here.

I would like to see something like that be supported in some way or form.

Edit:
I'd like to emphasize that I want this feature to work even if the integer values from the example would be some values other than the ordinal values, i.e. the following would also work:

public enum State {

    @JsonProperty("17") OFF,

    @JsonProperty("31") ON,

    @JsonProperty("99") UNKNOWN
}

And the enum, when serialized as integers, would be serialized with integers 17, 31 and 99.

@kistlers kistlers added the to-evaluate Issue that has been received but not yet evaluated label Aug 23, 2022
@yawkat
Copy link
Member

yawkat commented Aug 23, 2022

so basically the combination of @JsonProperty and @JsonFormat on enum should let the property value be coerced to the type specified by the format?

@kistlers
Copy link
Author

kistlers commented Aug 23, 2022

Yes, @yawkat, this is would be my first idea that would use existing annotations. I could also imagine some other annotation or argument to @JsonProperty that allows arguments of type other than String.

@yihtserns
Copy link
Contributor

@kistlers what version of Jackson were you using?

@kistlers
Copy link
Author

kistlers commented Apr 8, 2023

@yihtserns I suspect at this time we were using jackson within Spring Boot version ~ 2.7.0, so I suspect ~ jackson 2.13.3, as this version was bundled at this time.

@yihtserns
Copy link
Contributor

yihtserns commented Apr 8, 2023

Context

First of all, I just want to note that the best answer for https://stackoverflow.com/questions/37833557 should've been https://stackoverflow.com/a/56285491/229654, because the question author wrote:

I would like it to be 0, which is the ordinal value of OFF in the enum State.

As mentioned by the (supposed to be best) answer, to make Jackson de/serialize an enum value as its ordinal number, either annotate the enum class like this:

@JsonFormat(shape = JsonFormat.Shape.NUMBER) // or NUMBER_INT
public enum State {
    OFF,
    ON,
    UNKNOWN
}

...or annotate the property like this:

public record Pojo(@JsonFormat(shape = JsonFormat.Shape.NUMBER) State state) { // or NUMBER_INT
}

...both will result in:

objectMapper.writeValueAsString(new Pojo(State.OFF)); // {"state":0}
objectMapper.writeValueAsString(new Pojo(State.ON)); // {"state":1}
objectMapper.writeValueAsString(new Pojo(State.UNKNOWN)); // {"state":2}

Knowing the above, I re-tested using Jackson 2.13.3, and I see the same behaviour i.e. using JsonFormat.shape=NUMBER on either the enum type or a property will result in using the enum value's ordinal number.

Is that not already the behaviour this issue wants? Unless what you want is NOT the ordinal number, but your own custom number?

@kistlers
Copy link
Author

kistlers commented Apr 8, 2023

Yes, @yihtserns, you are indeed correct. I want it to be any number, not just it's ordinal value. I know the functionality you suggested that would use the ordinal value. I realise now that my given example unfortunately suggests that, please check my edit to the original issue.

@yihtserns
Copy link
Contributor

yihtserns commented Apr 8, 2023

OK after digging through the history a bit, here's what I understand:

  1. Usage of @JsonProperty on enum value is to enable "alternative enum name" (Specifying Enum value serialization using @JsonProperty #677).
  2. Usage of @JsonValue on enum property is to enable "alternative REPRESENTATION" e.g. non-ordinal number (703bf4a).

For the 2nd one, the commit message said:

Make @JsonValue the canonical serialization of Enums, so that deserializer also uses it...

JsonValue's javadoc also said:

NOTE: when use for Java enums, one additional feature is that value returned by annotated method is also considered to be the value to deserialize from...


Now knowing the above, I tested this:

public enum State {
    OFF(17),
    ON(31),
    UNKNOWN(99);

    private int value;
    State(int value) { this.value = value; }
    
    @JsonValue public int value() { return this.value; }
}
...

// Serialize
mapper.writeValueAsString(new Pojo(State.OFF)); // {"state":17}
mapper.writeValueAsString(new Pojo(State.ON)); // {"state":31}
mapper.writeValueAsString(new Pojo(State.UNKNOWN)); // {"state":99}

// Deserialize
mapper.readValue("{\"state\":17}", Pojo.class); // Pojo[state=OFF]
mapper.readValue("{\"state\":31}", Pojo.class); // Pojo[state=ON]
mapper.readValue("{\"state\":99}", Pojo.class); // Pojo[state=UNKNOWN]

// Try to use ordinal number
mapper.readValue("{\"state\":0}", Pojo.class); // InvalidFormatException...not one of the values accepted for Enum class: [99, 17, 31]

Seems like a JsonCreator factory method is not necessary at all, which I believe is the main motivation for opening this issue?

(I did find some complaints about needing JsonCreator factory method to make things work (#1850), but it was a (fixed) bug.)

@kistlers
Copy link
Author

kistlers commented Apr 8, 2023

Oh, this is new to me. I suppose I never fully read the JavaDoc of @JsonValue to find this behaviour (Probably because I simply would not have expected it). Thanks for digging it up!

I think this does exactly what I wanted this Issue to achieve since the @JsonCreator method in the first example exactly reverses the mapping of the @JsonValue method.

I just tested this and it then also again plays nicely with @JsonFormat(shape = JsonFormat.Shape.STRING) or similar:

record PojoString(@JsonFormat(shape = JsonFormat.Shape.STRING) State state) {}

// Serialize as string
mapper.writeValueAsString(new PojoString(State.OFF)); // {"state":"17"}
mapper.writeValueAsString(new PojoString(State.ON)); // {"state":"31"}
mapper.writeValueAsString(new PojoString(State.UNKNOWN)); // {"state":"99"}

// Deserialize as string
mapper.readValue("{\"state\":\"17\"}", PojoString.class); // PojoString[state=OFF]
mapper.readValue("{\"state\":\"31\"}", PojoString.class); // PojoString[state=ON]
mapper.readValue("{\"state\":\"99\"}", PojoString.class); // PojoString[state=UNKNOWN]

as one would expect @jsonformat to work anyway.

And I also realized at some point in the last 8 months that the getter is also not required, one can also just annotate the field directly and all the tests still pass:

public enum State {
    OFF(17),
    ON(31),
    UNKNOWN(99);

    @JsonValue private int value;
    State(int value) { this.value = value; }
}

My original idea would have also removed the need for the private field and the @JsonValue annotation. But not requiring the @JsonCreator method is already a large improvement in my opinion.

@cowtowncoder cowtowncoder added enum Related to handling of Enum values and removed to-evaluate Issue that has been received but not yet evaluated labels Apr 9, 2023
@cowtowncoder
Copy link
Member

Yeah, handling of @JsonValue wrt Enum has been improved over time; Javadoc comments may be quite new as well.
Enums are tricky, especially wrt ordinals (and esp. if considering "stringified numbers").
But hopefully things work out better now.

@kistlers
Copy link
Author

As far as I understand it now, using @JsonProperty can be considered a shortcut notation compared to using @JsonValue with a String field on the enum. In that case, I would expect the following enums to (de)serialize in the same way, when applying @JsonFormat(shape = JsonFormat.Shape.NUMBER) to the field:

enum StateField {
        OFF("17"),
        ON("31"),
        UNKNOWN("99");

        @JsonValue private final String value;

        StateField(String value) {
            this.value = value;
        }
    }

    enum StateProperty {
        @JsonProperty("17") OFF,
        @JsonProperty("31") ON,
        @JsonProperty("99") UNKNOWN
    }

    record Pojo(
            @JsonFormat(shape = JsonFormat.Shape.NUMBER) StateField stateField,
            @JsonFormat(shape = JsonFormat.Shape.NUMBER) StateProperty stateProperty) {}

However, they do not:

    // Serialize
        mapper.writeValueAsString(new Pojo(StateField.OFF, StateProperty.OFF)); // {"stateField":"17", "stateProperty":0}
        mapper.writeValueAsString(new Pojo(StateField.ON, StateProperty.ON)); // {"stateField":"31", "stateProperty":1}
        mapper.writeValueAsString(new Pojo(StateField.UNKNOWN, StateProperty.UNKNOWN)); // {"stateField":"99", "stateProperty":2}

        // Deserialize
        mapper.readValue("{\"stateField\":\"17\",\"stateProperty\":0}", Pojo.class); // Pojo[stateField=OFF,stateProperty=UNKNOWN]
        mapper.readValue("{\"stateField\":\"31\",\"stateProperty\":1}", Pojo.class); // Pojo[stateField=ON,stateProperty=UNKNOWN]
        mapper.readValue("{\"stateField\":\"99\",\"stateProperty\":2}", Pojo.class); // Pojo[stateField=UNKNOWN,stateProperty=UNKNOWN]

On the first enum, the annotation is ignored, as Jackson does not coerce the String value to an int. But in the second enum, the ordinal value is used. I suppose this is expected behaviour, yet somewhat surprising to me.

@cowtowncoder
Copy link
Member

Coercions/conversions between Numbers and Strings are tricky... and in a way I wish there was no default support for (de)serializing Enums by index (without some sort of explicit configuration).
So existing behavior is defined in many cases as "that's how it is implemented" instead of proper definitions.

But I will note one thing: @JsonValue behavior is not quite like that of @JsonProperty -- with POJOs they are very distinct. @JsonValue "replaces" serialization completely. That doesn't really explain difference you are seeing, but I mention this as background. It would play a big role of @JsonValue annotated field or getter had type of int tho, in which case those values should be used instead of Enum index.

Conversely intent with @JsonProperty is to give an alternate String, so it cannot really be used to specify number (stringified or otherwise) for Enum -- internally number for Enums used would still be index.
But I can see why from user perspective this does not make much difference, and passing Stringified number (because @JsonProperty can not take int value) is the thing to use, along with shape.

I just don't know if:

  1. It is possible to define consistent set of rules to indicate desired way everything should work together, and
  2. ... to implement (1)

since everything is sort of cobbled together from separate pieces of functionality.

@kistlers
Copy link
Author

Thanks for the detailed answer, I appreciate it. I expected something along those lines to explain the observed behaviour.

I suppose we can close this issue then?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enum Related to handling of Enum values
Projects
None yet
Development

No branches or pull requests

4 participants