From 34d95fb7e4d84a7504db92a27ae44b501b16c025 Mon Sep 17 00:00:00 2001 From: Oliver Gierke Date: Fri, 28 Oct 2016 09:31:05 +0200 Subject: [PATCH] DAATCMNS-1449 - Added API to record method invocations. Introduced MethodInvocationRecorder to record method invocations on types to obtain the property traversal those invocations represent. Recorded recorded = MethodInvocationRecorder.forProxyOf(Person.class) .record(Person::getAddress) .record(Address::getZipCode); assertThat(recorded.getPropertyPath)).hasValue("address.zipCode"); --- .../data/util/MethodInvocationRecorder.java | 327 ++++++++++++++++++ .../MethodInvocationRecorderUnitTests.java | 99 ++++++ 2 files changed, 426 insertions(+) create mode 100644 src/main/java/org/springframework/data/util/MethodInvocationRecorder.java create mode 100644 src/test/java/org/springframework/data/util/MethodInvocationRecorderUnitTests.java diff --git a/src/main/java/org/springframework/data/util/MethodInvocationRecorder.java b/src/main/java/org/springframework/data/util/MethodInvocationRecorder.java new file mode 100644 index 000000000..cfcc848be --- /dev/null +++ b/src/main/java/org/springframework/data/util/MethodInvocationRecorder.java @@ -0,0 +1,327 @@ +/* + * Copyright 2016-2018 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.util; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.ToString; +import lombok.Value; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; + +import javax.annotation.Nonnull; + +import org.aopalliance.intercept.MethodInvocation; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.core.CollectionFactory; +import org.springframework.core.ResolvableType; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; +import org.springframework.util.StringUtils; + +/** + * API to record method invocations via method references on a proxy. + * + * @author Oliver Gierke + * @since 2.2 + * @soundtrack The Intersphere - Don't Think Twice (The Grand Delusion) + */ +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class MethodInvocationRecorder { + + public static PropertyNameDetectionStrategy DEFAULT = DefaultPropertyNameDetectionStrategy.INSTANCE; + + private Optional interceptor; + + /** + * Creates a new {@link MethodInvocationRecorder}. For ad-hoc instantation prefer the static + * {@link #forProxyOf(Class)}. + */ + private MethodInvocationRecorder() { + this(Optional.empty()); + } + + /** + * Creates a new {@link Recorded} for the given type. + * + * @param type must not be {@literal null}. + * @return + */ + public static Recorded forProxyOf(Class type) { + + Assert.notNull(type, "Type must not be null!"); + + return new MethodInvocationRecorder().create(type); + } + + /** + * Creates a new {@link Recorded} for the given type based on the current {@link MethodInvocationRecorder} setup. + * + * @param type + * @return + */ + @SuppressWarnings("unchecked") + private Recorded create(Class type) { + + RecordingMethodInterceptor interceptor = new RecordingMethodInterceptor(); + + ProxyFactory proxyFactory = new ProxyFactory(); + proxyFactory.addAdvice(interceptor); + + if (!type.isInterface()) { + proxyFactory.setTargetClass(type); + proxyFactory.setProxyTargetClass(true); + } else { + proxyFactory.addInterface(type); + } + + T proxy = (T) proxyFactory.getProxy(type.getClassLoader()); + + return new Recorded(proxy, new MethodInvocationRecorder(Optional.ofNullable(interceptor))); + } + + private Optional getPropertyPath(List strategies) { + return interceptor.flatMap(it -> it.getPropertyPath(strategies)); + } + + private class RecordingMethodInterceptor implements org.aopalliance.intercept.MethodInterceptor { + + private InvocationInformation information = InvocationInformation.NOT_INVOKED; + + /* + * (non-Javadoc) + * @see org.aopalliance.intercept.MethodInterceptor#invoke(org.aopalliance.intercept.MethodInvocation) + */ + @Override + @SuppressWarnings("null") + public Object invoke(MethodInvocation invocation) throws Throwable { + + Method method = invocation.getMethod(); + Object[] arguments = invocation.getArguments(); + + if (ReflectionUtils.isObjectMethod(method)) { + return method.invoke(this, arguments); + } + + ResolvableType type = ResolvableType.forMethodReturnType(method); + Class rawType = type.resolve(Object.class); + + if (Collection.class.isAssignableFrom(rawType)) { + + Class clazz = type.getGeneric(0).resolve(Object.class); + + InvocationInformation information = registerInvocation(method, clazz); + + Collection collection = CollectionFactory.createCollection(rawType, 1); + collection.add(information.getCurrentInstance()); + + return collection; + } + + if (Map.class.isAssignableFrom(rawType)) { + + Class clazz = type.getGeneric(1).resolve(Object.class); + InvocationInformation information = registerInvocation(method, clazz); + + Map map = CollectionFactory.createMap(rawType, 1); + map.put("_key_", information.getCurrentInstance()); + + return map; + } + + return registerInvocation(method, rawType).getCurrentInstance(); + } + + private Optional getPropertyPath(List strategies) { + return this.information.getPropertyPath(strategies); + } + + private InvocationInformation registerInvocation(Method method, Class proxyType) { + + Recorded create = Modifier.isFinal(proxyType.getModifiers()) ? new Unrecorded() : create(proxyType); + InvocationInformation information = new InvocationInformation(create, method); + + return this.information = information; + } + } + + @Value + private static class InvocationInformation { + + static final InvocationInformation NOT_INVOKED = new InvocationInformation(new Unrecorded(), null); + + @NonNull Recorded recorded; + @Nullable Method invokedMethod; + + @Nullable + Object getCurrentInstance() { + return recorded.currentInstance; + } + + Optional getPropertyPath(List strategies) { + + Method invokedMethod = this.invokedMethod; + + if (invokedMethod == null) { + return Optional.empty(); + } + + String propertyName = getPropertyName(invokedMethod, strategies); + Optional next = recorded.getPropertyPath(strategies); + + return Optionals.firstNonEmpty(() -> next.map(it -> propertyName.concat(".").concat(it)), // + () -> Optional.of(propertyName)); + } + + private static String getPropertyName(Method invokedMethod, List strategies) { + + return strategies.stream() // + .map(it -> it.getPropertyName(invokedMethod)) // + .findFirst() // + .orElseThrow(() -> new IllegalArgumentException( + String.format("No property name found for method %s!", invokedMethod))); + } + } + + public interface PropertyNameDetectionStrategy { + + @Nullable + String getPropertyName(Method method); + } + + private static enum DefaultPropertyNameDetectionStrategy implements PropertyNameDetectionStrategy { + + INSTANCE; + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.core.Recorder.PropertyNameDetectionStrategy#getPropertyName(java.lang.reflect.Method) + */ + @Nonnull + @Override + public String getPropertyName(Method method) { + return getPropertyName(method.getReturnType(), method.getName()); + } + + private static String getPropertyName(Class type, String methodName) { + + String pattern = getPatternFor(type); + String replaced = methodName.replaceFirst(pattern, ""); + + return StringUtils.uncapitalize(replaced); + } + + private static String getPatternFor(Class type) { + return type.equals(boolean.class) ? "^(is)" : "^(get|set)"; + } + } + + @ToString + @RequiredArgsConstructor + public static class Recorded { + + private final @Nullable T currentInstance; + private final @Nullable MethodInvocationRecorder recorder; + + public Optional getPropertyPath() { + return getPropertyPath(MethodInvocationRecorder.DEFAULT); + } + + public Optional getPropertyPath(PropertyNameDetectionStrategy strategy) { + + MethodInvocationRecorder recorder = this.recorder; + + return recorder == null ? Optional.empty() : recorder.getPropertyPath(Arrays.asList(strategy)); + } + + public Optional getPropertyPath(List strategies) { + + MethodInvocationRecorder recorder = this.recorder; + + return recorder == null ? Optional.empty() : recorder.getPropertyPath(strategies); + } + + /** + * Applies the given Converter to the recorded value and remembers the property accessed. + * + * @param converter must not be {@literal null}. + * @return + */ + public Recorded record(Function converter) { + + Assert.notNull(converter, "Function must not be null!"); + + return new Recorded(converter.apply(currentInstance), recorder); + } + + /** + * Record the method invocation traversing to a collection property. + * + * @param converter must not be {@literal null}. + * @return + */ + public Recorded record(ToCollectionConverter converter) { + + Assert.notNull(converter, "Converter must not be null!"); + + return new Recorded(converter.apply(currentInstance).iterator().next(), recorder); + } + + /** + * Record the method invocation traversing to a map property. + * + * @param converter must not be {@literal null}. + * @return + */ + public Recorded record(ToMapConverter converter) { + + Assert.notNull(converter, "Converter must not be null!"); + + return new Recorded(converter.apply(currentInstance).values().iterator().next(), recorder); + } + + public interface ToCollectionConverter extends Function> {} + + public interface ToMapConverter extends Function> {} + } + + static class Unrecorded extends Recorded { + + @SuppressWarnings("null") + private Unrecorded() { + super(null, null); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.util.MethodInvocationRecorder.Recorded#getPropertyPath(java.util.List) + */ + @Override + public Optional getPropertyPath(List strategies) { + return Optional.empty(); + } + } +} diff --git a/src/test/java/org/springframework/data/util/MethodInvocationRecorderUnitTests.java b/src/test/java/org/springframework/data/util/MethodInvocationRecorderUnitTests.java new file mode 100644 index 000000000..cd6f7ede5 --- /dev/null +++ b/src/test/java/org/springframework/data/util/MethodInvocationRecorderUnitTests.java @@ -0,0 +1,99 @@ +/* + * Copyright 2016-2018 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.util; + +import static org.assertj.core.api.Assertions.*; + +import lombok.Getter; + +import java.util.Collection; + +import org.junit.Test; +import org.springframework.data.util.MethodInvocationRecorder.Recorded; + +/** + * Unit tests for {@link MethodInvocationRecorder}. + * + * @author Oliver Gierke + * @soundtrack The Intersphere - Don't Think Twice (The Grand Delusion) + */ +public class MethodInvocationRecorderUnitTests { + + Recorded recorder = MethodInvocationRecorder.forProxyOf(Foo.class); + + @Test // DATACMNS-1449 + public void createsPropertyPathForSimpleMethodReference() { + + Recorded wrapper = recorder.record(Foo::getBar); + + assertThat(wrapper.getPropertyPath()).hasValue("bar"); + } + + @Test // DATACMNS-1449 + public void createsPropertyPathForNestedMethodReference() { + + Recorded wrapper = recorder.record(Foo::getBar).record(Bar::getFooBar); + + assertThat(wrapper.getPropertyPath()).hasValue("bar.fooBar"); + } + + @Test // DATACMNS-1449 + public void createsPropertyPathForNestedCall() { + + Recorded wrapper = recorder.record((Foo source) -> source.getBar().getFooBar()); + + assertThat(wrapper.getPropertyPath()).hasValue("bar.fooBar"); + } + + @Test // DATACMNS-1449 + public void usesCustomPropertyNamingStrategy() { + + Recorded recorded = MethodInvocationRecorder.forProxyOf(Foo.class).record(Foo::getBar); + + assertThat(recorded.getPropertyPath(method -> method.getName())).hasValue("getBar"); + } + + @Test // DATACMNS-1449 + public void registersLookupToFinalType() { + assertThat(recorder.record(Foo::getName).getPropertyPath()).hasValue("name"); + } + + @Test + public void recordsInvocationOnInterface() { + + Recorded recorder = MethodInvocationRecorder.forProxyOf(Sample.class); + + assertThat(recorder.record(Sample::getName).getPropertyPath()).hasValue("name"); + } + + @Getter + static class Foo { + Bar bar; + Collection bars; + String name; + } + + @Getter + static class Bar { + FooBar fooBar; + } + + static class FooBar {} + + interface Sample { + String getName(); + } +}