unitsofmeasurement / uom-lib

Units of Measurement Libraries
Other
14 stars 13 forks source link

Jackson deserialization for `Quantity` interface does not exist #60

Closed lucasvc closed 2 years ago

lucasvc commented 4 years ago

I have recently discovered and started to use all the Units of Measurement library echosystem. Now I wanted to serialize/deserialize some Quantity's instances, but I have realised that is not possible to deserialize them.

The following JUnit 5 (with pojo class included)

class ChargeDeserializationTest
{

    static final ObjectMapper mapper = new ObjectMapper();
    static
    {
        mapper.registerModule(new UnitJacksonModule())
            .registerModules(new JavaTimeModule());
    }

    @Test
    public void roundtrip() throws Exception
    {
        Charge charge = new Charge();
        charge.setWhen(Instant.ofEpochMilli(nextLong()));
        charge.setRunAlong(Quantities.getQuantity(nextLong(), Units.METRE));
        charge.setWith(Quantities.getQuantity(nextLong(), Units.CUBIC_METRE));

        String serialization = mapper.writeValueAsString(charge);
        Charge deserialization = mapper.readValue(serialization, Charge.class);
    }

    static class Charge
    {

        private Instant when;
        private Quantity<Length> runAlong;
        private Quantity<Volume> with;

        public Instant getWhen()
        {
            return when;
        }

        public void setWhen(final Instant when)
        {
            this.when = when;
        }

        public Quantity<Length> getRunAlong()
        {
            return runAlong;
        }

        public void setRunAlong(final Quantity<Length> runAlong)
        {
            this.runAlong = runAlong;
        }

        public Quantity<Volume> getWith()
        {
            return with;
        }

        public void setWith(final Quantity<Volume> with)
        {
            this.with = with;
        }

    }

}

throws the exception,

Cannot construct instance of `javax.measure.Quantity` (no Creators, like default construct, exist): abstract types either need to be mapped to concrete types, have custom deserializer, or contain additional type information
 at [Source: (String)"{"when":4484040231309519.872000000,"runAlong":{"unit":"m","scale":"ABSOLUTE","value":3535414706998429696},"with":{"unit":"m3","scale":"ABSOLUTE","value":6316882200111963136}}"; line: 1, column: 47] (through reference chain: com.github.lucasvc.uom.lib.jackson.quantity.deserialize.ChargeDeserializationTest$Charge["runAlong"])
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `javax.measure.Quantity` (no Creators, like default construct, exist): abstract types either need to be mapped to concrete types, have custom deserializer, or contain additional type information
 at [Source: (String)"{"when":4484040231309519.872000000,"runAlong":{"unit":"m","scale":"ABSOLUTE","value":3535414706998429696},"with":{"unit":"m3","scale":"ABSOLUTE","value":6316882200111963136}}"; line: 1, column: 47] (through reference chain: com.github.lucasvc.uom.lib.jackson.quantity.deserialize.ChargeDeserializationTest$Charge["runAlong"])
    at com.fasterxml.jackson.databind.exc.InvalidDefinitionException.from(InvalidDefinitionException.java:67)
    at com.fasterxml.jackson.databind.DeserializationContext.reportBadDefinition(DeserializationContext.java:1589)
    at com.fasterxml.jackson.databind.DeserializationContext.handleMissingInstantiator(DeserializationContext.java:1055)
    at com.fasterxml.jackson.databind.deser.AbstractDeserializer.deserialize(AbstractDeserializer.java:265)
    at com.fasterxml.jackson.databind.deser.impl.MethodProperty.deserializeAndSet(MethodProperty.java:129)
    at com.fasterxml.jackson.databind.deser.BeanDeserializer.vanillaDeserialize(BeanDeserializer.java:288)
    at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:151)
    at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:4202)
    at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3205)
    at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3173)
    at com.github.lucasvc.uom.lib.jackson.quantity.deserialize.ChargeDeserializationTest.roundtrip(ChargeDeserializationTest.java:40)
    ....
    at java.lang.Thread.run(Thread.java:748)
keilw commented 4 years ago

You're correct and I don't think you did something wrong. This Jackson library was ported from the now archived https://github.com/unitsofmeasurement/jackson-module-unitsofmeasure created by Opower before Oracle took it over. There are serializers and deserializers for Unit and Dimension, but so far they did not need or add one for a Quantity. The functionality was pretty much as-is, but if you'd like to contribute to enhancements, you are more than welcome.

