FasterXML / jackson-databind

General data-binding package for Jackson (2.x): works on streaming API (core) implementation(s)
Apache License 2.0
3.53k stars 1.38k forks source link

Relaxed name-handling for deserializer (beyond case-insensitivity) #2954

Open Hronom opened 3 years ago

Hronom commented 3 years ago

Is your feature request related to a problem? Please describe. Sometimes need to do relaxed deserialization to bean. Like here https://stackoverflow.com/questions/55846834/relaxed-fields-names-for-jackson

Describe the solution you'd like Have worked solution, want to merge to core lib and provide possibility to enable it by deserialization property(feature). Solution:

// Inspired by https://stackoverflow.com/a/55856242/285571
public class RelaxedBeanDeserializer extends BeanDeserializer {
    private final Map<String, String> relaxedBeanProperties = new HashMap<>();

    public RelaxedBeanDeserializer(BeanDeserializerBase src) {
        super(src);
        _beanProperties.forEach(property -> {
            relaxedBeanProperties.put(property.getName().toLowerCase(), property.getName());
        });
    }

    // Sync realization with root class
    @Override
    public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
        // common case first
        if (p.isExpectedStartObjectToken()) {
            if (_vanillaProcessing) {
                return vanillaDeserialize(p, ctxt, p.nextToken());
            }
            // 23-Sep-2015, tatu: This is wrong at some many levels, but for now... it is
            //    what it is, including "expected behavior".
            p.nextToken();
            if (_objectIdReader != null) {
                return deserializeWithObjectId(p, ctxt);
            }
            return deserializeFromObject(p, ctxt);
        }
        return _deserializeOther(p, ctxt, p.getCurrentToken());
    }

    // Sync realization with root class
    private Object vanillaDeserialize(JsonParser p, DeserializationContext ctxt, JsonToken t) throws IOException {
        final Object bean = _valueInstantiator.createUsingDefault(ctxt);
        // [databind#631]: Assign current value, to be accessible by custom serializers
        p.setCurrentValue(bean);
        if (p.hasTokenId(JsonTokenId.ID_FIELD_NAME)) {
            String propName = p.getCurrentName();
            do {
                String relaxedPropName = getRelaxedName(propName);
                p.nextToken();
                String beanPropName = relaxedBeanProperties.get(relaxedPropName);
                if (beanPropName != null) {
                    SettableBeanProperty prop = _beanProperties.find(beanPropName);

                    if (prop != null) { // normal case
                        try {
                            prop.deserializeAndSet(p, ctxt, bean);
                        } catch (Exception e) {
                            wrapAndThrow(e, bean, relaxedPropName, ctxt);
                        }
                        continue;
                    }
                }
                handleUnknownVanilla(p, ctxt, bean, relaxedPropName);
            }
            while ((propName = p.nextFieldName()) != null);
        }
        return bean;
    }

    private String getRelaxedName(String name) {
        return name.replaceAll("[_\\-]", "").toLowerCase();
    }
}
// Inspired by https://stackoverflow.com/a/55856242/285571
public class RelaxedBeanDeserializerModifier extends BeanDeserializerModifier {

    @Override
    public JsonDeserializer<?> modifyDeserializer(DeserializationConfig config, BeanDescription beanDesc, JsonDeserializer<?> deserializer) {
        JsonDeserializer<?> base = super.modifyDeserializer(config, beanDesc, deserializer);
        if (base instanceof BeanDeserializer) {
            return new RelaxedBeanDeserializer((BeanDeserializer) base);
        }
        return base;
    }
}
public class JsonApp {

    public static void main(String[] args) throws Exception {
        File jsonFile = new File("./resource/test.json").getAbsoluteFile();

        SimpleModule relaxedModule = new SimpleModule();
        relaxedModule.setDeserializerModifier(new RelaxedBeanDeserializerModifier());

        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule(relaxedModule);

        System.out.println(mapper.readValue(jsonFile, DeserializeIt.class));
    }
}

Usage example https://stackoverflow.com/q/55846834/285571

Thank you in advance, this will be very useful feature!

cowtowncoder commented 3 years ago

I suspect code above would fail in many cases, as it only covers "vanilla" (optimized) processing, but the general idea sounds reasonable. I'll tag this with 2.13 since we are past 2.12.0-rc2 so no new features can go in 2.12; this gives more time to consider aspects.

I think I would suggest bit different approach on where to handle mapping, however; instead of doing it in BeanDeserializer (sub-classing of which is problematic, due to various reasons), I'd consider checking if BeanPropertyMap could be changed -- it already handles aliases, as well as case-insensitive lookups. Within that code I'd go with existing logic of using given key as-is first, and only if no match is found, going for secondary lookup structure (with modified key, target). This could even take handler that defines what exactly relaxation means (process of getting canonical "simplified" version of property name). With that, feature would probably be enabled via JsonMapper.builder() (see MapperBuilder); I could help with configuration aspects.

Hronom commented 3 years ago

@cowtowncoder thank you, but keep in mind it will be cool if in 2.13 it will be possible to enable it by simple:

        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.configure(DeserializationFeature.RELAXED, true);
cowtowncoder commented 3 years ago

@Hronom question of API for users is one thing, and ability to have global default sounds reasonable (although there are a few details that matter -- for technical reasons this might have to be MapperFeature). But I think that as long as there is a way to implement this on per-POJO basis, similar to case-sensitivity, figuring out how to generalize is possible. There are a few approaches, including @JsonFormat options (see JsonFormat.Feature flags), since those can be applied using "configOverrides()" as well (so either as annotations or programmatically associating), and some of settings even have global defaults available.

I am also not quite convinced that there is just one "relaxed" mode: my experience suggests that as soon as one definition is implemented there will be user request for particular tweaks for names. It seems possible that question of relaxed/non-relaxed can be separated from "relaxizer" (logic/strategy for creating "relaxed" names to compare against); and that there is a default "relaxizer" to use unless custom one specified.