Allow single element to override array in synthesized annotation

This commit picks up where 8ff9e818a5
left off.

Specifically, this commit introduces support that allows a single
element attribute to override an array attribute with a matching
component type when synthesizing annotations (e.g., in annotations
synthesized from attributes that have been merged from the annotation
hierarchy above a composed annotation).

Issue: SPR-13972
This commit is contained in:
Sam Brannen 2016-03-07 13:27:27 +01:00
parent e086a5d58b
commit eb654dc177
4 changed files with 259 additions and 74 deletions

View File

@ -18,6 +18,7 @@ package org.springframework.core.annotation;
import java.lang.annotation.Annotation;
import java.lang.reflect.AnnotatedElement;
import java.lang.reflect.Array;
import java.lang.reflect.Method;
import java.util.LinkedHashMap;
import java.util.List;
@ -132,8 +133,16 @@ class MapAnnotationAttributeExtractor extends AbstractAliasAwareAnnotationAttrib
if (!ClassUtils.isAssignable(requiredReturnType, actualReturnType)) {
boolean converted = false;
// Single element overriding an array of the same type?
if (requiredReturnType.isArray() && requiredReturnType.getComponentType() == actualReturnType) {
Object array = Array.newInstance(requiredReturnType.getComponentType(), 1);
Array.set(array, 0, attributeValue);
attributes.put(attributeName, array);
converted = true;
}
// Nested map representing a single annotation?
if (Annotation.class.isAssignableFrom(requiredReturnType) &&
else if (Annotation.class.isAssignableFrom(requiredReturnType) &&
Map.class.isAssignableFrom(actualReturnType)) {
Class<? extends Annotation> nestedAnnotationType =
(Class<? extends Annotation>) requiredReturnType;

View File

@ -33,8 +33,11 @@ import javax.annotation.Resource;
import org.junit.Ignore;
import org.junit.Rule;
import org.junit.Test;
import org.junit.internal.ArrayComparisonFailure;
import org.junit.rules.ExpectedException;
import org.springframework.core.annotation.AnnotationUtilsTests.WebController;
import org.springframework.core.annotation.AnnotationUtilsTests.WebMapping;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.util.MultiValueMap;
@ -44,6 +47,7 @@ import static java.util.stream.Collectors.*;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.*;
import static org.springframework.core.annotation.AnnotatedElementUtils.*;
import static org.springframework.core.annotation.AnnotationUtilsTests.*;
/**
* Unit tests for {@link AnnotatedElementUtils}.
@ -379,11 +383,21 @@ public class AnnotatedElementUtilsTests {
assertGetMergedAnnotation(TransitiveImplicitAliasesContextConfigClass.class, "test.groovy");
}
@Test
public void getMergedAnnotationWithTransitiveImplicitAliasesWithSingleElementOverridingAnArrayViaAliasFor() {
assertGetMergedAnnotation(SingleLocationTransitiveImplicitAliasesContextConfigClass.class, "test.groovy");
}
@Test
public void getMergedAnnotationWithTransitiveImplicitAliasesWithSkippedLevel() {
assertGetMergedAnnotation(TransitiveImplicitAliasesWithSkippedLevelContextConfigClass.class, "test.xml");
}
@Test
public void getMergedAnnotationWithTransitiveImplicitAliasesWithSkippedLevelWithSingleElementOverridingAnArrayViaAliasFor() {
assertGetMergedAnnotation(SingleLocationTransitiveImplicitAliasesWithSkippedLevelContextConfigClass.class, "test.xml");
}
private void assertGetMergedAnnotation(Class<?> element, String... expected) {
String name = ContextConfig.class.getName();
ContextConfig contextConfig = getMergedAnnotation(element, ContextConfig.class);
@ -560,6 +574,53 @@ public class AnnotatedElementUtilsTests {
assertEquals("TX qualifier via synthesized annotation.", qualifier, annotation.qualifier());
}
@Test
public void findMergedAnnotationAttributesOnClassWithAttributeAliasInComposedAnnotationAndNestedAnnotationsInTargetAnnotation() {
AnnotationAttributes attributes = assertComponentScanAttributes(TestComponentScanClass.class,
"com.example.app.test");
Filter[] excludeFilters = attributes.getAnnotationArray("excludeFilters", Filter.class);
assertNotNull(excludeFilters);
List<String> patterns = stream(excludeFilters).map(Filter::pattern).collect(toList());
assertEquals(asList("*Test", "*Tests"), patterns);
}
/**
* This test ensures that {@link AnnotationUtils#postProcessAnnotationAttributes}
* uses {@code ObjectUtils.nullSafeEquals()} to check for equality between annotation
* attributes since attributes may be arrays.
*/
@Test
public void findMergedAnnotationAttributesOnClassWithBothAttributesOfAnAliasPairDeclared() {
assertComponentScanAttributes(ComponentScanWithBasePackagesAndValueAliasClass.class, "com.example.app.test");
}
@Test
public void findMergedAnnotationAttributesWithSingleElementOverridingAnArrayViaConvention() {
assertComponentScanAttributes(ConventionBasedSinglePackageComponentScanClass.class, "com.example.app.test");
}
@Test
public void findMergedAnnotationAttributesWithSingleElementOverridingAnArrayViaAliasFor() {
assertComponentScanAttributes(AliasForBasedSinglePackageComponentScanClass.class, "com.example.app.test");
}
private AnnotationAttributes assertComponentScanAttributes(Class<?> element, String... expected) {
AnnotationAttributes attributes = findMergedAnnotationAttributes(element, ComponentScan.class);
assertNotNull("Should find @ComponentScan on " + element, attributes);
assertArrayEquals("value: ", expected, attributes.getStringArray("value"));
assertArrayEquals("basePackages: ", expected, attributes.getStringArray("basePackages"));
return attributes;
}
private AnnotationAttributes findMergedAnnotationAttributes(AnnotatedElement element, Class<? extends Annotation> annotationType) {
Assert.notNull(annotationType, "annotationType must not be null");
return AnnotatedElementUtils.findMergedAnnotationAttributes(element, annotationType.getName(), false, false);
}
@Test
public void findMergedAnnotationWithAttributeAliasesInTargetAnnotation() {
Class<?> element = AliasedTransactionalComponentClass.class;
@ -593,38 +654,6 @@ public class AnnotatedElementUtilsTests {
assertArrayEquals("value", propFiles, testPropSource.value());
}
@Test
public void findMergedAnnotationAttributesOnClassWithAttributeAliasInComposedAnnotationAndNestedAnnotationsInTargetAnnotation() {
String[] expected = asArray("com.example.app.test");
Class<?> element = TestComponentScanClass.class;
AnnotationAttributes attributes = findMergedAnnotationAttributes(element, ComponentScan.class);
assertNotNull("Should find @ComponentScan on " + element, attributes);
assertArrayEquals("basePackages for " + element, expected, attributes.getStringArray("basePackages"));
Filter[] excludeFilters = attributes.getAnnotationArray("excludeFilters", Filter.class);
assertNotNull(excludeFilters);
List<String> patterns = stream(excludeFilters).map(Filter::pattern).collect(toList());
assertEquals(asList("*Test", "*Tests"), patterns);
}
/**
* This test ensures that {@link AnnotationUtils#postProcessAnnotationAttributes}
* uses {@code ObjectUtils.nullSafeEquals()} to check for equality between annotation
* attributes since attributes may be arrays.
*/
@Test
public void findMergedAnnotationAttributesOnClassWithBothAttributesOfAnAliasPairDeclared() {
String[] expected = asArray("com.example.app.test");
Class<?> element = ComponentScanWithBasePackagesAndValueAliasClass.class;
AnnotationAttributes attributes = findMergedAnnotationAttributes(element, ComponentScan.class);
assertNotNull("Should find @ComponentScan on " + element, attributes);
assertArrayEquals("value: ", expected, attributes.getStringArray("value"));
assertArrayEquals("basePackages: ", expected, attributes.getStringArray("basePackages"));
}
@Test
public void findMergedAnnotationWithLocalAliasesThatConflictWithAttributesInMetaAnnotationByConvention() {
final String[] EMPTY = new String[0];
@ -638,6 +667,24 @@ public class AnnotatedElementUtilsTests {
assertArrayEquals("classes for " + element, new Class<?>[] {Number.class}, contextConfig.classes());
}
@Test
public void findMergedAnnotationWithSingleElementOverridingAnArrayViaConvention() throws Exception {
assertWebMapping(WebController.class.getMethod("postMappedWithPathAttribute"));
}
@Test
public void findMergedAnnotationWithSingleElementOverridingAnArrayViaAliasFor() throws Exception {
assertWebMapping(WebController.class.getMethod("getMappedWithValueAttribute"));
assertWebMapping(WebController.class.getMethod("getMappedWithPathAttribute"));
}
private void assertWebMapping(AnnotatedElement element) throws ArrayComparisonFailure {
WebMapping webMapping = findMergedAnnotation(element, WebMapping.class);
assertNotNull(webMapping);
assertArrayEquals("value attribute: ", asArray("/test"), webMapping.value());
assertArrayEquals("path attribute: ", asArray("/test"), webMapping.path());
}
@Test
public void javaLangAnnotationTypeViaFindMergedAnnotation() throws Exception {
Constructor<?> deprecatedCtor = Date.class.getConstructor(String.class);
@ -651,28 +698,10 @@ public class AnnotatedElementUtilsTests {
assertEquals(SpringAppConfigClass.class.getAnnotation(Resource.class), findMergedAnnotation(SpringAppConfigClass.class, Resource.class));
}
private Set<String> names(Class<?>... classes) {
return stream(classes).map(Class::getName).collect(toSet());
}
@SafeVarargs
// The following "varargs" suppression is necessary for javac from OpenJDK
// (1.8.0_60-b27); however, Eclipse warns that it's unnecessary. See the following
// Eclipse issues for details.
//
// https://bugs.eclipse.org/bugs/show_bug.cgi?id=344783
// https://bugs.eclipse.org/bugs/show_bug.cgi?id=349669#c10
// @SuppressWarnings("varargs")
private static <T> T[] asArray(T... arr) {
return arr;
}
private static AnnotationAttributes findMergedAnnotationAttributes(AnnotatedElement element, Class<? extends Annotation> annotationType) {
Assert.notNull(annotationType, "annotationType must not be null");
return AnnotatedElementUtils.findMergedAnnotationAttributes(element, annotationType.getName(), false, false);
}
// -------------------------------------------------------------------------
@ -881,6 +910,17 @@ public class AnnotatedElementUtilsTests {
String[] groovy() default {};
}
@ImplicitAliasesContextConfig
@Retention(RetentionPolicy.RUNTIME)
@interface SingleLocationTransitiveImplicitAliasesContextConfig {
@AliasFor(annotation = ImplicitAliasesContextConfig.class, attribute = "xmlFiles")
String xml() default "";
@AliasFor(annotation = ImplicitAliasesContextConfig.class, attribute = "groovyScripts")
String groovy() default "";
}
@ImplicitAliasesContextConfig
@Retention(RetentionPolicy.RUNTIME)
@interface TransitiveImplicitAliasesWithSkippedLevelContextConfig {
@ -892,6 +932,17 @@ public class AnnotatedElementUtilsTests {
String[] groovy() default {};
}
@ImplicitAliasesContextConfig
@Retention(RetentionPolicy.RUNTIME)
@interface SingleLocationTransitiveImplicitAliasesWithSkippedLevelContextConfig {
@AliasFor(annotation = ContextConfig.class, attribute = "locations")
String xml() default "";
@AliasFor(annotation = ImplicitAliasesContextConfig.class, attribute = "groovyScripts")
String groovy() default "";
}
/**
* Invalid because the configuration declares a value for 'value' and
* requires a value for the aliased 'locations'. So we likely end up with
@ -958,6 +1009,21 @@ public class AnnotatedElementUtilsTests {
@Retention(RetentionPolicy.RUNTIME)
@interface TestComponentScan {
@AliasFor(attribute = "basePackages", annotation = ComponentScan.class)
String[] packages();
}
@ComponentScan
@Retention(RetentionPolicy.RUNTIME)
@interface ConventionBasedSinglePackageComponentScan {
String basePackages();
}
@ComponentScan
@Retention(RetentionPolicy.RUNTIME)
@interface AliasForBasedSinglePackageComponentScan {
@AliasFor(attribute = "basePackages", annotation = ComponentScan.class)
String pkg();
}
@ -1132,10 +1198,18 @@ public class AnnotatedElementUtilsTests {
static class TransitiveImplicitAliasesContextConfigClass {
}
@SingleLocationTransitiveImplicitAliasesContextConfig(groovy = "test.groovy")
static class SingleLocationTransitiveImplicitAliasesContextConfigClass {
}
@TransitiveImplicitAliasesWithSkippedLevelContextConfig(xml = "test.xml")
static class TransitiveImplicitAliasesWithSkippedLevelContextConfigClass {
}
@SingleLocationTransitiveImplicitAliasesWithSkippedLevelContextConfig(xml = "test.xml")
static class SingleLocationTransitiveImplicitAliasesWithSkippedLevelContextConfigClass {
}
@ComposedImplicitAliasesContextConfig
static class ComposedImplicitAliasesContextConfigClass {
}
@ -1152,10 +1226,18 @@ public class AnnotatedElementUtilsTests {
static class ComponentScanWithBasePackagesAndValueAliasClass {
}
@TestComponentScan(pkg = "com.example.app.test")
@TestComponentScan(packages = "com.example.app.test")
static class TestComponentScanClass {
}
@ConventionBasedSinglePackageComponentScan(basePackages = "com.example.app.test")
static class ConventionBasedSinglePackageComponentScanClass {
}
@AliasForBasedSinglePackageComponentScan(pkg = "com.example.app.test")
static class AliasForBasedSinglePackageComponentScanClass {
}
@SpringAppConfig(Number.class)
static class SpringAppConfigClass {
}

View File

@ -465,8 +465,8 @@ public class AnnotationUtilsTests {
assertNotNull(attributes);
assertEquals(WebMapping.class, attributes.annotationType());
assertEquals("name attribute: ", "foo", attributes.getString("name"));
assertEquals("value attribute: ", "/test", attributes.getString(VALUE));
assertEquals("path attribute: ", "/test", attributes.getString("path"));
assertArrayEquals("value attribute: ", asArray("/test"), attributes.getStringArray(VALUE));
assertArrayEquals("path attribute: ", asArray("/test"), attributes.getStringArray("path"));
method = WebController.class.getMethod("handleMappedWithPathAttribute");
webMapping = method.getAnnotation(WebMapping.class);
@ -474,15 +474,18 @@ public class AnnotationUtilsTests {
assertNotNull(attributes);
assertEquals(WebMapping.class, attributes.annotationType());
assertEquals("name attribute: ", "bar", attributes.getString("name"));
assertEquals("value attribute: ", "/test", attributes.getString(VALUE));
assertEquals("path attribute: ", "/test", attributes.getString("path"));
assertArrayEquals("value attribute: ", asArray("/test"), attributes.getStringArray(VALUE));
assertArrayEquals("path attribute: ", asArray("/test"), attributes.getStringArray("path"));
}
@Test
public void getAnnotationAttributesWithAttributeAliasesWithDifferentValues() throws Exception {
exception.expect(AnnotationConfigurationException.class);
exception.expectMessage(containsString("attribute 'value' and its alias 'path'"));
exception.expectMessage(containsString("values of [/enigma] and [/test]"));
exception.expectMessage(containsString("values of [{/enigma}] and [{/test}]"));
method = WebController.class.getMethod("handleMappedWithDifferentPathAndValueAttributes");
webMapping = method.getAnnotation(WebMapping.class);
Method method = WebController.class.getMethod("handleMappedWithDifferentPathAndValueAttributes");
WebMapping webMapping = method.getAnnotation(WebMapping.class);
getAnnotationAttributes(webMapping);
}
@ -835,8 +838,8 @@ public class AnnotationUtilsTests {
assertSame(synthesizedWebMapping, synthesizedAgainWebMapping);
assertEquals("name attribute: ", "foo", synthesizedAgainWebMapping.name());
assertEquals("aliased path attribute: ", "/test", synthesizedAgainWebMapping.path());
assertEquals("actual value attribute: ", "/test", synthesizedAgainWebMapping.value());
assertArrayEquals("aliased path attribute: ", asArray("/test"), synthesizedAgainWebMapping.path());
assertArrayEquals("actual value attribute: ", asArray("/test"), synthesizedAgainWebMapping.value());
}
@Test
@ -956,16 +959,16 @@ public class AnnotationUtilsTests {
assertNotSame(webMapping, synthesizedWebMapping1);
assertEquals("name attribute: ", "foo", synthesizedWebMapping1.name());
assertEquals("aliased path attribute: ", "/test", synthesizedWebMapping1.path());
assertEquals("actual value attribute: ", "/test", synthesizedWebMapping1.value());
assertArrayEquals("aliased path attribute: ", asArray("/test"), synthesizedWebMapping1.path());
assertArrayEquals("actual value attribute: ", asArray("/test"), synthesizedWebMapping1.value());
WebMapping synthesizedWebMapping2 = synthesizeAnnotation(webMapping);
assertThat(synthesizedWebMapping2, instanceOf(SynthesizedAnnotation.class));
assertNotSame(webMapping, synthesizedWebMapping2);
assertEquals("name attribute: ", "foo", synthesizedWebMapping2.name());
assertEquals("aliased path attribute: ", "/test", synthesizedWebMapping2.path());
assertEquals("actual value attribute: ", "/test", synthesizedWebMapping2.value());
assertArrayEquals("aliased path attribute: ", asArray("/test"), synthesizedWebMapping2.path());
assertArrayEquals("actual value attribute: ", asArray("/test"), synthesizedWebMapping2.value());
}
@Test
@ -1182,6 +1185,21 @@ public class AnnotationUtilsTests {
assertEquals("location: ", "test.xml", contextConfig.location());
}
@Test
public void synthesizeAnnotationFromMapWithAttributeAliasesThatOverrideArraysWithSingleElements() throws Exception {
Map<String, Object> map = Collections.singletonMap("value", "/foo");
Get get = synthesizeAnnotation(map, Get.class, null);
assertNotNull(get);
assertEquals("value: ", "/foo", get.value());
assertEquals("path: ", "/foo", get.path());
map = Collections.singletonMap("path", "/foo");
get = synthesizeAnnotation(map, Get.class, null);
assertNotNull(get);
assertEquals("value: ", "/foo", get.value());
assertEquals("path: ", "/foo", get.path());
}
@Test
public void synthesizeAnnotationFromMapWithImplicitAttributeAliases() throws Exception {
assertAnnotationSynthesisFromMapWithImplicitAliases("value");
@ -1287,8 +1305,8 @@ public class AnnotationUtilsTests {
private void assertToStringForWebMappingWithPathAndValue(WebMapping webMapping) {
String string = webMapping.toString();
assertThat(string, startsWith("@" + WebMapping.class.getName() + "("));
assertThat(string, containsString("value=/test"));
assertThat(string, containsString("path=/test"));
assertThat(string, containsString("value=[/test]"));
assertThat(string, containsString("path=[/test]"));
assertThat(string, containsString("name=bar"));
assertThat(string, containsString("method="));
assertThat(string, containsString("[GET, POST]"));
@ -1461,6 +1479,18 @@ public class AnnotationUtilsTests {
assertArrayEquals(new char[] { 'x', 'y', 'z' }, chars);
}
@SafeVarargs
// The following "varargs" suppression is necessary for javac from OpenJDK
// (1.8.0_60-b27); however, Eclipse warns that it's unnecessary. See the following
// Eclipse issues for details.
//
// https://bugs.eclipse.org/bugs/show_bug.cgi?id=344783
// https://bugs.eclipse.org/bugs/show_bug.cgi?id=349669#c10
// @SuppressWarnings("varargs")
static <T> T[] asArray(T... arr) {
return arr;
}
@Component("meta1")
@Order
@ -1768,14 +1798,40 @@ public class AnnotationUtilsTests {
String name();
@AliasFor("path")
String value() default "";
String[] value() default "";
@AliasFor(attribute = "value")
String path() default "";
String[] path() default "";
RequestMethod[] method() default {};
}
/**
* Mock of {@code org.springframework.web.bind.annotation.GetMapping}, except
* that the String arrays are overridden with single String elements.
*/
@Retention(RetentionPolicy.RUNTIME)
@WebMapping(method = RequestMethod.GET, name = "")
@interface Get {
@AliasFor(annotation = WebMapping.class)
String value() default "";
@AliasFor(annotation = WebMapping.class)
String path() default "";
}
/**
* Mock of {@code org.springframework.web.bind.annotation.PostMapping}, except
* that the path is overridden by convention with single String element.
*/
@Retention(RetentionPolicy.RUNTIME)
@WebMapping(method = RequestMethod.POST, name = "")
@interface Post {
String path() default "";
}
@Component("webController")
static class WebController {
@ -1787,6 +1843,18 @@ public class AnnotationUtilsTests {
public void handleMappedWithPathAttribute() {
}
@Get("/test")
public void getMappedWithValueAttribute() {
}
@Get(path = "/test")
public void getMappedWithPathAttribute() {
}
@Post(path = "/test")
public void postMappedWithPathAttribute() {
}
/**
* mapping is logically "equal" to handleMappedWithPathAttribute().
*/

View File

@ -1,5 +1,5 @@
/*
* Copyright 2002-2015 the original author or authors.
* Copyright 2002-2016 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.
@ -26,11 +26,14 @@ import org.junit.Before;
import org.junit.Test;
import org.springframework.core.annotation.AnnotationUtilsTests.ImplicitAliasesContextConfig;
import org.springframework.core.annotation.AnnotationUtilsTests.RequestMethod;
import org.springframework.core.annotation.AnnotationUtilsTests.WebMapping;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.*;
import static org.springframework.core.annotation.AnnotationUtilsTests.*;
/**
* Unit tests for {@link MapAnnotationAttributeExtractor}.
@ -38,6 +41,7 @@ import static org.junit.Assert.*;
* @author Sam Brannen
* @since 4.2.1
*/
@SuppressWarnings("serial")
public class MapAnnotationAttributeExtractorTests extends AbstractAliasAwareAnnotationAttributeExtractorTestCase {
@Before
@ -46,7 +50,6 @@ public class MapAnnotationAttributeExtractorTests extends AbstractAliasAwareAnno
}
@Test
@SuppressWarnings("serial")
public void enrichAndValidateAttributesWithImplicitAliasesAndMinimalAttributes() {
Map<String, Object> attributes = new HashMap<String, Object>();
Map<String, Object> expectedAttributes = new HashMap<String, Object>() {{
@ -64,7 +67,6 @@ public class MapAnnotationAttributeExtractorTests extends AbstractAliasAwareAnno
}
@Test
@SuppressWarnings("serial")
public void enrichAndValidateAttributesWithImplicitAliases() {
Map<String, Object> attributes = new HashMap<String, Object>() {{
put("groovyScript", "groovy!");
@ -84,6 +86,31 @@ public class MapAnnotationAttributeExtractorTests extends AbstractAliasAwareAnno
assertEnrichAndValidateAttributes(attributes, expectedAttributes);
}
@Test
public void enrichAndValidateAttributesWithSingleElementThatOverridesAnArray() {
// @formatter:off
Map<String, Object> attributes = new HashMap<String, Object>() {{
// Intentionally storing 'value' as a single String instead of an array.
// put("value", asArray("/foo"));
put("value", "/foo");
put("name", "test");
}};
Map<String, Object> expected = new HashMap<String, Object>() {{
put("value", asArray("/foo"));
put("path", asArray("/foo"));
put("name", "test");
put("method", new RequestMethod[0]);
}};
// @formatter:on
MapAnnotationAttributeExtractor extractor = new MapAnnotationAttributeExtractor(attributes, WebMapping.class, null);
Map<String, Object> enriched = extractor.getSource();
assertEquals("attribute map size", expected.size(), enriched.size());
expected.forEach((attr, expectedValue) -> assertThat("for attribute '" + attr + "'", enriched.get(attr), is(expectedValue)));
}
@SuppressWarnings("unchecked")
private void assertEnrichAndValidateAttributes(Map<String, Object> sourceAttributes, Map<String, Object> expected) {
Class<? extends Annotation> annotationType = ImplicitAliasesContextConfig.class;
@ -114,8 +141,7 @@ public class MapAnnotationAttributeExtractorTests extends AbstractAliasAwareAnno
Map<String, Object> enriched = extractor.getSource();
assertEquals("attribute map size", expected.size(), enriched.size());
expected.keySet().stream().forEach( attr ->
assertThat("for attribute '" + attr + "'", enriched.get(attr), is(expected.get(attr))));
expected.forEach((attr, expectedValue) -> assertThat("for attribute '" + attr + "'", enriched.get(attr), is(expectedValue)));
}
@Override