Skip to content

Commit

Permalink
Add PropertyAccessor
Browse files Browse the repository at this point in the history
  • Loading branch information
tumbarumba committed Nov 30, 2024
1 parent d023d54 commit a7ad1e1
Show file tree
Hide file tree
Showing 5 changed files with 102 additions and 104 deletions.
3 changes: 1 addition & 2 deletions hamcrest/src/main/java/org/hamcrest/beans/HasProperty.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,7 @@ public HasProperty(String propertyName) {
@Override
public boolean matchesSafely(T obj) {
try {
return PropertyUtil.getPropertyDescriptor(propertyName, obj) != null ||
PropertyUtil.getMethodDescriptor(propertyName, obj) != null;
return PropertyUtil.getPropertyAccessor(propertyName, obj) != null;
} catch (IllegalArgumentException e) {
return false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@
import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeDiagnosingMatcher;
import org.hamcrest.beans.PropertyUtil.PropertyAccessor;

import java.beans.FeatureDescriptor;
import java.beans.MethodDescriptor;
import java.beans.PropertyDescriptor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Arrays;
Expand Down Expand Up @@ -71,7 +69,7 @@
*/
public class HasPropertyWithValue<T> extends TypeSafeDiagnosingMatcher<T> {

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

private Condition<FeatureDescriptor> propertyOn(T bean, Description mismatch) {
FeatureDescriptor property = PropertyUtil.getFeatureDescriptor(propertyName, bean);
private Condition<PropertyAccessor> propertyOn(T bean, Description mismatch) {
PropertyAccessor property = PropertyUtil.getPropertyAccessor(propertyName, bean);
if (property == null) {
mismatch.appendText("No property \"" + propertyName + "\"");
return notMatched();
Expand Down Expand Up @@ -146,13 +144,11 @@ private static Matcher<Object> nastyGenericsWorkaround(Matcher<?> valueMatcher)
return (Matcher<Object>) valueMatcher;
}

private static Condition.Step<FeatureDescriptor, Method> withReadMethod() {
return (property, mismatch) -> {
final Method readMethod = property instanceof PropertyDescriptor ?
((PropertyDescriptor) property).getReadMethod() :
(((MethodDescriptor) property).getMethod());
private static Condition.Step<PropertyAccessor, Method> withReadMethod() {
return (accessor, mismatch) -> {
final Method readMethod = accessor.readMethod();
if (null == readMethod || readMethod.getReturnType() == void.class) {
mismatch.appendText("property \"" + property.getName() + "\" is not readable");
mismatch.appendText("property \"" + accessor.propertyName() + "\" is not readable");
return notMatched();
}
return matched(readMethod, mismatch);
Expand Down
108 changes: 65 additions & 43 deletions hamcrest/src/main/java/org/hamcrest/beans/PropertyUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@

import java.beans.*;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Collectors;

/**
* Utility class with static methods for accessing properties on JavaBean objects.
* Utility class with static methods for accessing properties on JavaBean objects, or bean-like
* objects such as records.
* <p>
* See <a href="https://docs.oracle.com/javase/8/docs/technotes/guides/beans/index.html">https://docs.oracle.com/javase/8/docs/technotes/guides/beans/index.html</a> for
* more information on JavaBeans.
*
Expand All @@ -22,20 +26,25 @@ public class PropertyUtil {
private PropertyUtil() {
}

public static <T> FeatureDescriptor[] featureDescriptorsFor(T expectedBean) {
FeatureDescriptor[] descriptors = propertyDescriptorsFor(expectedBean, Object.class);
public static <T> List<PropertyAccessor> propertyAccessorsFor(T bean) {
PropertyDescriptor[] descriptors = propertyDescriptorsFor(bean, Object.class);
if (descriptors != null && descriptors.length > 0) {
return descriptors;
return Arrays.stream(descriptors)
.map(pd -> PropertyAccessor.fromProperty(bean, pd))
.collect(Collectors.toList());
}
return recordReadAccessorMethodDescriptorsFor(expectedBean);

MethodDescriptor[] methodDescriptors = fieldReadDescriptorsFor(bean);
return Arrays.stream(methodDescriptors)
.map(md -> PropertyAccessor.fromMethod(bean, md))
.collect(Collectors.toList());
}

public static <T> FeatureDescriptor getFeatureDescriptor(String propertyName, T bean) {
FeatureDescriptor property = getPropertyDescriptor(propertyName, bean);
if (property != null) {
return property;
}
return getMethodDescriptor(propertyName, bean);
public static <T> PropertyAccessor getPropertyAccessor(String propertyName, T bean) {
return propertyAccessorsFor(bean).stream()
.filter(pa -> propertyName.equals(pa.propertyName()))
.findFirst()
.orElse(null);
}

/**
Expand Down Expand Up @@ -73,29 +82,6 @@ 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)) {
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
Expand All @@ -106,13 +92,13 @@ public static MethodDescriptor getMethodDescriptor(String propertyName, Object f
* @return Method descriptors for read accessor methods
* @throws IllegalArgumentException if there's an introspection failure
*/
public static MethodDescriptor[] recordReadAccessorMethodDescriptorsFor(Object fromObj) throws IllegalArgumentException {
public static MethodDescriptor[] fieldReadDescriptorsFor(Object fromObj) throws IllegalArgumentException {
try {
Set<String> fieldNames = getFieldNames(fromObj);
MethodDescriptor[] methodDescriptors = Introspector.getBeanInfo(fromObj.getClass(), null).getMethodDescriptors();

return Arrays.stream(methodDescriptors)
.filter(IsFieldAccessor.forOneOf(fieldNames))
.filter(IsPropertyAccessor.forOneOf(fieldNames))
.toArray(MethodDescriptor[]::new);
} catch (IntrospectionException e) {
throw new IllegalArgumentException("Could not get method descriptors for " + fromObj.getClass(), e);
Expand All @@ -125,25 +111,61 @@ public static MethodDescriptor[] recordReadAccessorMethodDescriptorsFor(Object f
* This predicate assumes a method is a field access if the method name exactly
* matches the field name, takes no parameters and returns a non-void type.
*/
private static class IsFieldAccessor implements Predicate<MethodDescriptor> {
private final Set<String> fieldNames;
private static class IsPropertyAccessor implements Predicate<MethodDescriptor> {
private final Set<String> propertyNames;

private IsFieldAccessor(Set<String> fieldNames) {
this.fieldNames = fieldNames;
private IsPropertyAccessor(Set<String> propertyNames) {
this.propertyNames = propertyNames;
}

public static IsFieldAccessor forOneOf(Set<String> fieldNames) {
return new IsFieldAccessor(fieldNames);
public static IsPropertyAccessor forOneOf(Set<String> propertyNames) {
return new IsPropertyAccessor(propertyNames);
}

@Override
public boolean test(MethodDescriptor md) {
return fieldNames.contains(md.getDisplayName()) &&
return propertyNames.contains(md.getDisplayName()) &&
md.getMethod().getReturnType() != void.class &&
md.getMethod().getParameterCount() == 0;
}
}

public static final class PropertyAccessor {
private final Object theObject;
private final String propertyName;
private final Method readMethod;

public PropertyAccessor(Object theObject, String propertyName, Method readMethod) {
this.theObject = theObject;
this.propertyName = propertyName;
this.readMethod = readMethod;
}

public static PropertyAccessor fromProperty(Object theObject, PropertyDescriptor pd) {
return new PropertyAccessor(theObject, pd.getDisplayName(), pd.getReadMethod());
}

public static PropertyAccessor fromMethod(Object theObject, MethodDescriptor md) {
return new PropertyAccessor(theObject, md.getDisplayName(), md.getMethod());
}

public String propertyName() {
return propertyName;
}

public Object propertyValue() {
try {
return readMethod.invoke(theObject, NO_ARGUMENTS);
} catch (Exception e) {
throw new IllegalArgumentException("Could not invoke " + readMethod + " on " + theObject, e);
}
}

public Method readMethod() {
return readMethod;
}
}

/**
* Returns the field names of the given object.
* It can be the names of the record components of Java Records, for example.
Expand Down
71 changes: 26 additions & 45 deletions hamcrest/src/main/java/org/hamcrest/beans/SamePropertyValuesAs.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,13 @@
import org.hamcrest.Description;
import org.hamcrest.DiagnosingMatcher;
import org.hamcrest.Matcher;
import org.hamcrest.beans.PropertyUtil.PropertyAccessor;

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

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.core.IsEqual.equalTo;

/**
Expand All @@ -35,11 +32,11 @@ public class SamePropertyValuesAs<T> extends DiagnosingMatcher<T> {
*/
@SuppressWarnings("WeakerAccess")
public SamePropertyValuesAs(T expectedBean, List<String> ignoredProperties) {
FeatureDescriptor[] descriptors = PropertyUtil.featureDescriptorsFor(expectedBean);
List<PropertyAccessor> accessors = PropertyUtil.propertyAccessorsFor(expectedBean);
this.expectedBean = expectedBean;
this.ignoredFields = ignoredProperties;
this.propertyNames = propertyNamesFrom(descriptors, ignoredProperties);
this.propertyMatchers = propertyMatchersFor(expectedBean, descriptors, ignoredProperties);
this.propertyNames = propertyNamesFrom(accessors, ignoredProperties);
this.propertyMatchers = propertyMatchersFor(expectedBean, accessors, ignoredProperties);
}

@Override
Expand Down Expand Up @@ -70,7 +67,8 @@ private boolean isCompatibleType(Object actual, Description mismatchDescription)
}

private boolean hasNoExtraProperties(Object actual, Description mismatchDescription) {
Set<String> actualPropertyNames = propertyNamesFrom(propertyDescriptorsFor(actual, Object.class), ignoredFields);
List<PropertyAccessor> accessors = PropertyUtil.propertyAccessorsFor(actual);
Set<String> actualPropertyNames = propertyNamesFrom(accessors, ignoredFields);
actualPropertyNames.removeAll(propertyNames);
if (!actualPropertyNames.isEmpty()) {
mismatchDescription.appendText("has extra properties called " + actualPropertyNames);
Expand All @@ -89,24 +87,18 @@ private boolean hasMatchingValues(Object actual, Description mismatchDescription
return true;
}

private static <T> List<PropertyMatcher> propertyMatchersFor(T bean, FeatureDescriptor[] descriptors, List<String> ignoredFields) {
List<PropertyMatcher> result = new ArrayList<>(descriptors.length);
for (FeatureDescriptor descriptor : descriptors) {
if (isNotIgnored(ignoredFields, descriptor)) {
result.add(new PropertyMatcher(descriptor, bean));
}
}
return result;
private static <T> List<PropertyMatcher> propertyMatchersFor(T bean, List<PropertyAccessor> accessors, List<String> ignoredFields) {
return accessors.stream()
.filter(pa -> !ignoredFields.contains(pa.propertyName()))
.map(pa -> new PropertyMatcher(pa, bean))
.collect(Collectors.toList());
}

private static Set<String> propertyNamesFrom(FeatureDescriptor[] descriptors, List<String> ignoredFields) {
HashSet<String> result = new HashSet<>();
for (FeatureDescriptor descriptor : descriptors) {
if (isNotIgnored(ignoredFields, descriptor)) {
result.add(descriptor.getDisplayName());
}
}
return result;
private static Set<String> propertyNamesFrom(List<PropertyAccessor> accessors, List<String> ignoredFields) {
return accessors.stream()
.map(PropertyAccessor::propertyName)
.filter(name -> !ignoredFields.contains(name))
.collect(Collectors.toSet());
}

private static boolean isNotIgnored(List<String> ignoredFields, FeatureDescriptor propertyDescriptor) {
Expand All @@ -115,23 +107,20 @@ private static boolean isNotIgnored(List<String> ignoredFields, FeatureDescripto

@SuppressWarnings("WeakerAccess")
private static class PropertyMatcher extends DiagnosingMatcher<Object> {
private final Method readMethod;
private final PropertyAccessor expectedAccessor;
private final Matcher<Object> matcher;
private final String propertyName;

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

public PropertyMatcher(PropertyAccessor expectedAccessor, Object expectedObject) {
this.expectedAccessor = expectedAccessor;
this.matcher = equalTo(expectedAccessor.propertyValue());
}

@Override
public boolean matches(Object actual, Description mismatch) {
final Object actualValue = readProperty(readMethod, actual);
PropertyAccessor actualAccessor = PropertyUtil.getPropertyAccessor(expectedAccessor.propertyName(), actual);
Object actualValue = actualAccessor.propertyValue();
if (!matcher.matches(actualValue)) {
mismatch.appendText(propertyName + " ");
mismatch.appendText(expectedAccessor.propertyName() + " ");
matcher.describeMismatch(actualValue, mismatch);
return false;
}
Expand All @@ -140,15 +129,7 @@ public boolean matches(Object actual, Description mismatch) {

@Override
public void describeTo(Description description) {
description.appendText(propertyName + ": ").appendDescriptionOf(matcher);
}
}

private static Object readProperty(Method method, Object target) {
try {
return method.invoke(target, NO_ARGUMENTS);
} catch (Exception e) {
throw new IllegalArgumentException("Could not invoke " + method + " on " + target, e);
description.appendText(expectedAccessor.propertyName() + ": ").appendDescriptionOf(matcher);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ void testReturnsTheNamesOfAllFieldsFromTargetRecord() {
void testReturnsArrayOfMethodDescriptorFromTargetClass() {
SamePropertyValuesAsTest.ExampleBean input = new SamePropertyValuesAsTest.ExampleBean("test", 1, null);

MethodDescriptor[] output = PropertyUtil.recordReadAccessorMethodDescriptorsFor(input);
MethodDescriptor[] output = PropertyUtil.fieldReadDescriptorsFor(input);

assertThat(output, arrayWithSize(0));
}
Expand All @@ -56,7 +56,7 @@ void testReturnsArrayOfMethodDescriptorFromTargetRecord() {
RecordLikeClass.SmallClass smallClass2 = new RecordLikeClass.SmallClass("small", 3, BigDecimal.ONE, LocalDateTime.of(2024, 1, 2, 3, 4, 5));
RecordLikeClass input = new RecordLikeClass("uno", 22, true, new Long[] {1L, 2L, 3L}, new ArrayList<>(Arrays.asList(smallClass1, smallClass2)));

MethodDescriptor[] output = PropertyUtil.recordReadAccessorMethodDescriptorsFor(input);
MethodDescriptor[] output = PropertyUtil.fieldReadDescriptorsFor(input);

assertThat(output, arrayWithSize(5));
assertThat(Arrays.stream(output).map(MethodDescriptor::getDisplayName).collect(Collectors.toList()),
Expand Down

0 comments on commit a7ad1e1

Please sign in to comment.