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}