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

Would you be interested in a `AntPathPropertyFilter`? #690

Closed Antibrumm closed 9 years ago

Antibrumm commented 9 years ago

Hi I asked once on StackOverflow if it's possible to filter the same bean differently depending on the path.

http://stackoverflow.com/questions/26648287/jackson2-propertyfilter-for-nested-properties-or-is-there-another-way

While I got no answer there I now found a way using a filter implementation. I wanted to use a least intrusive approach and desided to try to use filters.

Basically I would need some results like this: Example: user -> manager (user)

Filter Definition = ["id", "firstName", "lastName", "email", "manager", "manager.id", "manager.fullName"]

{
  "id" : 2,
  "firstName" : "John",
  "lastName" : "Doe",
  "email" : "someone@no.where",
  "manager" : {
    "id" : 1,
    "fullName" : "Jane Doe"
  }
}

Finally I have implemented it using the AntPath-approach and now I'm able to serialize the same structure in different ways just by changing the filter values.

The implementation is based on the SimpleBeanPropertyFilter but allows filtering by the complete path instead of just local attributes.

As I currently base the implementation on Spring's AntPathMatcher I would need to rewrite that part so that Jackson can use it independently. That's why I ask the question of interest first.

Supported features Inclusion of filtering by path

Exclusion

Now I can write some filters like this:

[ "*", "user.id", "user.login", "user.firstName", "user.lastName","user.email", "token", "authorities.**", "-**.password"]

Any interest?

cowtowncoder commented 9 years ago

Thank you! Asking the question first makes indeed sense.

From functionality perspective, I think users would like it, and I would like to see such an addition. So +1 from me on that.

Regarding notation to use, I wonder if use of JsonPointer might make more sense, just from consistency perspective. Use of * (and '**') would be an added thing of course, above and beyond JP. So perhaps it makes more sense to use distinct notation. This might be something to ask on user or dev mailing list.

But aside from that, I wonder how big an addition this would be; and how it should be packaged. Specifically, I wonder if there is any way this could be packaged as an additional library. If changes are small, this would not be needed. But ideally I would like to see more an more functionality to be external.

Antibrumm commented 9 years ago

Currently the implentation consists of 2 files:

There will be a third class to write which takes over the spring functionality.

I will add another post with the current implementation classes later once i have access to my machine.

Antibrumm commented 9 years ago

And here's the code:

@JsonFilter("antPathFilter")
@JsonIgnoreProperties({ "hibernateLazyInitializer", "handler" })
public class AntPathFilterMixin {
}
/**
 * Implementation that allows to set nested properties. The filter will use the parents from the context to identify if
 * a property has to be filtered.
 * 
 * Example: user -> manager (user)
 * 
 * "id", "firstName", "lastName", "manager.id", "manager.fullName"
 * 
 * { "id" : "2", "firstName" : "Martin", "lastName" : "Frey", manager : { "id" : "1", "fullName" :
 * "System Administrator"}}
 * 
 * @author Antibrumm
 */
public class AntPathPropertyFilter extends SimpleBeanPropertyFilter {

    /** The _properties to exclude. */
    protected final Set<String> _propertiesToExclude;

    /**
     * Set of property names to include.
     */
    protected final Set<String> _propertiesToInclude;

    /** The matcher. */
    private static final AntPathMatcher matcher = new AntPathMatcher(".");

    public AntPathPropertyFilter(final String... properties) {
        super();
        _propertiesToInclude = new HashSet<>(properties.length);
        _propertiesToExclude = new HashSet<>(properties.length);
        for (int i = 0; i < properties.length; i++) {
            if (properties[i].startsWith("-")) {
                _propertiesToExclude.add(properties[i].substring(1));
            } else {
                _propertiesToInclude.add(properties[i]);
            }
        }
    }

    private String getPathToTest(final PropertyWriter writer, final JsonGenerator jgen) {
        StringBuilder nestedPath = new StringBuilder();
        nestedPath.append(writer.getName());
        JsonStreamContext sc = jgen.getOutputContext();
        if (sc != null) {
            sc = sc.getParent();
        }
        while (sc != null) {
            if (sc.getCurrentName() != null) {
                if (nestedPath.length() > 0) {
                    nestedPath.insert(0, ".");
                }
                nestedPath.insert(0, sc.getCurrentName());
            }
            sc = sc.getParent();
        }
        return nestedPath.toString();
    }

    @Override
    protected boolean include(final BeanPropertyWriter writer) {
        throw new UnsupportedOperationException("Cannot call include without JsonGenerator");
    }

