001/*
002 *    GeoAPI - Java interfaces for OGC/ISO standards
003 *    http://www.geoapi.org
004 *
005 *    Copyright (C) 2018-2019 Open Geospatial Consortium, Inc.
006 *    All Rights Reserved. http://www.opengeospatial.org/ogc/legal
007 *
008 *    Permission to use, copy, and modify this software and its documentation, with
009 *    or without modification, for any purpose and without fee or royalty is hereby
010 *    granted, provided that you include the following on ALL copies of the software
011 *    and documentation or portions thereof, including modifications, that you make:
012 *
013 *    1. The full text of this NOTICE in a location viewable to users of the
014 *       redistributed or derivative work.
015 *    2. Notice of any changes or modifications to the OGC files, including the
016 *       date changes were made.
017 *
018 *    THIS SOFTWARE AND DOCUMENTATION IS PROVIDED "AS IS," AND COPYRIGHT HOLDERS MAKE
019 *    NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
020 *    TO, WARRANTIES OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT
021 *    THE USE OF THE SOFTWARE OR DOCUMENTATION WILL NOT INFRINGE ANY THIRD PARTY
022 *    PATENTS, COPYRIGHTS, TRADEMARKS OR OTHER RIGHTS.
023 *
024 *    COPYRIGHT HOLDERS WILL NOT BE LIABLE FOR ANY DIRECT, INDIRECT, SPECIAL OR
025 *    CONSEQUENTIAL DAMAGES ARISING OUT OF ANY USE OF THE SOFTWARE OR DOCUMENTATION.
026 *
027 *    The name and trademarks of copyright holders may NOT be used in advertising or
028 *    publicity pertaining to the software without specific, written prior permission.
029 *    Title to copyright in this software and any associated documentation will at all
030 *    times remain with copyright holders.
031 */
032package org.opengis.test.dataset;
033
034import java.util.AbstractMap;
035import java.util.ArrayList;
036import java.util.Arrays;
037import java.util.Collection;
038import java.util.ConcurrentModificationException;
039import java.util.HashMap;
040import java.util.HashSet;
041import java.util.Iterator;
042import java.util.LinkedHashMap;
043import java.util.List;
044import java.util.Map;
045import java.util.Objects;
046import java.util.Set;
047import java.util.TreeMap;
048import java.lang.reflect.InvocationTargetException;
049import java.lang.reflect.Method;
050import java.lang.reflect.ParameterizedType;
051import java.lang.reflect.Type;
052import java.lang.reflect.WildcardType;
053import org.opengis.annotation.UML;
054import org.opengis.metadata.Metadata;
055import org.opengis.util.GenericName;
056import org.opengis.util.InternationalString;
057import org.opengis.util.ControlledVocabulary;
058import org.opengis.referencing.crs.CoordinateReferenceSystem;
059import org.junit.Assert;
060
061
062/**
063 * Verification operations that compare metadata or CRS properties against the expected values.
064 * The metadata or CRS to verify (typically read from a dataset) is specified by a call to one
065 * of the {@code addMetadataToVerify(…)} methods. After the actual values have been specified,
066 * they can be compared against the expected value by a call to {@code assertMetadataEquals(…)}.
067 *
068 * @author  Martin Desruisseaux (Geomatys)
069 * @version 3.1
070 * @since   3.1
071 */
072public class ContentVerifier {
073    /**
074     * Path to a metadata elements. This is non-empty only while scanning a metadata object by the
075     * {@link #addPropertyValue(Class, Object)} method. Values of this string builders are used as
076     * keys in {@link #metadataValues} map.
077     */
078    private final StringBuilder path;
079
080    /**
081     * Instances already visited, for avoiding never-ending recursive loops. This is non-empty only
082     * while scanning a metadata object by the {@link #addPropertyValue(Class, Object)} method.
083     */
084    private final Set<Element> visited;
085
086    /**
087     * A (class, value) pair where the value is compared by identity. This is used for detecting never-ending loops.
088     * Values shall not be compared with {@link Object#equals(Object)} because we have no guarantee that users wrote
089     * a safe implementation and because it would produce false positives anyway.
090     *
091     * <p>We take in account the type, not only the value instance, because implementations are free to implement
092     * more than one interface with the same class. For example the same {@code value} instance could implement
093     * both {@code Metadata} and {@code DataIdentification} interfaces.</p>
094     */
095    private static final class Element {
096        private final Class<?> type;
097        private final Object value;
098
099        Element(final Class<?> type, final Object value) {
100            this.type  = type;
101            this.value = value;
102        }
103
104        @Override public int hashCode() {
105            return type.hashCode() ^ System.identityHashCode(value);
106        }
107
108        @Override public boolean equals(final Object obj) {
109            if (obj instanceof Element) {
110                final Element other = (Element) obj;
111                return type.equals(other.type) && value == other.value;
112            }
113            return false;
114        }
115    }
116
117    /**
118     * All non-null metadata values found by the {@link #addPropertyValue(Class, Object)} method.
119     */
120    private final Map<String,Object> metadataValues;
121
122    /**
123     * Paths of properties that were expected but not found.
124     */
125    private final List<Map.Entry<String,Object>> missings;
126
127    /**
128     * Metadata values that do not match the expected values. We use a {@code List} instead than a {@code Map}
129     * because the same key may appear more than once if the user invokes {@code addMetadataToVerify(…)} and
130     * {@code compareMetadata(…)} many times.
131     */
132    private final List<Map.Entry<String,Object>> mismatches;
133
134    /**
135     * A mismatched value.
136     */
137    private static final class Mismatch {
138        /** The expected metadata value. */ public final Object expected;
139        /** The value found in metadata. */ public final Object actual;
140
141        /** Creates a new entry for a mismatched value. */
142        Mismatch(final Object expected, final Object actual) {
143            this.expected = expected;
144            this.actual   = actual;
145        }
146
147        /** Returns a string representation for debugging purpose. */
148        @Override public String toString() {
149            return toString(new StringBuilder()).toString();
150        }
151
152        /** Formats the string representation in the given buffer. */
153        final StringBuilder toString(final StringBuilder appendTo) {
154            formatValue(expected, appendTo.append("expected "));
155            formatValue(actual,   appendTo.append(" but was "));
156            return appendTo;
157        }
158    }
159
160    /**
161     * Properties to ignore. They are specified by user with calls to {@link #addPropertyToIgnore(Class, String)}.
162     */
163    private final Map<Class<?>, Set<String>> ignore;
164
165    /**
166     * Creates a new dataset content verifier.
167     */
168    public ContentVerifier() {
169        path           = new StringBuilder(80);
170        visited        = new HashSet<>();
171        metadataValues = new TreeMap<>();
172        mismatches     = new ArrayList<>();
173        missings       = new ArrayList<>();
174        ignore         = new HashMap<>();
175    }
176
177    /**
178     * Resets this verifier to the same state than after construction.
179     * This method can be invoked for reusing the same verifier with different metadata objects.
180     */
181    public void clear() {
182        path.setLength(0);
183        visited.clear();
184        metadataValues.clear();
185        mismatches.clear();
186        missings.clear();
187        ignore.clear();
188    }
189
190    /**
191     * Adds a metadata property to ignore. The property is identified by a GeoAPI interface and the
192     * {@link UML} identifier of a property in that interface. Properties to ignore must be declared
193     * before to invoke {@code addMetadataToVerify(…)}.
194     *
195     * @param  type      GeoAPI interface containing the property to ignore.
196     * @param  property  UML identifier of a property in the given interface.
197     */
198    public void addPropertyToIgnore(final Class<?> type, final String property) {
199        Objects.requireNonNull(type);
200        Objects.requireNonNull(property);
201        Set<String> properties = ignore.get(type);
202        if (properties == null) {
203            properties = new HashSet<>();
204            ignore.put(type, properties);               // TODO: use Map.compureIfAbsent with JDK8.
205        }
206        properties.add(property);
207    }
208
209    /**
210     * Returns {@code true} if the given property shall be ignored.
211     */
212    private boolean isIgnored(final Class<?> type, final UML property) {
213        final Set<String> properties = ignore.get(type);
214        return (properties != null) && properties.contains(property.identifier());
215    }
216
217    /**
218     * Stores all properties of the given metadata, for later comparison against expected values.
219     * If this method is invoked more than once, then the given metadata objects shall not provide values
220     * for the same properties (unless the values are equal, or unless {@link #clear()} has been invoked).
221     *
222     * @param  actual  the metadata read from a dataset, or {@code null} if none.
223     * @throws IllegalStateException if the given metadata contains a property already found in a previous
224     *         call to this method, and the values found in those two invocations are not equal.
225     */
226    public void addMetadataToVerify(final Metadata actual) {
227        explode(Metadata.class, actual);
228    }
229
230    /**
231     * Stores all properties of the given CRS, for later comparison against expected values.
232     * In this class, a Coordinate Reference System is considered as a kind of metadata.
233     * If this method is invoked more than once, then the given CRS objects shall not provide values
234     * for the same properties (unless the values are equal, or unless {@link #clear()} has been invoked).
235     *
236     * @param  actual  the CRS read from a dataset, or {@code null} if none.
237     * @throws IllegalStateException if the given CRS contains a property already found in a previous
238     *         call to this method, and the values found in those two invocations are not equal.
239     */
240    public void addMetadataToVerify(final CoordinateReferenceSystem actual) {
241        explode(CoordinateReferenceSystem.class, actual);
242    }
243
244    /**
245     * Adds a snapshot of the given object for later comparison against expected values.
246     *
247     * @param  actual  the metadata or CRS read from a dataset, or {@code null} if none.
248     * @throws IllegalStateException if the given object contains a property already found in a previous
249     *         call to this method, and the values found in those two invocations are not equal.
250     */
251    private <T> void explode(final Class<T> type, final T actual) {
252        if (actual != null) try {
253            addPropertyValue(type, actual);
254        } catch (InvocationTargetException e) {
255            Throwable cause = e.getTargetException();
256            if (cause instanceof RuntimeException) {
257                throw (RuntimeException) cause;
258            } else if (cause instanceof Error) {
259                throw (Error) cause;
260            } else {
261                throw new RuntimeException(cause);
262            }
263        } catch (IllegalAccessException e) {
264            throw new AssertionError(e);                    // Should never happen since we invoked only public methods.
265        } finally {
266            path.setLength(0);
267            visited.clear();
268        }
269    }
270
271    /**
272     * Returns the sub-interfaces implemented by the given implementation class. For example is a property type
273     * is {@code CoordinateReferenceSystem}, a given instance could implement the {@code GeographicCRS} subtype.
274     *
275     * @param  baseType        the property type.
276     * @param  implementation  the class which may implement a specialized type.
277     * @return the given type or one of its subtypes implemented by the given class.
278     */
279    private static Class<?> specialized(final Class<?> baseType, Class<?> implementation) {
280        do {
281            for (final Class<?> s : implementation.getInterfaces()) {
282                if (baseType.isAssignableFrom(s) && s.isAnnotationPresent(UML.class)) {
283                    return s;
284                }
285            }
286            implementation = implementation.getSuperclass();
287        } while (implementation != null);
288        return baseType;
289    }
290
291    /**
292     * Adds the given value in the {@link #metadataValues} map. If the given value is another metadata object,
293     * then this method iterates recursively over all elements in that metadata. The key is the current value
294     * of {@link #path}.
295     *
296     * @param  type  the GeoAPI interface implemented by the given object, or the standard Java class if not a metadata type.
297     * @param  obj   non-null instance of {@code type} to add in the map.
298     * @throws InvocationTargetException if an error occurred while invoking client code.
299     * @throws IllegalStateException if a different metadata value is already presents for the current {@link #path} key.
300     */
301    private void addPropertyValue(Class<?> type, final Object obj) throws InvocationTargetException, IllegalAccessException {
302        if (InternationalString.class.isAssignableFrom(type) ||        // Most common case first.
303           ControlledVocabulary.class.isAssignableFrom(type) ||
304                    GenericName.class.isAssignableFrom(type) ||
305                       !type.isAnnotationPresent(UML.class))
306        {
307            final String key = path.toString();
308            final Object previous = metadataValues.put(key, obj);
309            if (previous != null && !previous.equals(obj)) {
310                throw new IllegalStateException(String.format("Metadata element \"%s\" is specified twice "
311                        + "with two different values:%nValue 1: %s%nValue 2: %s%n", key, previous, obj));
312            }
313        } else {
314            final Element recursivityGuard = new Element(type, obj);
315            if (visited.add(recursivityGuard)) {
316                final int pathElementPosition = path.length();
317                type = specialized(type, obj.getClass());               // Example: Identification may actually be DataIdentification
318                for (final Method getter : type.getMethods()) {
319                    if (getter.getParameterTypes().length != 0) {       // TODO: use getParameterCount() with JDK8.
320                        continue;
321                    }
322                    if (getter.isAnnotationPresent(Deprecated.class)) {
323                        continue;
324                    }
325                    final UML spec = getter.getAnnotation(UML.class);
326                    if (spec == null || isIgnored(type, spec)) {
327                        continue;
328                    }
329                    Class<?> valueType = getter.getReturnType();
330                    if (Void.TYPE.equals(valueType)) {
331                        continue;
332                    }
333                    final Object value = getter.invoke(obj, (Object[]) null);
334                    if (value == null) {
335                        continue;
336                    }
337                    final Iterator<?> values;
338                    if (Map.class.isAssignableFrom(valueType)) {
339                        values = ((Map<?,?>) value).keySet().iterator();
340                        if (!values.hasNext()) continue;
341                    } else if (Iterable.class.isAssignableFrom(valueType)) {
342                        values = ((Collection<?>) value).iterator();
343                        if (!values.hasNext()) continue;
344                    } else {
345                        values = null;
346                    }
347                    if (pathElementPosition != 0) {
348                        path.append('.');
349                    }
350                    path.append(spec.identifier());
351                    if (values == null) {
352                        addPropertyValue(valueType, value);
353                    } else {
354                        valueType = boundOfParameterizedProperty(getter.getGenericReturnType());
355                        final int indexPosition = path.append('[').length();
356                        int i = 0;
357                        do {
358                            path.append(i++).append(']');
359                            addPropertyValue(valueType, values.next());
360                            path.setLength(indexPosition);
361                        } while (values.hasNext());
362                    }
363                    path.setLength(pathElementPosition);
364                }
365                if (!visited.remove(recursivityGuard)) {
366                    // Should never happen unless the map is modified concurrently in another thread.
367                    throw new ConcurrentModificationException();
368                }
369            }
370        }
371    }
372
373    /**
374     * Returns the upper bounds of the parameterized type. For example if a method returns {@code Collection<String>},
375     * then {@code boundOfParameterizedProperty(method.getGenericReturnType())} should return {@code String.class}.
376     */
377    private static Class<?> boundOfParameterizedProperty(Type type) {
378        if (type instanceof ParameterizedType) {
379            Type[] p = ((ParameterizedType) type).getActualTypeArguments();
380            if (p != null && p.length == 2) {
381                final Type raw = ((ParameterizedType) type).getRawType();
382                if (raw instanceof Class<?> && Map.class.isAssignableFrom((Class<?>) raw)) {
383                    /*
384                     * If the type is a map, keep only the first type parameter (for keys type).
385                     * The type that we retain here must be consistent with the choice of iterator
386                     * (keys or values) done in above addPropertyValue(…) method.
387                     */
388                    p = Arrays.copyOf(p, 1);
389                }
390            }
391            while (p != null && p.length == 1) {
392                type = p[0];
393                if (type instanceof WildcardType) {
394                    p = ((WildcardType) type).getUpperBounds();
395                } else {
396                    if (type instanceof ParameterizedType) {
397                        type = ((ParameterizedType) type).getRawType();
398                    }
399                    if (type instanceof Class<?>) {
400                        return (Class<?>) type;
401                    }
402                    break;                              // Unknown type.
403                }
404            }
405        }
406        throw new IllegalArgumentException("Can not find the parameterized type of " + type);
407    }
408
409    /**
410     * Returns {@code true} if the given value should be considered as a "primitive" for formatting purpose.
411     * Primitive are null, numbers or booleans, but we extend this definition to enumerations and code lists.
412     */
413    private static boolean isPrimitive(final Object value) {
414        return (value == null) || (value instanceof ControlledVocabulary)
415                || (value instanceof Number) || (value instanceof Boolean);
416    }
417
418    /**
419     * Implementation of {@code compareMetadata(…)} public methods. This implementation removes properties
420     * from the given map as they are found. After this method completed, the remaining entries in the given
421     * map are properties not found in the metadata given to {@code addMetadataToVerify(…)} methods.
422     */
423    private boolean filterProperties(final Set<Map.Entry<String,Object>> entries) {
424        final Iterator<Map.Entry<String,Object>> it = entries.iterator();
425        while (it.hasNext()) {
426            final Map.Entry<String,Object> entry = it.next();
427            final String key = entry.getKey();
428            final Object actual = metadataValues.remove(key);
429            if (actual != null) {
430                it.remove();
431                final Object expected = entry.getValue();
432                if (Objects.equals(expected, actual)) {
433                    continue;
434                } else if (expected instanceof Number && actual instanceof Number) {
435                    if (expected instanceof Float) {
436                        if (Float.floatToIntBits((Float) expected) ==
437                            Float.floatToIntBits(((Number) actual).floatValue()))
438                        {
439                            continue;
440                        }
441                    } else if (expected instanceof Double) {
442                        if (Double.doubleToLongBits((Double) expected) ==
443                            Double.doubleToLongBits(((Number) actual).doubleValue()))
444                        {
445                            continue;
446                        }
447                    }
448                } else if (expected instanceof CharSequence) {
449                    // The main intent is to convert InternationalString.
450                    if (Objects.equals(expected.toString(), actual.toString())) {
451                        continue;
452                    }
453                }
454                mismatches.add(new AbstractMap.SimpleEntry<String,Object>(key, new Mismatch(expected, actual)));
455            }
456        }
457        missings.addAll(entries);
458        return mismatches.isEmpty() && metadataValues.isEmpty() && entries.isEmpty();
459    }
460
461    /**
462     * Compares actual metadata properties against the expected values given in a map.
463     * For each entry in the map, the key is a path to a metadata element like the following examples
464     * ({@code [0]} is the index of an element in lists or collections):
465     *
466     * <ul>
467     *   <li>{@code "identificationInfo[0].citation.identifier[0].code"}</li>
468     *   <li>{@code "spatialRepresentationInfo[0].axisDimensionProperties[0].dimensionSize"}</li>
469     * </ul>
470     *
471     * Values in the map are the expected values for the properties identified by the keys.
472     * Comparison result can be viewed after this method call with {@link #toString()}.
473     *
474     * @param  expected  the expected values of properties identified by the keys.
475     * @return {@code true} if all properties match, with no missing property and no unexpected property.
476     */
477    public boolean compareMetadata(final Map<String,Object> expected) {
478        return filterProperties(new LinkedHashMap<>(expected).entrySet());
479    }
480
481    /**
482     * Compares actual metadata properties against the expected values.
483     * The {@code path} argument identifies a metadata element like the following examples
484     * ({@code [0]} is the index of an element in lists or collections):
485     *
486     * <ul>
487     *   <li>{@code "identificationInfo[0].citation.identifier[0].code"}</li>
488     *   <li>{@code "spatialRepresentationInfo[0].axisDimensionProperties[0].dimensionSize"}</li>
489     * </ul>
490     *
491     * The {@code value} argument is the expected value for the property identified by the path.
492     * Comparison result can be viewed after this method call with {@link #toString()}.
493     *
494     * @param  path           path of the property to compare.
495     * @param  expectedValue  expected value for the property at the given path.
496     * @param  others         other ({@code path}, {@code expectedValue}) pairs.
497     * @return {@code true} if all properties match, with no missing property and no unexpected property.
498     */
499    public boolean compareMetadata(final String path, final Object expectedValue, final Object... others) {
500        final int length = others.length;
501        final Map<String,Object> m = new LinkedHashMap<>((length - length/2) / 2);
502        m.put(path, expectedValue);
503        for (int i=0; i<length; i++) {
504            final Object key = others[i];
505            if (!(key instanceof String)) {
506                throw new ClassCastException(String.format("others[%d] shall be a String, but given value class is %s.",
507                            i, (key != null) ? key.getClass() : null));
508            }
509            final Object value = others[++i];
510            final Object previous = m.put((String) key, value);
511            if (previous != null) {
512                throw new IllegalStateException(String.format("Metadata element \"%s\" is specified twice:"
513                        + "%nValue 1: %s%nValue 2: %s%n", key, previous, value));
514            }
515        }
516        return filterProperties(m.entrySet());
517    }
518
519    /**
520     * Asserts that actual metadata properties are equal to the expected values.
521     * The {@code path} argument identifies a metadata element like the following examples
522     * ({@code [0]} is the index of an element in lists or collections):
523     *
524     * <ul>
525     *   <li>{@code "identificationInfo[0].citation.identifier[0].code"}</li>
526     *   <li>{@code "spatialRepresentationInfo[0].axisDimensionProperties[0].dimensionSize"}</li>
527     * </ul>
528     *
529     * The {@code value} argument is the expected value for the property identified by the path.
530     * If there is any <em>mismatched</em>, <em>missing</em> or <em>unexpected</em> value, then
531     * the assertion fails with an error message listing all differences found.
532     *
533     * @param  path           path of the property to compare.
534     * @param  expectedValue  expected value for the property at the given path.
535     * @param  others         other ({@code path}, {@code expectedValue}) pairs, in any order.
536     */
537    public void assertMetadataEquals(final String path, final Object expectedValue, final Object... others) {
538        if (!compareMetadata(path, expectedValue, others)) {
539            Assert.fail(toString());
540        }
541    }
542
543    /**
544     * Returns a string representation of the comparison results.
545     * This method formats up to three blocks in a JSON-like format:
546     *
547     * <ul>
548     *   <li>List of actual values that do no match the expected values.</li>
549     *   <li>List of expected values that are missing in the actual values.</li>
550     *   <li>List of actual values that were unexpected.</li>
551     * </ul>
552     *
553     * @return a string representation of the comparison results.
554     */
555    @Override
556    public String toString() {
557        final StringBuilder buffer = new StringBuilder();
558        final String lineSeparator = System.lineSeparator();
559        formatTable("mismatches", mismatches,                buffer, lineSeparator);
560        formatTable("missings",   missings,                  buffer, lineSeparator);
561        formatTable("unexpected", metadataValues.entrySet(), buffer, lineSeparator);
562        return buffer.length() != 0 ? buffer.toString() : "No difference found.";
563    }
564
565    /**
566     * Formats the given entry as a table in the given {@link StringBuilder}.
567     */
568    private static void formatTable(final String label, final Collection<Map.Entry<String,Object>> values,
569            final StringBuilder appendTo, final String lineSeparator)
570    {
571        if (!values.isEmpty()) {
572            appendTo.append(label).append(" = {").append(lineSeparator);
573            int width = -1;
574            for (final Map.Entry<String,Object> entry : values) {
575                final int length = entry.getKey().length();
576                if (length > width) width = length;
577            }
578            width++;
579            for (final Map.Entry<String,Object> entry : values) {
580                final String key = entry.getKey();
581                appendTo.append("    \"").append(key).append("\":");
582                for (int i = width - key.length(); --i >= 0;) {
583                    appendTo.append(' ');
584                }
585                final Object value = entry.getValue();
586                if (value instanceof Mismatch) {
587                    ((Mismatch) value).toString(appendTo);
588                } else {
589                    formatValue(value, appendTo);
590                }
591                appendTo.append(',').append(lineSeparator);
592            }
593            if (width > 0) {                                                                // Paranoiac check.
594                appendTo.deleteCharAt(appendTo.length() - lineSeparator.length() - 1);      // Remove last comma.
595            }
596            appendTo.append('}').append(lineSeparator);
597        }
598    }
599
600    /**
601     * Formats the given value in the given buffer, eventually between quotes.
602     */
603    private static void formatValue(final Object value, final StringBuilder appendTo) {
604        final boolean quote = !isPrimitive(value);
605        if (quote) appendTo.append('"');
606        appendTo.append(value);
607        if (quote) appendTo.append('"');
608    }
609}