lucasvc commented 4 years ago

Thanks for your reply @keilw. I've been on holidays, but I see you took it over :) Thanks²

keilw commented 4 years ago

I didn't start working on it, so help or PRs are appreciated if you or someone else had time to help. The existing functionality is pretty identical to what was donated by the ex Opower developer, they did not seem to have a use for quantity serialization, thus it did not exist so far. I also started working on a Jakarta JSON-B variant using Jakarta JSON standards, but that is also work in progress: https://github.com/unitsofmeasurement/uom-lib/tree/master/yasson

lucasvc commented 4 years ago

Oh sorry, I could not review all the commits and I assumed 😅 Then I will try to do some work on it.

daniel-shuy commented 3 years ago

I've started looking into this. The main issue is with ProductUnits (eg. Units#CUBIC_METRE).

The value that is used for serialization and deserialization must be the same.

For example, if we take a naive approach to serialize Units#CUBIC_METRE using SimpleUnitFormat#format(Unit), it returns . But if we then try to deserialize it using SimpleUnitFormat#parse(String), it will fail, because it expects m^3.

To work around this, to serialize/deserialize Units, UnitJacksonModule uses UCUMFormat instead of SimpleUnitFormat, which serializes/deserializes Units#CUBIC_METRE as m3.

Unfortunately, for Quantity, SimpleQuantityFormat uses SimpleUnitFormat internally, and there is no UCUM equivalent for it. The most obvious solution now would be to create an AbstractQuantityFormat that uses UCUMFormat internally instead of SimpleUnitFormat (eg. UCUMQuantityFormat), or to manually parse the String to separate the numeric value from the unit.

GregJohnStewart commented 2 years ago

Bump to this! A hard blocker on the project I am working on, need any help?

bantu commented 2 years ago

I hacked this together. However, this is unlikely a proper solution.

import java.io.IOException;
import java.math.BigDecimal;

import javax.measure.Quantity;
import javax.measure.Quantity.Scale;
import javax.measure.Unit;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;

import tech.units.indriya.quantity.Quantities;

@SuppressWarnings("rawtypes")
public class QuantityJsonDeserializer extends StdDeserializer<Quantity> {
    public QuantityJsonDeserializer() {
        super(Quantity.class);
    }

    @Override
    public Quantity deserialize(JsonParser jp, DeserializationContext deserializationContext)
        throws IOException, JsonProcessingException {
        JsonToken currentToken = null;
        BigDecimal value = null;
        Unit<?> unit = null;
        Scale scale = null;
        while ((currentToken = jp.nextValue()) != null) {
            switch (currentToken) {
                case VALUE_NUMBER_FLOAT:
                case VALUE_NUMBER_INT:
                    switch (jp.getCurrentName()) {
                        case "value":
                            value = jp.getDecimalValue();
                            break;
                        default:
                            break;
                    }
                    break;
                case VALUE_STRING:
                    switch (jp.getCurrentName()) {
                        case "unit":
                            unit = jp.readValueAs(Unit.class);
                            break;
                        case "scale":
                            scale = Scale.valueOf(jp.getText());
                            break;
                        default:
                            break;
                    }
                default:
                    break;
            }
        }
        return Quantities.getQuantity(value, unit, scale);
    }
}
keilw commented 2 years ago

Regardless of the maturiy, would you be able to turn that into a PR @bantu? Unlike the core JSR modules this one is free to contribute (as long as you abide to BSD license) without JCP membership, so any PR is welcome.

GregJohnStewart commented 2 years ago

I was in the midst of making my own deserializer, and this is along the lines of what I was attempting to come up with. I actually have a fork going that has a few other organization changes as well. I don't mind as long as I can deserialize Quantity, but thought I would mention! I can also wait for the simpler fix to get in, I can't test this anyways due to the other PR I posted on using Java 11

GregJohnStewart commented 2 years ago

Only thing I might add is error checking, to ensure all three items are actually found and appropriately deserialized before handing them to Quantities.getQuantity()

bantu commented 2 years ago

Regardless of the maturiy, would you be able to turn that into a PR @bantu? Unlike the core JSR modules this one is free to contribute (as long as you abide to BSD license) without JCP membership, so any PR is welcome.

PR opened: #72

Only thing I might add is error checking, to ensure all three items are actually found and appropriately deserialized before handing them to Quantities.getQuantity()

Feel free to add that to the PR.