Skip to content

Commit

Permalink
Make hasProperty(), hasPropertyAtPath(), samePropertyValuesAs()
Browse files Browse the repository at this point in the history
… work for Java Records (#426)

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

Resolves #392
  • Loading branch information
djkeh authored Nov 30, 2024
1 parent f089c7e commit 3d58e99
Show file tree
Hide file tree
Showing 9 changed files with 437 additions and 49 deletions.
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

0 comments on commit 3d58e99

Please sign in to comment.