Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make hasProperty(), hasPropertyAtPath(), samePropertyValuesAs() work for Java Records #426

Merged
merged 10 commits into from
Nov 30, 2024
1 change: 1 addition & 0 deletions hamcrest/src/main/java/org/hamcrest/Condition.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public abstract class Condition<T> {
* @param <I> the initial value type
* @param <O> the next step value type
*/
@FunctionalInterface
public interface Step<I, O> {
/**
* Apply this condition to a value
Expand Down
4 changes: 2 additions & 2 deletions hamcrest/src/main/java/org/hamcrest/beans/HasProperty.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher;
import org.hamcrest.collection.ArrayMatching;

/**
* A matcher that checks if an object has a JavaBean property with the
Expand Down Expand Up @@ -31,7 +30,8 @@ public HasProperty(String propertyName) {
@Override
public boolean matchesSafely(T obj) {
try {
return PropertyUtil.getPropertyDescriptor(propertyName, obj) != null;
return PropertyUtil.getPropertyDescriptor(propertyName, obj) != null ||
PropertyUtil.getMethodDescriptor(propertyName, obj) != null;
} catch (IllegalArgumentException e) {
return false;
}
Expand Down
61 changes: 31 additions & 30 deletions hamcrest/src/main/java/org/hamcrest/beans/HasPropertyWithValue.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeDiagnosingMatcher;

import java.beans.FeatureDescriptor;
import java.beans.MethodDescriptor;
import java.beans.PropertyDescriptor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
Expand All @@ -26,7 +28,7 @@
* <h2>Example Usage</h2>
* Consider the situation where we have a class representing a person, which
* follows the basic JavaBean convention of having get() and possibly set()
* methods for it's properties:
* methods for its properties:
* <pre>{@code public class Person {
* private String name;
* public Person(String person) {
Expand Down Expand Up @@ -69,7 +71,7 @@
*/
public class HasPropertyWithValue<T> extends TypeSafeDiagnosingMatcher<T> {

private static final Condition.Step<PropertyDescriptor, Method> WITH_READ_METHOD = withReadMethod();
private static final Condition.Step<FeatureDescriptor, Method> WITH_READ_METHOD = withReadMethod();
private final String propertyName;
private final Matcher<Object> valueMatcher;
private final String messageFormat;
Expand Down Expand Up @@ -111,8 +113,11 @@ public void describeTo(Description description) {
.appendDescriptionOf(valueMatcher).appendText(")");
}

private Condition<PropertyDescriptor> propertyOn(T bean, Description mismatch) {
PropertyDescriptor property = PropertyUtil.getPropertyDescriptor(propertyName, bean);
private Condition<FeatureDescriptor> propertyOn(T bean, Description mismatch) {
FeatureDescriptor property = PropertyUtil.getPropertyDescriptor(propertyName, bean);
if (property == null) {
property = PropertyUtil.getMethodDescriptor(propertyName, bean);
}
if (property == null) {
mismatch.appendText("No property \"" + propertyName + "\"");
return notMatched();
Expand All @@ -122,22 +127,19 @@ private Condition<PropertyDescriptor> propertyOn(T bean, Description mismatch) {
}

private Condition.Step<Method, Object> withPropertyValue(final T bean) {
return new Condition.Step<Method, Object>() {
@Override
public Condition<Object> apply(Method readMethod, Description mismatch) {
try {
return matched(readMethod.invoke(bean, NO_ARGUMENTS), mismatch);
} catch (InvocationTargetException e) {
mismatch
.appendText("Calling '")
.appendText(readMethod.toString())
.appendText("': ")
.appendValue(e.getTargetException().getMessage());
return notMatched();
} catch (Exception e) {
throw new IllegalStateException(
"Calling: '" + readMethod + "' should not have thrown " + e);
}
return (readMethod, mismatch) -> {
try {
return matched(readMethod.invoke(bean, NO_ARGUMENTS), mismatch);
} catch (InvocationTargetException e) {
mismatch
.appendText("Calling '")
.appendText(readMethod.toString())
.appendText("': ")
.appendValue(e.getTargetException().getMessage());
return notMatched();
} catch (Exception e) {
throw new IllegalStateException(
"Calling: '" + readMethod + "' should not have thrown " + e);
}
};
}
Expand All @@ -147,17 +149,16 @@ private static Matcher<Object> nastyGenericsWorkaround(Matcher<?> valueMatcher)
return (Matcher<Object>) valueMatcher;
}

private static Condition.Step<PropertyDescriptor, Method> withReadMethod() {
return new Condition.Step<PropertyDescriptor, java.lang.reflect.Method>() {
@Override
public Condition<Method> apply(PropertyDescriptor property, Description mismatch) {
final Method readMethod = property.getReadMethod();
if (null == readMethod) {
mismatch.appendText("property \"" + property.getName() + "\" is not readable");
return notMatched();
}
return matched(readMethod, mismatch);
private static Condition.Step<FeatureDescriptor, Method> withReadMethod() {
return (property, mismatch) -> {
final Method readMethod = property instanceof PropertyDescriptor ?
((PropertyDescriptor) property).getReadMethod() :
(((MethodDescriptor) property).getMethod());
if (null == readMethod || readMethod.getReturnType() == void.class) {
mismatch.appendText("property \"" + property.getName() + "\" is not readable");
return notMatched();
}
return matched(readMethod, mismatch);
};
}

Expand Down
77 changes: 75 additions & 2 deletions hamcrest/src/main/java/org/hamcrest/beans/PropertyUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

import java.beans.IntrospectionException;
import java.beans.Introspector;
import java.beans.MethodDescriptor;
import java.beans.PropertyDescriptor;
import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;

/**
* Utility class with static methods for accessing properties on JavaBean objects.
Expand All @@ -11,6 +16,7 @@
*
* @author Iain McGinniss
* @author Steve Freeman
* @author Uno Kim
* @since 1.1.0
*/
public class PropertyUtil {
Expand All @@ -27,7 +33,7 @@ private PropertyUtil() {
* @param fromObj
* the object to check.
* @return the descriptor of the property, or null if the property does not exist.
* @throws IllegalArgumentException if there's a introspection failure
* @throws IllegalArgumentException if there's an introspection failure
*/
public static PropertyDescriptor getPropertyDescriptor(String propertyName, Object fromObj) throws IllegalArgumentException {
for (PropertyDescriptor property : propertyDescriptorsFor(fromObj, null)) {
Expand All @@ -45,7 +51,7 @@ public static PropertyDescriptor getPropertyDescriptor(String propertyName, Obje
* @param fromObj Use the class of this object
* @param stopClass Don't include any properties from this ancestor class upwards.
* @return Property descriptors
* @throws IllegalArgumentException if there's a introspection failure
* @throws IllegalArgumentException if there's an introspection failure
*/
public static PropertyDescriptor[] propertyDescriptorsFor(Object fromObj, Class<Object> stopClass) throws IllegalArgumentException {
try {
Expand All @@ -55,6 +61,73 @@ public static PropertyDescriptor[] propertyDescriptorsFor(Object fromObj, Class<
}
}

/**
* Returns the description of the read accessor method with the provided
* name on the provided object's interface.
* This is what you need when you try to find a property from a target object
* when it doesn't follow standard JavaBean specification, a Java Record for example.
*
* @param propertyName the object property name.
* @param fromObj the object to check.
* @return the descriptor of the method, or null if the method does not exist.
* @throws IllegalArgumentException if there's an introspection failure
* @see <a href="https://docs.oracle.com/en/java/javase/17/language/records.html">Java Records</a>
*
*/
public static MethodDescriptor getMethodDescriptor(String propertyName, Object fromObj) throws IllegalArgumentException {
for (MethodDescriptor method : recordReadAccessorMethodDescriptorsFor(fromObj, null)) {
if (method.getName().equals(propertyName)) {
return method;
}
}

return null;
}

/**
* Returns read accessor method descriptors for the class associated with the given object.
* This is useful when you find getter methods for the fields from the object
* when it doesn't follow standard JavaBean specification, a Java Record for example.
* Be careful as this doesn't return standard JavaBean getter methods, like a method starting with {@code get-}.
*
* @param fromObj Use the class of this object
* @param stopClass Don't include any properties from this ancestor class upwards.
* @return Method descriptors for read accessor methods
* @throws IllegalArgumentException if there's an introspection failure
*/
public static MethodDescriptor[] recordReadAccessorMethodDescriptorsFor(Object fromObj, Class<Object> stopClass) throws IllegalArgumentException {
try {
Set<String> recordComponentNames = getFieldNames(fromObj);
MethodDescriptor[] methodDescriptors = Introspector.getBeanInfo(fromObj.getClass(), stopClass).getMethodDescriptors();

return Arrays.stream(methodDescriptors)
.filter(x -> recordComponentNames.contains(x.getDisplayName()))
.filter(x -> x.getMethod().getReturnType() != void.class)
.filter(x -> x.getMethod().getParameterCount() == 0)
.toArray(MethodDescriptor[]::new);
} catch (IntrospectionException e) {
throw new IllegalArgumentException("Could not get method descriptors for " + fromObj.getClass(), e);
}
}

/**
* Returns the field names of the given object.
* It can be the names of the record components of Java Records, for example.
*
* @param fromObj the object to check
* @return The field names
* @throws IllegalArgumentException if there's a security issue reading the fields
*/
public static Set<String> getFieldNames(Object fromObj) throws IllegalArgumentException {
try {
return Arrays.stream(fromObj.getClass().getDeclaredFields())
.map(Field::getName)
.collect(Collectors.toSet());
} catch (SecurityException e) {
throw new IllegalArgumentException("Could not get record component names for " + fromObj.getClass(), e);
}
}

/**
* Empty object array, used for documenting that we are deliberately passing no arguments to a method.
*/
Expand Down
33 changes: 21 additions & 12 deletions hamcrest/src/main/java/org/hamcrest/beans/SamePropertyValuesAs.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@
import org.hamcrest.DiagnosingMatcher;
import org.hamcrest.Matcher;

import java.beans.FeatureDescriptor;
import java.beans.MethodDescriptor;
import java.beans.PropertyDescriptor;
import java.lang.reflect.Method;
import java.util.*;

import static java.util.Arrays.asList;
import static org.hamcrest.beans.PropertyUtil.NO_ARGUMENTS;
import static org.hamcrest.beans.PropertyUtil.propertyDescriptorsFor;
import static org.hamcrest.beans.PropertyUtil.recordReadAccessorMethodDescriptorsFor;
import static org.hamcrest.core.IsEqual.equalTo;

/**
Expand All @@ -33,7 +36,11 @@ public class SamePropertyValuesAs<T> extends DiagnosingMatcher<T> {
*/
@SuppressWarnings("WeakerAccess")
public SamePropertyValuesAs(T expectedBean, List<String> ignoredProperties) {
PropertyDescriptor[] descriptors = propertyDescriptorsFor(expectedBean, Object.class);
FeatureDescriptor[] descriptors = propertyDescriptorsFor(expectedBean, Object.class);
if (descriptors == null || descriptors.length == 0) {
descriptors = recordReadAccessorMethodDescriptorsFor(expectedBean, Object.class);
}

this.expectedBean = expectedBean;
this.ignoredFields = ignoredProperties;
this.propertyNames = propertyNamesFrom(descriptors, ignoredProperties);
Expand Down Expand Up @@ -87,27 +94,27 @@ private boolean hasMatchingValues(Object actual, Description mismatchDescription
return true;
}

private static <T> List<PropertyMatcher> propertyMatchersFor(T bean, PropertyDescriptor[] descriptors, List<String> ignoredFields) {
private static <T> List<PropertyMatcher> propertyMatchersFor(T bean, FeatureDescriptor[] descriptors, List<String> ignoredFields) {
List<PropertyMatcher> result = new ArrayList<>(descriptors.length);
for (PropertyDescriptor propertyDescriptor : descriptors) {
if (isIgnored(ignoredFields, propertyDescriptor)) {
result.add(new PropertyMatcher(propertyDescriptor, bean));
for (FeatureDescriptor descriptor : descriptors) {
if (isNotIgnored(ignoredFields, descriptor)) {
result.add(new PropertyMatcher(descriptor, bean));
}
}
return result;
}

private static Set<String> propertyNamesFrom(PropertyDescriptor[] descriptors, List<String> ignoredFields) {
private static Set<String> propertyNamesFrom(FeatureDescriptor[] descriptors, List<String> ignoredFields) {
HashSet<String> result = new HashSet<>();
for (PropertyDescriptor propertyDescriptor : descriptors) {
if (isIgnored(ignoredFields, propertyDescriptor)) {
result.add(propertyDescriptor.getDisplayName());
for (FeatureDescriptor descriptor : descriptors) {
if (isNotIgnored(ignoredFields, descriptor)) {
result.add(descriptor.getDisplayName());
}
}
return result;
}

private static boolean isIgnored(List<String> ignoredFields, PropertyDescriptor propertyDescriptor) {
private static boolean isNotIgnored(List<String> ignoredFields, FeatureDescriptor propertyDescriptor) {
return ! ignoredFields.contains(propertyDescriptor.getDisplayName());
}

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

public PropertyMatcher(PropertyDescriptor descriptor, Object expectedObject) {
public PropertyMatcher(FeatureDescriptor descriptor, Object expectedObject) {
this.propertyName = descriptor.getDisplayName();
this.readMethod = descriptor.getReadMethod();
this.readMethod = descriptor instanceof PropertyDescriptor ?
((PropertyDescriptor) descriptor).getReadMethod() :
((MethodDescriptor) descriptor).getMethod();
this.matcher = equalTo(readProperty(readMethod, expectedObject));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
public final class HasPropertyTest {

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

@Test public void
copesWithNullsAndUnknownTypes() {
Expand All @@ -28,11 +29,14 @@ public final class HasPropertyTest {
@Test public void
matchesWhenThePropertyExists() {
assertMatches(hasProperty("writeOnlyProperty"), bean);
assertMatches(hasProperty("property"), record);
}

@Test public void
doesNotMatchIfPropertyDoesNotExist() {
assertDoesNotMatch(hasProperty("aNonExistentProp"), bean);
assertDoesNotMatch(hasProperty("aNonExistentProp"), record);
assertDoesNotMatch(hasProperty("notAGetterMethod"), record);
}

@Test public void
Expand All @@ -44,6 +48,8 @@ public final class HasPropertyTest {
describesAMismatch() {
assertMismatchDescription("no \"aNonExistentProp\" in <[Person: a bean]>",
hasProperty("aNonExistentProp"), bean);
assertMismatchDescription("no \"aNonExistentProp\" in <[Person: a record]>",
hasProperty("aNonExistentProp"), record);
}

}
Loading