From 4820d491209fd848ddb9bb307b8b1a7c90c676e7 Mon Sep 17 00:00:00 2001 From: Tanguy Leroux Date: Tue, 14 Jun 2016 15:14:54 +0200 Subject: [PATCH] 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 --- .../reference/search/search-template.asciidoc | 123 ++++++++ .../mustache/CustomMustacheFactory.java | 279 ++++++++++++++++++ .../mustache/JsonEscapingMustacheFactory.java | 41 --- .../mustache/MustacheScriptEngineService.java | 33 +-- .../mustache/NoneEscapingMustacheFactory.java | 40 --- .../mustache/MustacheScriptEngineTests.java | 11 +- .../script/mustache/MustacheTests.java | 224 +++++++++++++- 7 files changed, 635 insertions(+), 116 deletions(-) create mode 100644 modules/lang-mustache/src/main/java/org/elasticsearch/script/mustache/CustomMustacheFactory.java delete mode 100644 modules/lang-mustache/src/main/java/org/elasticsearch/script/mustache/JsonEscapingMustacheFactory.java delete mode 100644 modules/lang-mustache/src/main/java/org/elasticsearch/script/mustache/NoneEscapingMustacheFactory.java diff --git a/docs/reference/search/search-template.asciidoc b/docs/reference/search/search-template.asciidoc index 8533984428b..359b692f528 100644 --- a/docs/reference/search/search-template.asciidoc +++ b/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 diff --git a/modules/lang-mustache/src/main/java/org/elasticsearch/script/mustache/CustomMustacheFactory.java b/modules/lang-mustache/src/main/java/org/elasticsearch/script/mustache/CustomMustacheFactory.java new file mode 100644 index 00000000000..ceb187bcc63 --- /dev/null +++ b/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 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 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 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 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) 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 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 { + + @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 { + + @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); + } + } + } +} diff --git a/modules/lang-mustache/src/main/java/org/elasticsearch/script/mustache/JsonEscapingMustacheFactory.java b/modules/lang-mustache/src/main/java/org/elasticsearch/script/mustache/JsonEscapingMustacheFactory.java deleted file mode 100644 index 38d48b98f4e..00000000000 --- a/modules/lang-mustache/src/main/java/org/elasticsearch/script/mustache/JsonEscapingMustacheFactory.java +++ /dev/null @@ -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); - } - } -} diff --git a/modules/lang-mustache/src/main/java/org/elasticsearch/script/mustache/MustacheScriptEngineService.java b/modules/lang-mustache/src/main/java/org/elasticsearch/script/mustache/MustacheScriptEngineService.java index 2a48567333b..66ecf23fa02 100644 --- a/modules/lang-mustache/src/main/java/org/elasticsearch/script/mustache/MustacheScriptEngineService.java +++ b/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 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 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() { - @Override - public Void run() { - ((Mustache) template.compiled()).execute(writer, vars); - return null; - } + AccessController.doPrivileged((PrivilegedAction) () -> { + ((Mustache) template.compiled()).execute(writer, vars); + return null; }); } catch (Exception e) { logger.error("Error running {}", e, template); diff --git a/modules/lang-mustache/src/main/java/org/elasticsearch/script/mustache/NoneEscapingMustacheFactory.java b/modules/lang-mustache/src/main/java/org/elasticsearch/script/mustache/NoneEscapingMustacheFactory.java deleted file mode 100644 index 3539402df98..00000000000 --- a/modules/lang-mustache/src/main/java/org/elasticsearch/script/mustache/NoneEscapingMustacheFactory.java +++ /dev/null @@ -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); - } - } -} diff --git a/modules/lang-mustache/src/test/java/org/elasticsearch/script/mustache/MustacheScriptEngineTests.java b/modules/lang-mustache/src/test/java/org/elasticsearch/script/mustache/MustacheScriptEngineTests.java index 254020066b5..054268ef681 100644 --- a/modules/lang-mustache/src/test/java/org/elasticsearch/script/mustache/MustacheScriptEngineTests.java +++ b/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())); } } diff --git a/modules/lang-mustache/src/test/java/org/elasticsearch/script/mustache/MustacheTests.java b/modules/lang-mustache/src/test/java/org/elasticsearch/script/mustache/MustacheTests.java index f850f117cb6..8b6d0e69573 100644 --- a/modules/lang-mustache/src/test/java/org/elasticsearch/script/mustache/MustacheTests.java +++ b/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 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 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 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 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 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 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 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 human0 = new HashMap<>(); + human0.put("age", 42); + human0.put("name", "John Smith"); + human0.put("height", 1.84); + + Map 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 human0 = new HashMap<>(); + human0.put("age", 42); + human0.put("name", "John Smith"); + human0.put("height", 1.84); + + Map human1 = new HashMap<>(); + human1.put("age", 27); + human1.put("name", "Dave Smith"); + human1.put("height", 1.71); + + Map humans = new HashMap<>(); + humans.put("first", human0); + humans.put("second", human1); + + Map 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 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 list = Arrays.asList("one", "two", "three"); + Map 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 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 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 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 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 vars, Matcher 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()); + } }