This is an automated email from the ASF dual-hosted git repository. ddekany pushed a commit to branch FREEMARKER-183 in repository https://gitbox.apache.org/repos/asf/freemarker.git
commit ba05292bbfc6338e193054044d70078d00c63211 Author: ddekany <[email protected]> AuthorDate: Mon Jan 8 09:59:42 2024 +0100 Added support for marking obj.prop and obj.prop() to be the same in templates. Made Java zero argument methods to be such properties by default, if incompatibleImprovements is at least 2.3.33. --- build.gradle.kts | 2 +- .../src/main/java/freemarker/core/Dot.java | 21 ++- .../java/freemarker/core/DotBeforeMethodCall.java | 58 +++++++ .../main/java/freemarker/ext/beans/APIModel.java | 2 +- .../ext/beans/{APIModel.java => APIModelEx.java} | 30 ++-- .../main/java/freemarker/ext/beans/BeanModel.java | 97 ++++++++++- .../java/freemarker/ext/beans/BeansWrapper.java | 124 ++++++++++++-- .../ext/beans/BeansWrapperConfiguration.java | 30 ++++ .../freemarker/ext/beans/ClassIntrospector.java | 72 +++++++- .../ext/beans/ClassIntrospectorBuilder.java | 48 +++++- .../ext/beans/FastPropertyDescriptor.java | 18 +- .../ext/beans/MethodAppearanceFineTuner.java | 26 +++ .../beans/MethodCallAwareTemplateHashModel.java | 115 +++++++++++++ .../java/freemarker/ext/beans/StringModel.java | 11 +- .../java/freemarker/ext/beans/StringModelEx.java | 53 ++++++ ...l.java => ZeroArgumentNonVoidMethodPolicy.java} | 38 ++--- .../main/java/freemarker/ext/beans/_BeansAPI.java | 4 +- .../src/main/javacc/freemarker/core/FTL.jj | 3 + .../beans/TestZeroArgumentNonVoidMethodPolicy.java | 187 +++++++++++++++++++++ .../template/DefaultObjectWrapperTest.java | 4 +- 20 files changed, 856 insertions(+), 87 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 6e6f1f93..4114dec8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -57,7 +57,7 @@ freemarkerRoot { configureSourceSet("jython20") configureSourceSet("jython22") configureSourceSet("jython25") { enableTests() } - configureSourceSet("core16", "16") + configureSourceSet("core16", "16") { enableTests() } } val compileJavacc = tasks.register<freemarker.build.CompileJavaccTask>("compileJavacc") { diff --git a/freemarker-core/src/main/java/freemarker/core/Dot.java b/freemarker-core/src/main/java/freemarker/core/Dot.java index 54bae57b..712945cc 100644 --- a/freemarker-core/src/main/java/freemarker/core/Dot.java +++ b/freemarker-core/src/main/java/freemarker/core/Dot.java @@ -27,7 +27,7 @@ import freemarker.template.TemplateModel; * The dot operator. Used to reference items inside a * <code>TemplateHashModel</code>. */ -final class Dot extends Expression { +class Dot extends Expression { private final Expression target; private final String key; @@ -36,11 +36,20 @@ final class Dot extends Expression { this.key = key; } + /** + * Shallow copy constructor + */ + public Dot(Dot dot) { + this(dot.target, dot.key); + this.constantValue = dot.constantValue; // Probably always will be null here + copyFieldsFrom(dot); + } + @Override TemplateModel _eval(Environment env) throws TemplateException { TemplateModel leftModel = target.eval(env); if (leftModel instanceof TemplateHashModel) { - return ((TemplateHashModel) leftModel).get(key); + return evalOnHash((TemplateHashModel) leftModel); } if (leftModel == null && env.isClassicCompatible()) { return null; // ${noSuchVar.foo} has just printed nothing in FM 1. @@ -48,6 +57,14 @@ final class Dot extends Expression { throw new NonHashException(target, leftModel, env); } + protected TemplateModel evalOnHash(TemplateHashModel leftModel) throws TemplateException { + return leftModel.get(key); + } + + public String getKey() { + return key; + } + @Override public String getCanonicalForm() { return target.getCanonicalForm() + getNodeTypeSymbol() + _CoreStringUtils.toFTLIdentifierReferenceAfterDot(key); diff --git a/freemarker-core/src/main/java/freemarker/core/DotBeforeMethodCall.java b/freemarker-core/src/main/java/freemarker/core/DotBeforeMethodCall.java new file mode 100644 index 00000000..e2758c1c --- /dev/null +++ b/freemarker-core/src/main/java/freemarker/core/DotBeforeMethodCall.java @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 freemarker.core; + +import freemarker.ext.beans.BeansWrapper; +import freemarker.ext.beans.MethodCallAwareTemplateHashModel; +import freemarker.template.TemplateException; +import freemarker.template.TemplateHashModel; +import freemarker.template.TemplateModel; + +/** + * Like {@link Dot}, but when used before method call (but as of 2.3.33, before 0-argument calls only), as in + * {@code obj.key()}. The reason it's only used before 0-argument calls (as of 2.3.33 at least) is that it adds some + * overhead, and this was only added for the + * {@link BeansWrapper.MethodAppearanceDecision#setMethodInsteadOfPropertyValueBeforeCall(boolean)} feature, which is + * always used for 0-argument calls. We don't necessarily want to go beyond that hack, as we don't have a proper + * method namespace separation in the template language. + */ +class DotBeforeMethodCall extends Dot { + public DotBeforeMethodCall(Dot dot) { + super(dot); + } + + @Override + protected TemplateModel evalOnHash(TemplateHashModel leftModel) throws TemplateException { + if (leftModel instanceof MethodCallAwareTemplateHashModel) { + try { + return ((MethodCallAwareTemplateHashModel) leftModel).getBeforeMethodCall(getKey()); + } catch (MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException e) { + String hint = e.getHint(); + throw new NonMethodException( + this, + e.getActualValue(), + hint != null ? new String[] { hint } : null, + Environment.getCurrentEnvironment()); + } + } else { + return super.evalOnHash(leftModel); + } + } +} diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/APIModel.java b/freemarker-core/src/main/java/freemarker/ext/beans/APIModel.java index 4580ac8d..779cda55 100644 --- a/freemarker-core/src/main/java/freemarker/ext/beans/APIModel.java +++ b/freemarker-core/src/main/java/freemarker/ext/beans/APIModel.java @@ -32,7 +32,7 @@ package freemarker.ext.beans; * * @since 2.3.22 */ -final class APIModel extends BeanModel { +class APIModel extends BeanModel { APIModel(Object object, BeansWrapper wrapper) { super(object, wrapper, false); diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/APIModel.java b/freemarker-core/src/main/java/freemarker/ext/beans/APIModelEx.java similarity index 59% copy from freemarker-core/src/main/java/freemarker/ext/beans/APIModel.java copy to freemarker-core/src/main/java/freemarker/ext/beans/APIModelEx.java index 4580ac8d..e61837c2 100644 --- a/freemarker-core/src/main/java/freemarker/ext/beans/APIModel.java +++ b/freemarker-core/src/main/java/freemarker/ext/beans/APIModelEx.java @@ -19,27 +19,23 @@ package freemarker.ext.beans; +import freemarker.template.TemplateMethodModelEx; +import freemarker.template.TemplateModelException; + /** - * Exposes the Java API (and properties) of an object. - * - * <p> - * Notes: - * <ul> - * <li>The exposing level is inherited from the {@link BeansWrapper}</li> - * <li>But methods will always shadow properties and fields with identical name, regardless of {@link BeansWrapper} - * settings</li> - * </ul> - * - * @since 2.3.22 + * Like {@link APIModel}, but adds support for {@link MethodCallAwareTemplateHashModel}. + * + * @since 2.3.33 */ -final class APIModel extends BeanModel { +final class APIModelEx extends APIModel implements MethodCallAwareTemplateHashModel { - APIModel(Object object, BeansWrapper wrapper) { - super(object, wrapper, false); + APIModelEx(Object object, BeansWrapper wrapper) { + super(object, wrapper); } - protected boolean isMethodsShadowItems() { - return true; + @Override + public TemplateMethodModelEx getBeforeMethodCall(String key) throws TemplateModelException, + ShouldNotBeGetAsMethodException { + return super.getBeforeMethodCall(key); } - } diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/BeanModel.java b/freemarker-core/src/main/java/freemarker/ext/beans/BeanModel.java index 79110b90..6601e353 100644 --- a/freemarker-core/src/main/java/freemarker/ext/beans/BeanModel.java +++ b/freemarker-core/src/main/java/freemarker/ext/beans/BeanModel.java @@ -30,6 +30,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import freemarker.core.BugException; import freemarker.core.CollectionAndSequence; import freemarker.core._DelayedFTLTypeDescription; import freemarker.core._DelayedJQuote; @@ -43,11 +44,13 @@ import freemarker.template.SimpleScalar; import freemarker.template.SimpleSequence; import freemarker.template.TemplateCollectionModel; import freemarker.template.TemplateHashModelEx; +import freemarker.template.TemplateMethodModelEx; import freemarker.template.TemplateModel; import freemarker.template.TemplateModelException; import freemarker.template.TemplateModelIterator; import freemarker.template.TemplateModelWithAPISupport; import freemarker.template.TemplateScalarModel; +import freemarker.template.utility.CollectionUtils; import freemarker.template.utility.StringUtil; /** @@ -134,12 +137,67 @@ implements TemplateHashModelEx, AdapterTemplateModel, WrapperTemplateModel, Temp * then {@code non-void-return-type get(java.lang.Object)}, or * alternatively (if the wrapped object is a resource bundle) * {@code Object getObject(java.lang.String)}. + * + * <p>As of 2.3.33, the default implementation of this method delegates to {@link #get(String, boolean)}. It's + * better to override that, instead of this method. Otherwise, unwanted behavior can arise if the model class also + * implements {@link MethodCallAwareTemplateHashModel}, as that will certainly call {@link #get(String, boolean)} + * internally, and not the overridden version of this method. + * * @throws TemplateModelException if there was no property nor method nor * a generic {@code get} method to invoke. */ @Override - public TemplateModel get(String key) - throws TemplateModelException { + public TemplateModel get(String key) throws TemplateModelException { + try { + return get(key, false); + } catch (MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException e) { + throw new BugException(e); + } + } + + /** + * Can be overridden to be public, to implement {@link MethodCallAwareTemplateHashModel}. We don't implement that + * in {@link BeanModel} for backward compatibility, but the functionality is present. If you expose this method by + * implementing {@link MethodCallAwareTemplateHashModel}, then be sure that {@link #get(String)} is + * not overridden in custom subclasses; if it is, then those subclasses should be modernized to override + * {@link #get(String, boolean)} instead. + * + * @since 2.3.33 + */ + protected TemplateMethodModelEx getBeforeMethodCall(String key) + throws TemplateModelException, MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException { + TemplateModel result = get(key, true); + if (result instanceof TemplateMethodModelEx) { + return (TemplateMethodModelEx) result; + } + if (result == null) { + return null; + } + throw new MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException(result, null); + } + + /** + * Override this if you want to customize the behavior of {@link #get(String)}. + * In standard implementations at least, this is what {@link #get(String)}, and + * {@link MethodCallAwareTemplateHashModel#getBeforeMethodCall(String)} delegates to. + * + * @param key + * Same as the parameter of {@link #get(String)}. + * @param beforeMethodCall + * This is a hint that tells that the returned value will be called in the template. This was added to + * implement {@link BeansWrapper.MethodAppearanceDecision#setMethodInsteadOfPropertyValueBeforeCall(boolean)}. + * But in short, it's up to the model if itb will be lenient, and return a method, or fails with + * {@link MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException}. In standard implementations at + * least, this parameter is {@code false} when {@link #get(String)} is called, and + * {@code true} when {@link MethodCallAwareTemplateHashModel#getBeforeMethodCall(String)} is called. + * + * @since 2.3.33 + */ + // Before calling this from FreeMarker classes, consider that some users may have overridden {@link #get(String)} + // instead, as this class didn't exist before 2.3.33. So with incompatibleImprovements before that, that should be + // the only place where this gets called, or else the behavior of the model will be inconsistent. + protected TemplateModel get(String key, boolean beforeMethodCall) + throws TemplateModelException, MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException { Class<?> clazz = object.getClass(); Map<Object, Object> classInfo = wrapper.getClassIntrospector().get(clazz); TemplateModel retval = null; @@ -148,7 +206,7 @@ implements TemplateHashModelEx, AdapterTemplateModel, WrapperTemplateModel, Temp if (wrapper.isMethodsShadowItems()) { Object fd = classInfo.get(key); if (fd != null) { - retval = invokeThroughDescriptor(fd, classInfo); + retval = invokeThroughDescriptor(fd, classInfo, beforeMethodCall); } else { retval = invokeGenericGet(classInfo, clazz, key); } @@ -160,7 +218,7 @@ implements TemplateHashModelEx, AdapterTemplateModel, WrapperTemplateModel, Temp } Object fd = classInfo.get(key); if (fd != null) { - retval = invokeThroughDescriptor(fd, classInfo); + retval = invokeThroughDescriptor(fd, classInfo, beforeMethodCall); if (retval == UNKNOWN && model == nullModel) { // This is the (somewhat subtle) case where the generic get() returns null // and we have no bean info, so we respect the fact that @@ -181,6 +239,9 @@ implements TemplateHashModelEx, AdapterTemplateModel, WrapperTemplateModel, Temp } catch (TemplateModelException e) { throw e; } catch (Exception e) { + if (beforeMethodCall && e instanceof MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException) { + throw (MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException) e; + } throw new _TemplateModelException(e, "An error has occurred when reading existing sub-variable ", new _DelayedJQuote(key), "; see cause exception! The type of the containing value was: ", @@ -203,8 +264,9 @@ implements TemplateHashModelEx, AdapterTemplateModel, WrapperTemplateModel, Temp return wrapper.getClassIntrospector().get(object.getClass()).get(ClassIntrospector.GENERIC_GET_KEY) != null; } - private TemplateModel invokeThroughDescriptor(Object desc, Map<Object, Object> classInfo) - throws IllegalAccessException, InvocationTargetException, TemplateModelException { + private TemplateModel invokeThroughDescriptor(Object desc, Map<Object, Object> classInfo, boolean beforeMethodCall) + throws IllegalAccessException, InvocationTargetException, TemplateModelException, + MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException { // See if this particular instance has a cached implementation for the requested feature descriptor TemplateModel cachedModel; synchronized (this) { @@ -229,8 +291,27 @@ implements TemplateHashModelEx, AdapterTemplateModel, WrapperTemplateModel, Temp ClassIntrospector.getArgTypes(classInfo, indexedReadMethod), wrapper); } } else { - resultModel = wrapper.invokeMethod(object, pd.getReadMethod(), null); - // cachedModel remains null, as we don't cache these + if (!beforeMethodCall) { + resultModel = wrapper.invokeMethod(object, pd.getReadMethod(), null); + // cachedModel remains null, as we don't cache these + } else { + if (pd.isMethodInsteadOfPropertyValueBeforeCall()) { + Method method = pd.getReadMethod(); + resultModel = cachedModel = new SimpleMethodModel( + object, method, CollectionUtils.EMPTY_CLASS_ARRAY, wrapper); + } else { + resultModel = wrapper.invokeMethod(object, pd.getReadMethod(), null); + // cachedModel remains null, as we don't cache these + + if (!(resultModel instanceof TemplateMethodModelEx)) { + throw new MethodCallAwareTemplateHashModel.ShouldNotBeGetAsMethodException( + resultModel, + "This member of the parent object is seen by templates as a property of it " + + "(with other words, an attribute, or a field), not a method of it. " + + "Thus, to get its value, it must not be called as a method."); + } + } + } } } else if (desc instanceof Field) { resultModel = wrapper.readField(object, (Field) desc); 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 be2455eb..df10ef46 100644 --- a/freemarker-core/src/main/java/freemarker/ext/beans/BeansWrapper.java +++ b/freemarker-core/src/main/java/freemarker/ext/beans/BeansWrapper.java @@ -19,6 +19,7 @@ package freemarker.ext.beans; +import java.beans.IntrospectionException; import java.beans.Introspector; import java.beans.PropertyDescriptor; import java.lang.reflect.AccessibleObject; @@ -193,9 +194,11 @@ public class BeansWrapper implements RichObjectWrapper, WriteProtectable { private boolean simpleMapWrapper; // initialized from the BeansWrapperConfiguration private boolean strict; // initialized from the BeansWrapperConfiguration private boolean preferIndexedReadMethod; // initialized from the BeansWrapperConfiguration - + private final boolean useExModels; + private final Version incompatibleImprovements; - + + /** * Creates a new instance with the incompatible-improvements-version specified in * {@link Configuration#DEFAULT_INCOMPATIBLE_IMPROVEMENTS}. @@ -262,6 +265,19 @@ public class BeansWrapper implements RichObjectWrapper, WriteProtectable { * The default of the {@link #setPreferIndexedReadMethod(boolean) preferIndexedReadMethod} setting changes * from {@code true} to {@code false}. * </li> + * <li> + * <p>2.3.33 (or higher): + * Java records 0-argument public methods with non-void return type are now exposed both as properties, and as + * methods, while earlier they were only exposed as methods. That is, if in a record you have + * {@code public String name()}, now in 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). + * + * <p>On more technical level, {@link BeansWrapper} will create {@link StringModelEx} instead of + * {@link StringModel}, etc. So some models will implement {@link MethodCallAwareTemplateHashModel} with that. + * That also affects {@link DefaultObjectWrapper}, for the "generic" Java objects (that is, for classes that + * are not {@link Number}-s, {@link List}-s, {@link Map}-s, etc., that has a specific wrapping in + * {@link DefaultObjectWrapper}), and for the result of the {@code obj?api} (in case the object is a record). + * </li> * </ul> * * <p>Note that the version will be normalized to the lowest version where the same incompatible @@ -353,6 +369,7 @@ public class BeansWrapper implements RichObjectWrapper, WriteProtectable { defaultDateType = bwConf.getDefaultDateType(); outerIdentity = bwConf.getOuterIdentity() != null ? bwConf.getOuterIdentity() : this; strict = bwConf.isStrict(); + useExModels = incompatibleImprovements.intValue() >= _VersionInts.V_2_3_33; if (!writeProtected) { // As this is not a read-only BeansWrapper, the classIntrospector will be possibly replaced for a few times, @@ -633,7 +650,37 @@ public class BeansWrapper implements RichObjectWrapper, WriteProtectable { replaceClassIntrospector(builder); } } - + + /** + * Sets the {@link ZeroArgumentNonVoidMethodPolicy} for classes that are not Java records. + * + * @since 2.3.33 + */ + public void setNonRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy nonRecordZeroArgumentNonVoidMethodPolicy) { + checkModifiable(); + + if (classIntrospector.getNonRecordZeroArgumentNonVoidMethodPolicy() != nonRecordZeroArgumentNonVoidMethodPolicy) { + ClassIntrospectorBuilder builder = classIntrospector.createBuilder(); + builder.setNonRecordZeroArgumentNonVoidMethodPolicy(nonRecordZeroArgumentNonVoidMethodPolicy); + replaceClassIntrospector(builder); + } + } + + /** + * Sets the {@link ZeroArgumentNonVoidMethodPolicy} for classes that are not Java records. + * + * @since 2.3.33 + */ + public void setRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy recordZeroArgumentNonVoidMethodPolicy) { + checkModifiable(); + + if (classIntrospector.getRecordZeroArgumentNonVoidMethodPolicy() != recordZeroArgumentNonVoidMethodPolicy) { + ClassIntrospectorBuilder builder = classIntrospector.createBuilder(); + builder.setRecordZeroArgumentNonVoidMethodPolicy(recordZeroArgumentNonVoidMethodPolicy); + replaceClassIntrospector(builder); + } + } + /** * Returns whether exposure of public instance fields of classes is * enabled. See {@link #setExposeFields(boolean)} for details. @@ -651,7 +698,25 @@ public class BeansWrapper implements RichObjectWrapper, WriteProtectable { public boolean getTreatDefaultMethodsAsBeanMembers() { return classIntrospector.getTreatDefaultMethodsAsBeanMembers(); } - + + /** + * See {@link #setNonRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy)}. + * + * @since 2.3.33 + */ + public ZeroArgumentNonVoidMethodPolicy getNonRecordZeroArgumentNonVoidMethodPolicy() { + return classIntrospector.getNonRecordZeroArgumentNonVoidMethodPolicy(); + } + + /** + * See {@link #setRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy)}. + * + * @since 2.3.33 + */ + public ZeroArgumentNonVoidMethodPolicy getRecordZeroArgumentNonVoidMethodPolicy() { + return classIntrospector.getRecordZeroArgumentNonVoidMethodPolicy(); + } + public MethodAppearanceFineTuner getMethodAppearanceFineTuner() { return classIntrospector.getMethodAppearanceFineTuner(); } @@ -865,7 +930,7 @@ public class BeansWrapper implements RichObjectWrapper, WriteProtectable { /** * Returns the version given with {@link #BeansWrapper(Version)}, normalized to the lowest version where a change * has occurred. Thus, this is not necessarily the same version than that was given to the constructor. - * + * * @since 2.3.21 */ public Version getIncompatibleImprovements() { @@ -894,7 +959,8 @@ public class BeansWrapper implements RichObjectWrapper, WriteProtectable { */ protected static Version normalizeIncompatibleImprovementsVersion(Version incompatibleImprovements) { _TemplateAPI.checkVersionNotNullAndSupported(incompatibleImprovements); - return incompatibleImprovements.intValue() >= _VersionInts.V_2_3_27 ? Configuration.VERSION_2_3_27 + return 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 : is2321Bugfixed(incompatibleImprovements) ? Configuration.VERSION_2_3_21 @@ -969,7 +1035,7 @@ public class BeansWrapper implements RichObjectWrapper, WriteProtectable { */ @Override public TemplateHashModel wrapAsAPI(Object obj) throws TemplateModelException { - return new APIModel(obj, this); + return useExModels ? new APIModelEx(obj, this) : new APIModel(obj, this); } /** @@ -1033,7 +1099,7 @@ public class BeansWrapper implements RichObjectWrapper, WriteProtectable { if (clazz.isArray()) { return ArrayModel.FACTORY; } - return StringModel.FACTORY; + return useExModels ? StringModelEx.FACTORY : StringModel.FACTORY; } /** @@ -1855,15 +1921,32 @@ public class BeansWrapper implements RichObjectWrapper, WriteProtectable { */ static public final class MethodAppearanceDecision { private PropertyDescriptor exposeAsProperty; + private boolean methodInsteadOfPropertyValueBeforeCall; private boolean replaceExistingProperty; private String exposeMethodAs; private boolean methodShadowsProperty; - - void setDefaults(Method m) { - exposeAsProperty = null; + + /** + * @param appliedZeroArgumentNonVoidMethodPolicy + * {@code null} if this is not a zero argument method with non-void return type. + */ + void setDefaults(Method m, ZeroArgumentNonVoidMethodPolicy appliedZeroArgumentNonVoidMethodPolicy) { + if (appliedZeroArgumentNonVoidMethodPolicy != null && appliedZeroArgumentNonVoidMethodPolicy != ZeroArgumentNonVoidMethodPolicy.METHOD_ONLY) { + try { + exposeAsProperty = new PropertyDescriptor(m.getName(), m, null); + } catch (IntrospectionException e) { + throw new BugException("Failed to create PropertyDescriptor for " + m, e); + } + methodShadowsProperty = false; + methodInsteadOfPropertyValueBeforeCall = appliedZeroArgumentNonVoidMethodPolicy == + ZeroArgumentNonVoidMethodPolicy.BOTH_PROPERTY_AND_METHOD; + } else { + exposeAsProperty = null; + methodInsteadOfPropertyValueBeforeCall = false; + methodShadowsProperty = true; + } replaceExistingProperty = false; exposeMethodAs = m.getName(); - methodShadowsProperty = true; } /** @@ -1935,6 +2018,23 @@ public class BeansWrapper implements RichObjectWrapper, WriteProtectable { this.methodShadowsProperty = shadowEarlierProperty; } + /** + * See in the documentation of {@link MethodAppearanceFineTuner#process}. + * + * @since 2.3.33 + */ + public boolean isMethodInsteadOfPropertyValueBeforeCall() { + return methodInsteadOfPropertyValueBeforeCall; + } + + /** + * See in the documentation of {@link MethodAppearanceFineTuner#process}. + * + * @since 2.3.33 + */ + public void setMethodInsteadOfPropertyValueBeforeCall(boolean methodInsteadOfPropertyValueBeforeCall) { + this.methodInsteadOfPropertyValueBeforeCall = methodInsteadOfPropertyValueBeforeCall; + } } /** diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/BeansWrapperConfiguration.java b/freemarker-core/src/main/java/freemarker/ext/beans/BeansWrapperConfiguration.java index 5daaa909..22cc8d51 100644 --- a/freemarker-core/src/main/java/freemarker/ext/beans/BeansWrapperConfiguration.java +++ b/freemarker-core/src/main/java/freemarker/ext/beans/BeansWrapperConfiguration.java @@ -251,6 +251,36 @@ public abstract class BeansWrapperConfiguration implements Cloneable { classIntrospectorBuilder.setTreatDefaultMethodsAsBeanMembers(treatDefaultMethodsAsBeanMembers); } + /** + * @since 2.3.33 + */ + public ZeroArgumentNonVoidMethodPolicy getNonRecordZeroArgumentNonVoidMethodPolicy() { + return classIntrospectorBuilder.getNonRecordZeroArgumentNonVoidMethodPolicy(); + } + + /** + * See {@link BeansWrapper#setNonRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy)} + * @since 2.3.33 + */ + public void setNonRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy nonRecordZeroArgumentNonVoidMethodPolicy) { + classIntrospectorBuilder.setNonRecordZeroArgumentNonVoidMethodPolicy(nonRecordZeroArgumentNonVoidMethodPolicy); + } + + /** + * @since 2.3.33 + */ + public ZeroArgumentNonVoidMethodPolicy getRecordZeroArgumentNonVoidMethodPolicy() { + return classIntrospectorBuilder.getRecordZeroArgumentNonVoidMethodPolicy(); + } + + /** + * See {@link BeansWrapper#setRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy)} + * @since 2.3.33 + */ + public void setRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy recordZeroArgumentNonVoidMethodPolicy) { + classIntrospectorBuilder.setRecordZeroArgumentNonVoidMethodPolicy(recordZeroArgumentNonVoidMethodPolicy); + } + public MethodAppearanceFineTuner getMethodAppearanceFineTuner() { return classIntrospectorBuilder.getMethodAppearanceFineTuner(); } 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 bf867814..8f95e182 100644 --- a/freemarker-core/src/main/java/freemarker/ext/beans/ClassIntrospector.java +++ b/freemarker-core/src/main/java/freemarker/ext/beans/ClassIntrospector.java @@ -48,11 +48,13 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import freemarker.core.BugException; +import freemarker.core._JavaVersions; import freemarker.ext.beans.BeansWrapper.MethodAppearanceDecision; import freemarker.ext.beans.BeansWrapper.MethodAppearanceDecisionInput; import freemarker.ext.util.ModelCache; import freemarker.log.Logger; import freemarker.template.Version; +import freemarker.template.utility.CollectionUtils; import freemarker.template.utility.NullArgumentException; import freemarker.template.utility.SecurityUtilities; @@ -82,7 +84,7 @@ class ClassIntrospector { private static final ExecutableMemberSignature GET_OBJECT_SIGNATURE = new ExecutableMemberSignature("get", new Class[] { Object.class }); private static final ExecutableMemberSignature TO_STRING_SIGNATURE = - new ExecutableMemberSignature("toString", new Class[0]); + new ExecutableMemberSignature("toString", CollectionUtils.EMPTY_CLASS_ARRAY); /** * When this property is true, some things are stricter. This is mostly to catch suspicious things in development @@ -151,6 +153,9 @@ class ClassIntrospector { final MethodAppearanceFineTuner methodAppearanceFineTuner; final MethodSorter methodSorter; final boolean treatDefaultMethodsAsBeanMembers; + final ZeroArgumentNonVoidMethodPolicy nonRecordZeroArgumentNonVoidMethodPolicy; + final ZeroArgumentNonVoidMethodPolicy recordZeroArgumentNonVoidMethodPolicy; + final private boolean recordAware; final Version incompatibleImprovements; /** See {@link #getHasSharedInstanceRestrictions()} */ @@ -192,6 +197,14 @@ class ClassIntrospector { this.methodAppearanceFineTuner = builder.getMethodAppearanceFineTuner(); this.methodSorter = builder.getMethodSorter(); this.treatDefaultMethodsAsBeanMembers = builder.getTreatDefaultMethodsAsBeanMembers(); + this.nonRecordZeroArgumentNonVoidMethodPolicy = builder.getNonRecordZeroArgumentNonVoidMethodPolicy(); + this.recordZeroArgumentNonVoidMethodPolicy = builder.getRecordZeroArgumentNonVoidMethodPolicy(); + this.recordAware = nonRecordZeroArgumentNonVoidMethodPolicy != recordZeroArgumentNonVoidMethodPolicy; + if (recordAware && _JavaVersions.JAVA_16 == null) { + throw new IllegalArgumentException( + "nonRecordZeroArgumentNonVoidMethodPolicy != recordZeroArgumentNonVoidMethodPolicy, " + + "but Java 16 support is not available."); + } this.incompatibleImprovements = builder.getIncompatibleImprovements(); this.sharedLock = sharedLock; @@ -329,13 +342,26 @@ class ClassIntrospector { Map<ExecutableMemberSignature, List<Method>> accessibleMethods, ClassMemberAccessPolicy effClassMemberAccessPolicy) throws IntrospectionException { BeanInfo beanInfo = Introspector.getBeanInfo(clazz); + + boolean treatClassAsRecord = recordAware && _JavaVersions.JAVA_16.isRecord(clazz); + ZeroArgumentNonVoidMethodPolicy zeroArgumentNonVoidMethodPolicy = treatClassAsRecord + ? recordZeroArgumentNonVoidMethodPolicy + : nonRecordZeroArgumentNonVoidMethodPolicy; + + // For real Java Beans properties only, used to exclude them from creating fake properties based on ZeroArgumentNonVoidMethod. + Set<String> beanPropertyReadMethodNameCollector = zeroArgumentNonVoidMethodPolicy != ZeroArgumentNonVoidMethodPolicy.METHOD_ONLY + ? new HashSet<String>() + : null; + List<PropertyDescriptor> pdas = getPropertyDescriptors(beanInfo, clazz); int pdasLength = pdas.size(); // Reverse order shouldn't mater, but we keep it to not risk backward incompatibility. for (int i = pdasLength - 1; i >= 0; --i) { addPropertyDescriptorToClassIntrospectionData( - introspData, pdas.get(i), - accessibleMethods, effClassMemberAccessPolicy); + introspData, pdas.get(i), false, + accessibleMethods, + beanPropertyReadMethodNameCollector, + effClassMemberAccessPolicy); } if (exposureLevel < BeansWrapper.EXPOSE_PROPERTIES_ONLY) { @@ -348,7 +374,11 @@ class ClassIntrospector { for (int i = mdsSize - 1; i >= 0; --i) { final Method method = getMatchingAccessibleMethod(mds.get(i).getMethod(), accessibleMethods); if (method != null && effClassMemberAccessPolicy.isMethodExposed(method)) { - decision.setDefaults(method); + ZeroArgumentNonVoidMethodPolicy appliedZeroArgumentNonVoidMethodPolicy = + getAppliedZeroArgumentNonVoidMethodPolicy( + method, beanPropertyReadMethodNameCollector, zeroArgumentNonVoidMethodPolicy); + + decision.setDefaults(method, appliedZeroArgumentNonVoidMethodPolicy); if (methodAppearanceFineTuner != null) { if (decisionInput == null) { decisionInput = new MethodAppearanceDecisionInput(); @@ -364,7 +394,8 @@ class ClassIntrospector { (decision.getReplaceExistingProperty() || !(introspData.get(propDesc.getName()) instanceof FastPropertyDescriptor))) { addPropertyDescriptorToClassIntrospectionData( - introspData, propDesc, accessibleMethods, effClassMemberAccessPolicy); + introspData, propDesc, decision.isMethodInsteadOfPropertyValueBeforeCall(), + accessibleMethods, null, effClassMemberAccessPolicy); } String methodKey = decision.getExposeMethodAs(); @@ -404,6 +435,18 @@ class ClassIntrospector { } // end if (exposureLevel < EXPOSE_PROPERTIES_ONLY) } + private static ZeroArgumentNonVoidMethodPolicy getAppliedZeroArgumentNonVoidMethodPolicy(Method method, Set<String> beanPropertyReadMethodNameCollector, ZeroArgumentNonVoidMethodPolicy zeroArgumentNonVoidMethodPolicy) { + if (method.getParameterCount() == 0 && method.getReturnType() != void.class) { + if (beanPropertyReadMethodNameCollector != null && beanPropertyReadMethodNameCollector.contains(method.getName())) { + return ZeroArgumentNonVoidMethodPolicy.METHOD_ONLY; + } else { + return zeroArgumentNonVoidMethodPolicy; + } + } else { + return null; + } + } + /** * Very similar to {@link BeanInfo#getPropertyDescriptors()}, but can deal with Java 8 default methods too. */ @@ -673,8 +716,9 @@ class ClassIntrospector { } private void addPropertyDescriptorToClassIntrospectionData(Map<Object, Object> introspData, - PropertyDescriptor pd, + PropertyDescriptor pd, boolean methodInsteadOfPropertyValueBeforeCall, Map<ExecutableMemberSignature, List<Method>> accessibleMethods, + Set<String> beanPropertyReadMethodNameCollector, ClassMemberAccessPolicy effClassMemberAccessPolicy) { Method readMethod = getMatchingAccessibleMethod(pd.getReadMethod(), accessibleMethods); if (readMethod != null && !effClassMemberAccessPolicy.isMethodExposed(readMethod)) { @@ -697,7 +741,13 @@ class ClassIntrospector { } if (readMethod != null || indexedReadMethod != null) { - introspData.put(pd.getName(), new FastPropertyDescriptor(readMethod, indexedReadMethod)); + introspData.put(pd.getName(), new FastPropertyDescriptor( + readMethod, indexedReadMethod, + methodInsteadOfPropertyValueBeforeCall)); + } + + if (readMethod != null && beanPropertyReadMethodNameCollector != null) { + beanPropertyReadMethodNameCollector.add(readMethod.getName()); } } @@ -1076,6 +1126,14 @@ class ClassIntrospector { return treatDefaultMethodsAsBeanMembers; } + ZeroArgumentNonVoidMethodPolicy getNonRecordZeroArgumentNonVoidMethodPolicy() { + return nonRecordZeroArgumentNonVoidMethodPolicy; + } + + ZeroArgumentNonVoidMethodPolicy getRecordZeroArgumentNonVoidMethodPolicy() { + return recordZeroArgumentNonVoidMethodPolicy; + } + MethodAppearanceFineTuner getMethodAppearanceFineTuner() { return methodAppearanceFineTuner; } 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 76e42318..24ad2735 100644 --- a/freemarker-core/src/main/java/freemarker/ext/beans/ClassIntrospectorBuilder.java +++ b/freemarker-core/src/main/java/freemarker/ext/beans/ClassIntrospectorBuilder.java @@ -26,6 +26,7 @@ import java.util.HashMap; import java.util.Iterator; import java.util.Map; +import freemarker.core._JavaVersions; import freemarker.template.Configuration; import freemarker.template.Version; import freemarker.template._TemplateAPI; @@ -46,6 +47,8 @@ final class ClassIntrospectorBuilder implements Cloneable { private boolean exposeFields; private MemberAccessPolicy memberAccessPolicy; private boolean treatDefaultMethodsAsBeanMembers; + private ZeroArgumentNonVoidMethodPolicy nonRecordZeroArgumentNonVoidMethodPolicy; + private ZeroArgumentNonVoidMethodPolicy recordZeroArgumentNonVoidMethodPolicy; private MethodAppearanceFineTuner methodAppearanceFineTuner; private MethodSorter methodSorter; // Attention: @@ -60,6 +63,8 @@ final class ClassIntrospectorBuilder implements Cloneable { exposeFields = ci.exposeFields; memberAccessPolicy = ci.memberAccessPolicy; treatDefaultMethodsAsBeanMembers = ci.treatDefaultMethodsAsBeanMembers; + nonRecordZeroArgumentNonVoidMethodPolicy = ci.nonRecordZeroArgumentNonVoidMethodPolicy; + recordZeroArgumentNonVoidMethodPolicy = ci.recordZeroArgumentNonVoidMethodPolicy; methodAppearanceFineTuner = ci.methodAppearanceFineTuner; methodSorter = ci.methodSorter; } @@ -69,15 +74,18 @@ final class ClassIntrospectorBuilder implements Cloneable { // change in the BeansWrapper.normalizeIncompatibleImprovements results. That is, this class may don't react // 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; + treatDefaultMethodsAsBeanMembers = incompatibleImprovements.intValue() >= _VersionInts.V_2_3_26; + nonRecordZeroArgumentNonVoidMethodPolicy = ZeroArgumentNonVoidMethodPolicy.METHOD_ONLY; + recordZeroArgumentNonVoidMethodPolicy = incompatibleImprovements.intValue() >= _VersionInts.V_2_3_33 && _JavaVersions.JAVA_16 != null + ? ZeroArgumentNonVoidMethodPolicy.BOTH_PROPERTY_AND_METHOD : ZeroArgumentNonVoidMethodPolicy.METHOD_ONLY; memberAccessPolicy = DefaultMemberAccessPolicy.getInstance(this.incompatibleImprovements); } private static Version normalizeIncompatibleImprovementsVersion(Version incompatibleImprovements) { _TemplateAPI.checkVersionNotNullAndSupported(incompatibleImprovements); // All breakpoints here must occur in BeansWrapper.normalizeIncompatibleImprovements! - return incompatibleImprovements.intValue() >= _VersionInts.V_2_3_30 ? Configuration.VERSION_2_3_30 + return 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; } @@ -98,6 +106,8 @@ final class ClassIntrospectorBuilder implements Cloneable { result = prime * result + incompatibleImprovements.hashCode(); result = prime * result + (exposeFields ? 1231 : 1237); result = prime * result + (treatDefaultMethodsAsBeanMembers ? 1231 : 1237); + result = prime * result + nonRecordZeroArgumentNonVoidMethodPolicy.hashCode(); + result = prime * result + recordZeroArgumentNonVoidMethodPolicy.hashCode(); result = prime * result + exposureLevel; result = prime * result + memberAccessPolicy.hashCode(); result = prime * result + System.identityHashCode(methodAppearanceFineTuner); @@ -115,6 +125,8 @@ final class ClassIntrospectorBuilder implements Cloneable { if (!incompatibleImprovements.equals(other.incompatibleImprovements)) return false; if (exposeFields != other.exposeFields) return false; if (treatDefaultMethodsAsBeanMembers != other.treatDefaultMethodsAsBeanMembers) return false; + if (nonRecordZeroArgumentNonVoidMethodPolicy != other.nonRecordZeroArgumentNonVoidMethodPolicy) return false; + if (recordZeroArgumentNonVoidMethodPolicy != other.recordZeroArgumentNonVoidMethodPolicy) return false; if (exposureLevel != other.exposureLevel) return false; if (!memberAccessPolicy.equals(other.memberAccessPolicy)) return false; if (methodAppearanceFineTuner != other.methodAppearanceFineTuner) return false; @@ -153,6 +165,36 @@ final class ClassIntrospectorBuilder implements Cloneable { this.treatDefaultMethodsAsBeanMembers = treatDefaultMethodsAsBeanMembers; } + /** + * @since 2.3.33 + */ + public ZeroArgumentNonVoidMethodPolicy getNonRecordZeroArgumentNonVoidMethodPolicy() { + return nonRecordZeroArgumentNonVoidMethodPolicy; + } + + /** + * @since 2.3.33 + */ + public void setNonRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy nonRecordZeroArgumentNonVoidMethodPolicy) { + NullArgumentException.check(nonRecordZeroArgumentNonVoidMethodPolicy); + this.nonRecordZeroArgumentNonVoidMethodPolicy = nonRecordZeroArgumentNonVoidMethodPolicy; + } + + /** + * @since 2.3.33 + */ + public ZeroArgumentNonVoidMethodPolicy getRecordZeroArgumentNonVoidMethodPolicy() { + return recordZeroArgumentNonVoidMethodPolicy; + } + + /** + * @since 2.3.33 + */ + public void setRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy recordZeroArgumentNonVoidMethodPolicy) { + NullArgumentException.check(recordZeroArgumentNonVoidMethodPolicy); + this.recordZeroArgumentNonVoidMethodPolicy = recordZeroArgumentNonVoidMethodPolicy; + } + public MemberAccessPolicy getMemberAccessPolicy() { return memberAccessPolicy; } diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/FastPropertyDescriptor.java b/freemarker-core/src/main/java/freemarker/ext/beans/FastPropertyDescriptor.java index 12d43de1..7ae8b674 100644 --- a/freemarker-core/src/main/java/freemarker/ext/beans/FastPropertyDescriptor.java +++ b/freemarker-core/src/main/java/freemarker/ext/beans/FastPropertyDescriptor.java @@ -29,10 +29,13 @@ import java.lang.reflect.Method; final class FastPropertyDescriptor { private final Method readMethod; private final Method indexedReadMethod; - - public FastPropertyDescriptor(Method readMethod, Method indexedReadMethod) { + private final boolean methodInsteadOfPropertyValueBeforeCall; + + public FastPropertyDescriptor( + Method readMethod, Method indexedReadMethod, boolean methodInsteadOfPropertyValueBeforeCall) { this.readMethod = readMethod; this.indexedReadMethod = indexedReadMethod; + this.methodInsteadOfPropertyValueBeforeCall = methodInsteadOfPropertyValueBeforeCall; } public Method getReadMethod() { @@ -42,5 +45,14 @@ final class FastPropertyDescriptor { public Method getIndexedReadMethod() { return indexedReadMethod; } - + + /** + * If this is true, and the property value is referred directly before it's called in a template, then + * the instead of the property value, it the value should be the read method (which therefore will be called). + * + * @since 2.3.33 + */ + public boolean isMethodInsteadOfPropertyValueBeforeCall() { + return methodInsteadOfPropertyValueBeforeCall; + } } diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/MethodAppearanceFineTuner.java b/freemarker-core/src/main/java/freemarker/ext/beans/MethodAppearanceFineTuner.java index 98c6416c..a514d8cf 100644 --- a/freemarker-core/src/main/java/freemarker/ext/beans/MethodAppearanceFineTuner.java +++ b/freemarker-core/src/main/java/freemarker/ext/beans/MethodAppearanceFineTuner.java @@ -24,6 +24,8 @@ import java.beans.PropertyDescriptor; import freemarker.ext.beans.BeansWrapper.MethodAppearanceDecision; import freemarker.ext.beans.BeansWrapper.MethodAppearanceDecisionInput; +import freemarker.template.DefaultObjectWrapper; +import freemarker.template.Version; /** * Used for customizing how the methods are visible from templates, via @@ -76,6 +78,30 @@ public interface MethodAppearanceFineTuner { * of the same name was already assigned earlier, it won't be * replaced by the new one by default, however this can be changed with * {@link MethodAppearanceDecision#setReplaceExistingProperty(boolean)}. + * <li>If something is exposed as property via + * {@link MethodAppearanceDecision#setExposeAsProperty(PropertyDescriptor)} (and not only because it's a real + * JavaBeans property), and you also want the property value to be accessible in templates as the return value + * of a 0-argument method of the same name, then call + * {@link MethodAppearanceDecision#setMethodInsteadOfPropertyValueBeforeCall(boolean)} with {@code true}. + * Here's an example to explain that. Let's say, you have a class that contains "public String name()", and you + * exposed that as a property via {@link MethodAppearanceDecision#setExposeAsProperty(PropertyDescriptor)}. So + * far, you can access the property value from templates as {@code user.name}, but {@code user.name()} will + * fail, saying that you try to call a {@code String} (because you apply the {@code ()} operator on the result + * of {@code user.name}). But with + * {@link MethodAppearanceDecision#setMethodInsteadOfPropertyValueBeforeCall(boolean)} {@code true}, + * both {@code user.name}, and {@code user.name()} will do the same. This is a parse-time + * trick that only works when the result of the dot operator is called immediately (and therefore the dot + * operator knows that you will call the result of it). The practical reason for this feature is that the + * convention of having {@code SomeType something()} instead of {@code SomeType getSomething()} spreads, and + * thus we can't tell anymore if {@code SomeType something()} just reads a value, and hence should be accessed + * like {@code obj.something}, or it's more like an operation that has side effect, and therefore should be + * accessed like {@code obj.something()}. So with being lenient, the template author is free to decide which is + * the more fitting. Also, for accessing Java records components, the proper way is {@code obj.something}, but + * before FreeMarker was aware of records (and hence that those methods are like property read methods), the + * only way that worked was {@code obj.something()}, so to be more backward compatible, we have to support both. + * Currently the default of this is {@code false}, except if {@link BeansWrapper#getIncompatibleImprovements()} + * (or {@link DefaultObjectWrapper#getIncompatibleImprovements()}, as that's in a subclass of + * {@link BeansWrapper}) is at least 2.3.33, but see details at {@link BeansWrapper#BeansWrapper(Version)}. * <li>Prevent the method to hide a JavaBeans property (fake or real) of * the same name by calling * {@link MethodAppearanceDecision#setMethodShadowsProperty(boolean)} diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/MethodCallAwareTemplateHashModel.java b/freemarker-core/src/main/java/freemarker/ext/beans/MethodCallAwareTemplateHashModel.java new file mode 100644 index 00000000..45f08852 --- /dev/null +++ b/freemarker-core/src/main/java/freemarker/ext/beans/MethodCallAwareTemplateHashModel.java @@ -0,0 +1,115 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 freemarker.ext.beans; + +import freemarker.core.NonMethodException; +import freemarker.template.ObjectWrapper; +import freemarker.template.TemplateHashModel; +import freemarker.template.TemplateMethodModelEx; +import freemarker.template.TemplateModel; +import freemarker.template.TemplateModelException; +import freemarker.template.utility.NullArgumentException; + +/** + * Adds a getter method that can return different result than {@link #get(String)}, knowing that the result of it will + * be called as a method. At least as of 2.3.33, this is only utilized by the template language for 0-argument method + * calls directly after the dot operator and the key. For example, if in the template you have + * {@code someRecord.someComponent()}, and there {@code someRecord} was wrapped by the {@link ObjectWrapper} into + * a {@link TemplateHashModel} that also implements this interface, then the dot operator will call + * {@link #getBeforeMethodCall(String) getBeforeMethodCall("someComponent")}, rather than + * {@link #get(String) get("someComponent")}. This is needed to implement the + * {@link BeansWrapper.MethodAppearanceDecision#setMethodInsteadOfPropertyValueBeforeCall(boolean)} feature; please see + * more there. + * + * <p>While technically we could do the same for method calls with more the 0 arguments, as of 2.3.33 at least we + * don't want to generalize this to that case. The FreeMarker 2.x template language doesn't have separated namespace for + * methods, so this is already a hack as is, but we had to address the issue with Java records (again, see that + * at {@link BeansWrapper.MethodAppearanceDecision#setMethodInsteadOfPropertyValueBeforeCall(boolean)}). + * + * @since 2.3.33 + */ +public interface MethodCallAwareTemplateHashModel extends TemplateHashModel { + + /** + * This is called instead of {@link #get(String)}, if we know that the return value must be a method. + * The advantage of this is that we can coerce the value to a method when desirable, and otherwise can give a more + * specific error message in the resulting exception than the standard {@link NonMethodException} would. + * + * @param key + * Same as for {@link #get(String)} + * + * @return + * Same as for just like {@link #get(String)}, except this must be a {@link TemplateMethodModelEx} (unless it's + * {@code null}). + * + * @throws ShouldNotBeGetAsMethodException + * If the value for the given key exists, but it shouldn't be coerced the method. This will be converted to + * {@link NonMethodException} by the engine, but in this exception you can optionally give a more specific + * explanation, and that will be added to the resulting {@link NonMethodException} as a hint to the user. + */ + TemplateMethodModelEx getBeforeMethodCall(String key) + throws TemplateModelException, ShouldNotBeGetAsMethodException; + + /** + * Thrown by {@link #getBeforeMethodCall(String)}; see there. + */ + final class ShouldNotBeGetAsMethodException extends Exception { + private final TemplateModel actualValue; + private final String hint; + + /** + * Same as {@link ShouldNotBeGetAsMethodException(TemplateModel, String, Throwable)}, with {@code null} + * cause exception argument. + */ + public ShouldNotBeGetAsMethodException(TemplateModel actualValue, String hint) { + this(actualValue, hint, null); + } + + /** + * @param actualValue + * The actual value we got instead of a method; can't be {@code null}! + * @param hint + * Hint for the user, that's added to the error message; {@code null} if you just want the plain + * {@link NonMethodException} error message. + * @param cause + * Can be {@code null}. + */ + public ShouldNotBeGetAsMethodException(TemplateModel actualValue, String hint, Throwable cause) { + super(null, cause, true, false); + NullArgumentException.check(actualValue); + this.actualValue = actualValue; + this.hint = hint; + } + + /** + * The actual value we got instead of a method; not {@code null}. + */ + public TemplateModel getActualValue() { + return actualValue; + } + + /** + * Additional hint for the user; maybe {@code null}. + */ + public String getHint() { + return hint; + } + } +} diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/StringModel.java b/freemarker-core/src/main/java/freemarker/ext/beans/StringModel.java index b53872d0..399a1ff1 100644 --- a/freemarker-core/src/main/java/freemarker/ext/beans/StringModel.java +++ b/freemarker-core/src/main/java/freemarker/ext/beans/StringModel.java @@ -20,8 +20,6 @@ package freemarker.ext.beans; import freemarker.ext.util.ModelFactory; -import freemarker.template.ObjectWrapper; -import freemarker.template.TemplateModel; import freemarker.template.TemplateScalarModel; /** @@ -31,14 +29,7 @@ import freemarker.template.TemplateScalarModel; */ public class StringModel extends BeanModel implements TemplateScalarModel { - static final ModelFactory FACTORY = - new ModelFactory() - { - @Override - public TemplateModel create(Object object, ObjectWrapper wrapper) { - return new StringModel(object, (BeansWrapper) wrapper); - } - }; + static final ModelFactory FACTORY = (object, wrapper) -> new StringModel(object, (BeansWrapper) wrapper); // Package visible for testing static final String TO_STRING_NOT_EXPOSED = "[toString not exposed]"; diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/StringModelEx.java b/freemarker-core/src/main/java/freemarker/ext/beans/StringModelEx.java new file mode 100644 index 00000000..e4d91f8f --- /dev/null +++ b/freemarker-core/src/main/java/freemarker/ext/beans/StringModelEx.java @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 freemarker.ext.beans; + +import freemarker.ext.util.ModelFactory; +import freemarker.template.TemplateMethodModelEx; +import freemarker.template.TemplateModelException; + +/** + * Like {@link StringModel}, but adds support for {@link MethodCallAwareTemplateHashModel}. + * + * @since 2.3.33 + */ +public class StringModelEx extends StringModel implements MethodCallAwareTemplateHashModel { + static final ModelFactory FACTORY = (object, wrapper) -> new StringModelEx(object, (BeansWrapper) wrapper); + + /** + * Creates a new model that wraps the specified object with BeanModel + scalar functionality. + * + * @param object + * the object to wrap into a model. + * @param wrapper + * the {@link BeansWrapper} associated with this model. Every model has to have an associated + * {@link BeansWrapper} instance. The model gains many attributes from its wrapper, including the caching + * behavior, method exposure level, method-over-item shadowing policy etc. + */ + public StringModelEx(Object object, BeansWrapper wrapper) { + super(object, wrapper); + } + + @Override + public TemplateMethodModelEx getBeforeMethodCall(String key) throws TemplateModelException, + ShouldNotBeGetAsMethodException { + return super.getBeforeMethodCall(key); + } +} diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/APIModel.java b/freemarker-core/src/main/java/freemarker/ext/beans/ZeroArgumentNonVoidMethodPolicy.java similarity index 61% copy from freemarker-core/src/main/java/freemarker/ext/beans/APIModel.java copy to freemarker-core/src/main/java/freemarker/ext/beans/ZeroArgumentNonVoidMethodPolicy.java index 4580ac8d..5c5bd75b 100644 --- a/freemarker-core/src/main/java/freemarker/ext/beans/APIModel.java +++ b/freemarker-core/src/main/java/freemarker/ext/beans/ZeroArgumentNonVoidMethodPolicy.java @@ -20,26 +20,26 @@ package freemarker.ext.beans; /** - * Exposes the Java API (and properties) of an object. - * - * <p> - * Notes: - * <ul> - * <li>The exposing level is inherited from the {@link BeansWrapper}</li> - * <li>But methods will always shadow properties and fields with identical name, regardless of {@link BeansWrapper} - * settings</li> - * </ul> - * - * @since 2.3.22 + * How to show 0 argument non-void public methods to templates. + * + * @since 2.3.33 */ -final class APIModel extends BeanModel { +public enum ZeroArgumentNonVoidMethodPolicy { + + /** + * {@code obj.m}, or {@code obj.m()}, both do the same. + * This can work because of {@link MethodCallAwareTemplateHashModel}. + * See also {@link BeansWrapper.MethodAppearanceDecision#setMethodInsteadOfPropertyValueBeforeCall(boolean)}. + */ + BOTH_PROPERTY_AND_METHOD, - APIModel(Object object, BeansWrapper wrapper) { - super(object, wrapper, false); - } + /** + * {@code obj.m()} + */ + METHOD_ONLY, - protected boolean isMethodsShadowItems() { - return true; - } - + /** + * {@code obj.m} + */ + PROPERTY_ONLY } diff --git a/freemarker-core/src/main/java/freemarker/ext/beans/_BeansAPI.java b/freemarker-core/src/main/java/freemarker/ext/beans/_BeansAPI.java index 64d9797e..a8a6c775 100644 --- a/freemarker-core/src/main/java/freemarker/ext/beans/_BeansAPI.java +++ b/freemarker-core/src/main/java/freemarker/ext/beans/_BeansAPI.java @@ -116,7 +116,7 @@ public class _BeansAPI { packedArgs = new Object[fixedArgCnt + 1]; for (int i = 0; i < fixedArgCnt; i++) { - packedArgs[i] = args[i]; +packedArgs[i] = args[i]; } final Class<?> compType = paramTypes[fixedArgCnt].getComponentType(); @@ -226,5 +226,5 @@ public class _BeansAPI { public static ClassIntrospectorBuilder getClassIntrospectorBuilder(BeansWrapperConfiguration bwc) { return bwc.getClassIntrospectorBuilder(); } - + } diff --git a/freemarker-core/src/main/javacc/freemarker/core/FTL.jj b/freemarker-core/src/main/javacc/freemarker/core/FTL.jj index f4390862..06495a36 100644 --- a/freemarker-core/src/main/javacc/freemarker/core/FTL.jj +++ b/freemarker-core/src/main/javacc/freemarker/core/FTL.jj @@ -2467,6 +2467,9 @@ MethodCall MethodArgs(Expression exp) : end = <CLOSE_PAREN> { args.trimToSize(); + if (args.isEmpty() && exp instanceof Dot) { + exp = new DotBeforeMethodCall((Dot) exp); + } MethodCall result = new MethodCall(exp, args); result.setLocation(template, exp, end); return result; diff --git a/freemarker-core16/src/test/java/freemarker/ext/beans/TestZeroArgumentNonVoidMethodPolicy.java b/freemarker-core16/src/test/java/freemarker/ext/beans/TestZeroArgumentNonVoidMethodPolicy.java new file mode 100644 index 00000000..e5e8916a --- /dev/null +++ b/freemarker-core16/src/test/java/freemarker/ext/beans/TestZeroArgumentNonVoidMethodPolicy.java @@ -0,0 +1,187 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 freemarker.ext.beans; + +import java.io.IOException; + +import org.junit.Before; +import org.junit.Test; + +import freemarker.template.Configuration; +import freemarker.template.DefaultObjectWrapper; +import freemarker.template.TemplateException; +import freemarker.test.TemplateTest; + +public class TestZeroArgumentNonVoidMethodPolicy extends TemplateTest { + + @Before + public void setup() throws Exception { + addToDataModel("rec", new TestRecord(1, "S")); + addToDataModel("nrc", new TestNonRecord()); + } + + @Test + public void testDefaultWithHighIncompatibleImprovements() throws TemplateException, IOException { + super.getConfiguration().setIncompatibleImprovements(Configuration.VERSION_2_3_33); + assertRecIsPropertyAndMethod(); + assertNrcIsMethodOnly(); + } + + @Test + public void testDefaultWithLowIncompatibleImprovements() throws TemplateException, IOException { + super.getConfiguration().setIncompatibleImprovements(Configuration.VERSION_2_3_32); + assertRecIsMethodOnly(); + assertNrcIsMethodOnly(); + } + + @Test + public void test() throws TemplateException, IOException { + DefaultObjectWrapper beansWrapper = (DefaultObjectWrapper) super.getConfiguration().getObjectWrapper(); + beansWrapper.setRecordZeroArgumentNonVoidMethodPolicy(ZeroArgumentNonVoidMethodPolicy.PROPERTY_ONLY); + assertRecIsPropertyOnly(); + assertNrcIsMethodOnly(); + } + + private void assertRecIsPropertyAndMethod() throws IOException, TemplateException { + assertOutput("${rec.x}", "1"); + assertOutput("${rec.x()}", "1"); + assertOutput("${rec.s}", "S"); + assertOutput("${rec.s()}", "S"); + assertOutput("${rec.y}", "2"); + assertOutput("${rec.y()}", "2"); + assertOutput("${rec.z}", "3"); + assertErrorContains("${rec.z()}", "SimpleNumber"); + assertOutput("${rec.getZ()}", "3"); + assertOutput("${rec.tenX}", "10"); + assertOutput("${rec.tenX()}", "10"); + assertOutput("${rec.xTimes(5)}", "5"); + assertErrorContains("${rec.xTimes}", "SimpleMethodModel"); + } + + private void assertRecIsMethodOnly() throws IOException, TemplateException { + assertErrorContains("${rec.x}", "SimpleMethodModel"); + assertOutput("${rec.x()}", "1"); + assertErrorContains("${rec.s}", "SimpleMethodModel"); + assertOutput("${rec.s()}", "S"); + assertErrorContains("${rec.y}", "SimpleMethodModel"); + assertOutput("${rec.y()}", "2"); + assertOutput("${rec.z}", "3"); + assertErrorContains("${rec.z()}", "SimpleNumber"); + assertOutput("${rec.getZ()}", "3"); + assertErrorContains("${rec.tenX}", "SimpleMethodModel"); + assertOutput("${rec.tenX()}", "10"); + assertOutput("${rec.xTimes(5)}", "5"); + assertErrorContains("${rec.xTimes}", "SimpleMethodModel"); + } + + private void assertRecIsPropertyOnly() throws IOException, TemplateException { + assertOutput("${rec.x}", "1"); + assertErrorContains("${rec.x()}", "SimpleNumber"); + assertOutput("${rec.s}", "S"); + assertErrorContains("${rec.s()}", "SimpleScalar"); + assertOutput("${rec.y}", "2"); + assertErrorContains("${rec.y()}", "SimpleNumber"); + assertOutput("${rec.z}", "3"); + assertErrorContains("${rec.z()}", "SimpleNumber"); + assertOutput("${rec.getZ()}", "3"); + assertOutput("${rec.tenX}", "10"); + assertErrorContains("${rec.tenX()}", "SimpleNumber"); + assertOutput("${rec.xTimes(5)}", "5"); + assertErrorContains("${rec.xTimes}", "SimpleMethodModel"); + } + + private void assertNrcIsMethodOnly() throws IOException, TemplateException { + assertErrorContains("${nrc.x}", "SimpleMethodModel"); + assertOutput("${nrc.x()}", "1"); + assertErrorContains("${nrc.y}", "SimpleMethodModel"); + assertOutput("${nrc.y()}", "2"); + assertOutput("${nrc.z}", "3"); + assertErrorContains("${nrc.z()}", "SimpleNumber"); + assertOutput("${nrc.getZ()}", "3"); + assertErrorContains("${nrc.tenX}", "SimpleMethodModel"); + assertOutput("${nrc.tenX()}", "10"); + assertOutput("${nrc.xTimes(5)}", "5"); + assertErrorContains("${nrc.xTimes}", "SimpleMethodModel"); + } + + private void assertNrcIsPropertyAndMethod() throws IOException, TemplateException { + assertOutput("${nrc.x}", "1"); + assertOutput("${nrc.x()}", "1"); + assertOutput("${nrc.y}", "2"); + assertOutput("${nrc.y()}", "2"); + assertOutput("${nrc.z}", "3"); + assertErrorContains("${nrc.z()}", "SimpleNumber"); + assertOutput("${nrc.getZ()}", "3"); + assertOutput("${nrc.tenX}", "10"); + assertOutput("${nrc.tenX()}", "10"); + assertOutput("${nrc.xTimes(5)}", "5"); + assertErrorContains("${nrc.xTimes}", "SimpleMethodModel"); + } + + public record TestRecord(int x, String s) implements TestInterface { + @Override + public int y() { + return 2; + } + + @Override + public int getZ() { + return 3; + } + + public int tenX() { + return x * 10; + } + + public int xTimes(int m) { + return x * m; + } + } + + public interface TestInterface { + int y(); + int getZ(); + } + + public static class TestNonRecord implements TestInterface { + public int x() { + return 1; + } + + @Override + public int y() { + return 2; + } + + @Override + public int getZ() { + return 3; + } + + public int tenX() { + return x() * 10; + } + + public int xTimes(int m) { + return x() * m; + } + } + +} diff --git a/freemarker-jython25/src/test/java/freemarker/template/DefaultObjectWrapperTest.java b/freemarker-jython25/src/test/java/freemarker/template/DefaultObjectWrapperTest.java index d67d1950..51152998 100644 --- a/freemarker-jython25/src/test/java/freemarker/template/DefaultObjectWrapperTest.java +++ b/freemarker-jython25/src/test/java/freemarker/template/DefaultObjectWrapperTest.java @@ -104,7 +104,7 @@ public class DefaultObjectWrapperTest { expected.add(Configuration.VERSION_2_3_27); // no non-BC change in 2.3.30 expected.add(Configuration.VERSION_2_3_27); // no non-BC change in 2.3.31 expected.add(Configuration.VERSION_2_3_27); // no non-BC change in 2.3.32 - expected.add(Configuration.VERSION_2_3_27); // no non-BC change in 2.3.33 + expected.add(Configuration.VERSION_2_3_33); List<Version> actual = new ArrayList<>(); for (int i = _VersionInts.V_2_3_0; i <= Configuration.getVersion().intValue(); i++) { @@ -383,7 +383,7 @@ public class DefaultObjectWrapperTest { assertTrue(ow.getUseAdaptersForContainers()); assertTrue(ow.getForceLegacyNonListCollections()); } - + try { new DefaultObjectWrapper(new Version(99, 9, 9)); fail();
