From 9fae13e008f45ee65754fdc870be107137001a83 Mon Sep 17 00:00:00 2001 From: Michael Bayne Date: Sat, 28 May 2011 10:42:45 -0700 Subject: [PATCH] Added Compiler.nullValue() for situations where one wants to provide a value for use when variables resolve to null, but still wishes for exceptions to be thrown when values are not resolvable (i.e. are typos). Pre-existing behavior is preserved if one uses defaultValue() or does not change the default configuration. Nota bene: there are complexities, so be sure to read the documentation. --- README.md | 34 ++++++++- .../com/samskivert/mustache/Mustache.java | 68 ++++++++++++----- .../com/samskivert/mustache/Template.java | 71 ++++++++++++++--- .../com/samskivert/mustache/MustacheTest.java | 76 +++++++++++++++++++ 4 files changed, 214 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 5050fe6..41ffa3e 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ Sections behave as you would expect: * `Boolean` values enable or disable the section. * Array, `Iterator`, or `Iterable` values repeatedly execute the section with each element used as the context for each iteration. Empty collections result in zero instances of the section being included in the template. + * An unresolvable or null value is treated as false (by default, see //Default Values// for more details). * Any other object results in a single execution of the section with that object as a context. See the code in @@ -134,9 +135,9 @@ the template. Default Values -------------- -By default, an exception will be thrown any time a variable cannot be found, or -resolves to null. A value to use in such circumstances can be provided when -creating your compiler: +By default, an exception will be thrown any time a variable cannot be resolved, +or resolves to null. You can change this behavior in two ways. If you want to +provide a value for use in all such circumstances, use `defaultValue()`: String tmpl = "{{exists}} {{nullValued}} {{doesNotExist}}?"; Mustache.compiler().defaultValue("what").compile(tmpl).execute(new Object() { @@ -147,6 +148,33 @@ creating your compiler: // result: Say what what? +If you only wish to provide a default value for variables that resolve to null, +and wish to preserve exceptions in cases where variables cannot be resolved, +use `nullValue()`: + + String tmpl = "{{exists}} {{nullValued}} {{doesNotExist}}?"; + Mustache.compiler().nullValue("what").compile(tmpl).execute(new Object() { + String exists = "Say"; + String nullValued = null; + // String doesNotExist + }); + // throws MustacheException when executing the template because + // doesNotExist cannot be resolved + +Note that any variable resolved against a `Map` context will be resolvable, but +will be treated as having the value null if the map contains no mapping for the +variable. Only variables resolved against Java object fields or methods risk +being unresolvable. + +Note that section behavior deviates from the above specification (for +historical reasons and because it's kind of useful). By default, a section that +is not resolvable or resolves to null will be omitted (and conversely, an +inverse section that is not resolvable or resolves to null will be included). +If you use `defaultValue()`, this behavior is preserved. If you use +`nullValue()`, sections that refer to an unresolvable variable will now throw +an exception (sections that refer to a resolvable, but null-valued variable, +will behave as before). + Extensions ========== diff --git a/src/main/java/com/samskivert/mustache/Mustache.java b/src/main/java/com/samskivert/mustache/Mustache.java index 2624e76..a7cd8b1 100644 --- a/src/main/java/com/samskivert/mustache/Mustache.java +++ b/src/main/java/com/samskivert/mustache/Mustache.java @@ -38,9 +38,15 @@ public class Mustache /** Whether or not standards mode is enabled. */ public final boolean standardsMode; - /** A value to use when a variable cannot be resolved, or resolves to null. If the default - * value is null (which is the default default value), an exception will be thrown. */ - public final String defaultValue; + /** A value to use when a variable resolves to null. If this value is null (which is the + * default null value), an exception will be thrown. If {@link #missingIsNull} is also + * true, this value will be used when a variable cannot be resolved. */ + public final String nullValue; + + /** If this value is true, missing variables will be treated like variables that return + * null. {@link #nullValue} will be used in their place, assuming {@link #nullValue} is + * configured to a non-null value. */ + public final boolean missingIsNull; /** The template loader in use during this compilation. */ public final TemplateLoader loader; @@ -57,32 +63,59 @@ public class Mustache /** Returns a compiler that either does or does not escape HTML by default. */ public Compiler escapeHTML (boolean escapeHTML) { - return new Compiler(escapeHTML, this.standardsMode, this.defaultValue, this.loader); + return new Compiler(escapeHTML, this.standardsMode, + this.nullValue, this.missingIsNull, this.loader); } /** Returns a compiler that either does or does not use standards mode. Standards mode * disables the non-standard JMustache extensions like looking up missing names in a parent * context. */ public Compiler standardsMode (boolean standardsMode) { - return new Compiler(this.escapeHTML, standardsMode, this.defaultValue, this.loader); + return new Compiler(this.escapeHTML, standardsMode, + this.nullValue, this.missingIsNull, this.loader); } /** Returns a compiler that will use the given value for any variable that is missing, or - * otherwise resolves to null. */ + * otherwise resolves to null. This is like {@link #nullValue} except that it returns the + * supplied default for missing keys and existing keys that return null values. Note that + * regardless of whether a null or default value is configured, if the resolution of part + * of a compound key results in a missing or null value, an exception will be raised. */ public Compiler defaultValue (String defaultValue) { - return new Compiler(this.escapeHTML, this.standardsMode, defaultValue, this.loader); + return new Compiler(this.escapeHTML, this.standardsMode, + defaultValue, true, this.loader); + } + + /** Returns a compiler that will use the given value for any variable that resolves to + * null, but will still raise an exception for variables for which an accessor cannot be + * found. This is like {@link #defaultValue} except that it differentiates between missing + * accessors, and accessors that exist but return null. + * + * Note that regardless of whether a null or default value is configured, if the resolution + * of part of a compound key results in a missing or null value, an exception will be + * raised. */ + public Compiler nullValue (String nullValue) { + return new Compiler(this.escapeHTML, this.standardsMode, + nullValue, false, this.loader); } /** Returns a compiler configured to use the supplied template loader to handle partials. */ public Compiler withLoader (TemplateLoader loader) { - return new Compiler(this.escapeHTML, this.standardsMode, this.defaultValue, loader); + return new Compiler(this.escapeHTML, this.standardsMode, + this.nullValue, this.missingIsNull, loader); } - protected Compiler (boolean escapeHTML, boolean standardsMode, String defaultValue, - TemplateLoader loader) { + protected Compiler (boolean escapeHTML, boolean standardsMode, + String nullValue, boolean missingIsNull, TemplateLoader loader) { this.escapeHTML = escapeHTML; this.standardsMode = standardsMode; - this.defaultValue = defaultValue; + this.nullValue = nullValue; + this.missingIsNull = missingIsNull; this.loader = loader; } } @@ -100,7 +133,7 @@ public class Mustache */ public static Compiler compiler () { - return new Compiler(true, false, null, FAILING_LOADER); + return new Compiler(true, false, null, true, FAILING_LOADER); } /** @@ -513,10 +546,7 @@ public class Mustache super(name, segs, line); } @Override public void execute (Template tmpl, Template.Context ctx, Writer out) { - Object value = tmpl.getValue(ctx, _name, _line); - if (value == null) { - return; // TODO: configurable behavior on missing values - } + Object value = tmpl.getSectionValue(ctx, _name, _line); // won't return null if (value instanceof Iterable) { value = ((Iterable)value).iterator(); } @@ -551,10 +581,8 @@ public class Mustache super(name, segs, line); } @Override public void execute (Template tmpl, Template.Context ctx, Writer out) { - Object value = tmpl.getValue(ctx, _name, _line); - if (value == null) { - executeSegs(tmpl, ctx, out); // TODO: configurable behavior on missing values - } else if (value instanceof Iterable) { + Object value = tmpl.getSectionValue(ctx, _name, _line); // won't return null + if (value instanceof Iterable) { Iterable iable = (Iterable)value; if (!iable.iterator().hasNext()) { executeSegs(tmpl, ctx, out); diff --git a/src/main/java/com/samskivert/mustache/Template.java b/src/main/java/com/samskivert/mustache/Template.java index b201f56..7ef22cd 100644 --- a/src/main/java/com/samskivert/mustache/Template.java +++ b/src/main/java/com/samskivert/mustache/Template.java @@ -9,6 +9,7 @@ import java.io.StringWriter; import java.io.Writer; import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.util.Collections; import java.util.Iterator; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -76,10 +77,12 @@ public class Template * * @param ctx the context in which to look up the variable. * @param name the name of the variable to be resolved, which must be an interned string. + * @param missingIsNull whether to fail if a variable cannot be resolved, or to return null in + * that case. * * @return the value associated with the supplied name or null if no value could be resolved. */ - protected Object getValue (Context ctx, String name, int line) + protected Object getValue (Context ctx, String name, int line, boolean missingIsNull) { if (!_compiler.standardsMode) { // if we're dealing with a compound key, resolve each component and use the result to @@ -89,10 +92,13 @@ public class Template // we want to allow the first component of a compound key to be located in a parent // context, but once we're selecting sub-components, they must only be resolved in the // object that represents that component - Object data = getValue(ctx, comps[0].intern(), line); + Object data = getValue(ctx, comps[0].intern(), line, missingIsNull); for (int ii = 1; ii < comps.length; ii++) { - // generate more helpful error message - if (data == null) { + if (data == NO_FETCHER_FOUND) { + throw new NullPointerException( + "Missing context for compound variable '" + name + "' on line " + line + + ". '" + comps[ii - 1] + "' was not found."); + } else if (data == null) { throw new NullPointerException( "Null context for compound variable '" + name + "' on line " + line + ". '" + comps[ii - 1] + "' resolved to null."); @@ -101,7 +107,7 @@ public class Template // contexts; that would be weird and confusing data = getValueIn(data, comps[ii].intern(), line); } - return data; + return checkForMissing(name, line, missingIsNull, data); } } @@ -116,18 +122,40 @@ public class Template // if we're in standards mode, we don't search our parent contexts if (_compiler.standardsMode) { - return getValueIn(ctx.data, name, line); + return checkForMissing(name, line, missingIsNull, getValueIn(ctx.data, name, line)); } + boolean variableMissing = true; while (ctx != null) { Object value = getValueIn(ctx.data, name, line); - if (value != null) { + if (value == NO_FETCHER_FOUND) { + // preserve variableMissing + } else if (value == null) { + // we found a fetcher, and it returned null; so we keep searching our parents, but + // we won't freak out about a missing variable if we have a nullValue configured + variableMissing = false; + } else { return value; } ctx = ctx.parent; } - // we've popped off the top of our stack of contexts, so return null - return null; + // we've popped off the top of our stack of contexts, if we never actually found a fetcher + // for our variable, we need to let checkForMissing() know + return checkForMissing(name, line, missingIsNull, variableMissing ? NO_FETCHER_FOUND : null); + } + + /** + * Returns the value of the specified variable, noting that it is intended to be used as the + * contents for a segment. Presently this does not do anything special, but eventually this + * will be the means by which we enact configured behavior for sections that reference null or + * missing variables. Right now, all such variables result in a length 0 section. + */ + protected Object getSectionValue (Context ctx, String name, int line) + { + // TODO: configurable behavior on missing values + Object value = getValue(ctx, name, line, _compiler.missingIsNull); + // TODO: configurable behavior on null values + return (value == null) ? Collections.emptyList() : value; } /** @@ -136,8 +164,11 @@ public class Template */ protected Object getValueOrDefault (Context ctx, String name, int line) { - Object value = getValue(ctx, name, line); - return (value == null) ? _compiler.defaultValue : value; + Object value = getValue(ctx, name, line, _compiler.missingIsNull); + // getValue will raise MustacheException if a variable cannot be resolved and missingIsNull + // is not configured; so we're safe to assume that any null that makes it up to this point + // can be converted to nullValue + return (value == null) ? _compiler.nullValue : value; } protected Object getValueIn (Object data, String name, int line) @@ -163,7 +194,7 @@ public class Template // if we were unable to create a fetcher, just return null and our caller can either try // the parent context, or do le freak out if (fetcher == null) { - return null; + return NO_FETCHER_FOUND; } try { @@ -176,6 +207,20 @@ public class Template } } + protected Object checkForMissing (String name, int line, boolean missingIsNull, Object value) + { + if (value == NO_FETCHER_FOUND) { + if (missingIsNull) { + return null; + } else { + throw new MustacheException( + "No method or field with name '" + name + "' on line " + line); + } + } else { + return value; + } + } + protected final Segment[] _segs; protected final Mustache.Compiler _compiler; protected final Map _fcache = @@ -339,6 +384,8 @@ public class Template } }; + protected static final Object NO_FETCHER_FOUND = new Object(); + protected static final String DOT_NAME = ".".intern(); protected static final String THIS_NAME = "this".intern(); protected static final String FIRST_NAME = "-first".intern(); diff --git a/src/test/java/com/samskivert/mustache/MustacheTest.java b/src/test/java/com/samskivert/mustache/MustacheTest.java index 816572e..06bf4e7 100644 --- a/src/test/java/com/samskivert/mustache/MustacheTest.java +++ b/src/test/java/com/samskivert/mustache/MustacheTest.java @@ -113,6 +113,43 @@ public class MustacheTest "d", new Object[] { context("e", "3"), context("e", "4") }))); } + @Test public void testNullSection () { + test("", "{{#foo}}{{bar}}{{/foo}}", new Object() { + Object foo = null; + }); + } + + @Test public void testNullSectionWithDefaultValue () { + test(Mustache.compiler().defaultValue(""), "", "{{#foo}}{{bar}}{{/foo}}", new Object() { + Object foo = null; + }); + } + + @Test public void testNullSectionWithNullValue () { + test(Mustache.compiler().nullValue(""), "", "{{#foo}}{{bar}}{{/foo}}", new Object() { + Object foo = null; + }); + } + + @Test public void testMissingSection () { + test("", "{{#foo}}{{bar}}{{/foo}}", new Object() { + // no foo + }); + } + + @Test public void testMissingSectionWithDefaultValue () { + test(Mustache.compiler().defaultValue(""), "", "{{#foo}}{{bar}}{{/foo}}", new Object() { + // no foo + }); + } + + @Test(expected=MustacheException.class) + public void testMissingSectionWithNullValue () { + test(Mustache.compiler().nullValue(""), "", "{{#foo}}{{bar}}{{/foo}}", new Object() { + // no foo + }); + } + @Test public void testComment () { test("foobar", "foo{{! nothing to see here}}bar", new Object()); } @@ -215,6 +252,28 @@ public class MustacheTest }); } + @Test(expected=NullPointerException.class) + public void testNullComponentInCompoundVariable () { + // make sure that even if we have a null value configured, we freak out + test(Mustache.compiler().nullValue("foo"), "unused", "{{foo.bar.baz}}", new Object() { + Object foo () { + return new Object() { + Object bar = null; + }; + } + }); + } + + @Test(expected=NullPointerException.class) + public void testMissingComponentInCompoundVariable () { + // make sure that even if we have a default value configured, we freak out + test(Mustache.compiler().defaultValue("foo"), "unused", "{{foo.bar.baz}}", new Object() { + Object foo () { + return new Object(); // no bar + } + }); + } + @Test public void testNewlineSkipping () { String tmpl = "list:\n" + "{{#items}}\n" + @@ -322,6 +381,23 @@ public class MustacheTest }); } + @Test(expected=MustacheException.class) + public void testMissingValueWithNullDefault () { + test(Mustache.compiler().nullValue(""), + "bar", "{{missing}}{{notmissing}}", new Object() { + String notmissing = "bar"; + // no field or method for 'missing' + }); + } + + @Test public void testNullValueGetsNullDefault () { + test(Mustache.compiler().nullValue("foo"), + "foobar", "{{nullvar}}{{nonnullvar}}", new Object() { + String nonnullvar = "bar"; + String nullvar = null; + }); + } + protected void test (Mustache.Compiler compiler, String expected, String template, Object ctx) { assertEquals(expected, compiler.compile(template).execute(ctx));