/**
* Copyright 2013 the original author or authors.
*
* 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 io.neba.core.resourcemodels.metadata;
import io.neba.api.annotations.Children;
import io.neba.api.annotations.Path;
import io.neba.api.annotations.Reference;
import io.neba.api.annotations.This;
import io.neba.api.resourcemodels.Optional;
import io.neba.core.util.Annotations;
import io.neba.core.util.ReflectionUtil;
import org.apache.commons.lang.ClassUtils;
import org.springframework.cglib.proxy.Enhancer;
import org.springframework.cglib.proxy.Factory;
import org.springframework.cglib.proxy.LazyLoader;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.lang.reflect.Type;
import java.util.Calendar;
import java.util.Collection;
import java.util.Date;
import static io.neba.core.util.Annotations.annotations;
import static io.neba.core.util.ReflectionUtil.getInstantiableCollectionTypes;
import static io.neba.core.util.ReflectionUtil.getLowerBoundOfSingleTypeParameter;
import static org.apache.commons.lang.StringUtils.*;
import static org.apache.commons.lang3.reflect.TypeUtils.getRawType;
import static org.springframework.util.ReflectionUtils.makeAccessible;
/**
* Represents meta-data of a mappable {@link io.neba.api.annotations.ResourceModel resource model} field.
* Used to prevent the costly retrieval of this meta-data upon each resource to model mapping.
*
* @author Olaf Otto
*/
public class MappedFieldMetaData {
/**
* Whether a property cannot be represented by a resource but must stem
* from a value map representing the properties of a resource.
*/
private static boolean isPropertyType(Class<?> type) {
return type.isPrimitive() ||
type == String.class ||
type == Date.class ||
type == Calendar.class ||
ClassUtils.wrapperToPrimitive(type) != null;
}
private final Field field;
private final Annotations annotations;
private final String path;
private final boolean isReference;
private final boolean isAppendPathPresentOnReference;
private final String appendPathOnReference;
private final boolean isThisReference;
private final boolean isPathAnnotationPresent;
private final boolean isPathExpressionPresent;
private final boolean isPropertyType;
private final boolean isCollectionType;
private final boolean isInstantiableCollectionType;
private final boolean isChildrenAnnotationPresent;
private final boolean isResolveBelowEveryChildPathPresentOnChildren;
private final String resolveBelowEveryChildPathOnChildren;
private final boolean isOptional;
private final Class<?> typeParameter;
private final Class<?> arrayTypeOfComponentType;
private final Type genericFieldType;
private final Class<?> fieldType;
private final Class<?> modelType;
private final Factory collectionProxyFactory;
/**
* Immediately extracts all metadata for the provided field.
*
* @param field must not be <code>null</code>.
*/
public MappedFieldMetaData(Field field, Class<?> modelType) {
if (field == null) {
throw new IllegalArgumentException("Constructor parameter field must not be null.");
}
if (modelType == null) {
throw new IllegalArgumentException("Method argument modelType must not be null.");
}
// Atomic initialization
this.modelType = modelType;
this.field = field;
this.isOptional = field.getType() == Optional.class;
this.annotations = annotations(field);
// Treat Optional<X> fields transparently like X fields: This way, anyone operating on the metadata is not
// forced to be aware of the lazy-loading value holder indirection but can operate on the target type directly.
this.genericFieldType = this.isOptional ? getParameterTypeOf(field.getGenericType()) : field.getGenericType();
this.fieldType = this.isOptional ? getRawType(this.genericFieldType, this.modelType) : field.getType();
this.isCollectionType = Collection.class.isAssignableFrom(this.fieldType);
this.isPathAnnotationPresent = this.annotations.contains(Path.class);
this.isReference = this.annotations.contains(Reference.class);
this.isThisReference = this.annotations.contains(This.class);
this.isChildrenAnnotationPresent = this.annotations.contains(Children.class);
// The following initializations are not atomic but order-sensitive.
this.isAppendPathPresentOnReference = isAppendPathPresentOnReferenceInternal();
this.appendPathOnReference = getAppendPathFromReference();
this.isResolveBelowEveryChildPathPresentOnChildren = isResolveBelowEveryChildPathPresentOnChildrenInternal();
this.resolveBelowEveryChildPathOnChildren = getResolveBelowEveryChildPathFromChildren();
this.typeParameter = resolveTypeParameter();
this.arrayTypeOfComponentType = resolveArrayTypeOfComponentType();
this.path = getPathInternal();
this.isPathExpressionPresent = isPathExpressionPresentInternal();
this.isPropertyType = isPropertyTypeInternal();
this.isInstantiableCollectionType = ReflectionUtil.isInstantiableCollectionType(this.fieldType);
enforceInstantiableCollectionTypeForExplicitlyMappedFields();
this.collectionProxyFactory = prepareProxyFactoryForCollectionTypes();
makeAccessible(field);
}
/**
* Wraps {@link io.neba.core.util.ReflectionUtil#getLowerBoundOfSingleTypeParameter(java.lang.reflect.Type)}
* in order to provide a field-related error message to signal users which field is affected.
*/
private Type getParameterTypeOf(Type type) {
try {
return getLowerBoundOfSingleTypeParameter(type);
} catch (Exception e) {
throw new IllegalArgumentException("Unable to resolve a generic parameter type of the mapped field " + this.field + ".", e);
}
}
/**
* Prepares a proxy instance of a collection type for use as a {@link org.springframework.cglib.proxy.Factory}.
* Proxy instances are always {@link org.springframework.cglib.proxy.Factory factories}.
* Using {@link org.springframework.cglib.proxy.Factory#newInstance(org.springframework.cglib.proxy.Callback)}
* is significantly more efficient than using {@link org.springframework.cglib.proxy.Enhancer#create(Class, org.springframework.cglib.proxy.Callback)}.
*/
private Factory prepareProxyFactoryForCollectionTypes() {
if (this.isInstantiableCollectionType) {
return (Factory) Enhancer.create(this.fieldType, (LazyLoader) () -> null);
}
return null;
}
public Factory getCollectionProxyFactory() {
return this.collectionProxyFactory;
}
private String getAppendPathFromReference() {
return this.isAppendPathPresentOnReference ? getAppendPathOfReference() : null;
}
private boolean isAppendPathPresentOnReferenceInternal() {
return isReference && !isBlank(getAppendPathOfReference());
}
private String getAppendPathOfReference() {
String path = this.annotations.get(Reference.class).append();
if (!isEmpty(path) && path.charAt(0) != '/') {
path = '/' + path;
}
return path;
}
private boolean isResolveBelowEveryChildPathPresentOnChildrenInternal() {
return this.isChildrenAnnotationPresent && !isBlank(getResolveBelowEveryChildPathOfChildren());
}
private String getResolveBelowEveryChildPathFromChildren() {
return this.isResolveBelowEveryChildPathPresentOnChildren ?
getResolveBelowEveryChildPathOfChildren() : null;
}
private String getResolveBelowEveryChildPathOfChildren() {
String relativePath = this.annotations.get(Children.class).resolveBelowEveryChild();
// The path must be relative, otherwise resource#getChild will be equivalent to
// resolver.getResource("/..."), i.e. the resolution will not be relative.
return isResolveBelowEveryChildPathPresentOnChildren &&
relativePath.charAt(0) == '/' ? relativePath.substring(1) : relativePath;
}
/**
* @return Whether the path name contains an expression.
* An expression has the form ${value}, e.g. @Path("/content/${language}/homepage").
*/
private boolean isPathExpressionPresentInternal() {
return this.isPathAnnotationPresent && this.path.contains("$");
}
private Class<?> resolveTypeParameter() {
Class<?> typeParameter = null;
if (this.isCollectionType) {
typeParameter = getRawType(getParameterTypeOf(this.genericFieldType), this.modelType);
} else if (getType().isArray()) {
typeParameter = getType().getComponentType();
}
return typeParameter;
}
private Class<?> resolveArrayTypeOfComponentType() {
if (this.typeParameter != null) {
return Array.newInstance(typeParameter, 0).getClass();
}
return null;
}
private void enforceInstantiableCollectionTypeForExplicitlyMappedFields() {
if (((this.isReference && this.isCollectionType) || this.isChildrenAnnotationPresent)
&& !this.isInstantiableCollectionType) {
throw new IllegalArgumentException("Unsupported type of field " +
this.field + ": Only " + join(getInstantiableCollectionTypes(), ", ") + " are supported.");
}
}
/**
* Determines the relative or absolute JCR path of a field.
* The path is derived from either the field name (this is the default)
* or from an explicit {@link io.neba.api.annotations.Path} annotation.
*/
private String getPathInternal() {
String resolvedPath;
if (isPathAnnotationPresent()) {
Path path = this.annotations.get(Path.class);
if (isBlank(path.value())) {
throw new IllegalArgumentException("The value of the @" + Path.class.getSimpleName() +
" annotation on " + field + " must not be empty");
}
resolvedPath = path.value();
} else {
resolvedPath = field.getName();
}
return resolvedPath;
}
/**
* Determines whether the {@link java.lang.reflect.Field#getType() field type}
* can only be represented by a property of a {@link org.apache.sling.api.resource.Resource},
* not a {@link org.apache.sling.api.resource.Resource} itself.
*/
private boolean isPropertyTypeInternal() {
Class<?> type = getType();
return
// References are always contained in properties of type String or String[].
isReference()
|| isPropertyType(type)
|| (type.isArray() || isCollectionType) && isPropertyType(getTypeParameter());
}
/**
* @return The {@link org.springframework.util.ReflectionUtils#makeAccessible(java.lang.reflect.Field) accessible}
* {@link java.lang.reflect.Field} represented by this meta data.
*/
public Field getField() {
return this.field;
}
/**
* @return Whether this field is annotated with {@link io.neba.api.annotations.Reference}.
*/
public boolean isReference() {
return this.isReference;
}
/**
* @return Whether this field has a {@link io.neba.api.annotations.Reference} annotation with a non-empty
* {@link io.neba.api.annotations.Reference#append() append path}.
*/
public boolean isAppendPathPresentOnReference() {
return isAppendPathPresentOnReference;
}
/**
* @return the {@link io.neba.api.annotations.Reference#append() append path} of the
* {@link io.neba.api.annotations.Reference} annotation, or <code>null</code> if either
* the annotation or the value for the append path are not present.
*/
public String getAppendPathOnReference() {
return appendPathOnReference;
}
/**
* @return Whether this field is annotated with {@link io.neba.api.annotations.This}.
*/
public boolean isThisReference() {
return this.isThisReference;
}
/**
* @return The path from which this field's value shall be mapped; may stem
* from the field name or a {@link io.neba.api.annotations.Path} annotation.
*/
public String getPath() {
return this.path;
}
/**
* @return the type the resolved value for the field shall have, which is either the {@link java.lang.reflect.Field#getType() field type},
* or the generic parameter type in case of {@link #isOptional() optional} fields.
*/
public Class<?> getType() {
return this.fieldType;
}
/**
* @return Whether this field has a {@link io.neba.api.annotations.Path} annotation.
*/
public boolean isPathAnnotationPresent() {
return this.isPathAnnotationPresent;
}
/**
* @ return Whether this field has a {@link io.neba.api.annotations.Path} annotation
* containing an expression such as <code>${path}</code>.
*/
public boolean isPathExpressionPresent() {
return this.isPathExpressionPresent;
}
/**
* Whether the type of this field can only be represented by a resource property (and not a resource).
*/
public boolean isPropertyType() {
return this.isPropertyType;
}
/**
* Whether the type of this field is assignable from {@link java.util.Collection}.
*/
public boolean isCollectionType() {
return this.isCollectionType;
}
/**
* @return whether this field {@link #isCollectionType() is a collection type}
* that can be {@link io.neba.core.util.ReflectionUtil#instantiateCollectionType(Class) instantiated}.
*/
public boolean isInstantiableCollectionType() {
return isInstantiableCollectionType;
}
/**
* Whether the field has a {@link io.neba.api.annotations.Children} annotation.
*/
public boolean isChildrenAnnotationPresent() {
return this.isChildrenAnnotationPresent;
}
/**
* @return whether a {@link io.neba.api.annotations.Children} annotation is present with a non-empty
* {@link io.neba.api.annotations.Children#resolveBelowEveryChild()} path.
*/
public boolean isResolveBelowEveryChildPathPresentOnChildren() {
return isResolveBelowEveryChildPathPresentOnChildren;
}
/**
* @return the {@link io.neba.api.annotations.Children#resolveBelowEveryChild()} path, or
* <code>null</code> if no such annotation or path exist.
*/
public String getResolveBelowEveryChildPathOnChildren() {
return resolveBelowEveryChildPathOnChildren;
}
/**
* @return The generic type of this field if it has a generic type declaration, such as <code>List<MyModel> field;</code>
* or <code>Optional<MyModel> field;</code>
*/
public Class<?> getTypeParameter() {
return this.typeParameter;
}
/**
* @return the array type representation of the {@link #getTypeParameter() component type},
* or <code>null</code> if the component type is <code>null</code>.
*/
public Class<?> getArrayTypeOfTypeParameter() {
return arrayTypeOfComponentType;
}
/**
* @return the annotations of the field, never <code>null</code>.
*/
public Annotations getAnnotations() {
return annotations;
}
/**
* @return whether the field is of type {@link io.neba.api.resourcemodels.Optional}.
*/
public boolean isOptional() {
return isOptional;
}
@Override
public int hashCode() {
return this.field.hashCode();
}
@Override
public boolean equals(Object obj) {
return obj == this ||
(
obj != null && obj.getClass() == getClass() &&
((MappedFieldMetaData) obj).field.equals(this.field)
);
}
@Override
public String toString() {
return getClass().getName() + " [" + this.field + "]";
}
}