com.github.cherimojava.data.mongo.entity.EntityInvocationHandler.java Source code

Java tutorial

Introduction

Here is the source code for com.github.cherimojava.data.mongo.entity.EntityInvocationHandler.java

Source

/**
 * Copyright (C) 2013 cherimojava (http://github.com/cherimojava/cherimodata)
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *         http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.github.cherimojava.data.mongo.entity;

import static com.github.cherimojava.data.mongo.entity.Entity.ID;
import static com.github.cherimojava.data.mongo.entity.EntityFactory.getDefaultClass;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import static java.lang.String.format;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.Collection;
import java.util.Map;

import org.apache.commons.lang3.builder.HashCodeBuilder;
import org.bson.BsonDocument;
import org.bson.BsonDocumentWrapper;
import org.bson.Document;
import org.bson.codecs.ValueCodecProvider;
import org.bson.codecs.configuration.CodecRegistries;
import org.bson.codecs.configuration.CodecRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.github.cherimojava.data.mongo.io.EntityCodec;
import com.google.common.collect.Maps;
import com.mongodb.client.MongoCollection;
import com.mongodb.client.MongoCursor;
import com.mongodb.client.model.UpdateOptions;
import com.mongodb.client.result.UpdateResult;

/**
 * Proxy class doing the magic for Entity based Interfaces
 *
 * @author philnate
 * @since 1.0.0
 */
class EntityInvocationHandler implements InvocationHandler {

    private static final Logger LOG = LoggerFactory.getLogger(EntityInvocationHandler.class);

    // TODO should be its own class
    /* registry containing information about codecs for encoding ids */
    private static CodecRegistry idRegistry = CodecRegistries.fromProviders(new ValueCodecProvider());

    /**
     * holds the properties backing this entity class
     */
    private final EntityProperties properties;

    /**
     * Mongo Collection to which this Entity is being save. Might be null, in which case it's not possible to perform
     * any MongoDB using operations like .save() on this entity
     */
    private final MongoCollection collection;

    /**
     * reference to the Proxy, which we're baking
     */
    private Entity proxy;

    /**
     * can this entity be modified or not. Obviously we can only block changes coming through setter of the entity, not
     * for Objects already set here
     */
    private boolean sealed = false;

    /**
     * was this object already saved or not
     */
    boolean persisted = false;

    /**
     * tells if this entity is lazy loaded or not.
     */
    private boolean lazy = false;

    /**
     * will be true if the entity is in the process of being saved, false otherwise
     */
    private volatile boolean saving = false;

    /**
     * holds the actual data of the Entity
     */
    Map<String, Object> data;

    /**
     * creates a new Handler for the given EntityProperties (Entity class). No Mongo reference will be created meaning
     * Mongo based operations like (.save()) are not supported
     *
     * @param properties
     *            EntityProperties for which a new Instance shall be created
     */
    public EntityInvocationHandler(EntityProperties properties) {
        this(properties, null);
    }

    /**
     * creates a new Handler for the givne EntityProperties (Entity class), Entity will be saved to the given
     * MongoCollection.
     *
     * @param properties
     *            EntityProperties for which a new Instance shall be created
     * @param collection
     *            MongoCollection to which the entity will be persisted to
     */
    public EntityInvocationHandler(EntityProperties properties, MongoCollection collection) {
        this.properties = properties;
        data = Maps.newHashMap();
        this.collection = collection;
    }

    /**
     * creates a new handler and marks it as lazy. Only setting the id. Everything else will be loaded once an
     * interaction with this object happens
     * 
     * @param properties
     * @param collection
     * @param id
     */
    public EntityInvocationHandler(EntityProperties properties, MongoCollection collection, Object id) {
        this(properties, collection);
        lazy = true;
        _put(properties.getProperty(Entity.ID), id);
    }

    /**
     * actual method which is invoked once the lazy entity is about to be filled with life
     */
    private void lazyLoad() {
        if (lazy) {
            data = ((EntityInvocationHandler) Proxy.getInvocationHandler(find(collection, data.get(ID)))).data;
            lazy = false;
        }
    }

    /**
     * Method which is actually invoked if a proxy method is being called. Used as dispatcher to actual methods doing
     * the work
     */
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        String methodName = method.getName();
        ParameterProperty pp;

