/** * 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.base.Defaults; import com.google.common.collect.Maps; import com.google.common.primitives.Primitives; 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" ) || methodName.startsWith( "is" ) ) { 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(); 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 ); } } if ( !value.getClass().isArray() ) { ( (Collection) data.get( pp.getMongoName() ) ).add( value ); } else { for ( Object val : (Object[]) value ) { ( (Collection) data.get( pp.getMongoName() ) ).add( val ); } } } /** * 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 { String name = property.getMongoName(); // add default value, in case nothing has been set yet if ( !data.containsKey( name ) && property.isPrimitiveType() ) { data.put( name, Defaults.defaultValue( Primitives.unwrap( property.getType() ) ) ); } return data.get( name ); } } /** * 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; } }