Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions src/main/java/com/hubspot/jinjava/el/ext/BeanELResolver.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.RecordComponent;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import javax.el.CompositeELResolver;
Expand All @@ -39,7 +41,7 @@
import javax.el.PropertyNotWritableException;

/**
* Defines property resolution behavior on objects using the JavaBeans component architecture. This
* Defines property resolution behavior for JavaBeans properties and record components. This
* resolver handles base objects of any type, as long as the base is not null. It accepts any object
* as a property, and coerces it to a string. That string is then used to find a JavaBeans compliant
* property on the base object. The value is accessed using JavaBeans getters and setters. This
Expand All @@ -66,7 +68,7 @@ protected static final class BeanProperties {
public BeanProperties(Class<?> baseClass) {
PropertyDescriptor[] descriptors;
try {
descriptors = Introspector.getBeanInfo(baseClass).getPropertyDescriptors();
descriptors = getPropertyDescriptors(baseClass);
} catch (IntrospectionException e) {
throw new ELException(e);
}
Expand All @@ -80,6 +82,28 @@ public BeanProperty getBeanProperty(String property) {
}
}

private static PropertyDescriptor[] getPropertyDescriptors(Class<?> baseClass)
throws IntrospectionException {
PropertyDescriptor[] descriptors = Introspector
.getBeanInfo(baseClass)
.getPropertyDescriptors();
if (!baseClass.isRecord()) {
return descriptors;
}

Map<String, PropertyDescriptor> properties = new LinkedHashMap<>();
for (PropertyDescriptor descriptor : descriptors) {
properties.put(descriptor.getName(), descriptor);
}
for (RecordComponent component : baseClass.getRecordComponents()) {
properties.putIfAbsent(
component.getName(),
new PropertyDescriptor(component.getName(), component.getAccessor(), null)
);
}
return properties.values().toArray(PropertyDescriptor[]::new);
}

protected static final class BeanProperty {

private final PropertyDescriptor descriptor;
Expand Down Expand Up @@ -236,7 +260,7 @@ public Iterator<FeatureDescriptor> getFeatureDescriptors(
if (isResolvable(base)) {
final PropertyDescriptor[] properties;
try {
properties = Introspector.getBeanInfo(base.getClass()).getPropertyDescriptors();
properties = getPropertyDescriptors(base.getClass());
} catch (IntrospectionException e) {
return Collections.<FeatureDescriptor>emptyList().iterator();
}
Expand Down
101 changes: 101 additions & 0 deletions src/test/java/com/hubspot/jinjava/el/ExpressionResolverTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
import com.hubspot.jinjava.BaseJinjavaTest;
import com.hubspot.jinjava.Jinjava;
import com.hubspot.jinjava.JinjavaConfig;
import com.hubspot.jinjava.el.ext.AllowlistMethodValidator;
import com.hubspot.jinjava.el.ext.AllowlistReturnTypeValidator;
import com.hubspot.jinjava.el.ext.MethodValidatorConfig;
import com.hubspot.jinjava.el.ext.ReturnTypeValidatorConfig;
import com.hubspot.jinjava.interpret.Context;
import com.hubspot.jinjava.interpret.Context.Library;
import com.hubspot.jinjava.interpret.JinjavaInterpreter;
Expand All @@ -20,6 +24,7 @@
import com.hubspot.jinjava.interpret.TemplateError.ErrorReason;
import com.hubspot.jinjava.objects.date.PyishDate;
import com.hubspot.jinjava.testobjects.ExpressionResolverTestObjects;
import java.lang.reflect.Method;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
Expand Down Expand Up @@ -51,6 +56,102 @@ public void teardown() {
JinjavaInterpreter.popCurrent();
}

@Test
public void itRendersRecordProperties() {
for (String expression : List.of("person.name", "person['name']", "person.name()")) {
RenderResult result = jinjava.renderForResult(
"{{ " + expression + " }}",
Map.of("person", new ExpressionResolverTestObjects.Person("Ada"))
);
assertThat(result.getOutput()).as(expression).isEqualTo("Ada");
assertThat(result.getErrors()).as(expression).isEmpty();
}
}

@Test
public void itResolvesRecordComponentTypesAndNames() {
context.put(
"record",
new ExpressionResolverTestObjects.RecordValues(
"Ada",
37,
true,
new ExpressionResolverTestObjects.Person("Byron")
)
);
context.put("person", new ExpressionResolverTestObjects.Person(null));
assertThat(interpreter.resolveELExpression("record.first_name", -1)).isEqualTo("Ada");
assertThat(interpreter.resolveELExpression("record['age']", -1)).isEqualTo(37);
assertThat(interpreter.resolveELExpression("record.active", -1)).isEqualTo(true);
assertThat(interpreter.resolveELExpression("record.child.name", -1))
.isEqualTo("Byron");
assertThat(interpreter.resolveELExpression("record['child']['name']", -1))
.isEqualTo("Byron");
assertThat(interpreter.resolveELExpression("person.name", -1)).isNull();
assertThat(interpreter.getErrorsCopy()).isEmpty();
}

@Test
public void itRejectsRecordAccessorsOutsideTheMethodAllowlist() throws Exception {
List<Method> rejectedMethods = Lists.newArrayList();
JinjavaConfig config = BaseJinjavaTest
.newConfigBuilder()
.withMethodValidator(
AllowlistMethodValidator.create(
MethodValidatorConfig
.builder()
.addDefaultAllowlistGroups()
.setOnRejectedMethod(rejectedMethods::add)
.build()
)
)
.build();
JinjavaInterpreter restrictedInterpreter = new Jinjava(config).newInterpreter();
restrictedInterpreter
.getContext()
.put("person", new ExpressionResolverTestObjects.Person("Ada"));
assertThat(restrictedInterpreter.render("{{ person.name }}")).isEmpty();
assertThat(rejectedMethods)
.contains(ExpressionResolverTestObjects.Person.class.getMethod("name"));
assertThat(restrictedInterpreter.getErrorsCopy())
.extracting(TemplateError::getItem)
.containsExactly(ErrorItem.PROPERTY);
}

@Test
public void itRejectsRecordComponentValuesOutsideTheReturnTypeAllowlist() {
List<Class<?>> rejectedTypes = Lists.newArrayList();
JinjavaConfig config = BaseJinjavaTest
.newConfigBuilder()
.withReturnTypeValidator(
AllowlistReturnTypeValidator.create(
ReturnTypeValidatorConfig
.builder()
.addDefaultAllowlistGroups()
.addAllowedCanonicalClassNames(
ExpressionResolverTestObjects.RecordValues.class.getCanonicalName()
)
.setOnRejectedClass(rejectedTypes::add)
.build()
)
)
.build();
JinjavaInterpreter restrictedInterpreter = new Jinjava(config).newInterpreter();
restrictedInterpreter
.getContext()
.put(
"record",
new ExpressionResolverTestObjects.RecordValues(
"Ada",
37,
true,
new ExpressionResolverTestObjects.Person("Byron")
)
);
assertThat(restrictedInterpreter.render("{{ record.child }}")).isEmpty();
assertThat(rejectedTypes).contains(ExpressionResolverTestObjects.Person.class);
}

@Test
public void itResolvesListLiterals() {
Object val = interpreter.resolveELExpression("['0.5','50']", -1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,16 @@
import com.hubspot.jinjava.interpret.AutoCloseableSupplier;
import com.hubspot.jinjava.interpret.JinjavaInterpreter;
import com.hubspot.jinjava.testobjects.JinjavaBeanELResolverTestObjects;
import java.beans.FeatureDescriptor;
import java.beans.Introspector;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import javax.el.ELContext;
import javax.el.ELResolver;
import javax.el.PropertyNotFoundException;
import javax.el.PropertyNotWritableException;
import org.junit.Before;
import org.junit.Test;

Expand All @@ -27,6 +35,142 @@ public void setUp() throws Exception {
jinjava = new Jinjava(BaseJinjavaTest.newConfigBuilder().build());
}

@Test
public void itReadsRecordComponentsAsReadOnlyProperties() {
var person = new JinjavaBeanELResolverTestObjects.Person("Ada", 36);
try (
var a = JinjavaInterpreter.closeablePushCurrent(jinjava.newInterpreter()).get()
) {
for (boolean readOnly : new boolean[] { false, true }) {
var resolver = new JinjavaBeanELResolver(readOnly);
elContext.setPropertyResolved(false);
assertThat(resolver.getValue(elContext, person, "name")).isEqualTo("Ada");
assertThat(elContext.isPropertyResolved()).isTrue();
assertThat(resolver.getValue(elContext, person, "age")).isEqualTo(36);

elContext.setPropertyResolved(false);
assertThat(resolver.getType(elContext, person, "name")).isEqualTo(String.class);
assertThat(elContext.isPropertyResolved()).isTrue();
assertThat(resolver.getType(elContext, person, "age")).isEqualTo(int.class);

elContext.setPropertyResolved(false);
assertThat(resolver.isReadOnly(elContext, person, "name")).isTrue();
assertThat(elContext.isPropertyResolved()).isTrue();
assertThatThrownBy(() -> resolver.setValue(elContext, person, "name", "Grace"))
.isInstanceOf(PropertyNotWritableException.class);
assertThat(person.name()).isEqualTo("Ada");

assertThatThrownBy(() -> resolver.getValue(elContext, person, "missing"))
.isInstanceOf(PropertyNotFoundException.class);
assertThatThrownBy(() -> resolver.getType(elContext, person, "missing"))
.isInstanceOf(PropertyNotFoundException.class);
}
}
}

@Test
public void itUsesCustomAndPrivateRecordAccessors() {
var custom = new JinjavaBeanELResolverTestObjects.CustomAccessor("Ada");
Object privatePerson = JinjavaBeanELResolverTestObjects.privatePerson("Grace");
try (
var a = JinjavaInterpreter.closeablePushCurrent(jinjava.newInterpreter()).get()
) {
assertThat(jinjavaBeanELResolver.getValue(elContext, custom, "name"))
.isEqualTo("accessor: Ada");
assertThat(jinjavaBeanELResolver.getValue(elContext, privatePerson, "name"))
.isEqualTo("Grace");
}
}

@Test
public void itDescribesRecordComponentsWithoutDuplicates() {
var person = new JinjavaBeanELResolverTestObjects.Person("Ada", 36);
List<FeatureDescriptor> descriptors = new ArrayList<>();
jinjavaBeanELResolver
.getFeatureDescriptors(elContext, person)
.forEachRemaining(descriptors::add);

assertThat(descriptors)
.extracting(FeatureDescriptor::getName)
.containsExactlyInAnyOrder("class", "name", "age");
for (FeatureDescriptor descriptor : descriptors) {
if (descriptor.getName().equals("name")) {
assertThat(descriptor.getValue(ELResolver.TYPE)).isEqualTo(String.class);
} else if (descriptor.getName().equals("age")) {
assertThat(descriptor.getValue(ELResolver.TYPE)).isEqualTo(int.class);
}
assertThat(descriptor.getValue(ELResolver.RESOLVABLE_AT_DESIGN_TIME))
.isEqualTo(true);
}
}

@Test
public void itPreservesExistingBeanGettersOnRecords() throws Exception {
var record = new JinjavaBeanELResolverTestObjects.BeanGetter("Ada");
var original = Arrays
.stream(Introspector.getBeanInfo(record.getClass()).getPropertyDescriptors())
.filter(descriptor -> descriptor.getName().equals("name"))
.findFirst()
.orElseThrow();
var originalReadMethod = original.getReadMethod();
try (
var a = JinjavaInterpreter.closeablePushCurrent(jinjava.newInterpreter()).get()
) {
for (boolean readOnly : new boolean[] { false, true }) {
var resolver = new JinjavaBeanELResolver(readOnly);
assertThat(resolver.getValue(elContext, record, "name")).isEqualTo(3);
assertThat(resolver.getType(elContext, record, "name")).isEqualTo(Integer.class);
List<FeatureDescriptor> descriptors = new ArrayList<>();
resolver
.getFeatureDescriptors(elContext, record)
.forEachRemaining(descriptors::add);
assertThat(descriptors)
.filteredOn(descriptor -> descriptor.getName().equals("name"))
.extracting(descriptor -> descriptor.getValue(ELResolver.TYPE))
.containsExactly(Integer.class);
}
}
assertThat(original.getReadMethod()).isEqualTo(originalReadMethod);
assertThat(original.getPropertyType()).isEqualTo(Integer.class);
}

@Test
public void itPreservesExistingWriteOnlyPropertiesOnRecords() throws Exception {
var writtenValue = new AtomicReference<String>();
var record = new JinjavaBeanELResolverTestObjects.WriteOnlyProperty(
"Ada",
writtenValue
);
var original = Arrays
.stream(Introspector.getBeanInfo(record.getClass()).getPropertyDescriptors())
.filter(descriptor -> descriptor.getName().equals("name"))
.findFirst()
.orElseThrow();
var originalWriteMethod = original.getWriteMethod();
assertThat(original.getReadMethod()).isNull();
try (
var a = JinjavaInterpreter.closeablePushCurrent(jinjava.newInterpreter()).get()
) {
for (boolean readOnly : new boolean[] { false, true }) {
var resolver = new JinjavaBeanELResolver(readOnly);
assertThatThrownBy(() -> resolver.getValue(elContext, record, "name"))
.isInstanceOf(PropertyNotFoundException.class);
assertThat(resolver.getType(elContext, record, "name")).isEqualTo(String.class);
assertThat(resolver.isReadOnly(elContext, record, "name")).isEqualTo(readOnly);
if (readOnly) {
assertThatThrownBy(() -> resolver.setValue(elContext, record, "name", "no"))
.isInstanceOf(PropertyNotWritableException.class);
} else {
resolver.setValue(elContext, record, "name", "Grace");
}
assertThat(writtenValue.get()).isEqualTo("Grace");
assertThat(record.name()).isEqualTo("Ada");
}
}
assertThat(original.getReadMethod()).isNull();
assertThat(original.getWriteMethod()).isEqualTo(originalWriteMethod);
}

@Test
public void itInvokesProperStringReplace() {
try (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,43 @@ public void itDefersNodeWhenNestedDeferredMapDotThrowsDeferredValueException() {
.isEqualTo("{{ foo_map.bar.deferred }}");
assertThat(interpreter.getContext().getDeferredNodes()).isNotEmpty();
}

@Test
public void itResolvesRecordComponents() {
interpreter
.getContext()
.put("record", new EagerAstDotTestObjects.RecordValue("resolved"));

assertThat(interpreter.render("{{ record.value }} {{ record['value'] }}"))
.isEqualTo("resolved resolved");
assertThat(interpreter.getErrors()).isEmpty();
}

@Test
public void itResolvesComponentsAfterARecordIsAvailable() {
String partiallyRendered = interpreter.render("{{ deferred.value }}");
assertThat(partiallyRendered).isEqualTo("{{ deferred.value }}");

interpreter
.getContext()
.put("deferred", new EagerAstDotTestObjects.RecordValue("resolved"));
assertThat(interpreter.render(partiallyRendered)).isEqualTo("resolved");
assertThat(interpreter.getErrors()).isEmpty();
}

@Test
public void itDefersWhenRecordAccessorThrowsDeferredValueException() {
interpreter
.getContext()
.put("record", new EagerAstDotTestObjects.DeferredRecordValue(null));
String partiallyRendered = interpreter.render("{{ record.value }}");
assertThat(partiallyRendered).isEqualTo("{{ record.value }}");
assertThat(interpreter.getContext().getDeferredNodes()).isNotEmpty();

interpreter
.getContext()
.put("record", new EagerAstDotTestObjects.DeferredRecordValue("resolved"));
assertThat(interpreter.render(partiallyRendered)).isEqualTo("resolved");
assertThat(interpreter.getErrors()).isEmpty();
}
}
Loading