Browse Source

Mustache: Add util functions to render JSON and join array values

This pull request adds two util functions to the Mustache templating engine:
- {{#toJson}}my_map{{/toJson}} to render a Map parameter as a JSON string
- {{#join}}my_iterable{{/join}} to render any iterable (including arrays) as a comma separated list of values like `1, 2, 3`. It's also possible de change the default delimiter (comma) to something else.

closes #18970
Tanguy Leroux 9 years ago
parent
commit
4820d49120

+ 123 - 0
docs/reference/search/search-template.asciidoc

@@ -89,6 +89,89 @@ which is rendered as:
 }
 ------------------------------------------
 
+
+[float]
+===== Concatenating array of values
+
+The `{{#join}}array{{/join}}` function can be used to concatenate the
+values of an array as a comma delimited string:
+
+[source,js]
+------------------------------------------
+GET /_search/template
+{
+  "inline": {
+    "query": {
+      "match": {
+        "emails": "{{#join}}emails{{/join}}"
+      }
+    }
+  },
+  "params": {
+    "emails": [ "username@email.com", "lastname@email.com" ]
+  }
+}
+------------------------------------------
+
+which is rendered as:
+
+[source,js]
+------------------------------------------
+{
+    "query" : {
+        "match" : {
+            "emails" : "username@email.com,lastname@email.com"
+        }
+    }
+}
+------------------------------------------
+
+The function also accepts a custom delimiter:
+
+[source,js]
+------------------------------------------
+GET /_search/template
+{
+  "inline": {
+    "query": {
+      "range": {
+        "born": {
+            "gte"   : "{{date.min}}",
+            "lte"   : "{{date.max}}",
+            "format": "{{#join delimiter='||'}}date.formats{{/join delimiter='||'}}"
+	    }
+      }
+    }
+  },
+  "params": {
+    "date": {
+        "min": "2016",
+        "max": "31/12/2017",
+        "formats": ["dd/MM/yyyy", "yyyy"]
+    }
+  }
+}
+------------------------------------------
+
+which is rendered as:
+
+[source,js]
+------------------------------------------
+{
+    "query" : {
+      "range" : {
+        "born" : {
+          "gte" : "2016",
+          "lte" : "31/12/2017",
+          "format" : "dd/MM/yyyy||yyyy"
+        }
+      }
+    }
+}
+
+------------------------------------------
+
+
 [float]
 ===== Default values
 
@@ -140,6 +223,46 @@ for `end`:
 }
 ------------------------------------------
 
+[float]
+===== Converting parameters to JSON
+
+The `{{toJson}}parameter{{/toJson}}` function can be used to convert parameters
+like maps and array to their JSON representation:
+
+[source,js]
+------------------------------------------
+{
+    "inline": "{\"query\":{\"bool\":{\"must\": {{#toJson}}clauses{{/toJson}} }}}",
+    "params": {
+        "clauses": [
+            { "term": "foo" },
+            { "term": "bar" }
+        ]
+   }
+}
+------------------------------------------
+
+which is rendered as:
+
+[source,js]
+------------------------------------------
+{
+    "query" : {
+      "bool" : {
+        "must" : [
+          {
+            "term" : "foo"
+          },
+          {
+            "term" : "bar"
+          }
+        ]
+      }
+    }
+}
+------------------------------------------
+
+
 [float]
 ===== Conditional clauses
 

+ 279 - 0
modules/lang-mustache/src/main/java/org/elasticsearch/script/mustache/CustomMustacheFactory.java

@@ -0,0 +1,279 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch 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 org.elasticsearch.script.mustache;
+
+import com.fasterxml.jackson.core.io.JsonStringEncoder;
+import com.github.mustachejava.Code;
+import com.github.mustachejava.DefaultMustacheFactory;
+import com.github.mustachejava.DefaultMustacheVisitor;
+import com.github.mustachejava.Mustache;
+import com.github.mustachejava.MustacheException;
+import com.github.mustachejava.MustacheVisitor;
+import com.github.mustachejava.TemplateContext;
+import com.github.mustachejava.codes.IterableCode;
+import com.github.mustachejava.codes.WriteCode;
+import org.elasticsearch.common.xcontent.XContentBuilder;
+import org.elasticsearch.common.xcontent.XContentType;
+
+import java.io.IOException;
+import java.io.StringWriter;
+import java.io.Writer;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.StringJoiner;
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class CustomMustacheFactory extends DefaultMustacheFactory {
+
+    private final BiConsumer<String, Writer> encoder;
+
+    public CustomMustacheFactory(boolean escaping) {
+        super();
+        setObjectHandler(new CustomReflectionObjectHandler());
+        if (escaping) {
+            this.encoder = new JsonEscapeEncoder();
+        } else {
+            this.encoder = new NoEscapeEncoder();
+        }
+    }
+
+    @Override
+    public void encode(String value, Writer writer) {
+        encoder.accept(value, writer);
+    }
+
+    @Override
+    public MustacheVisitor createMustacheVisitor() {
+        return new CustomMustacheVisitor(this);
+    }
+
+    class CustomMustacheVisitor extends DefaultMustacheVisitor {
+
+        public CustomMustacheVisitor(DefaultMustacheFactory df) {
+            super(df);
+        }
+
+        @Override
+        public void iterable(TemplateContext templateContext, String variable, Mustache mustache) {
+            if (ToJsonCode.match(variable)) {
+                list.add(new ToJsonCode(templateContext, df, mustache, variable));
+            } else if (JoinerCode.match(variable)) {
+                list.add(new JoinerCode(templateContext, df, mustache));
+            } else if (CustomJoinerCode.match(variable)) {
+                list.add(new CustomJoinerCode(templateContext, df, mustache, variable));
+            } else {
+                list.add(new IterableCode(templateContext, df, mustache, variable));
+            }
+        }
+    }
+
+    /**
+     * Base class for custom Mustache functions
+     */
+    static abstract class CustomCode extends IterableCode {
+
+        private final String code;
+
+        public CustomCode(TemplateContext tc, DefaultMustacheFactory df, Mustache mustache, String code) {
+            super(tc, df, mustache, extractVariableName(code, mustache, tc));
+            this.code = Objects.requireNonNull(code);
+        }
+
+        @Override
+        public Writer execute(Writer writer, final List<Object> scopes) {
+            Object resolved = get(scopes);
+            writer = handle(writer, createFunction(resolved), scopes);
+            appendText(writer);
+            return writer;
+        }
+
+        @Override
+        protected void tag(Writer writer, String tag) throws IOException {
+            writer.write(tc.startChars());
+            writer.write(tag);
+            writer.write(code);
+            writer.write(tc.endChars());
+        }
+
+        protected abstract Function<String, String> createFunction(Object resolved);
+
+        /**
+         * At compile time, this function extracts the name of the variable:
+         * {{#toJson}}variable_name{{/toJson}}
+         */
+        protected static String extractVariableName(String fn, Mustache mustache, TemplateContext tc) {
+            Code[] codes = mustache.getCodes();
+            if (codes == null || codes.length != 1) {
+                throw new MustacheException("Mustache function [" + fn + "] must contain one and only one identifier");
+            }
+
+            try (StringWriter capture = new StringWriter()) {
+                // Variable name is in plain text and has type WriteCode
+                if (codes[0] instanceof WriteCode) {
+                    codes[0].execute(capture, Collections.emptyList());
+                    return capture.toString();
+                } else {
+                    codes[0].identity(capture);
+                    return capture.toString();
+                }
+            } catch (IOException e) {
+                throw new MustacheException("Exception while parsing mustache function [" + fn + "] at line " + tc.line(), e);
+            }
+        }
+    }
+
+    /**
+     * This function renders {@link Iterable} and {@link Map} as their JSON representation
+     */
+    static class ToJsonCode extends CustomCode {
+
+        private static final String CODE = "toJson";
+
+        public ToJsonCode(TemplateContext tc, DefaultMustacheFactory df, Mustache mustache, String variable) {
+            super(tc, df, mustache, CODE);
+            if (CODE.equalsIgnoreCase(variable) == false) {
+                throw new MustacheException("Mismatch function code [" + CODE + "] cannot be applied to [" + variable + "]");
+            }
+        }
+
+        @Override
+        @SuppressWarnings("unchecked")
+        protected Function<String, String> createFunction(Object resolved) {
+            return s -> {
+                if (resolved == null) {
+                    return null;
+                }
+                try (XContentBuilder builder = XContentBuilder.builder(XContentType.JSON.xContent())) {
+                    if (resolved == null) {
+                        builder.nullValue();
+                    } else if (resolved instanceof Iterable) {
+                        builder.startArray();
+                        for (Object o : (Iterable) resolved) {
+                            builder.value(o);
+                        }
+                        builder.endArray();
+                    } else if (resolved instanceof Map) {
+                        builder.map((Map<String, ?>) resolved);
+                    } else {
+                        // Do not handle as JSON
+                        return oh.stringify(resolved);
+                    }
+                    return builder.string();
+                } catch (IOException e) {
+                    throw new MustacheException("Failed to convert object to JSON", e);
+                }
+            };
+        }
+
+        static boolean match(String variable) {
+            return CODE.equalsIgnoreCase(variable);
+        }
+    }
+
+    /**
+     * This function concatenates the values of an {@link Iterable} using a given delimiter
+     */
+    static class JoinerCode extends CustomCode {
+
+        protected static final String CODE = "join";
+        private static final String DEFAULT_DELIMITER = ",";
+
+        private final String delimiter;
+
+        public JoinerCode(TemplateContext tc, DefaultMustacheFactory df, Mustache mustache, String delimiter) {
+            super(tc, df, mustache, CODE);
+            this.delimiter = delimiter;
+        }
+
+        public JoinerCode(TemplateContext tc, DefaultMustacheFactory df, Mustache mustache) {
+            this(tc, df, mustache, DEFAULT_DELIMITER);
+        }
+
+        @Override
+        protected Function<String, String> createFunction(Object resolved) {
+            return s -> {
+                if (s == null) {
+                    return null;
+                } else if (resolved instanceof Iterable) {
+                    StringJoiner joiner = new StringJoiner(delimiter);
+                    for (Object o : (Iterable) resolved) {
+                        joiner.add(oh.stringify(o));
+                    }
+                    return joiner.toString();
+                }
+                return s;
+            };
+        }
+
+        static boolean match(String variable) {
+            return CODE.equalsIgnoreCase(variable);
+        }
+    }
+
+    static class CustomJoinerCode extends JoinerCode {
+
+        private static final Pattern PATTERN = Pattern.compile("^(?:" + CODE + " delimiter='(.*)')$");
+
+        public CustomJoinerCode(TemplateContext tc, DefaultMustacheFactory df, Mustache mustache, String variable) {
+            super(tc, df, mustache, extractDelimiter(variable));
+        }
+
+        private static String extractDelimiter(String variable) {
+            Matcher matcher = PATTERN.matcher(variable);
+            if (matcher.find()) {
+                return matcher.group(1);
+            }
+            throw new MustacheException("Failed to extract delimiter for join function");
+        }
+
+        static boolean match(String variable) {
+            return PATTERN.matcher(variable).matches();
+        }
+    }
+
+    class NoEscapeEncoder implements BiConsumer<String, Writer> {
+
+        @Override
+        public void accept(String s, Writer writer) {
+            try {
+                writer.write(s);
+            } catch (IOException e) {
+                throw new MustacheException("Failed to encode value: " + s);
+            }
+        }
+    }
+
+    class JsonEscapeEncoder implements BiConsumer<String, Writer> {
+
+        @Override
+        public void accept(String s, Writer writer) {
+            try {
+                writer.write(JsonStringEncoder.getInstance().quoteAsString(s));
+            } catch (IOException e) {
+                throw new MustacheException("Failed to escape and encode value: " + s);
+            }
+        }
+    }
+}

+ 0 - 41
modules/lang-mustache/src/main/java/org/elasticsearch/script/mustache/JsonEscapingMustacheFactory.java

@@ -1,41 +0,0 @@
-/*
- * Licensed to Elasticsearch under one or more contributor
- * license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright
- * ownership. Elasticsearch 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 org.elasticsearch.script.mustache;
-
-import com.fasterxml.jackson.core.io.JsonStringEncoder;
-import com.github.mustachejava.DefaultMustacheFactory;
-import com.github.mustachejava.MustacheException;
-
-import java.io.IOException;
-import java.io.Writer;
-
-/**
- * A MustacheFactory that does simple JSON escaping.
- */
-final class JsonEscapingMustacheFactory extends DefaultMustacheFactory {
-
-    @Override
-    public void encode(String value, Writer writer) {
-        try {
-            writer.write(JsonStringEncoder.getInstance().quoteAsString(value));
-        } catch (IOException e) {
-            throw new MustacheException("Failed to encode value: " + value);
-        }
-    }
-}

+ 11 - 22
modules/lang-mustache/src/main/java/org/elasticsearch/script/mustache/MustacheScriptEngineService.java

@@ -18,8 +18,8 @@
  */
 package org.elasticsearch.script.mustache;
 
-import com.github.mustachejava.DefaultMustacheFactory;
 import com.github.mustachejava.Mustache;
+import com.github.mustachejava.MustacheFactory;
 import org.elasticsearch.SpecialPermission;
 import org.elasticsearch.common.Nullable;
 import org.elasticsearch.common.component.AbstractComponent;
@@ -29,8 +29,8 @@ import org.elasticsearch.common.io.stream.BytesStreamOutput;
 import org.elasticsearch.common.settings.Settings;
 import org.elasticsearch.script.CompiledScript;
 import org.elasticsearch.script.ExecutableScript;
-import org.elasticsearch.script.ScriptEngineService;
 import org.elasticsearch.script.GeneralScriptException;
+import org.elasticsearch.script.ScriptEngineService;
 import org.elasticsearch.script.SearchScript;
 import org.elasticsearch.search.lookup.SearchLookup;
 
@@ -89,21 +89,13 @@ public final class MustacheScriptEngineService extends AbstractComponent impleme
      * */
     @Override
     public Object compile(String templateName, String templateSource, Map<String, String> params) {
-        String contentType = params.getOrDefault(CONTENT_TYPE_PARAM, JSON_CONTENT_TYPE);
-        final DefaultMustacheFactory mustacheFactory;
-        switch (contentType){
-            case PLAIN_TEXT_CONTENT_TYPE:
-                mustacheFactory = new NoneEscapingMustacheFactory();
-                break;
-            case JSON_CONTENT_TYPE:
-            default:
-                // assume that the default is json encoding:
-                mustacheFactory = new JsonEscapingMustacheFactory();
-                break;
-        }
-        mustacheFactory.setObjectHandler(new CustomReflectionObjectHandler());
+        final MustacheFactory factory = new CustomMustacheFactory(isJsonEscapingEnabled(params));
         Reader reader = new FastStringReader(templateSource);
-        return mustacheFactory.compile(reader, "query-template");
+        return factory.compile(reader, "query-template");
+    }
+
+    private boolean isJsonEscapingEnabled(Map<String, String> params) {
+        return JSON_CONTENT_TYPE.equals(params.getOrDefault(CONTENT_TYPE_PARAM, JSON_CONTENT_TYPE));
     }
 
     @Override
@@ -168,12 +160,9 @@ public final class MustacheScriptEngineService extends AbstractComponent impleme
                 if (sm != null) {
                     sm.checkPermission(SPECIAL_PERMISSION);
                 }
-                AccessController.doPrivileged(new PrivilegedAction<Void>() {
-                    @Override
-                    public Void run() {
-                        ((Mustache) template.compiled()).execute(writer, vars);
-                        return null;
-                    }
+                AccessController.doPrivileged((PrivilegedAction<Void>) () -> {
+                    ((Mustache) template.compiled()).execute(writer, vars);
+                    return null;
                 });
             } catch (Exception e) {
                 logger.error("Error running {}", e, template);

+ 0 - 40
modules/lang-mustache/src/main/java/org/elasticsearch/script/mustache/NoneEscapingMustacheFactory.java

@@ -1,40 +0,0 @@
-/*
- * Licensed to Elasticsearch under one or more contributor
- * license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright
- * ownership. Elasticsearch 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 org.elasticsearch.script.mustache;
-
-import com.github.mustachejava.DefaultMustacheFactory;
-import com.github.mustachejava.MustacheException;
-
-import java.io.IOException;
-import java.io.Writer;
-
-/**
- * A MustacheFactory that does no string escaping.
- */
-final class NoneEscapingMustacheFactory extends DefaultMustacheFactory {
-
-    @Override
-    public void encode(String value, Writer writer) {
-        try {
-            writer.write(value);
-        } catch (IOException e) {
-            throw new MustacheException("Failed to encode value: " + value);
-        }
-    }
-}

+ 6 - 5
modules/lang-mustache/src/test/java/org/elasticsearch/script/mustache/MustacheScriptEngineTests.java

@@ -18,6 +18,7 @@
  */
 package org.elasticsearch.script.mustache;
 
+import com.github.mustachejava.MustacheFactory;
 import org.elasticsearch.common.bytes.BytesReference;
 import org.elasticsearch.common.settings.Settings;
 import org.elasticsearch.script.CompiledScript;
@@ -39,12 +40,12 @@ import static org.hamcrest.Matchers.equalTo;
  */
 public class MustacheScriptEngineTests extends ESTestCase {
     private MustacheScriptEngineService qe;
-    private JsonEscapingMustacheFactory escaper;
+    private MustacheFactory factory;
 
     @Before
     public void setup() {
         qe = new MustacheScriptEngineService(Settings.Builder.EMPTY_SETTINGS);
-        escaper = new JsonEscapingMustacheFactory();
+        factory = new CustomMustacheFactory(true);
     }
 
     public void testSimpleParameterReplace() {
@@ -75,12 +76,12 @@ public class MustacheScriptEngineTests extends ESTestCase {
     public void testEscapeJson() throws IOException {
         {
             StringWriter writer = new StringWriter();
-            escaper.encode("hello \n world", writer);
+            factory.encode("hello \n world", writer);
             assertThat(writer.toString(), equalTo("hello \\n world"));
         }
         {
             StringWriter writer = new StringWriter();
-            escaper.encode("\n", writer);
+            factory.encode("\n", writer);
             assertThat(writer.toString(), equalTo("\\n"));
         }
 
@@ -135,7 +136,7 @@ public class MustacheScriptEngineTests extends ESTestCase {
                 expect.append(escapedChars[charIndex]);
             }
             StringWriter target = new StringWriter();
-            escaper.encode(writer.toString(), target);
+            factory.encode(writer.toString(), target);
             assertThat(expect.toString(), equalTo(target.toString()));
         }
     }

+ 216 - 8
modules/lang-mustache/src/test/java/org/elasticsearch/script/mustache/MustacheTests.java

@@ -19,13 +19,16 @@
 package org.elasticsearch.script.mustache;
 
 import com.github.mustachejava.Mustache;
+import com.github.mustachejava.MustacheException;
 import org.elasticsearch.common.bytes.BytesReference;
 import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.common.xcontent.XContentBuilder;
+import org.elasticsearch.common.xcontent.XContentHelper;
 import org.elasticsearch.script.CompiledScript;
 import org.elasticsearch.script.ExecutableScript;
 import org.elasticsearch.script.ScriptEngineService;
-import org.elasticsearch.script.ScriptService;
 import org.elasticsearch.test.ESTestCase;
+import org.hamcrest.Matcher;
 
 import java.util.Arrays;
 import java.util.Collections;
@@ -38,6 +41,8 @@ import java.util.Set;
 
 import static java.util.Collections.singleton;
 import static java.util.Collections.singletonMap;
+import static org.elasticsearch.common.xcontent.XContentFactory.jsonBuilder;
+import static org.elasticsearch.script.ScriptService.ScriptType.INLINE;
 import static org.elasticsearch.script.mustache.MustacheScriptEngineService.CONTENT_TYPE_PARAM;
 import static org.elasticsearch.script.mustache.MustacheScriptEngineService.JSON_CONTENT_TYPE;
 import static org.elasticsearch.script.mustache.MustacheScriptEngineService.PLAIN_TEXT_CONTENT_TYPE;
@@ -45,6 +50,8 @@ import static org.hamcrest.Matchers.both;
 import static org.hamcrest.Matchers.containsString;
 import static org.hamcrest.Matchers.equalTo;
 import static org.hamcrest.Matchers.instanceOf;
+import static org.hamcrest.Matchers.isEmptyOrNullString;
+import static org.hamcrest.Matchers.not;
 import static org.hamcrest.Matchers.notNullValue;
 
 public class MustacheTests extends ESTestCase {
@@ -59,7 +66,7 @@ public class MustacheTests extends ESTestCase {
         Map<String, Object> params = Collections.singletonMap("boost_val", "0.2");
 
         Mustache mustache = (Mustache) engine.compile(null, template, Collections.emptyMap());
-        CompiledScript compiledScript = new CompiledScript(ScriptService.ScriptType.INLINE, "my-name", "mustache", mustache);
+        CompiledScript compiledScript = new CompiledScript(INLINE, "my-name", "mustache", mustache);
         ExecutableScript result = engine.executable(compiledScript, params);
         assertEquals(
                 "Mustache templating broken",
@@ -71,7 +78,7 @@ public class MustacheTests extends ESTestCase {
 
     public void testArrayAccess() throws Exception {
         String template = "{{data.0}} {{data.1}}";
-        CompiledScript mustache = new CompiledScript(ScriptService.ScriptType.INLINE, "inline", "mustache", engine.compile(null, template, Collections.emptyMap()));
+        CompiledScript mustache = new CompiledScript(INLINE, "inline", "mustache", engine.compile(null, template, Collections.emptyMap()));
         Map<String, Object> vars = new HashMap<>();
         Object data = randomFrom(
             new String[] { "foo", "bar" },
@@ -97,7 +104,7 @@ public class MustacheTests extends ESTestCase {
 
     public void testArrayInArrayAccess() throws Exception {
         String template = "{{data.0.0}} {{data.0.1}}";
-        CompiledScript mustache = new CompiledScript(ScriptService.ScriptType.INLINE, "inline", "mustache", engine.compile(null, template, Collections.emptyMap()));
+        CompiledScript mustache = new CompiledScript(INLINE, "inline", "mustache", engine.compile(null, template, Collections.emptyMap()));
         Map<String, Object> vars = new HashMap<>();
         Object data = randomFrom(
             new String[][] { new String[] { "foo", "bar" }},
@@ -114,7 +121,7 @@ public class MustacheTests extends ESTestCase {
 
     public void testMapInArrayAccess() throws Exception {
         String template = "{{data.0.key}} {{data.1.key}}";
-        CompiledScript mustache = new CompiledScript(ScriptService.ScriptType.INLINE, "inline", "mustache", engine.compile(null, template, Collections.emptyMap()));
+        CompiledScript mustache = new CompiledScript(INLINE, "inline", "mustache", engine.compile(null, template, Collections.emptyMap()));
         Map<String, Object> vars = new HashMap<>();
         Object data = randomFrom(
             new Object[] { singletonMap("key", "foo"), singletonMap("key", "bar") },
@@ -142,7 +149,7 @@ public class MustacheTests extends ESTestCase {
         // json string escaping enabled:
         Map<String, String> params = randomBoolean() ? Collections.emptyMap() : Collections.singletonMap(CONTENT_TYPE_PARAM, JSON_CONTENT_TYPE);
         Mustache mustache = (Mustache) engine.compile(null, "{ \"field1\": \"{{value}}\"}", Collections.emptyMap());
-        CompiledScript compiledScript = new CompiledScript(ScriptService.ScriptType.INLINE, "name", "mustache", mustache);
+        CompiledScript compiledScript = new CompiledScript(INLINE, "name", "mustache", mustache);
         ExecutableScript executableScript = engine.executable(compiledScript, Collections.singletonMap("value", "a \"value\""));
         BytesReference rawResult = (BytesReference) executableScript.run();
         String result = rawResult.toUtf8();
@@ -150,7 +157,7 @@ public class MustacheTests extends ESTestCase {
 
         // json string escaping disabled:
         mustache = (Mustache) engine.compile(null, "{ \"field1\": \"{{value}}\"}", Collections.singletonMap(CONTENT_TYPE_PARAM, PLAIN_TEXT_CONTENT_TYPE));
-        compiledScript = new CompiledScript(ScriptService.ScriptType.INLINE, "name", "mustache", mustache);
+        compiledScript = new CompiledScript(INLINE, "name", "mustache", mustache);
         executableScript = engine.executable(compiledScript, Collections.singletonMap("value", "a \"value\""));
         rawResult = (BytesReference) executableScript.run();
         result = rawResult.toUtf8();
@@ -162,7 +169,7 @@ public class MustacheTests extends ESTestCase {
         List<String> randomList = Arrays.asList(generateRandomStringArray(10, 20, false));
 
         String template = "{{data.array.size}} {{data.list.size}}";
-        CompiledScript mustache = new CompiledScript(ScriptService.ScriptType.INLINE, "inline", "mustache", engine.compile(null, template, Collections.emptyMap()));
+        CompiledScript mustache = new CompiledScript(INLINE, "inline", "mustache", engine.compile(null, template, Collections.emptyMap()));
         Map<String, Object> data = new HashMap<>();
         data.put("array", randomArrayValues);
         data.put("list", randomList);
@@ -177,4 +184,205 @@ public class MustacheTests extends ESTestCase {
         String expectedString = String.format(Locale.ROOT, "%s %s", randomArrayValues.length, randomList.size());
         assertThat(bytes.toUtf8(), equalTo(expectedString));
     }
+
+    public void testPrimitiveToJSON() throws Exception {
+        String template = "{{#toJson}}ctx{{/toJson}}";
+        assertScript(template, Collections.singletonMap("ctx", "value"), equalTo("value"));
+        assertScript(template, Collections.singletonMap("ctx", ""), equalTo(""));
+        assertScript(template, Collections.singletonMap("ctx", true), equalTo("true"));
+        assertScript(template, Collections.singletonMap("ctx", 42), equalTo("42"));
+        assertScript(template, Collections.singletonMap("ctx", 42L), equalTo("42"));
+        assertScript(template, Collections.singletonMap("ctx", 42.5f), equalTo("42.5"));
+        assertScript(template, Collections.singletonMap("ctx", null), equalTo(""));
+
+        template = "{{#toJson}}.{{/toJson}}";
+        assertScript(template, Collections.singletonMap("ctx", "value"), equalTo("{\"ctx\":\"value\"}"));
+        assertScript(template, Collections.singletonMap("ctx", ""), equalTo("{\"ctx\":\"\"}"));
+        assertScript(template, Collections.singletonMap("ctx", true), equalTo("{\"ctx\":true}"));
+        assertScript(template, Collections.singletonMap("ctx", 42), equalTo("{\"ctx\":42}"));
+        assertScript(template, Collections.singletonMap("ctx", 42L), equalTo("{\"ctx\":42}"));
+        assertScript(template, Collections.singletonMap("ctx", 42.5f), equalTo("{\"ctx\":42.5}"));
+        assertScript(template, Collections.singletonMap("ctx", null), equalTo("{\"ctx\":null}"));
+    }
+
+    public void testSimpleMapToJSON() throws Exception {
+        Map<String, Object> human0 = new HashMap<>();
+        human0.put("age", 42);
+        human0.put("name", "John Smith");
+        human0.put("height", 1.84);
+
+        Map<String, Object> ctx = Collections.singletonMap("ctx", human0);
+
+        assertScript("{{#toJson}}.{{/toJson}}", ctx, equalTo("{\"ctx\":{\"name\":\"John Smith\",\"age\":42,\"height\":1.84}}"));
+        assertScript("{{#toJson}}ctx{{/toJson}}", ctx, equalTo("{\"name\":\"John Smith\",\"age\":42,\"height\":1.84}"));
+        assertScript("{{#toJson}}ctx.name{{/toJson}}", ctx, equalTo("John Smith"));
+    }
+
+    public void testMultipleMapsToJSON() throws Exception {
+        Map<String, Object> human0 = new HashMap<>();
+        human0.put("age", 42);
+        human0.put("name", "John Smith");
+        human0.put("height", 1.84);
+
+        Map<String, Object> human1 = new HashMap<>();
+        human1.put("age", 27);
+        human1.put("name", "Dave Smith");
+        human1.put("height", 1.71);
+
+        Map<String, Object> humans = new HashMap<>();
+        humans.put("first", human0);
+        humans.put("second", human1);
+
+        Map<String, Object> ctx = Collections.singletonMap("ctx", humans);
+
+        assertScript("{{#toJson}}.{{/toJson}}", ctx,
+                equalTo("{\"ctx\":{\"first\":{\"name\":\"John Smith\",\"age\":42,\"height\":1.84},\"second\":{\"name\":\"Dave Smith\",\"age\":27,\"height\":1.71}}}"));
+
+        assertScript("{{#toJson}}ctx{{/toJson}}", ctx,
+                equalTo("{\"first\":{\"name\":\"John Smith\",\"age\":42,\"height\":1.84},\"second\":{\"name\":\"Dave Smith\",\"age\":27,\"height\":1.71}}"));
+
+        assertScript("{{#toJson}}ctx.first{{/toJson}}", ctx,
+                equalTo("{\"name\":\"John Smith\",\"age\":42,\"height\":1.84}"));
+
+        assertScript("{{#toJson}}ctx.second{{/toJson}}", ctx,
+                equalTo("{\"name\":\"Dave Smith\",\"age\":27,\"height\":1.71}"));
+    }
+
+    public void testSimpleArrayToJSON() throws Exception {
+        String[] array = new String[]{"one", "two", "three"};
+        Map<String, Object> ctx = Collections.singletonMap("array", array);
+
+        assertScript("{{#toJson}}.{{/toJson}}", ctx, equalTo("{\"array\":[\"one\",\"two\",\"three\"]}"));
+        assertScript("{{#toJson}}array{{/toJson}}", ctx, equalTo("[\"one\",\"two\",\"three\"]"));
+        assertScript("{{#toJson}}array.0{{/toJson}}", ctx, equalTo("one"));
+        assertScript("{{#toJson}}array.1{{/toJson}}", ctx, equalTo("two"));
+        assertScript("{{#toJson}}array.2{{/toJson}}", ctx, equalTo("three"));
+        assertScript("{{#toJson}}array.size{{/toJson}}", ctx, equalTo("3"));
+    }
+
+    public void testSimpleListToJSON() throws Exception {
+        List<String> list = Arrays.asList("one", "two", "three");
+        Map<String, Object> ctx = Collections.singletonMap("ctx", list);
+
+        assertScript("{{#toJson}}.{{/toJson}}", ctx, equalTo("{\"ctx\":[\"one\",\"two\",\"three\"]}"));
+        assertScript("{{#toJson}}ctx{{/toJson}}", ctx, equalTo("[\"one\",\"two\",\"three\"]"));
+        assertScript("{{#toJson}}ctx.0{{/toJson}}", ctx, equalTo("one"));
+        assertScript("{{#toJson}}ctx.1{{/toJson}}", ctx, equalTo("two"));
+        assertScript("{{#toJson}}ctx.2{{/toJson}}", ctx, equalTo("three"));
+        assertScript("{{#toJson}}ctx.size{{/toJson}}", ctx, equalTo("3"));
+    }
+
+    public void testsUnsupportedTagsToJson() {
+        MustacheException e = expectThrows(MustacheException.class, () -> compile("{{#toJson}}{{foo}}{{bar}}{{/toJson}}"));
+        assertThat(e.getMessage(), containsString("Mustache function [toJson] must contain one and only one identifier"));
+
+        e = expectThrows(MustacheException.class, () -> compile("{{#toJson}}{{/toJson}}"));
+        assertThat(e.getMessage(), containsString("Mustache function [toJson] must contain one and only one identifier"));
+    }
+
+    public void testEmbeddedToJSON() throws Exception {
+        XContentBuilder builder = jsonBuilder().startObject()
+                        .startArray("bulks")
+                            .startObject()
+                                .field("index", "index-1")
+                                .field("type", "type-1")
+                                .field("id", 1)
+                            .endObject()
+                            .startObject()
+                                .field("index", "index-2")
+                                .field("type", "type-2")
+                                .field("id", 2)
+                            .endObject()
+                        .endArray()
+                    .endObject();
+
+        Map<String, Object> ctx = Collections.singletonMap("ctx", XContentHelper.convertToMap(builder.bytes(), false).v2());
+
+        assertScript("{{#ctx.bulks}}{{#toJson}}.{{/toJson}}{{/ctx.bulks}}", ctx,
+                equalTo("{\"index\":\"index-1\",\"id\":1,\"type\":\"type-1\"}{\"index\":\"index-2\",\"id\":2,\"type\":\"type-2\"}"));
+
+        assertScript("{{#ctx.bulks}}<{{#toJson}}id{{/toJson}}>{{/ctx.bulks}}", ctx,
+                equalTo("<1><2>"));
+    }
+
+    public void testSimpleArrayJoin() throws Exception {
+        String template = "{{#join}}array{{/join}}";
+        assertScript(template, Collections.singletonMap("array", new String[]{"one", "two", "three"}), equalTo("one,two,three"));
+        assertScript(template, Collections.singletonMap("array", new int[]{1, 2, 3}), equalTo("1,2,3"));
+        assertScript(template, Collections.singletonMap("array", new long[]{1L, 2L, 3L}), equalTo("1,2,3"));
+        assertScript(template, Collections.singletonMap("array", new double[]{1.5, 2.5, 3.5}), equalTo("1.5,2.5,3.5"));
+        assertScript(template, Collections.singletonMap("array", new boolean[]{true, false, true}), equalTo("true,false,true"));
+        assertScript(template, Collections.singletonMap("array", new boolean[]{true, false, true}), equalTo("true,false,true"));
+    }
+
+    public void testEmbeddedArrayJoin() throws Exception {
+        XContentBuilder builder = jsonBuilder().startObject()
+                                                    .startArray("people")
+                                                        .startObject()
+                                                            .field("name", "John Smith")
+                                                            .startArray("emails")
+                                                                .value("john@smith.com")
+                                                                .value("john.smith@email.com")
+                                                                .value("jsmith@email.com")
+                                                            .endArray()
+                                                        .endObject()
+                                                        .startObject()
+                                                            .field("name", "John Doe")
+                                                            .startArray("emails")
+                                                                .value("john@doe.com")
+                                                                .value("john.doe@email.com")
+                                                                .value("jdoe@email.com")
+                                                            .endArray()
+                                                        .endObject()
+                                                    .endArray()
+                                                .endObject();
+
+        Map<String, Object> ctx = Collections.singletonMap("ctx", XContentHelper.convertToMap(builder.bytes(), false).v2());
+
+        assertScript("{{#join}}ctx.people.0.emails{{/join}}", ctx,
+                equalTo("john@smith.com,john.smith@email.com,jsmith@email.com"));
+
+        assertScript("{{#join}}ctx.people.1.emails{{/join}}", ctx,
+                equalTo("john@doe.com,john.doe@email.com,jdoe@email.com"));
+
+        assertScript("{{#ctx.people}}to: {{#join}}emails{{/join}};{{/ctx.people}}", ctx,
+                equalTo("to: john@smith.com,john.smith@email.com,jsmith@email.com;to: john@doe.com,john.doe@email.com,jdoe@email.com;"));
+    }
+
+    public void testJoinWithToJson() {
+        Map<String, Object> params = Collections.singletonMap("terms",
+                Arrays.asList(singletonMap("term", "foo"), singletonMap("term", "bar")));
+
+        assertScript("{{#join}}{{#toJson}}terms{{/toJson}}{{/join}}", params,
+                equalTo("[{\"term\":\"foo\"},{\"term\":\"bar\"}]"));
+    }
+
+    public void testsUnsupportedTagsJoin() {
+        MustacheException e = expectThrows(MustacheException.class, () -> compile("{{#join}}{{/join}}"));
+        assertThat(e.getMessage(), containsString("Mustache function [join] must contain one and only one identifier"));
+
+        e = expectThrows(MustacheException.class, () -> compile("{{#join delimiter='a'}}{{/join delimiter='b'}}"));
+        assertThat(e.getMessage(), containsString("Mismatched start/end tags"));
+    }
+
+    public void testJoinWithCustomDelimiter() {
+        Map<String, Object> params = Collections.singletonMap("params", Arrays.asList(1, 2, 3, 4));
+
+        assertScript("{{#join delimiter=''}}params{{/join delimiter=''}}", params, equalTo("1234"));
+        assertScript("{{#join delimiter=','}}params{{/join delimiter=','}}", params, equalTo("1,2,3,4"));
+        assertScript("{{#join delimiter='/'}}params{{/join delimiter='/'}}", params, equalTo("1/2/3/4"));
+        assertScript("{{#join delimiter=' and '}}params{{/join delimiter=' and '}}", params, equalTo("1 and 2 and 3 and 4"));
+    }
+
+    private void assertScript(String script, Map<String, Object> vars, Matcher<Object> matcher) {
+        Object result = engine.executable(new CompiledScript(INLINE, "inline", "mustache", compile(script)), vars).run();
+        assertThat(result, notNullValue());
+        assertThat(result, instanceOf(BytesReference.class));
+        assertThat(((BytesReference) result).toUtf8(), matcher);
+    }
+
+    private Object compile(String script) {
+        assertThat("cannot compile null or empty script", script, not(isEmptyOrNullString()));
+        return engine.compile(null, script, Collections.emptyMap());
+    }
 }