    @Override
    protected boolean include(final PropertyWriter writer) {
        throw new UnsupportedOperationException("Cannot call include without JsonGenerator");
    }

    protected boolean include(final PropertyWriter writer, final JsonGenerator jgen) {
        String pathToTest = getPathToTest(writer, jgen);
        if (_propertiesToInclude.isEmpty()) {
            for (String pattern : _propertiesToExclude) {
                if (matcher.match(pattern, pathToTest)) {
                    return false;
                }
            }
            return true;
        } else {
            boolean include = false;
            for (String pattern : _propertiesToInclude) {
                if (matcher.match(pattern, pathToTest)) {
                    include = true;
                    break;
                }
            }
            if (include && !_propertiesToExclude.isEmpty()) {
                for (String pattern : _propertiesToExclude) {
                    if (matcher.match(pattern, pathToTest)) {
                        return false;
                    }
                }
            }
            return include;
        }
    }

    @Override
    public void serializeAsField(final Object pojo, final JsonGenerator jgen, final SerializerProvider provider,
        final PropertyWriter writer) throws Exception {
        if (include(writer, jgen)) {
            writer.serializeAsField(pojo, jgen, provider);
        } else if (!jgen.canOmitFields()) { // since 2.3
            writer.serializeAsOmittedField(pojo, jgen, provider);
        }
    }
}
  public ObjectMapper getAntPathFilterCopy(final String... properties) {
        ObjectMapper copy = getCopy();
        copy.addMixIn(Object.class, AntPathMixin.class);
        if (properties != null && properties.length > 0) {
            copy.setFilters(createAntPathFilter(properties));
        } else {
            // by default serialize only 3 levels
            // just in case we forget the filter parameters 
            copy.setFilters(createAntPathFilter("*", "*.*", "*.*.*"));
        }
        return copy;
    }

    public FilterProvider createAntPathFilter(final String... properties) {
        return new SimpleFilterProvider().addFilter("antPathFilter", new AntPathPropertyFilter(properties));
    }

I'm not sure anymore why i copy the objectMapper each time i need a filtered version. Probably it's not neccessary (anymore)? I'm also pretty sure that this code is not optimised yet. The filter providers could be cached for example and potentially also the include / exclude paths.

cowtowncoder commented 9 years ago

Ah. Interesting. I like the idea, very clever -- was wondering how the hierarchical matching was to be handled.

I can see how inclusion could help, since the filter would need to be enabled globally, sort of, and while it is a perfectly valid use case for mix-ins (and on Object.class, which I specfically supported for use like this), it'd be nice to offer some other way to get the same effect. One possiblity would be via AnnotationIntrospector. But I also think that since it is something that is somewhat high overhead (it'll be significantly slower to serialize than without, due to path construction etc), it needs to be in some sort of extension.

I think the copy was created because mix-ins can not be dynamically added; but a single copy should really suffice, once filtering is enabled, and lookup code invoked for POJOs.

So. Thank you for sharing this code; I think it is very cool functionality, and I think users would love to see it. I will need to think a bit about how to proceed with it. Of course, if you wanted, it could first be a simple add-on project for Jackson, whether via ObjectMapper sub-class, or something else?

Antibrumm commented 9 years ago

Yes that's true, performance was not my main concern at the time i wrote this. It should be more of a productivity boost instead.

In case we need "real" performance we could always fall back to the standard implementation of jackson responses ;)

I could create a simple addon library for jackson under my name for now and once/if you like to integrate it we just merge it.

I will stick with the current implementation of filter in this case just for the sake of not having to configure the objectmapper too much just to use the feature.

It might be interesting to extend the writecontext and build the currentPath on each step. This way we would not need to traverse always to the rootscope, just to calculate the path. Should be a bit more efficient, but would need more config as there is currently no callback to do this kind of work.

cowtowncoder commented 9 years ago

Yes and a performance cost itself is not a problem, as long as "you pay for what you use". So there should be a way to enable costlier features.

I think the idea of starting with a standalone package, with current implementation (to work with existing Jackson version) is an excellent starting point.

And yes, write context is something I thought about and would make sense I'd do it using a (linked) list (not building String, since that is relatively expensive way). One challenge there is that some other backends (non-JSON) also extend implementation, so it is not necessarily enough to do it just for JSON backend.

Antibrumm commented 9 years ago

Done :) https://github.com/Antibrumm/jackson-antpathfilter

I don't have it deployed into a maven repository yet.

cowtowncoder commented 9 years ago

Great! I added a link to this from https://github.com/FasterXML/jackson, and will tweet as well.