        switch (methodName) {
        case "get":
            pp = checkPropertyExists((String) args[0]);
            if (!ID.equals(pp.getMongoName())) {
                // lazy loading isn't needed for the ID itself
                lazyLoad();
            }
            return _get(pp);// we know that this is a string param
        case "set":
            lazyLoad();
            _put(checkPropertyExists((String) args[0]), args[1]);
            return proxy;
        case "save":
            checkState(collection != null,
                    "Entity was created without MongoDB reference. You have to save the entity through an EntityFactory");
            lazyLoad();
            if (!saving) {
                saving = true;// mark that we're about to save to break potential cycles
                // TODO create for accessable Id some way to get it validated through validator
                if (properties.hasExplicitId()) {
                    // TODO we can release this if it's of type ObjectId
                    checkNotNull(data.get(ID), "An explicit defined Id must be set before saving");
                }
                save(this, collection);
                // change state only after successful saving to Mongo
                saving = false;// we're done with saving next one, can write object. Which isn't coming from within this
                               // instance
                return true;
            } else {
                LOG.info("Did not save Entity with id {} of class {} as it's cyclic called.", data.get(ID),
                        properties.getEntityClass());
                return false;
            }
        case "drop":
            checkState(collection != null,
                    "Entity was created without MongoDB reference. You have to drop the entity through an EntityFactory");
            drop(this, collection);
            return null;
        case "equals":
            lazyLoad();
            return _equals(args[0]);
        case "seal":
            sealed = true;
            return null;
        case "entityClass":
            return properties.getEntityClass();
        case "toString":
            lazyLoad();
            return _toString();
        case "hashCode":
            lazyLoad();
            return _hashCode();
        case "load":
            checkState(collection != null,
                    "Entity was created without MongoDB reference. You have to load entities through an EntityFactory");
            return find(collection, args[0]);
        }

