Skip to content

Commit 3d58e99

Browse files
authored
Make hasProperty(), hasPropertyAtPath(), samePropertyValuesAs() work for Java Records (#426)
Make hasProperty(), hasPropertyAtPath(), samePropertyValuesAs() work for Java Records Resolves #392
1 parent f089c7e commit 3d58e99

9 files changed

+437
-49
lines changed

hamcrest/src/main/java/org/hamcrest/Condition.java

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ public abstract class Condition<T> {
1919
* @param <I> the initial value type
2020
* @param <O> the next step value type
2121
*/
22+
@FunctionalInterface
2223
public interface Step<I, O> {
2324
/**
2425
* Apply this condition to a value

hamcrest/src/main/java/org/hamcrest/beans/HasProperty.java

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import org.hamcrest.Description;
44
import org.hamcrest.Matcher;
55
import org.hamcrest.TypeSafeMatcher;
6-
import org.hamcrest.collection.ArrayMatching;
76

87
/**
98
* A matcher that checks if an object has a JavaBean property with the
@@ -31,7 +30,8 @@ public HasProperty(String propertyName) {
3130
@Override
3231
public boolean matchesSafely(T obj) {
3332
try {
34-
return PropertyUtil.getPropertyDescriptor(propertyName, obj) != null;
33+
return PropertyUtil.getPropertyDescriptor(propertyName, obj) != null ||
34+
PropertyUtil.getMethodDescriptor(propertyName, obj) != null;
3535
} catch (IllegalArgumentException e) {
3636
return false;
3737
}

hamcrest/src/main/java/org/hamcrest/beans/HasPropertyWithValue.java

+31-30
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import org.hamcrest.Matcher;
66
import org.hamcrest.TypeSafeDiagnosingMatcher;
77

8+
import java.beans.FeatureDescriptor;
9+
import java.beans.MethodDescriptor;
810
import java.beans.PropertyDescriptor;
911
import java.lang.reflect.InvocationTargetException;
1012
import java.lang.reflect.Method;
@@ -26,7 +28,7 @@
2628
* <h2>Example Usage</h2>
2729
* Consider the situation where we have a class representing a person, which
2830
* follows the basic JavaBean convention of having get() and possibly set()
29-
* methods for it's properties:
31+
* methods for its properties:
3032
* <pre>{@code public class Person {
3133
* private String name;
3234
* public Person(String person) {
@@ -69,7 +71,7 @@
6971
*/
7072
public class HasPropertyWithValue<T> extends TypeSafeDiagnosingMatcher<T> {
7173

72-
private static final Condition.Step<PropertyDescriptor, Method> WITH_READ_METHOD = withReadMethod();
74+
private static final Condition.Step<FeatureDescriptor, Method> WITH_READ_METHOD = withReadMethod();
7375
private final String propertyName;
7476
private final Matcher<Object> valueMatcher;
7577
private final String messageFormat;
@@ -111,8 +113,11 @@ public void describeTo(Description description) {
111113
.appendDescriptionOf(valueMatcher).appendText(")");
112114
}
113115

114-
private Condition<PropertyDescriptor> propertyOn(T bean, Description mismatch) {
115-
PropertyDescriptor property = PropertyUtil.getPropertyDescriptor(propertyName, bean);
116+
private Condition<FeatureDescriptor> propertyOn(T bean, Description mismatch) {
117+
FeatureDescriptor property = PropertyUtil.getPropertyDescriptor(propertyName, bean);
118+
if (property == null) {
119+
property = PropertyUtil.getMethodDescriptor(propertyName, bean);
120+
}
116121
if (property == null) {
117122
mismatch.appendText("No property \"" + propertyName + "\"");
118123
return notMatched();
@@ -122,22 +127,19 @@ private Condition<PropertyDescriptor> propertyOn(T bean, Description mismatch) {
122127
}
123128

124129
private Condition.Step<Method, Object> withPropertyValue(final T bean) {
125-
return new Condition.Step<Method, Object>() {
126-
@Override
127-
public Condition<Object> apply(Method readMethod, Description mismatch) {
128-
try {
129-
return matched(readMethod.invoke(bean, NO_ARGUMENTS), mismatch);
130-
} catch (InvocationTargetException e) {
131-
mismatch
132-
.appendText("Calling '")
133-
.appendText(readMethod.toString())
134-
.appendText("': ")
135-
.appendValue(e.getTargetException().getMessage());
136-
return notMatched();
137-
} catch (Exception e) {
138-
throw new IllegalStateException(
139-
"Calling: '" + readMethod + "' should not have thrown " + e);
140-
}
130+
return (readMethod, mismatch) -> {
131+
try {
132+
return matched(readMethod.invoke(bean, NO_ARGUMENTS), mismatch);
133+
} catch (InvocationTargetException e) {
134+
mismatch
135+
.appendText("Calling '")
136+
.appendText(readMethod.toString())
137+
.appendText("': ")
138+
.appendValue(e.getTargetException().getMessage());
139+
return notMatched();
140+
} catch (Exception e) {
141+
throw new IllegalStateException(
142+
"Calling: '" + readMethod + "' should not have thrown " + e);
141143
}
142144
};
143145
}
@@ -147,17 +149,16 @@ private static Matcher<Object> nastyGenericsWorkaround(Matcher<?> valueMatcher)
147149
return (Matcher<Object>) valueMatcher;
148150
}
149151

150-
private static Condition.Step<PropertyDescriptor, Method> withReadMethod() {
151-
return new Condition.Step<PropertyDescriptor, java.lang.reflect.Method>() {
152-
@Override
153-
public Condition<Method> apply(PropertyDescriptor property, Description mismatch) {
154-
final Method readMethod = property.getReadMethod();
155-
if (null == readMethod) {
156-
mismatch.appendText("property \"" + property.getName() + "\" is not readable");
157-
return notMatched();
158-
}
159-
return matched(readMethod, mismatch);
152+
private static Condition.Step<FeatureDescriptor, Method> withReadMethod() {
153+
return (property, mismatch) -> {
154+
final Method readMethod = property instanceof PropertyDescriptor ?
155+
((PropertyDescriptor) property).getReadMethod() :
156+
(((MethodDescriptor) property).getMethod());
157+
if (null == readMethod || readMethod.getReturnType() == void.class) {
158+
mismatch.appendText("property \"" + property.getName() + "\" is not readable");
159+
return notMatched();
160160
}
161+
return matched(readMethod, mismatch);
161162
};
162163
}
163164

hamcrest/src/main/java/org/hamcrest/beans/PropertyUtil.java

+75-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
import java.beans.IntrospectionException;
44
import java.beans.Introspector;
5+
import java.beans.MethodDescriptor;
56
import java.beans.PropertyDescriptor;
7+
import java.lang.reflect.Field;
8+
import java.util.Arrays;
9+
import java.util.Set;
10+
import java.util.stream.Collectors;
611

712
/**
813
* Utility class with static methods for accessing properties on JavaBean objects.
@@ -11,6 +16,7 @@
1116
*
1217
* @author Iain McGinniss
1318
* @author Steve Freeman
19+
* @author Uno Kim
1420
* @since 1.1.0
1521
*/
1622
public class PropertyUtil {
@@ -27,7 +33,7 @@ private PropertyUtil() {
2733
* @param fromObj
2834
* the object to check.
2935
* @return the descriptor of the property, or null if the property does not exist.
30-
* @throws IllegalArgumentException if there's a introspection failure
36+
* @throws IllegalArgumentException if there's an introspection failure
3137
*/
3238
public static PropertyDescriptor getPropertyDescriptor(String propertyName, Object fromObj) throws IllegalArgumentException {
3339
for (PropertyDescriptor property : propertyDescriptorsFor(fromObj, null)) {
@@ -45,7 +51,7 @@ public static PropertyDescriptor getPropertyDescriptor(String propertyName, Obje
4551
* @param fromObj Use the class of this object
4652
* @param stopClass Don't include any properties from this ancestor class upwards.
4753
* @return Property descriptors
48-
* @throws IllegalArgumentException if there's a introspection failure
54+
* @throws IllegalArgumentException if there's an introspection failure
4955
*/
5056
public static PropertyDescriptor[] propertyDescriptorsFor(Object fromObj, Class<Object> stopClass) throws IllegalArgumentException {
5157
try {
@@ -55,6 +61,73 @@ public static PropertyDescriptor[] propertyDescriptorsFor(Object fromObj, Class<
5561
}
5662
}
5763

64+
/**
65+
* Returns the description of the read accessor method with the provided
66+
* name on the provided object's interface.
67+
* This is what you need when you try to find a property from a target object
68+
* when it doesn't follow standard JavaBean specification, a Java Record for example.
69+
*
70+
* @param propertyName the object property name.
71+
* @param fromObj the object to check.
72+
* @return the descriptor of the method, or null if the method does not exist.
73+
* @throws IllegalArgumentException if there's an introspection failure
74+
* @see <a href="https://docs.oracle.com/en/java/javase/17/language/records.html">Java Records</a>
75+
*
76+
*/
77+
public static MethodDescriptor getMethodDescriptor(String propertyName, Object fromObj) throws IllegalArgumentException {
78+
for (MethodDescriptor method : recordReadAccessorMethodDescriptorsFor(fromObj, null)) {
79+
if (method.getName().equals(propertyName)) {
80+
return method;
81+
}
82+
}
83+
84+
return null;
85+
}
86+
87+
/**
88+
* Returns read accessor method descriptors for the class associated with the given object.
89+
* This is useful when you find getter methods for the fields from the object
90+
* when it doesn't follow standard JavaBean specification, a Java Record for example.
91+
* Be careful as this doesn't return standard JavaBean getter methods, like a method starting with {@code get-}.
92+
*
93+
* @param fromObj Use the class of this object
94+
* @param stopClass Don't include any properties from this ancestor class upwards.
95+
* @return Method descriptors for read accessor methods
96+
* @throws IllegalArgumentException if there's an introspection failure
97+
*/
98+
public static MethodDescriptor[] recordReadAccessorMethodDescriptorsFor(Object fromObj, Class<Object> stopClass) throws IllegalArgumentException {
99+
try {
100+
Set<String> recordComponentNames = getFieldNames(fromObj);
101+
MethodDescriptor[] methodDescriptors = Introspector.getBeanInfo(fromObj.getClass(), stopClass).getMethodDescriptors();
102+
103+
return Arrays.stream(methodDescriptors)
104+
.filter(x -> recordComponentNames.contains(x.getDisplayName()))
105+
.filter(x -> x.getMethod().getReturnType() != void.class)
106+
.filter(x -> x.getMethod().getParameterCount() == 0)
107+
.toArray(MethodDescriptor[]::new);
108+
} catch (IntrospectionException e) {
109+
throw new IllegalArgumentException("Could not get method descriptors for " + fromObj.getClass(), e);
110+
}
111+
}
112+
113+
/**
114+
* Returns the field names of the given object.
115+
* It can be the names of the record components of Java Records, for example.
116+
*
117+
* @param fromObj the object to check
118+
* @return The field names
119+
* @throws IllegalArgumentException if there's a security issue reading the fields
120+
*/
121+
public static Set<String> getFieldNames(Object fromObj) throws IllegalArgumentException {
122+
try {
123+
return Arrays.stream(fromObj.getClass().getDeclaredFields())
124+
.map(Field::getName)
125+
.collect(Collectors.toSet());
126+
} catch (SecurityException e) {
127+
throw new IllegalArgumentException("Could not get record component names for " + fromObj.getClass(), e);
128+
}
129+
}
130+
58131
/**
59132
* Empty object array, used for documenting that we are deliberately passing no arguments to a method.
60133
*/

hamcrest/src/main/java/org/hamcrest/beans/SamePropertyValuesAs.java

+21-12
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@
44
import org.hamcrest.DiagnosingMatcher;
55
import org.hamcrest.Matcher;
66

7+
import java.beans.FeatureDescriptor;
8+
import java.beans.MethodDescriptor;
79
import java.beans.PropertyDescriptor;
810
import java.lang.reflect.Method;
911
import java.util.*;
1012

1113
import static java.util.Arrays.asList;
1214
import static org.hamcrest.beans.PropertyUtil.NO_ARGUMENTS;
1315
import static org.hamcrest.beans.PropertyUtil.propertyDescriptorsFor;
16+
import static org.hamcrest.beans.PropertyUtil.recordReadAccessorMethodDescriptorsFor;
1417
import static org.hamcrest.core.IsEqual.equalTo;
1518

1619
/**
@@ -33,7 +36,11 @@ public class SamePropertyValuesAs<T> extends DiagnosingMatcher<T> {
3336
*/
3437
@SuppressWarnings("WeakerAccess")
3538
public SamePropertyValuesAs(T expectedBean, List<String> ignoredProperties) {
36-
PropertyDescriptor[] descriptors = propertyDescriptorsFor(expectedBean, Object.class);
39+
FeatureDescriptor[] descriptors = propertyDescriptorsFor(expectedBean, Object.class);
40+
if (descriptors == null || descriptors.length == 0) {
41+
descriptors = recordReadAccessorMethodDescriptorsFor(expectedBean, Object.class);
42+
}
43+
3744
this.expectedBean = expectedBean;
3845
this.ignoredFields = ignoredProperties;
3946
this.propertyNames = propertyNamesFrom(descriptors, ignoredProperties);
@@ -87,27 +94,27 @@ private boolean hasMatchingValues(Object actual, Description mismatchDescription
8794
return true;
8895
}
8996

90-
private static <T> List<PropertyMatcher> propertyMatchersFor(T bean, PropertyDescriptor[] descriptors, List<String> ignoredFields) {
97+
private static <T> List<PropertyMatcher> propertyMatchersFor(T bean, FeatureDescriptor[] descriptors, List<String> ignoredFields) {
9198
List<PropertyMatcher> result = new ArrayList<>(descriptors.length);
92-
for (PropertyDescriptor propertyDescriptor : descriptors) {
93-
if (isIgnored(ignoredFields, propertyDescriptor)) {
94-
result.add(new PropertyMatcher(propertyDescriptor, bean));
99+
for (FeatureDescriptor descriptor : descriptors) {
100+
if (isNotIgnored(ignoredFields, descriptor)) {
101+
result.add(new PropertyMatcher(descriptor, bean));
95102
}
96103
}
97104
return result;
98105
}
99106

100-
private static Set<String> propertyNamesFrom(PropertyDescriptor[] descriptors, List<String> ignoredFields) {
107+
private static Set<String> propertyNamesFrom(FeatureDescriptor[] descriptors, List<String> ignoredFields) {
101108
HashSet<String> result = new HashSet<>();
102-
for (PropertyDescriptor propertyDescriptor : descriptors) {
103-
if (isIgnored(ignoredFields, propertyDescriptor)) {
104-
result.add(propertyDescriptor.getDisplayName());
109+
for (FeatureDescriptor descriptor : descriptors) {
110+
if (isNotIgnored(ignoredFields, descriptor)) {
111+
result.add(descriptor.getDisplayName());
105112
}
106113
}
107114
return result;
108115
}
109116

110-
private static boolean isIgnored(List<String> ignoredFields, PropertyDescriptor propertyDescriptor) {
117+
private static boolean isNotIgnored(List<String> ignoredFields, FeatureDescriptor propertyDescriptor) {
111118
return ! ignoredFields.contains(propertyDescriptor.getDisplayName());
112119
}
113120

@@ -117,9 +124,11 @@ private static class PropertyMatcher extends DiagnosingMatcher<Object> {
117124
private final Matcher<Object> matcher;
118125
private final String propertyName;
119126

120-
public PropertyMatcher(PropertyDescriptor descriptor, Object expectedObject) {
127+
public PropertyMatcher(FeatureDescriptor descriptor, Object expectedObject) {
121128
this.propertyName = descriptor.getDisplayName();
122-
this.readMethod = descriptor.getReadMethod();
129+
this.readMethod = descriptor instanceof PropertyDescriptor ?
130+
((PropertyDescriptor) descriptor).getReadMethod() :
131+
((MethodDescriptor) descriptor).getMethod();
123132
this.matcher = equalTo(readProperty(readMethod, expectedObject));
124133
}
125134

hamcrest/src/test/java/org/hamcrest/beans/HasPropertyTest.java

+6
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
public final class HasPropertyTest {
1717

1818
private final HasPropertyWithValueTest.BeanWithoutInfo bean = new HasPropertyWithValueTest.BeanWithoutInfo("a bean", false);
19+
private final HasPropertyWithValueTest.RecordLikeBeanWithoutInfo record = new HasPropertyWithValueTest.RecordLikeBeanWithoutInfo("a record", false);
1920

2021
@Test public void
2122
copesWithNullsAndUnknownTypes() {
@@ -28,11 +29,14 @@ public final class HasPropertyTest {
2829
@Test public void
2930
matchesWhenThePropertyExists() {
3031
assertMatches(hasProperty("writeOnlyProperty"), bean);
32+
assertMatches(hasProperty("property"), record);
3133
}
3234

3335
@Test public void
3436
doesNotMatchIfPropertyDoesNotExist() {
3537
assertDoesNotMatch(hasProperty("aNonExistentProp"), bean);
38+
assertDoesNotMatch(hasProperty("aNonExistentProp"), record);
39+
assertDoesNotMatch(hasProperty("notAGetterMethod"), record);
3640
}
3741

3842
@Test public void
@@ -44,6 +48,8 @@ public final class HasPropertyTest {
4448
describesAMismatch() {
4549
assertMismatchDescription("no \"aNonExistentProp\" in <[Person: a bean]>",
4650
hasProperty("aNonExistentProp"), bean);
51+
assertMismatchDescription("no \"aNonExistentProp\" in <[Person: a record]>",
52+
hasProperty("aNonExistentProp"), record);
4753
}
4854

4955
}

0 commit comments

Comments
 (0)