/*
* Copyright (C) 2011 Laurent Caillette
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation, either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.novelang.outfit;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.Proxy;
import java.util.Map;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Maps;
/**
* Creates an immutable object with chainable mutators.
*
* Given such an interface which pairs getters and copy-on-change operators:
* <pre>
public interface Vanilla {
String getString() ;
Vanilla withString( String newString ) ;
int getInt() ;
Vanilla withInt( int newInt ) ;
float getFloat() ;
Vanilla withFloat( float newFloat ) ;
}</pre>
* Basing on {@code get} and {@code with} prefixes and type similarity, the {@link Husk#create(Class)}
* method generates an object instance behaving as one could expect:
* <pre>
final Vanilla initial = Husk.create( Vanilla.class ) ;
final Vanilla updated = initial.withInt( 1 ).withString( "Foo" ).withFloat( 2.0f ) ;
assertEquals( "Foo", updated.getString() ) ;
</pre>
* When it's convenient to create one object with several parameters, the
* {@link Converter#converterClass()} annotation indicates a class containing static methods
* for conversion.
* <pre>
@Husk.Converter( converterClass = SomeConverter.class )
public interface Convertible {
String getString() ;
Convertible withString( int i, float f ) ;
}
@SuppressWarnings( { "UnusedDeclaration" } )
public static final class SomeConverter {
public static String convert( final int i, final float f ) {
return "" + i + ", " + f ;
}
}
</pre>
* One known problem is, such static method appear as never called.
* The {@code @SuppressWarnings( { "UnusedDeclaration" } )} may save some warnings, then.
* <p>
* Another limitation: for a given graph of interfaces, only one {@link Converter} is taken
* in account.
*
*
* @author Laurent Caillette
*/
public final class Husk {
private Husk() { }
public static< T > T create( final Class< T > huskClass ) {
Preconditions.checkArgument( huskClass.isInterface() ) ;
final Method[] huskMethods = huskClass.getMethods() ;
final Map< String, PropertyDeclaration > properties = Maps.newHashMap() ;
for( final Method method : huskMethods ) {
final String methodName = method.getName();
if( methodName.startsWith( "get" ) ) {
if( method.getReturnType() == null ) {
throw new BadDeclarationException(
"Bad return type for " + methodName + ", should not be void" ) ;
}
if( method.getParameterTypes().length > 0 ) {
throw new BadDeclarationException(
"Bad parameter count for " + methodName + ", there should be no parameter" ) ;
}
final String propertyName = methodName.substring( 3 ) ;
getForSure( properties, propertyName ).getter = method ;
} else if( methodName.startsWith( "with" ) ) {
if( !method.getReturnType().isAssignableFrom( huskClass ) ) { // Support subclassing.
throw new BadDeclarationException(
"Bad return type for " + methodName + ": " + method.getReturnType() +
", should be " + huskClass.getName()
) ;
}
if( method.getParameterTypes().length == 0 ) {
throw new BadDeclarationException(
"Bad parameter count for " + methodName +
", there should be at least one parameter"
) ;
}
final String propertyName = methodName.substring( 4 ) ;
getForSure( properties, propertyName ).updater = method ;
} else throw new BadDeclarationException(
"Unsupported property (must be named getXxx or withXxx):" + methodName ) ;
}
for( final Map.Entry< String, PropertyDeclaration > entry : properties.entrySet() ) {
final String propertyName = entry.getKey() ;
final PropertyDeclaration declaration = entry.getValue() ;
if( declaration.getter == null ) {
throw new BadDeclarationException( "Missing get" + propertyName + " method" ) ;
}
if( entry.getValue().updater == null ) {
throw new BadDeclarationException( "Missing with" + entry.getKey() + " method" ) ;
}
final Class< ? >[] updaterParameterTypes = declaration.updater.getParameterTypes() ;
final Class< ? > updaterParameterType0 = updaterParameterTypes[ 0 ] ;
if( updaterParameterTypes.length > 1
|| declaration.getter.getReturnType() != updaterParameterType0
) {
final Converter converter = findConverterAnnotation( huskClass ) ;
if( converter == null ) {
throw new BadDeclarationException(
"Incompatible types: '" +
declaration.updater.getName() + "' takes " + updaterParameterType0 +
", while '" +
declaration.getter.getName() + "' returns " + declaration.getter.getReturnType()
) ;
} else {
final Class< ? > converterClass = converter.converterClass() ;
final Method convertMethod = findConvertMethod(
converterClass,
declaration.getter.getReturnType(),
declaration.updater.getParameterTypes()
) ;
if( convertMethod == null ) {
throw new BadDeclarationException( "Can't find converter for " + propertyName ) ;
} else {
declaration.converter = convertMethod ;
}
}
}
}
final ImmutableMap.Builder< String, Method > convertersBuilder =
new ImmutableMap.Builder< String, Method >() ;
for( final Map.Entry< String, PropertyDeclaration > entry : properties.entrySet() ) {
final Method converter = entry.getValue().converter ;
if( converter != null ) {
convertersBuilder.put( entry.getKey(), converter ) ;
}
}
//noinspection unchecked
return ( T ) Proxy.newProxyInstance(
Husk.class.getClassLoader(),
new Class< ? >[] { huskClass },
new PropertiesKeeper( huskClass, convertersBuilder.build(), EMPTY_MAP )
) ;
}
private static Converter findConverterAnnotation( final Class< ? > huskClass ) {
if( huskClass != null ) {
final Converter converterAnnotation = huskClass.getAnnotation( Converter.class ) ;
if( converterAnnotation != null ) {
return converterAnnotation ;
}
final Class< ? >[] interfaces = huskClass.getInterfaces() ;
for( final Class parentInterface : interfaces ) {
final Converter annotation = findConverterAnnotation( parentInterface );
if( annotation != null ) {
return annotation ;
}
}
}
return null ;
}
public static class BadDeclarationException extends RuntimeException {
public BadDeclarationException( final String message ) {
super( message );
}
}
private static PropertyDeclaration getForSure(
final Map< String, PropertyDeclaration > properties,
final String propertyName
) {
final PropertyDeclaration existingProperty = properties.get( propertyName ) ;
if( existingProperty == null ) {
final PropertyDeclaration newProperty = new PropertyDeclaration() ;
properties.put( propertyName, newProperty ) ;
return newProperty ;
} else {
return existingProperty ;
}
}
private static Method findConvertMethod(
final Class< ? > converterClass,
final Class< ? > returnType,
final Class< ? >[] parameterTypes
) {
for( final Method candidateConvertMethod : converterClass.getMethods() ) {
if( Modifier.isStatic( candidateConvertMethod.getModifiers() )
&& returnType.isAssignableFrom( candidateConvertMethod.getReturnType() )
&& areCompatible( parameterTypes, candidateConvertMethod.getParameterTypes() )
) {
return candidateConvertMethod ;
}
}
return null ;
}
private static boolean areCompatible(
final Class< ? >[] definition,
final Class< ? >[] occurence
) {
if( definition.length == occurence.length ) {
for( int i = 0 ; i < occurence.length ; i ++ ) {
final Class< ? > defined = definition[ i ] ;
final Class< ? > occuring = occurence[ i ] ;
if( ! defined.isAssignableFrom( occuring ) ) {
return false ;
}
}
return true ;
} else {
return false ;
}
}
private static class PropertyDeclaration {
public Method getter = null ;
public Method updater = null ;
public Method converter = null ;
}
private static final ImmutableMap< String, MaybeNullHolder > EMPTY_MAP = ImmutableMap.of() ;
private static class PropertiesKeeper implements InvocationHandler {
private final Class< ? > huskClass;
private final Map< String, Method > converters ;
private final Map< String, MaybeNullHolder > values ;
private PropertiesKeeper(
final Class< ? > huskClass,
final Map< String, Method > converters,
final Map< String, MaybeNullHolder > values
) {
this.huskClass = huskClass;
this.converters = converters ;
this.values = ImmutableMap.copyOf( values ) ;
}
@Override
public Object invoke(
final Object proxy,
final Method method,
final Object[] args
) throws Throwable {
final String methodName = method.getName() ;
if( methodName.startsWith( "with" ) ) {
final String propertyName = methodName.substring( 4 ) ;
final Object updateValue ;
final Method converter = converters.get( propertyName ) ;
if( converter == null ) {
updateValue = args[ 0 ] ;
} else {
updateValue = converter.invoke( null, args ) ;
}
final Map< String, MaybeNullHolder > updatedValues = Maps.newHashMap() ;
updatedValues.putAll( values ) ;
updatedValues.put( propertyName, new MaybeNullHolder( updateValue ) ) ;
return Proxy.newProxyInstance(
Husk.class.getClassLoader(),
new Class< ? >[] { huskClass },
new PropertiesKeeper( huskClass, converters, updatedValues )
) ;
}
if( methodName.startsWith( "get" ) ) {
final String propertyName = methodName.substring( 3 ) ;
final MaybeNullHolder mapValue = values.get( propertyName ) ;
final Object pureValue = mapValue == null ? null : mapValue.maybeNull ;
final Class< ? > returnType = method.getReturnType() ;
if( pureValue == null && returnType.isPrimitive() ) {
return 0 ;
} else {
return pureValue ;
}
}
if( "toString".equals( methodName ) && method.getParameterTypes().length == 0 ) {
return huskClass.getName() + "{proxy@" + System.identityHashCode( proxy ) + "}" ;
}
throw new IllegalStateException(
"This should not happend: invoking unsupported method " + methodName ) ;
}
}
/**
* The {@link ImmutableMap} can't hold null values, we deal with that.
*/
private static class MaybeNullHolder {
public final Object maybeNull ;
public MaybeNullHolder( final Object maybeNull ) {
this.maybeNull = maybeNull ;
}
@Override
public String toString() {
if( maybeNull == null ) {
return "null" ;
} else {
return maybeNull.toString() ;
}
}
}
@Retention( RetentionPolicy.RUNTIME )
@Target( ElementType.TYPE )
public @interface Converter {
Class< ? > converterClass() ;
}
}