        lazyLoad();
        pp = properties.getProperty(method);
        if (methodName.startsWith("get")) {
            return _get(pp);
        }
        if (methodName.startsWith("set")) {
            _put(pp, args[0]);
            // if we want this to be fluent we need to return this
            if (pp.isFluent(ParameterProperty.MethodType.SETTER)) {
                return proxy;
            }
        }
        if (methodName.startsWith("add")) {
            // for now we know that there's only one parameter
            _add(pp, args[0]);
            // if we want this to be fluent we need to return this
            if (pp.isFluent(ParameterProperty.MethodType.ADDER)) {
                return proxy;
            }
        }
        return null;
    }

    /**
     * verifies that the entity isn't sealed, if the entity is sealed no further modification is allowed and an
     * IllegalArgumentException is thrown
     */
    private void checkNotSealed() {
        checkArgument(!sealed, "Entity is sealed and does not allow further modification");
    }

    /**
     * verifies that the given property isn't final and if it's that the entity wasn't saved yet.
     * 
     * @param pp
     *            parameterproperty to check for final
     */
    private void checkNotFinal(ParameterProperty pp) {
        if (pp.isFinal()) {
            checkState(!persisted, "Entity was already saved, can't modify value of @Final property later on");
        }
    }

    /**
     * check if the given propertyName exists as Mongo property
     *
     * @param propertyName
     *            mongoDB name of property to check
     * @return ParameterProperty belonging to the given PropertyName
     * @throws java.lang.IllegalArgumentException
     *             if the given PropertyName isn't declared
     */
    private ParameterProperty checkPropertyExists(String propertyName) {
        ParameterProperty pp = properties.getProperty(propertyName);
        checkArgument(pp != null, "Unknown property %s, not declared for Entity %s", propertyName,
                properties.getEntityClass());
        return pp;
    }

    @SuppressWarnings("unchecked")
    private void _add(ParameterProperty pp, Object value) {
        checkNotSealed();
        if (data.get(pp.getMongoName()) == null) {
            try {
                if (getDefaultClass(pp.getType()) != null) {
                    Collection coll = (Collection) getDefaultClass(pp.getType()).newInstance();
                    coll.add(value);
                    data.put(pp.getMongoName(), coll);
                } else {
                    throw new IllegalStateException(
                            format("Property is of interface %s, but no suitable implementation was registered",
                                    pp.getType()));
                }
            } catch (InstantiationException | IllegalAccessException e) {
                throw new IllegalStateException("The impossible happened. Could not instantiate Class", e);
            }
        } else {
            ((Collection) data.get(pp.getMongoName())).add(value);
        }
    }

    /**
     * Does put operation, Verifies that Entity isn't sealed and that given value matches property constraints.
     *
     * @param pp
     *            property to set
     * @param value
     *            new value of the property
     * @throws java.lang.IllegalArgumentException
     *             if the entity is sealed and doesn't allow further modifications
     */
    private void _put(ParameterProperty pp, Object value) {
        checkNotSealed();
        checkNotFinal(pp);
        pp.validate(value);
        data.put(pp.getMongoName(), value);
    }

    /**
     * Returns the currently assigned value for the given Property or null if the property currently isn't set
     *
     * @param property
     *            to get value from
     * @return value of the property or null if the property isn't set. Might return null if the property is computed
     *         and the computer returns null
     */
    @SuppressWarnings("unchecked")
    private Object _get(ParameterProperty property) {
        if (property.isComputed()) {
            // if this property is computed we need to calculate the value for it
            return property.getComputer().compute(proxy);
        } else {
            return data.get(property.getMongoName());
        }
    }

    /**
     * equals method of the entity represented by this EntityInvocationHandler instance. Objects are considered unequal
     * (false) if o is:
     * <ul>
     * <li>null
     * <li>no Proxy
     * <li>different Proxy class
     * <li>Different Entity class
     * <li>Data doesn't match
     * </ul>
     * If all the above is false both entities are considered equal and true will be returned
     *
     * @param o
     *            object to compare this instance with
     * @return true if both objects match the before mentioned criteria otherwise false
     */
    private boolean _equals(Object o) {
        if (o == null) {
            return false;
        }
        if (!Proxy.isProxyClass(o.getClass())) {
            // for all non proxies we know that we can return false
            return false;
        }
        InvocationHandler ihandler = Proxy.getInvocationHandler(o);
        if (!ihandler.getClass().equals(getClass())) {
            // for all proxies not being EntityInvocationHandler return false
            return false;
        }
        EntityInvocationHandler handler = (EntityInvocationHandler) ihandler;
        if (!handler.properties.getEntityClass().equals(properties.getEntityClass())) {
            // this is not the same entity class, so false
            return false;
        }
        // make sure both have all lazy dependencies resolved
        lazyLoad();
        handler.lazyLoad();
        return data.equals(handler.data);
    }

    /**
     * hashCode method of the entity represented by this EntityInvocationHandler instance
     *
     * @return hashCode of this Entity
     */
    private int _hashCode() {
        HashCodeBuilder hcb = new HashCodeBuilder();
        for (Object key : data.values()) {
            hcb.append(key);
        }
        return hcb.build();
    }

    /**
     * toString method of the entity represented by this EntityInvocationHandler instance. String is JSON representation
     * of the current state of the Entity
     *
     * @return JSON representation of the Entity
     */
    private String _toString() {
        return new EntityCodec<>(null, properties).asString(proxy);
    }

    /**
     * stores the given EntityInvocationHandler represented Entity in the given Collection
     *
     * @param handler
     *            EntityInvocationHandler (Entity) to save
     * @param coll
     *            MongoCollection to save entity into
     */
    @SuppressWarnings("unchecked")
    static <T extends Entity> void save(EntityInvocationHandler handler, MongoCollection<T> coll) {
        for (ParameterProperty cpp : handler.properties.getValidationProperties()) {
            cpp.validate(handler.data.get(cpp.getMongoName()));
        }
        BsonDocumentWrapper wrapper = new BsonDocumentWrapper<>(handler.proxy,
                (org.bson.codecs.Encoder<Entity>) coll.getCodecRegistry().get(handler.properties.getEntityClass()));
        UpdateResult res = coll.updateOne(
                new BsonDocument("_id",
                        BsonDocumentWrapper.asBsonDocument(EntityCodec._obtainId(handler.proxy), idRegistry)),
                new BsonDocument("$set", wrapper), new UpdateOptions());
        if (res.getMatchedCount() == 0) {
            // TODO this seems too nasty, there must be a better way.for now live with it
            coll.insertOne((T) handler.proxy);
        }
        handler.persist();
    }

    /**
     * removes the given EntityInvocationHandler represented Entity from the given Collection
     *
     * @param handler
     *            EntityInvocationHandler (Entity) to drop
     * @param coll
     *            MongoCollection in which this entity is saved
     */
    static <T extends Entity> void drop(EntityInvocationHandler handler, MongoCollection<T> coll) {
        coll.findOneAndDelete(new Document(ID, (handler.proxy).get(ID)));
    }

    /**
     * searches for the given Id within the MongoCollection and returns, if the id was found the corresponding entity.
     * If the entity wasn't found null will be returned
     *
     * @param collection
     *            where the entity class is stored in
     * @param id
     *            of the entity to load
     * @param <T>
     *            Type of the entity
     * @return returns the entity belonging to the given Id within the collection or null if no such entity exists in
     *         the given collection
     */
    @SuppressWarnings("unchecked")
    static <T extends Entity> T find(MongoCollection<T> collection, Object id) {
        try (MongoCursor<? extends Entity> curs = collection.find(new Document(Entity.ID, id)).limit(1)
                .iterator()) {
            return (T) ((curs.hasNext()) ? curs.next() : null);
        }
    }

    /**
     * returns the {@link com.github.cherimojava.data.mongo.entity.EntityInvocationHandler} of the given entity
     * 
     * @param e
     *            entity to retrieve handler from
     * @return EntityInvocationHandler the entity is baked by
     */
    public static EntityInvocationHandler getHandler(Entity e) {
        return (EntityInvocationHandler) Proxy.getInvocationHandler(checkNotNull(e));
    }

    /**
     * sets the proxy this handler backs, needed for internal work
     *
     * @param proxy
     *            this handler is for, allows internal component to get access to outside view of itself
     */
    void setProxy(Entity proxy) {
        checkArgument(this.proxy == null, "Proxy for Handler can be only set once");
        this.proxy = proxy;
    }

    /**
     * marks that the given entity is persisted
     */
    public void persist() {
        persisted = true;
    }
}