diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/BeansWrapper.java b/freemarker-core/src/main/java/freemarker/ext/beans/BeansWrapper.java index 2a7f91250..2a2c1886d 100644 --- a/freemarker-core/src/main/java/freemarker/ext/beans/BeansWrapper.java +++ b/freemarker-core/src/main/java/freemarker/ext/beans/BeansWrapper.java @@ -274,6 +274,16 @@ public BeansWrapper() { * templates the value can be accessed both as {@code obj.name} (like a property), and as {@code obj.name()} * (for better backward compatibility only - it's bad style). * + *
  • + *

    2.3.35 (or higher): + * {@code isFoo()} methods that return {@link Boolean} (the wrapper class) are now exposed as the {@code foo} + * bean property, just like {@code isFoo()} methods returning primitive {@code boolean} already were. Before + * this, {@link java.beans.Introspector} (which is spec-strict) reported only primitive-{@code boolean} + * {@code isFoo()} methods as property readers, so {@code Boolean isFoo()} was accessible only as a method + * ({@code obj.isFoo()}), even though the documented behavior claims {@code obj.foo} should work for either + * {@code getFoo()} or {@code isFoo()}. Static {@code isFoo()} methods and {@code isFoo()} methods with any + * other return type are still not exposed as properties. + *

  • * * *

    Note that the version will be normalized to the lowest version where the same incompatible @@ -967,7 +977,8 @@ static boolean is2324Bugfixed(Version version) { */ protected static Version normalizeIncompatibleImprovementsVersion(Version incompatibleImprovements) { _TemplateAPI.checkVersionNotNullAndSupported(incompatibleImprovements); - return incompatibleImprovements.intValue() >= _VersionInts.V_2_3_33 ? Configuration.VERSION_2_3_33 + return incompatibleImprovements.intValue() >= _VersionInts.V_2_3_35 ? Configuration.VERSION_2_3_35 + : incompatibleImprovements.intValue() >= _VersionInts.V_2_3_33 ? Configuration.VERSION_2_3_33 : incompatibleImprovements.intValue() >= _VersionInts.V_2_3_27 ? Configuration.VERSION_2_3_27 : incompatibleImprovements.intValue() == _VersionInts.V_2_3_26 ? Configuration.VERSION_2_3_26 : is2324Bugfixed(incompatibleImprovements) ? Configuration.VERSION_2_3_24 diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/ClassIntrospector.java b/freemarker-core/src/main/java/freemarker/ext/beans/ClassIntrospector.java index 5e229937d..4eb05c349 100644 --- a/freemarker-core/src/main/java/freemarker/ext/beans/ClassIntrospector.java +++ b/freemarker-core/src/main/java/freemarker/ext/beans/ClassIntrospector.java @@ -154,6 +154,7 @@ class ClassIntrospector { final MethodAppearanceFineTuner methodAppearanceFineTuner; final MethodSorter methodSorter; final boolean treatDefaultMethodsAsBeanMembers; + final boolean treatBooleanWrapperIsGettersAsProperties; final ZeroArgumentNonVoidMethodPolicy defaultZeroArgumentNonVoidMethodPolicy; final ZeroArgumentNonVoidMethodPolicy recordZeroArgumentNonVoidMethodPolicy; final private boolean recordAware; @@ -198,6 +199,7 @@ class ClassIntrospector { this.methodAppearanceFineTuner = builder.getMethodAppearanceFineTuner(); this.methodSorter = builder.getMethodSorter(); this.treatDefaultMethodsAsBeanMembers = builder.getTreatDefaultMethodsAsBeanMembers(); + this.treatBooleanWrapperIsGettersAsProperties = builder.getTreatBooleanWrapperIsGettersAsProperties(); this.defaultZeroArgumentNonVoidMethodPolicy = builder.getDefaultZeroArgumentNonVoidMethodPolicy(); this.recordZeroArgumentNonVoidMethodPolicy = builder.getRecordZeroArgumentNonVoidMethodPolicy(); this.recordAware = defaultZeroArgumentNonVoidMethodPolicy != recordZeroArgumentNonVoidMethodPolicy; @@ -471,11 +473,11 @@ private List getPropertyDescriptors(BeanInfo beanInfo, Class List introspectorPDs = introspectorPDsArray != null ? Arrays.asList(introspectorPDsArray) : Collections.emptyList(); - if (!treatDefaultMethodsAsBeanMembers) { + if (!treatDefaultMethodsAsBeanMembers && !treatBooleanWrapperIsGettersAsProperties) { // java.beans.Introspector was good enough then. return introspectorPDs; } - + // introspectorPDs contains each property exactly once. But as now we will search them manually too, it can // happen that we find the same property for multiple times. Worse, because of indexed properties, it's possible // that we have to merge entries (like one has the normal reader method, the other has the indexed reader @@ -483,17 +485,26 @@ private List getPropertyDescriptors(BeanInfo beanInfo, Class // which holds the methods belonging to the same property name. IndexedPropertyDescriptor is not good for that, // as it can't store two methods whose types are incompatible, and we have to wait until all the merging was // done to see if the incompatibility goes away. - + // This could be Map, but since we rarely need to do merging, we try to avoid // creating those and use the source objects as much as possible. Also note that we initialize this lazily. LinkedHashMap mergedPRMPs = null; - // Collect Java 8 default methods that look like property readers into mergedPRMPs: + // Collect methods that look like property readers but that java.beans.Introspector did not report as such: + // - Java 8 default interface methods (not reported by Introspector prior to its own support) + // - isXxx() returning Boolean wrapper (spec-wise Introspector only recognises primitive boolean here) // (Note that java.beans.Introspector discovers non-accessible public methods, and to emulate that behavior // here, we don't utilize the accessibleMethods Map, which we might already have at this point.) for (Method method : clazz.getMethods()) { - if (method.isDefault() && method.getReturnType() != void.class - && !method.isBridge()) { + if (method.isBridge() || method.getReturnType() == void.class) { + continue; + } + boolean isDefaultMethodCandidate = treatDefaultMethodsAsBeanMembers && method.isDefault(); + boolean isBooleanWrapperIsGetterCandidate = treatBooleanWrapperIsGettersAsProperties + && !Modifier.isStatic(method.getModifiers()) + && method.getReturnType() == Boolean.class + && method.getName().startsWith("is"); + if (isDefaultMethodCandidate || isBooleanWrapperIsGetterCandidate) { Class[] paramTypes = method.getParameterTypes(); if (paramTypes.length == 0 || paramTypes.length == 1 && paramTypes[0] == int.class /* indexed property reader */) { @@ -514,9 +525,9 @@ private List getPropertyDescriptors(BeanInfo beanInfo, Class } } } // for clazz.getMethods() - + if (mergedPRMPs == null) { - // We had no interfering Java 8 default methods, so we can chose the fast route. + // No methods needed supplementing, so we can chose the fast route. return introspectorPDs; } @@ -1143,6 +1154,10 @@ boolean getTreatDefaultMethodsAsBeanMembers() { return treatDefaultMethodsAsBeanMembers; } + boolean getTreatBooleanWrapperIsGettersAsProperties() { + return treatBooleanWrapperIsGettersAsProperties; + } + ZeroArgumentNonVoidMethodPolicy getDefaultZeroArgumentNonVoidMethodPolicy() { return defaultZeroArgumentNonVoidMethodPolicy; } diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/ClassIntrospectorBuilder.java b/freemarker-core/src/main/java/freemarker/ext/beans/ClassIntrospectorBuilder.java index 141917ccf..ee1b478d8 100644 --- a/freemarker-core/src/main/java/freemarker/ext/beans/ClassIntrospectorBuilder.java +++ b/freemarker-core/src/main/java/freemarker/ext/beans/ClassIntrospectorBuilder.java @@ -47,6 +47,7 @@ final class ClassIntrospectorBuilder implements Cloneable { private boolean exposeFields; private MemberAccessPolicy memberAccessPolicy; private boolean treatDefaultMethodsAsBeanMembers; + private boolean treatBooleanWrapperIsGettersAsProperties; private ZeroArgumentNonVoidMethodPolicy defaultZeroArgumentNonVoidMethodPolicy; private ZeroArgumentNonVoidMethodPolicy recordZeroArgumentNonVoidMethodPolicy; private MethodAppearanceFineTuner methodAppearanceFineTuner; @@ -63,6 +64,7 @@ final class ClassIntrospectorBuilder implements Cloneable { exposeFields = ci.exposeFields; memberAccessPolicy = ci.memberAccessPolicy; treatDefaultMethodsAsBeanMembers = ci.treatDefaultMethodsAsBeanMembers; + treatBooleanWrapperIsGettersAsProperties = ci.treatBooleanWrapperIsGettersAsProperties; defaultZeroArgumentNonVoidMethodPolicy = ci.defaultZeroArgumentNonVoidMethodPolicy; recordZeroArgumentNonVoidMethodPolicy = ci.recordZeroArgumentNonVoidMethodPolicy; methodAppearanceFineTuner = ci.methodAppearanceFineTuner; @@ -75,6 +77,7 @@ final class ClassIntrospectorBuilder implements Cloneable { // to some version changes that affects BeansWrapper, but not the other way around. this.incompatibleImprovements = normalizeIncompatibleImprovementsVersion(incompatibleImprovements); treatDefaultMethodsAsBeanMembers = incompatibleImprovements.intValue() >= _VersionInts.V_2_3_26; + treatBooleanWrapperIsGettersAsProperties = incompatibleImprovements.intValue() >= _VersionInts.V_2_3_35; defaultZeroArgumentNonVoidMethodPolicy = ZeroArgumentNonVoidMethodPolicy.METHOD_ONLY; recordZeroArgumentNonVoidMethodPolicy = incompatibleImprovements.intValue() >= _VersionInts.V_2_3_33 && _Java16.INSTANCE.isSupported() @@ -86,7 +89,8 @@ final class ClassIntrospectorBuilder implements Cloneable { private static Version normalizeIncompatibleImprovementsVersion(Version incompatibleImprovements) { _TemplateAPI.checkVersionNotNullAndSupported(incompatibleImprovements); // All breakpoints here must occur in BeansWrapper.normalizeIncompatibleImprovements! - return incompatibleImprovements.intValue() >= _VersionInts.V_2_3_33 ? Configuration.VERSION_2_3_33 + return incompatibleImprovements.intValue() >= _VersionInts.V_2_3_35 ? Configuration.VERSION_2_3_35 + : incompatibleImprovements.intValue() >= _VersionInts.V_2_3_33 ? Configuration.VERSION_2_3_33 : incompatibleImprovements.intValue() >= _VersionInts.V_2_3_30 ? Configuration.VERSION_2_3_30 : incompatibleImprovements.intValue() >= _VersionInts.V_2_3_21 ? Configuration.VERSION_2_3_21 : Configuration.VERSION_2_3_0; @@ -108,6 +112,7 @@ public int hashCode() { result = prime * result + incompatibleImprovements.hashCode(); result = prime * result + (exposeFields ? 1231 : 1237); result = prime * result + (treatDefaultMethodsAsBeanMembers ? 1231 : 1237); + result = prime * result + (treatBooleanWrapperIsGettersAsProperties ? 1231 : 1237); result = prime * result + defaultZeroArgumentNonVoidMethodPolicy.hashCode(); result = prime * result + recordZeroArgumentNonVoidMethodPolicy.hashCode(); result = prime * result + exposureLevel; @@ -127,6 +132,7 @@ public boolean equals(Object obj) { if (!incompatibleImprovements.equals(other.incompatibleImprovements)) return false; if (exposeFields != other.exposeFields) return false; if (treatDefaultMethodsAsBeanMembers != other.treatDefaultMethodsAsBeanMembers) return false; + if (treatBooleanWrapperIsGettersAsProperties != other.treatBooleanWrapperIsGettersAsProperties) return false; if (defaultZeroArgumentNonVoidMethodPolicy != other.defaultZeroArgumentNonVoidMethodPolicy) return false; if (recordZeroArgumentNonVoidMethodPolicy != other.recordZeroArgumentNonVoidMethodPolicy) return false; if (exposureLevel != other.exposureLevel) return false; @@ -167,6 +173,14 @@ public void setTreatDefaultMethodsAsBeanMembers(boolean treatDefaultMethodsAsBea this.treatDefaultMethodsAsBeanMembers = treatDefaultMethodsAsBeanMembers; } + public boolean getTreatBooleanWrapperIsGettersAsProperties() { + return treatBooleanWrapperIsGettersAsProperties; + } + + public void setTreatBooleanWrapperIsGettersAsProperties(boolean treatBooleanWrapperIsGettersAsProperties) { + this.treatBooleanWrapperIsGettersAsProperties = treatBooleanWrapperIsGettersAsProperties; + } + /** * The getter pair of {@link #setDefaultZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy)}. * diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/_MethodUtil.java b/freemarker-core/src/main/java/freemarker/ext/beans/_MethodUtil.java index 99a733ed6..c7c24c687 100644 --- a/freemarker-core/src/main/java/freemarker/ext/beans/_MethodUtil.java +++ b/freemarker-core/src/main/java/freemarker/ext/beans/_MethodUtil.java @@ -302,7 +302,7 @@ public static String getBeanPropertyNameFromReaderMethodName(String name, Class< int start; if (name.startsWith("get")) { start = 3; - } else if (returnType == boolean.class && name.startsWith("is")) { + } else if ((returnType == boolean.class || returnType == Boolean.class) && name.startsWith("is")) { start = 2; } else { return null; diff --git a/freemarker-core/src/main/java/freemarker/template/_VersionInts.java b/freemarker-core/src/main/java/freemarker/template/_VersionInts.java index b07c9bd73..b0fe1b583 100644 --- a/freemarker-core/src/main/java/freemarker/template/_VersionInts.java +++ b/freemarker-core/src/main/java/freemarker/template/_VersionInts.java @@ -48,5 +48,6 @@ private _VersionInts() { public static final int V_2_3_32 = Configuration.VERSION_2_3_32.intValue(); public static final int V_2_3_33 = Configuration.VERSION_2_3_33.intValue(); public static final int V_2_3_34 = Configuration.VERSION_2_3_34.intValue(); + public static final int V_2_3_35 = Configuration.VERSION_2_3_35.intValue(); public static final int V_2_4_0 = Version.intValueFor(2, 4, 0); } diff --git a/freemarker-core/src/test/java/freemarker/ext/beans/BeansWrapperMiscTest.java b/freemarker-core/src/test/java/freemarker/ext/beans/BeansWrapperMiscTest.java index ac7a7b773..ff9f38733 100644 --- a/freemarker-core/src/test/java/freemarker/ext/beans/BeansWrapperMiscTest.java +++ b/freemarker-core/src/test/java/freemarker/ext/beans/BeansWrapperMiscTest.java @@ -118,6 +118,59 @@ public void java8InaccessibleIndexedAccessibleNonIndexedReadMethodTest() throws } } + @Test + public void booleanWrapperIsGetterAsPropertyTest() throws TemplateModelException { + // Pre-2.3.35 (legacy): Boolean isXxx() is NOT exposed as a property, only as a method. + { + BeansWrapper bw = new BeansWrapperBuilder(Configuration.VERSION_2_3_34).build(); + TemplateHashModel beanTM = (TemplateHashModel) bw.wrap(new BeanWithBooleanWrapperIsGetter()); + assertNull(beanTM.get("obsolete")); + assertThat(beanTM.get("isObsolete"), instanceOf(TemplateMethodModelEx.class)); + } + + // At 2.3.35+: Boolean isXxx() is exposed as a property (FREEMARKER-234). + { + BeansWrapper bw = new BeansWrapperBuilder(Configuration.VERSION_2_3_35).build(); + TemplateHashModel beanTM = (TemplateHashModel) bw.wrap(new BeanWithBooleanWrapperIsGetter()); + + TemplateModel obsoleteTM = beanTM.get("obsolete"); + assertThat(obsoleteTM, instanceOf(TemplateBooleanModel.class)); + assertTrue(((TemplateBooleanModel) obsoleteTM).getAsBoolean()); + + // Primitive boolean isXxx() still works (unchanged). + TemplateModel activeTM = beanTM.get("active"); + assertThat(activeTM, instanceOf(TemplateBooleanModel.class)); + assertFalse(((TemplateBooleanModel) activeTM).getAsBoolean()); + + // String isXxx() is never a property (not a recognised reader). + assertNull(beanTM.get("label")); + + // Static Boolean isXxx() must not be exposed as a property. + assertNull(beanTM.get("archived")); + + // The method form is still reachable (method exposure unchanged). + assertThat(beanTM.get("isObsolete"), instanceOf(TemplateMethodModelEx.class)); + } + } + + public static class BeanWithBooleanWrapperIsGetter { + public Boolean isObsolete() { + return Boolean.TRUE; + } + + public boolean isActive() { + return false; + } + + public String isLabel() { + return "not a property"; + } + + public static Boolean isArchived() { + return Boolean.TRUE; + } + } + public static class BeanWithBothIndexedAndArrayProperty { private final static String[] FOO = new String[] { "a", "b" }; diff --git a/freemarker-core/src/test/java/freemarker/ext/beans/GetPropertyNameFromReaderMethodNameTest.java b/freemarker-core/src/test/java/freemarker/ext/beans/GetPropertyNameFromReaderMethodNameTest.java index d100b775b..082a4a33a 100644 --- a/freemarker-core/src/test/java/freemarker/ext/beans/GetPropertyNameFromReaderMethodNameTest.java +++ b/freemarker-core/src/test/java/freemarker/ext/beans/GetPropertyNameFromReaderMethodNameTest.java @@ -36,11 +36,12 @@ public void test() { assertEquals("fooBar", _MethodUtil.getBeanPropertyNameFromReaderMethodName("getFooBar", String.class)); assertEquals("FOoBar", _MethodUtil.getBeanPropertyNameFromReaderMethodName("getFOoBar", String.class)); - assertEquals("foo", _MethodUtil.getBeanPropertyNameFromReaderMethodName("getFoo", boolean.class)); - assertEquals("foo", _MethodUtil.getBeanPropertyNameFromReaderMethodName("isFoo", boolean.class)); - assertNull(_MethodUtil.getBeanPropertyNameFromReaderMethodName("isFoo", Boolean.class)); + assertEquals("foo", _MethodUtil.getBeanPropertyNameFromReaderMethodName("getFoo", boolean.class)); + assertEquals("foo", _MethodUtil.getBeanPropertyNameFromReaderMethodName("isFoo", boolean.class)); + assertEquals("foo", _MethodUtil.getBeanPropertyNameFromReaderMethodName("isFoo", Boolean.class)); assertNull(_MethodUtil.getBeanPropertyNameFromReaderMethodName("isFoo", String.class)); - assertEquals("f", _MethodUtil.getBeanPropertyNameFromReaderMethodName("isF", boolean.class)); + assertEquals("f", _MethodUtil.getBeanPropertyNameFromReaderMethodName("isF", boolean.class)); + assertEquals("f", _MethodUtil.getBeanPropertyNameFromReaderMethodName("isF", Boolean.class)); assertEquals("foo", _MethodUtil.getBeanPropertyNameFromReaderMethodName("getfoo", String.class)); assertEquals("fo", _MethodUtil.getBeanPropertyNameFromReaderMethodName("getfo", String.class)); diff --git a/freemarker-manual/src/main/docgen/en_US/book.xml b/freemarker-manual/src/main/docgen/en_US/book.xml index 3f35428e3..33bafe685 100644 --- a/freemarker-manual/src/main/docgen/en_US/book.xml +++ b/freemarker-manual/src/main/docgen/en_US/book.xml @@ -30598,6 +30598,25 @@ TemplateModel x = env.getVariable("x"); // get variable x reflection and resource access for your application; see more here! + + + FREEMARKER-234: + BeansWrapper, and therefore + DefaultObjectWrapper, now exposes + isFoo() methods that return + java.lang.Boolean (the wrapper class) as the + foo bean property, if you set the incompatible_improvements + setting to 2.3.35 or higher. Earlier this only worked if + the return type was primitive boolean, which + contradicted the documented behavior stating that + model.foo invokes either + obj.getFoo() or + obj.isFoo(). Static methods, and + isFoo() methods with any other return type, + are still not exposed as properties. +