/* * GeoTools - The Open Source Java GIS Toolkit * http://geotools.org * * (C) 2007-2008, Open Source Geospatial Foundation (OSGeo) * * This library 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; * version 2.1 of the License. * * This library 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 * Lesser General Public License for more details. */ package org.geotools.metadata; import java.io.File; import java.lang.reflect.Array; import java.lang.reflect.Method; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.UndeclaredThrowableException; import java.net.URI; import java.net.URL; import java.net.URISyntaxException; import java.net.MalformedURLException; import java.util.LinkedHashSet; import java.util.Collection; import java.util.Iterator; import java.util.HashMap; import java.util.Locale; import java.util.Map; import java.util.Set; import org.opengis.annotation.UML; import org.opengis.util.InternationalString; import org.geotools.util.Utilities; import org.geotools.resources.XArray; import org.geotools.resources.Classes; import org.geotools.resources.i18n.Errors; import org.geotools.resources.i18n.ErrorKeys; import org.geotools.util.CheckedCollection; import org.geotools.util.SimpleInternationalString; /** * The getters declared in a GeoAPI interface, together with setters (if any) * declared in the Geotools implementation. * * @source $URL$ * @version $Id$ * @author Martin Desruisseaux */ final class PropertyAccessor { /** * The locale to use for changing character case. */ private static final Locale LOCALE = Locale.US; /** * The prefix for getters on boolean values. */ private static final String IS = "is"; /** * The prefix for getters (general case). */ private static final String GET = "get"; /** * The prefix for setters. */ private static final String SET = "set"; /** * Methods to exclude from {@link #getGetters}. They are method inherited from * {@link java.lang.Object}. Some of them, especially {@link Object#hashCode()} * {@link Object#toString()} and {@link Object#clone()}, may be declared explicitly * in some interface with a formal contract. Note: only no-argument methods need to * be declared in this list. */ private static final String[] EXCLUDES = { "clone", "finalize", "getClass", "hashCode", "notify", "notifyAll", "toString", "wait" }; /** * Getters shared between many instances of this class. Two different implementations * may share the same getters but different setters. */ private static final Map<Class<?>, Method[]> SHARED_GETTERS = new HashMap<Class<?>, Method[]>(); /** * The implemented metadata interface. */ final Class<?> type; /** * The implementation class. The following condition must hold: * * <blockquote><pre> * type.{@linkplain Class#isAssignableFrom isAssignableFrom}(implementation); * </pre></blockquote> */ final Class<?> implementation; /** * The getter methods. This array should not contain any null element. */ private final Method[] getters; /** * The corresponding setter methods, or {@code null} if none. This array must have * the same length than {@link #getters}. For every {@code getters[i]} element, * {@code setters[i]} is the corresponding setter or {@code null} if there is none. */ private final Method[] setters; /** * Index of getter or setter for a given name. The name must be all lower cases with * conversion done using {@link #LOCALE}. This map must be considered as immutable * after construction. */ private final Map<String,Integer> mapping; /** * Creates a new property reader for the specified metadata implementation. * * @param metadata The metadata implementation to wrap. * @param type The interface implemented by the metadata. * Should be the value returned by {@link #getType}. */ PropertyAccessor(final Class<?> implementation, final Class<?> type) { this.implementation = implementation; this.type = type; assert type.isAssignableFrom(implementation) : implementation; getters = getGetters(type); mapping = new HashMap<String,Integer>(getters.length + (getters.length + 3) / 4); Method[] setters = null; final Class<?>[] arguments = new Class[1]; for (int i=0; i<getters.length; i++) { /* * Fetch the getter and remind its name. We do the same for * the UML tag attached to the getter, if any. */ final Integer index = i; Method getter = getters[i]; String name = getter.getName(); final int base = prefix(name).length(); addMapping(name.substring(base), index); final UML annotation = getter.getAnnotation(UML.class); if (annotation != null) { addMapping(annotation.identifier(), index); } /* * Now try to infer the setter from the getter. We replace the "get" prefix by * "set" and look for a parameter of the same type than the getter return type. */ Class<?> returnType = getter.getReturnType(); arguments[0] = returnType; if (name.length() > base) { final char lo = name.charAt(base); final char up = Character.toUpperCase(lo); if (lo != up) { name = SET + up + name.substring(base + 1); } else { name = SET + name.substring(base); } } Method setter; try { setter = implementation.getMethod(name, arguments); } catch (NoSuchMethodException e) { /* * If we found no setter method expecting an argument of the same type than the * argument returned by the GeoAPI method, try again with the type returned by * the implementation class. It is typically the same type, but sometime it may * be a subtype. */ try { getter = implementation.getMethod(getter.getName(), (Class[]) null); } catch (NoSuchMethodException error) { // Should never happen, since the implementation class // implements the the interface where the getter come from. throw new AssertionError(error); } if (returnType.equals(returnType = getter.getReturnType())) { continue; } arguments[0] = returnType; try { setter = implementation.getMethod(name, arguments); } catch (NoSuchMethodException ignore) { continue; } } if (setters == null) { setters = new Method[getters.length]; } setters[i] = setter; } this.setters = setters; } /** * Adds the given (name, index) pair to {@link #mapping}, making sure we don't * overwrite an existing entry with different value. */ private void addMapping(String name, final Integer index) throws IllegalArgumentException { name = name.trim(); if (name.length() != 0) { final String lower = name.toLowerCase(LOCALE); final Integer old = mapping.put(lower, index); if (old != null && !old.equals(index)) { throw new IllegalArgumentException(Errors.format( ErrorKeys.PARAMETER_NAME_CLASH_$4, name, index, lower, old)); } } } /** * Returns the metadata interface implemented by the specified implementation. * Only one metadata interface can be implemented. * * @param metadata The metadata implementation to wraps. * @param interfacePackage The root package for metadata interfaces. * @return The single interface, or {@code null} if none where found. */ static Class<?> getType(Class<?> implementation, final String interfacePackage) { if (implementation != null && !implementation.isInterface()) { /* * Gets every interfaces from the supplied package in declaration order, * including the ones declared in the super-class. */ final Set<Class<?>> interfaces = new LinkedHashSet<Class<?>>(); do { getInterfaces(implementation, interfacePackage, interfaces); implementation = implementation.getSuperclass(); } while (implementation != null); /* * If we found more than one interface, removes the * ones that are sub-interfaces of the other. */ for (final Iterator<Class<?>> it=interfaces.iterator(); it.hasNext();) { final Class<?> candidate = it.next(); for (final Class<?> child : interfaces) { if (candidate != child && candidate.isAssignableFrom(child)) { it.remove(); break; } } } final Iterator<Class<?>> it=interfaces.iterator(); if (it.hasNext()) { final Class<?> candidate = it.next(); if (!it.hasNext()) { return candidate; } // Found more than one interface; we don't know which one to pick. // Returns 'null' for now; the caller will thrown an exception. } } return null; } /** * Puts every interfaces for the given type in the specified collection. * This method invokes itself recursively for scanning parent interfaces. */ private static void getInterfaces(final Class<?> type, final String interfacePackage, final Collection<Class<?>> interfaces) { for (final Class<?> candidate : type.getInterfaces()) { if (candidate.getName().startsWith(interfacePackage)) { interfaces.add(candidate); } getInterfaces(candidate, interfacePackage, interfaces); } } /** * Returns the getters. The returned array should never be modified, * since it may be shared among many instances of {@code PropertyAccessor}. * * @param type The metadata interface. * @return The getters declared in the given interface (never {@code null}). */ private static Method[] getGetters(final Class<?> type) { synchronized (SHARED_GETTERS) { Method[] getters = SHARED_GETTERS.get(type); if (getters == null) { getters = type.getMethods(); int count = 0; for (int i=0; i<getters.length; i++) { final Method candidate = getters[i]; if (candidate.getAnnotation(Deprecated.class) != null) { // Ignores deprecated methods. continue; } if (!candidate.getReturnType().equals(Void.TYPE) && candidate.getParameterTypes().length == 0) { /* * We do not require a name starting with "get" or "is" prefix because some * methods do not begin with such prefix, as in "ConformanceResult.pass()". * Consequently we must provide special cases for no-arg methods inherited * from java.lang.Object because some interfaces declare explicitly the * contract for those methods. * * Note that testing candidate.getDeclaringClass().equals(Object.class) * is not suffisient because the method may be overriden in a subclass. */ final String name = candidate.getName(); if (!name.startsWith(SET) && !isExcluded(name)) { getters[count++] = candidate; } } } getters = XArray.resize(getters, count); SHARED_GETTERS.put(type, getters); } return getters; } } /** * Returns {@code true} if the specified method is on the exclusion list. */ private static boolean isExcluded(final String name) { for (int i=0; i<EXCLUDES.length; i++) { if (name.equals(EXCLUDES[i])) { return true; } } return false; } /** * Returns the prefix of the specified method name. If the method name don't starts with * a prefix (for example {@link org.opengis.metadata.quality.ConformanceResult#pass()}), * then this method returns an empty string. */ private static String prefix(final String name) { if (name.startsWith(GET)) { return GET; } if (name.startsWith(IS)) { return IS; } if (name.startsWith(SET)) { return SET; } return ""; } /** * Returns the number of properties that can be read. */ final int count() { return getters.length; } /** * Returns the index of the specified property, or -1 if none. * The search is case-insensitive. * * @param key The property to search. * @return The index of the given key, or -1 if none. */ final int indexOf(String key) { key = key.trim().toLowerCase(LOCALE); final Integer index = mapping.get(key); return (index != null) ? index.intValue() : -1; } /** * Always returns the index of the specified property (never -1). * The search is case-insensitive. * * @param key The property to search. * @return The index of the given key. * @throws IllegalArgumentException if the given key is not found. */ final int requiredIndexOf(String key) throws IllegalArgumentException { key = key.trim(); final Integer index = mapping.get(key.toLowerCase(LOCALE)); if (index != null) { return index; } throw new IllegalArgumentException(Errors.format(ErrorKeys.UNKNOW_PARAMETER_NAME_$1, key)); } /** * Returns {@code true} if the specified string starting at the specified index contains * no lower case characters. The characters don't have to be in upper case however (e.g. * non-alphabetic characters) */ private static boolean isAcronym(final String name, int offset) { final int length = name.length(); while (offset < length) { if (Character.isLowerCase(name.charAt(offset++))) { return false; } } return true; } /** * Returns the name of the property at the given index, or {@code null} if none. */ final String name(final int index) { if (index >= 0 && index < getters.length) { String name = getters[index].getName(); final int base = prefix(name).length(); /* * Remove the "get" or "is" prefix and turn the first character after the * prefix into lower case. For example the method name "getTitle" will be * replaced by the property name "title". We will performs this operation * only if there is at least 1 character after the prefix. */ if (name.length() > base) { if (isAcronym(name, base)) { name = name.substring(base); } else { final char up = name.charAt(base); final char lo = Character.toLowerCase(up); if (up != lo) { name = lo + name.substring(base + 1); } else { name = name.substring(base); } } } return name; } return null; } /** * Returns the type of the property at the given index. */ final Class<?> type(final int index) { if (index >= 0 && index < getters.length) { return getters[index].getReturnType(); } return null; } /** * Returns {@code true} if the property at the given index is writable. */ final boolean isWritable(final int index) { return (index >= 0) && (index < getters.length) && (setters != null) && (setters[index] != null); } /** * Returns the value for the specified metadata, or {@code null} if none. */ final Object get(final int index, final Object metadata) { return (index >= 0 && index < getters.length) ? get(getters[index], metadata) : null; } /** * Gets a value from the specified metadata. We do not expect any checked exception to * be thrown, since {@code org.opengis.metadata} do not declare any. * * @param method The method to use for the query. * @param metadata The metadata object to query. */ private static Object get(final Method method, final Object metadata) { assert !method.getReturnType().equals(Void.TYPE) : method; try { return method.invoke(metadata, (Object[]) null); } catch (IllegalAccessException e) { // Should never happen since 'getters' should contains only public methods. throw new AssertionError(e); } catch (InvocationTargetException e) { final Throwable cause = e.getTargetException(); if (cause instanceof RuntimeException) { throw (RuntimeException) cause; } if (cause instanceof Error) { throw (Error) cause; } throw new UndeclaredThrowableException(cause); } } /** * Sets a value for the specified metadata. * * @param index The index of the property to set. * @param metadata The metadata object on which to set the value. * @param value The new value. * @return The old value. * @throws IllegalArgumentException if the specified property can't be set. * @throws ClassCastException if the given value is not of the expected type. */ final Object set(final int index, final Object metadata, final Object value) throws IllegalArgumentException, ClassCastException { String key; if (index >= 0 && index < getters.length && setters != null) { final Method getter = getters[index]; final Method setter = setters[index]; if (setter != null) { final Object old = get(getter, metadata); set(getter, setter, metadata, new Object[] {value}); return old; } else { key = getter.getName(); key = key.substring(prefix(key).length()); } } else { key = String.valueOf(index); } throw new IllegalArgumentException(Errors.format(ErrorKeys.ILLEGAL_ARGUMENT_$1, key)); } /** * Sets a value for the specified metadata. We do not expect any checked exception to * be thrown. * * @param getter The method to use for fetching the previous value. * @param setter The method to use for setting the new value. * @param metadata The metadata object to query. * @param arguments The argument to give to the method to be invoked. * @throws ClassCastException if at least one element of the {@code arguments} array * is not of the expected type. */ private static void set(final Method getter, final Method setter, final Object metadata, final Object[] arguments) throws ClassCastException { final Class<?>[] paramTypes = setter.getParameterTypes(); for (int i=0; i<paramTypes.length; i++) { final Object argument = arguments[i]; if (argument == null) { continue; // Null argument (which is valid): nothing to do. } final Class<?> paramType = paramTypes[i]; if (Classes.primitiveToWrapper(paramType).isInstance(argument)) { continue; // Argument is of the expected type: nothing to do. } /* * If an argument is not of the expected type, tries to convert it. * We handle two cases: * * - Strings to be converted to Number, File, URL, etc. * - Singleton to be added into an existing collection. * * We check for the collection case first in order to extract the element * type, which will be used for String conversions (if applicable) later. * The collections are handled in one of the two ways below: * * - If the user gives a collection, the user's collection replaces any * previous one. The content of the previous collection is discarted. * * - If the user gives a singleton, the single value is added to existing * collection (if any). The previous values are not discarted. This * allow for incremental filling of an attribute. */ final Collection<?> addTo; final Class<?> elementType; if (Collection.class.isAssignableFrom(paramType) && !(argument instanceof Collection)) { // Expected a collection but got a singleton. addTo = (Collection) get(getter, metadata); if (addTo instanceof CheckedCollection) { elementType = ((CheckedCollection) addTo).getElementType(); } else { Class<?> c = Classes.boundOfParameterizedAttribute(setter); if (c == null) { c = Classes.boundOfParameterizedAttribute(getter); if (c == null) { c = Object.class; } } elementType = c; } } else { addTo = null; elementType = paramType; } /* * Handles the strings in a special way (converting to URI, URL, File, * Number, etc.). If there is no known way to parse the string, or if * the parsing failed, an exception is thrown. */ Object parsed = null; Exception failure = null; if (elementType.isInstance(argument)) { parsed = argument; } else if (argument instanceof CharSequence) { final String text = argument.toString(); if (InternationalString.class.isAssignableFrom(elementType)) { parsed = new SimpleInternationalString(text); } else if (File.class.isAssignableFrom(elementType)) { parsed = new File(text); } else if (URL.class.isAssignableFrom(elementType)) try { parsed = new URL(text); } catch (MalformedURLException e) { failure = e; } else if (URI.class.isAssignableFrom(elementType)) try { parsed = new URI(text); } catch (URISyntaxException e) { failure = e; } else try { parsed = Classes.valueOf(elementType, text); } catch (RuntimeException e) { // Include IllegalArgumentException and NumberFormatException failure = e; } } /* * Checks if there is no known conversion, or if the conversion failed. In the later * case the parse failure is saved as the cause. We still throw a ClassCastException * since we get here because the argument was not of the expected type. */ if (parsed == null) { final ClassCastException e = new ClassCastException(Errors.format( ErrorKeys.ILLEGAL_CLASS_$2, argument.getClass(), elementType)); e.initCause(failure); throw e; } /* * We now have an object of the appropriate type. If this is a singleton to be added in * an existing collection, add it now and set the new value to the whole collection. In * the later case, we rely on ModifiableMetadata.copyCollection(...) optimization for * detecting that the new collection is the same instance than the old one so there is * nothing to do. We could exit from the method, but let it goes in case the user define * (or override) the 'setFoo(...)' method in an other way. */ if (addTo != null) { addUnsafe(addTo, parsed); parsed = addTo; } arguments[i] = parsed; } try { setter.invoke(metadata, arguments); } catch (IllegalAccessException e) { // Should never happen since 'setters' should contains only public methods. throw new AssertionError(e); } catch (InvocationTargetException e) { final Throwable cause = e.getTargetException(); if (cause instanceof RuntimeException) { throw (RuntimeException) cause; } if (cause instanceof Error) { throw (Error) cause; } throw new UndeclaredThrowableException(cause); } } /** * Unsafe addition into a collection. In GeoTools implementation, the collection is actually * an instance of {@link CheckedCollection}, so the check will be performed at runtime. * However other implementations could use unchecked collection. There is not much we can do. */ @SuppressWarnings("unchecked") private static void addUnsafe(final Collection<?> addTo, final Object element) { ((Collection) addTo).add(element); } /** * Compares the two specified metadata objects. The comparaison is <cite>shallow</cite>, * i.e. all metadata attributes are compared using the {@link Object#equals} method without * recursive call to this {@code shallowEquals} method for other metadata. * <p> * This method can optionaly excludes null values from the comparaison. In metadata, * null value often means "don't know", so in some occasion we want to consider two * metadata as different only if an attribute value is know for sure to be different. * * @param metadata1 The first metadata object to compare. * @param metadata2 The second metadata object to compare. * @param skipNulls If {@code true}, only non-null values will be compared. */ public boolean shallowEquals(final Object metadata1, final Object metadata2, final boolean skipNulls) { assert type.isInstance(metadata1) : metadata1; assert type.isInstance(metadata2) : metadata2; for (int i=0; i<getters.length; i++) { final Method method = getters[i]; final Object value1 = get(method, metadata1); final Object value2 = get(method, metadata2); final boolean empty1 = isEmpty(value1); final boolean empty2 = isEmpty(value2); if (empty1 && empty2) { continue; } if (!Utilities.equals(value1, value2)) { if (!skipNulls || (!empty1 && !empty2)) { return false; } } } return true; } /** * Copies all metadata from source to target. The source can be any implementation of * the metadata interface, but the target must be the implementation expected by this * class. * * @param source The metadata to copy. * @param target The target metadata. * @param skipNulls If {@code true}, only non-null values will be copied. * @return {@code true} in case of success, or {@code false} if at least * one setter method was not found. * @throws UnmodifiableMetadataException if the target metadata is unmodifiable. */ public boolean shallowCopy(final Object source, final Object target, final boolean skipNulls) throws UnmodifiableMetadataException { boolean success = true; assert type .isInstance(source) : source; assert implementation.isInstance(target) : target; final Object[] arguments = new Object[1]; for (int i=0; i<getters.length; i++) { final Method getter = getters[i]; arguments[0] = get(getter, source); if (!skipNulls || !isEmpty(arguments[0])) { if (setters == null) { return false; } final Method setter = setters[i]; if (setter != null) { set(getter, setter, target, arguments); } else { success = false; } } } return success; } /** * Replaces every properties in the specified metadata by their * {@linkplain ModifiableMetadata#unmodifiable unmodifiable variant. */ final void freeze(final Object metadata) { assert implementation.isInstance(metadata) : metadata; if (setters != null) { final Object[] arguments = new Object[1]; for (int i=0; i<getters.length; i++) { final Method setter = setters[i]; if (setter != null) { final Method getter = getters[i]; final Object source = get(getter, metadata); final Object target = ModifiableMetadata.unmodifiable(source); if (source != target) { arguments[0] = target; set(getter, setter, metadata, arguments); } } } } } /** * Returns {@code true} if the metadata is modifiable. This method is not public because it * uses heuristic rules. In case of doubt, this method conservatively returns {@code true}. */ final boolean isModifiable() { if (setters != null) { return true; } for (int i=0; i<getters.length; i++) { // Immutable objects usually don't need to be cloned. So if // an object is cloneable, it is probably not immutable. if (Cloneable.class.isAssignableFrom(getters[i].getReturnType())) { return true; } } return false; } /** * Returns a hash code for the specified metadata. The hash code is defined as the * sum of hash code values of all non-null properties. This is the same contract than * {@link java.util.Set#hashCode} and ensure that the hash code value is insensitive * to the ordering of properties. */ public int hashCode(final Object metadata) { assert type.isInstance(metadata) : metadata; int code = 0; for (int i=0; i<getters.length; i++) { final Object value = get(getters[i], metadata); if (!isEmpty(value)) { code += value.hashCode(); } } return code; } /** * Counts the number of non-null properties. */ public int count(final Object metadata, final int max) { assert type.isInstance(metadata) : metadata; int count = 0; for (int i=0; i<getters.length; i++) { if (!isEmpty(get(getters[i], metadata))) { if (++count >= max) { break; } } } return count; } /** * Returns {@code true} if the specified object is null or an empty collection, * array or string. */ static boolean isEmpty(final Object value) { return value == null || ((value instanceof Collection) && ((Collection) value).isEmpty()) || ((value instanceof CharSequence) && value.toString().trim().length() == 0) || (value.getClass().isArray() && Array.getLength(value) == 0); } }