diff --git a/docs/changelog/121914.yaml b/docs/changelog/121914.yaml new file mode 100644 index 0000000000000..33e5f1351236e --- /dev/null +++ b/docs/changelog/121914.yaml @@ -0,0 +1,5 @@ +pr: 121914 +summary: Support Fields API in conditional ingest processors +area: Infra/Core +type: feature +issues: [] diff --git a/modules/ingest-common/src/yamlRestTest/resources/rest-api-spec/test/ingest/210_conditional_processor.yml b/modules/ingest-common/src/yamlRestTest/resources/rest-api-spec/test/ingest/210_conditional_processor.yml index 8ad2be2b41fe4..0ba1a0f7d78d7 100644 --- a/modules/ingest-common/src/yamlRestTest/resources/rest-api-spec/test/ingest/210_conditional_processor.yml +++ b/modules/ingest-common/src/yamlRestTest/resources/rest-api-spec/test/ingest/210_conditional_processor.yml @@ -74,3 +74,150 @@ teardown: - match: { _source.bytes_source_field: "1kb" } - match: { _source.conditional_field: "bar" } - is_false: _source.bytes_target_field + +--- +"Test conditional processor with fields API": + - do: + ingest.put_pipeline: + id: "my_pipeline" + body: + description: "_description" + processors: + - set: + if: "field('get.field').get('') == 'one'" + field: "one" + value: 1 + - set: + if: "field('get.field').get('') == 'two'" + field: "missing" + value: "missing" + - set: + if: " /* avoid yaml stash */ $('get.field', 'one') == 'one'" + field: "dollar" + value: true + - set: + if: "field('missing.field').get('fallback') == 'fallback'" + field: "fallback" + value: "fallback" + - set: + if: "field('nested.array.get.with.index.field').get(1, null) == 'two'" + field: "two" + value: 2 + - set: + if: "field('getName.field').getName() == 'getName.field'" + field: "three" + value: 3 + - set: + if: "field('existing.field').exists()" + field: "four" + value: 4 + - set: + if: "!field('empty.field').isEmpty()" + field: "missing" + value: "missing" + - set: + if: "field('size.field').size() == 2" + field: "five" + value: 5 + - set: + if: > + def iterator = field('iterator.field').iterator(); + def sum = 0; + while (iterator.hasNext()) { + sum += iterator.next(); + } + return sum == 6; + field: "six" + value: 6 + - set: + if: "field('hasValue.field').hasValue(v -> v == 'two')" + field: "seven" + value: 7 + - match: { acknowledged: true } + + - do: + index: + index: test + id: "1" + pipeline: "my_pipeline" + body: + get.field: "one" + nested: + array: + get.with.index.field: ["one", "two", "three"] + getName.field: "my_name" + existing.field: "indeed" + empty.field: [] + size.field: ["one", "two"] + iterator.field: [1, 2, 3] + hasValue.field: ["one", "two", "three"] + + - do: + get: + index: test + id: "1" + - match: { _source.get\.field: "one" } + - match: { _source.one: 1 } + - is_false: _source.missing + - is_true: _source.dollar + - match: { _source.fallback: "fallback" } + - match: { _source.nested.array.get\.with\.index\.field: ["one", "two", "three"] } + - match: { _source.two: 2 } + - match: { _source.three: 3 } + - match: { _source.four: 4 } + - match: { _source.five: 5 } + - match: { _source.six: 6 } + - match: { _source.seven: 7 } + +--- +"Test fields iterator is unmodifiable": + - do: + ingest.put_pipeline: + id: "my_pipeline" + body: + description: "_description" + processors: + - set: + if: > + def iterator = field('iterator.field').iterator(); + def sum = 0; + while (iterator.hasNext()) { + sum += iterator.next(); + iterator.remove(); + } + return sum == 6; + field: "sum" + value: 6 + - match: { acknowledged: true } + + - do: + index: + index: test + id: "1" + pipeline: "my_pipeline" + body: + test.field: [1, 2, 3] + - match: { error: null } + + - do: + index: + index: test + id: "2" + pipeline: "my_pipeline" + body: + iterator.field: [1, 2, 3] + catch: bad_request + - length: { error.root_cause: 1 } + + - do: + get: + index: test + id: "1" + - match: { _source.test\.field: [1, 2, 3] } + - is_false: _source.sum + + - do: + get: + index: test + id: "2" + catch: missing diff --git a/modules/lang-painless/src/main/resources/org/elasticsearch/painless/org.elasticsearch.script.ingest.txt b/modules/lang-painless/src/main/resources/org/elasticsearch/painless/org.elasticsearch.script.ingest.txt index 9cc88ab4f4607..5031682e5a123 100644 --- a/modules/lang-painless/src/main/resources/org/elasticsearch/painless/org.elasticsearch.script.ingest.txt +++ b/modules/lang-painless/src/main/resources/org/elasticsearch/painless/org.elasticsearch.script.ingest.txt @@ -46,15 +46,12 @@ class org.elasticsearch.script.IngestScript { } class org.elasticsearch.script.field.WriteField { - String getName() boolean exists() WriteField move(def) WriteField overwrite(def) void remove() WriteField set(def) WriteField append(def) - boolean isEmpty() - int size() Iterator iterator() def get(def) def get(int, def) diff --git a/modules/lang-painless/src/main/resources/org/elasticsearch/painless/org.elasticsearch.script.processor_conditional.txt b/modules/lang-painless/src/main/resources/org/elasticsearch/painless/org.elasticsearch.script.processor_conditional.txt new file mode 100644 index 0000000000000..eef74fb1be36b --- /dev/null +++ b/modules/lang-painless/src/main/resources/org/elasticsearch/painless/org.elasticsearch.script.processor_conditional.txt @@ -0,0 +1,22 @@ +# + # Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + # or more contributor license agreements. Licensed under the "Elastic License + # 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + # Public License v 1"; you may not use this file except in compliance with, at + # your election, the "Elastic License 2.0", the "GNU Affero General Public + # License v3.0 only", or the "Server Side Public License, v 1". +# + +# This file contains a whitelist for conditional ingest scripts + +class org.elasticsearch.script.IngestConditionalScript { + SourceMapField field(String) +} + +class org.elasticsearch.script.field.SourceMapField { + boolean exists() + Iterator iterator() + def get(def) + def get(int, def) + boolean hasValue(Predicate) +} diff --git a/modules/lang-painless/src/main/resources/org/elasticsearch/painless/org.elasticsearch.script.reindex.txt b/modules/lang-painless/src/main/resources/org/elasticsearch/painless/org.elasticsearch.script.reindex.txt index fa23e4b6acd1a..c75586137b283 100644 --- a/modules/lang-painless/src/main/resources/org/elasticsearch/painless/org.elasticsearch.script.reindex.txt +++ b/modules/lang-painless/src/main/resources/org/elasticsearch/painless/org.elasticsearch.script.reindex.txt @@ -42,15 +42,12 @@ class org.elasticsearch.script.ReindexScript { } class org.elasticsearch.script.field.WriteField { - String getName() boolean exists() WriteField move(def) WriteField overwrite(def) void remove() WriteField set(def) WriteField append(def) - boolean isEmpty() - int size() Iterator iterator() def get(def) def get(int, def) diff --git a/modules/lang-painless/src/main/resources/org/elasticsearch/painless/org.elasticsearch.script.update.txt b/modules/lang-painless/src/main/resources/org/elasticsearch/painless/org.elasticsearch.script.update.txt index 5a94e6b6b402f..307fa18645eb5 100644 --- a/modules/lang-painless/src/main/resources/org/elasticsearch/painless/org.elasticsearch.script.update.txt +++ b/modules/lang-painless/src/main/resources/org/elasticsearch/painless/org.elasticsearch.script.update.txt @@ -37,15 +37,12 @@ class org.elasticsearch.script.UpdateScript { } class org.elasticsearch.script.field.WriteField { - String getName() boolean exists() WriteField move(def) WriteField overwrite(def) void remove() WriteField set(def) WriteField append(def) - boolean isEmpty() - int size() Iterator iterator() def get(def) def get(int, def) diff --git a/modules/lang-painless/src/main/resources/org/elasticsearch/painless/org.elasticsearch.script.update_by_query.txt b/modules/lang-painless/src/main/resources/org/elasticsearch/painless/org.elasticsearch.script.update_by_query.txt index e4fdbe1157294..bd3dbfb675f11 100644 --- a/modules/lang-painless/src/main/resources/org/elasticsearch/painless/org.elasticsearch.script.update_by_query.txt +++ b/modules/lang-painless/src/main/resources/org/elasticsearch/painless/org.elasticsearch.script.update_by_query.txt @@ -36,15 +36,12 @@ class org.elasticsearch.script.UpdateByQueryScript { } class org.elasticsearch.script.field.WriteField { - String getName() boolean exists() WriteField move(def) WriteField overwrite(def) void remove() WriteField set(def) WriteField append(def) - boolean isEmpty() - int size() Iterator iterator() def get(def) def get(int, def) diff --git a/server/src/main/java/org/elasticsearch/ingest/ConditionalProcessor.java b/server/src/main/java/org/elasticsearch/ingest/ConditionalProcessor.java index cf234dee1861b..cdeda3e7a2efd 100644 --- a/server/src/main/java/org/elasticsearch/ingest/ConditionalProcessor.java +++ b/server/src/main/java/org/elasticsearch/ingest/ConditionalProcessor.java @@ -11,6 +11,7 @@ import org.elasticsearch.common.logging.DeprecationCategory; import org.elasticsearch.common.logging.DeprecationLogger; +import org.elasticsearch.script.CtxMapWrapper; import org.elasticsearch.script.DynamicMap; import org.elasticsearch.script.IngestConditionalScript; import org.elasticsearch.script.Script; @@ -57,6 +58,7 @@ public class ConditionalProcessor extends AbstractProcessor implements WrappingP private final IngestMetric metric; private final LongSupplier relativeTimeProvider; private final IngestConditionalScript precompiledConditionScript; + private final CtxMapWrapper ctxMapWrapper; ConditionalProcessor(String tag, String description, Script script, ScriptService scriptService, Processor processor) { this(tag, description, script, scriptService, processor, System::nanoTime); @@ -76,11 +78,12 @@ public class ConditionalProcessor extends AbstractProcessor implements WrappingP this.processor = processor; this.metric = new IngestMetric(); this.relativeTimeProvider = relativeTimeProvider; + this.ctxMapWrapper = new CtxMapWrapper(); try { final IngestConditionalScript.Factory factory = scriptService.compile(script, IngestConditionalScript.CONTEXT); if (ScriptType.INLINE.equals(script.getType())) { - precompiledConditionScript = factory.newInstance(script.getParams()); + precompiledConditionScript = factory.newInstance(script.getParams(), ctxMapWrapper); } else { // stored script, so will have to compile at runtime precompiledConditionScript = null; @@ -144,9 +147,14 @@ boolean evaluate(IngestDocument ingestDocument) { IngestConditionalScript script = precompiledConditionScript; if (script == null) { IngestConditionalScript.Factory factory = scriptService.compile(condition, IngestConditionalScript.CONTEXT); - script = factory.newInstance(condition.getParams()); + script = factory.newInstance(condition.getParams(), ctxMapWrapper); + } + ctxMapWrapper.setCtxMap(new UnmodifiableIngestData(new DynamicMap(ingestDocument.getSourceAndMetadata(), FUNCTIONS))); + try { + return script.execute(); + } finally { + ctxMapWrapper.clearCtxMap(); } - return script.execute(new UnmodifiableIngestData(new DynamicMap(ingestDocument.getSourceAndMetadata(), FUNCTIONS))); } public Processor getInnerProcessor() { diff --git a/server/src/main/java/org/elasticsearch/script/CtxMapWrapper.java b/server/src/main/java/org/elasticsearch/script/CtxMapWrapper.java new file mode 100644 index 0000000000000..11241064573fe --- /dev/null +++ b/server/src/main/java/org/elasticsearch/script/CtxMapWrapper.java @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.script; + +import java.util.Map; + +/** + * A wrapper for a {@link CtxMap} that allows for ad-hoc setting for script execution. + * This allows for precompilation of scripts that can be executed with different contexts. + * The wrapped {@link CtxMap} should be cleared after use to avoid leaks. + */ +public class CtxMapWrapper { + private Map ctxMap; + + public Map getCtxMap() { + if (ctxMap == null) { + throw new IllegalStateException("CtxMap is not set"); + } + return ctxMap; + } + + public void setCtxMap(Map ctxMap) { + this.ctxMap = ctxMap; + } + + public void clearCtxMap() { + this.ctxMap = null; + } +} diff --git a/server/src/main/java/org/elasticsearch/script/IngestConditionalScript.java b/server/src/main/java/org/elasticsearch/script/IngestConditionalScript.java index 1132264efe7a5..f15e72402dea2 100644 --- a/server/src/main/java/org/elasticsearch/script/IngestConditionalScript.java +++ b/server/src/main/java/org/elasticsearch/script/IngestConditionalScript.java @@ -15,10 +15,12 @@ /** * A script used by {@link org.elasticsearch.ingest.ConditionalProcessor}. + * To properly expose the {@link SourceMapFieldScript#field(String)} API, make sure to provide a valid {@link CtxMap} before execution + * through the {@link CtxMapWrapper} passed to the constructor and make sure to clear it after use to avoid leaks. */ -public abstract class IngestConditionalScript { +public abstract class IngestConditionalScript extends SourceMapFieldScript { - public static final String[] PARAMETERS = { "ctx" }; + public static final String[] PARAMETERS = {}; /** The context used to compile {@link IngestConditionalScript} factories. */ public static final ScriptContext CONTEXT = new ScriptContext<>( @@ -33,7 +35,8 @@ public abstract class IngestConditionalScript { /** The generic runtime parameters for the script. */ private final Map params; - public IngestConditionalScript(Map params) { + public IngestConditionalScript(Map params, CtxMapWrapper ctxMapWrapper) { + super(ctxMapWrapper); this.params = params; } @@ -42,9 +45,9 @@ public Map getParams() { return params; } - public abstract boolean execute(Map ctx); + public abstract boolean execute(); public interface Factory { - IngestConditionalScript newInstance(Map params); + IngestConditionalScript newInstance(Map params, CtxMapWrapper ctxMapWrapper); } } diff --git a/server/src/main/java/org/elasticsearch/script/SourceMapFieldScript.java b/server/src/main/java/org/elasticsearch/script/SourceMapFieldScript.java new file mode 100644 index 0000000000000..2088cb577f4ea --- /dev/null +++ b/server/src/main/java/org/elasticsearch/script/SourceMapFieldScript.java @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.script; + +import org.elasticsearch.script.field.SourceMapField; + +import java.util.Map; + +/** + * Abstract base class for scripts that read field values. + * These scripts provide {@code ctx} for backwards compatibility and expose {@link Metadata}. + */ +public abstract class SourceMapFieldScript { + protected final CtxMapWrapper ctxMapWrapper; + + public SourceMapFieldScript(CtxMapWrapper ctxMapWrapper) { + this.ctxMapWrapper = ctxMapWrapper; + } + + /** Provides backwards compatibility access to ctx */ + public Map getCtx() { + return ctxMapWrapper.getCtxMap(); + } + + public SourceMapField field(String path) { + return new SourceMapField(path, ctxMapWrapper::getCtxMap); + } +} diff --git a/server/src/main/java/org/elasticsearch/script/field/SourceMapField.java b/server/src/main/java/org/elasticsearch/script/field/SourceMapField.java new file mode 100644 index 0000000000000..c2258fa903642 --- /dev/null +++ b/server/src/main/java/org/elasticsearch/script/field/SourceMapField.java @@ -0,0 +1,211 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.script.field; + +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; +import java.util.function.Supplier; + +public class SourceMapField implements Field { + protected String path; + protected Supplier> rootSupplier; + + protected Map container; + protected String leaf; + + protected static final Object MISSING = new Object(); + + public SourceMapField(String path, Supplier> rootSupplier) { + this.path = path; + this.rootSupplier = rootSupplier; + resolveDepthFlat(); + } + + // Path Read + + /** + * Get the path represented by this Field + */ + public String getName() { + return path; + } + + /** + * Does the path exist? + */ + public boolean exists() { + return leaf != null && container.containsKey(leaf); + } + + /** + * Is this path associated with any values? + */ + @Override + public boolean isEmpty() { + return size() == 0; + } + + /** + * How many elements are at the leaf of this path? + */ + @Override + public int size() { + if (leaf == null) { + return 0; + } + + Object value = container.getOrDefault(leaf, MISSING); + if (value == MISSING) { + return 0; + } + + if (value instanceof List list) { + return list.size(); + } + return 1; + } + + /** + * Iterate through all elements of this path with an iterator that cannot mutate the underlying map. + */ + @Override + public Iterator iterator() { + if (leaf == null) { + return Collections.emptyIterator(); + } + + Object value = container.getOrDefault(leaf, MISSING); + if (value == MISSING) { + return Collections.emptyIterator(); + } + + if (value instanceof List list) { + return getListIterator(list); + } + return Collections.singleton(value).iterator(); + } + + /** + * Get an iterator for the given list that cannot mutate the underlying list. Subclasses can override this method to allow for + * mutating iterators. + * @param list the list to get an iterator for + * @return an iterator that cannot mutate the underlying list + */ + @SuppressWarnings("unchecked") + protected Iterator getListIterator(List list) { + return (Iterator) Collections.unmodifiableList(list).iterator(); + } + + /** + * Get the value at this path, if there is no value then get the provided {@param defaultValue} + */ + public Object get(Object defaultValue) { + if (leaf == null) { + return defaultValue; + } + + return container.getOrDefault(leaf, defaultValue); + } + + /** + * Get the value at the given index at this path or {@param defaultValue} if there is no such value. + */ + public Object get(int index, Object defaultValue) { + if (leaf == null) { + return defaultValue; + } + + Object value = container.getOrDefault(leaf, MISSING); + if (value instanceof List list) { + if (index < list.size()) { + return list.get(index); + } + } else if (value != MISSING && index == 0) { + return value; + } + + return defaultValue; + } + + /** + * Is there any value matching {@param predicate} at this path? + */ + public boolean hasValue(Predicate predicate) { + if (leaf == null) { + return false; + } + + Object value = container.getOrDefault(leaf, MISSING); + if (value == MISSING) { + return false; + } + + if (value instanceof List list) { + return list.stream().anyMatch(predicate); + } + + return predicate.test(value); + } + + /** + * Change the path and clear the existing resolution by setting {@link #leaf} and {@link #container} to null. + * Caller needs to re-resolve after this call. + */ + protected void setPath(String path) { + this.path = path; + this.leaf = null; + this.container = null; + } + + /** + * Resolve {@link #path} from the root. + * + * Tries to resolve the path one segment at a time, if the segment is not mapped to a Java Map, then + * treats that segment and the rest as the leaf if it resolves. + * + * a.b.c could be resolved as + * I) ['a']['b']['c'] if 'a' is a Map at the root and 'b' is a Map in 'a', 'c' need not exist in 'b'. + * II) ['a']['b.c'] if 'a' is a Map at the root and 'b' does not exist in 'a's Map but 'b.c' does. + * III) ['a.b.c'] if 'a' doesn't exist at the root but 'a.b.c' does. + * + * {@link #container} and {@link #leaf} and non-null if resolved. + */ + @SuppressWarnings("unchecked") + protected final void resolveDepthFlat() { + container = rootSupplier.get(); + + int index = path.indexOf('.'); + int lastIndex = 0; + String segment; + + while (index != -1) { + segment = path.substring(lastIndex, index); + Object value = container.get(segment); + if (value instanceof Map map) { + container = (Map) map; + lastIndex = index + 1; + index = path.indexOf('.', lastIndex); + } else { + // Check rest of segments as a single key + String rest = path.substring(lastIndex); + if (container.containsKey(rest)) { + leaf = rest; + } else { + leaf = null; + } + return; + } + } + leaf = path.substring(lastIndex); + } +} diff --git a/server/src/main/java/org/elasticsearch/script/field/WriteField.java b/server/src/main/java/org/elasticsearch/script/field/WriteField.java index 2bb062c8d8483..373a512ac865c 100644 --- a/server/src/main/java/org/elasticsearch/script/field/WriteField.java +++ b/server/src/main/java/org/elasticsearch/script/field/WriteField.java @@ -24,35 +24,10 @@ import java.util.function.Predicate; import java.util.function.Supplier; -public final class WriteField implements Field { - private String path; - private Supplier> rootSupplier; - - private Map container; - private String leaf; - - private static final Object MISSING = new Object(); +public final class WriteField extends SourceMapField { public WriteField(String path, Supplier> rootSupplier) { - this.path = path; - this.rootSupplier = rootSupplier; - resolveDepthFlat(); - } - - // Path Read - - /** - * Get the path represented by this Field - */ - public String getName() { - return path; - } - - /** - * Does the path exist? - */ - public boolean exists() { - return leaf != null && container.containsKey(leaf); + super(path, rootSupplier); } // Path Update @@ -225,106 +200,10 @@ public WriteField append(Object value) { return this; } - // Value Read - - /** - * Is this path associated with any values? - */ - @Override - public boolean isEmpty() { - return size() == 0; - } - - /** - * How many elements are at the leaf of this path? - */ - @Override - public int size() { - if (leaf == null) { - return 0; - } - - Object value = container.getOrDefault(leaf, MISSING); - if (value == MISSING) { - return 0; - } - - if (value instanceof List list) { - return list.size(); - } - return 1; - } - - /** - * Iterate through all elements of this path - */ - @Override @SuppressWarnings("unchecked") - public Iterator iterator() { - if (leaf == null) { - return Collections.emptyIterator(); - } - - Object value = container.getOrDefault(leaf, MISSING); - if (value == MISSING) { - return Collections.emptyIterator(); - } - - if (value instanceof List list) { - return (Iterator) list.iterator(); - } - return Collections.singleton(value).iterator(); - } - - /** - * Get the value at this path, if there is no value then get the provided {@param defaultValue} - */ - public Object get(Object defaultValue) { - if (leaf == null) { - return defaultValue; - } - - return container.getOrDefault(leaf, defaultValue); - } - - /** - * Get the value at the given index at this path or {@param defaultValue} if there is no such value. - */ - public Object get(int index, Object defaultValue) { - if (leaf == null) { - return defaultValue; - } - - Object value = container.getOrDefault(leaf, MISSING); - if (value instanceof List list) { - if (index < list.size()) { - return list.get(index); - } - } else if (value != MISSING && index == 0) { - return value; - } - - return defaultValue; - } - - /** - * Is there any value matching {@param predicate} at this path? - */ - public boolean hasValue(Predicate predicate) { - if (leaf == null) { - return false; - } - - Object value = container.getOrDefault(leaf, MISSING); - if (value == MISSING) { - return false; - } - - if (value instanceof List list) { - return list.stream().anyMatch(predicate); - } - - return predicate.test(value); + @Override + protected Iterator getListIterator(List list) { + return (Iterator) list.iterator(); } // Value Update @@ -600,16 +479,6 @@ public void remove() { throw new IllegalStateException("Unexpected value [" + value + "] of type [" + typeName(value) + "] at [" + path + "] for docs()"); } - /** - * Change the path and clear the existing resolution by setting {@link #leaf} and {@link #container} to null. - * Caller needs to re-resolve after this call. - */ - private void setPath(String path) { - this.path = path; - this.leaf = null; - this.container = null; - } - /** * Get the path to a leaf or create it if one does not exist. */ @@ -622,48 +491,6 @@ private void setLeaf() { } } - /** - * Resolve {@link #path} from the root. - * - * Tries to resolve the path one segment at a time, if the segment is not mapped to a Java Map, then - * treats that segment and the rest as the leaf if it resolves. - * - * a.b.c could be resolved as - * I) ['a']['b']['c'] if 'a' is a Map at the root and 'b' is a Map in 'a', 'c' need not exist in 'b'. - * II) ['a']['b.c'] if 'a' is a Map at the root and 'b' does not exist in 'a's Map but 'b.c' does. - * III) ['a.b.c'] if 'a' doesn't exist at the root but 'a.b.c' does. - * - * {@link #container} and {@link #leaf} and non-null if resolved. - */ - @SuppressWarnings("unchecked") - private void resolveDepthFlat() { - container = rootSupplier.get(); - - int index = path.indexOf('.'); - int lastIndex = 0; - String segment; - - while (index != -1) { - segment = path.substring(lastIndex, index); - Object value = container.get(segment); - if (value instanceof Map map) { - container = (Map) map; - lastIndex = index + 1; - index = path.indexOf('.', lastIndex); - } else { - // Check rest of segments as a single key - String rest = path.substring(lastIndex); - if (container.containsKey(rest)) { - leaf = rest; - } else { - leaf = null; - } - return; - } - } - leaf = path.substring(lastIndex); - } - /** * Create a new Map for each segment in path, if that segment is unmapped or mapped to null. * diff --git a/server/src/test/java/org/elasticsearch/ingest/ConditionalProcessorTests.java b/server/src/test/java/org/elasticsearch/ingest/ConditionalProcessorTests.java index fe1300b1d2645..6f1f57bcd67ee 100644 --- a/server/src/test/java/org/elasticsearch/ingest/ConditionalProcessorTests.java +++ b/server/src/test/java/org/elasticsearch/ingest/ConditionalProcessorTests.java @@ -206,9 +206,9 @@ public void testRuntimeCompileError() { if (fail.get()) { throw new ScriptException("bad script", new ParseException("error", 0), List.of(), "", "lang", null); } else { - return params -> new IngestConditionalScript(params) { + return (params, ctxMap) -> new IngestConditionalScript(params, ctxMap) { @Override - public boolean execute(Map ctx) { + public boolean execute() { return false; } }; @@ -226,9 +226,9 @@ public boolean execute(Map ctx) { public void testRuntimeError() { ScriptService scriptService = MockScriptService.singleContext( IngestConditionalScript.CONTEXT, - code -> params -> new IngestConditionalScript(params) { + code -> (params, ctxMapWrapper) -> new IngestConditionalScript(params, ctxMapWrapper) { @Override - public boolean execute(Map ctx) { + public boolean execute() { throw new IllegalArgumentException("runtime problem"); } }, diff --git a/server/src/test/java/org/elasticsearch/script/field/SourceMapFieldTests.java b/server/src/test/java/org/elasticsearch/script/field/SourceMapFieldTests.java new file mode 100644 index 0000000000000..3fad8ff94c5b1 --- /dev/null +++ b/server/src/test/java/org/elasticsearch/script/field/SourceMapFieldTests.java @@ -0,0 +1,169 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.script.field; + +import org.elasticsearch.test.ESTestCase; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public class SourceMapFieldTests extends ESTestCase { + + public void testResolveDepthFlat() { + Map map = new HashMap<>(); + map.put("abc.d.ef", "flat"); + + Map abc = new HashMap<>(); + map.put("abc", abc); + abc.put("d.ef", "mixed"); + + Map d = new HashMap<>(); + abc.put("d", d); + d.put("ef", "nested"); + + // { "abc.d.ef", "flat", "abc": { "d.ef": "mixed", "d": { "ef": "nested" } } } + SourceMapField field = new SourceMapField("abc.d.ef", () -> map); + assertTrue(field.exists()); + + assertEquals("nested", field.get("missing")); + // { "abc.d.ef", "flat", "abc": { "d.ef": "mixed", "d": { } } } + d.remove("ef"); + assertEquals("missing", field.get("missing")); + // { "abc.d.ef", "flat", "abc": { "d.ef": "mixed" } + // TODO(stu): this should be inaccessible + abc.remove("d"); + assertEquals("missing", field.get("missing")); + + // resolution at construction time + field = new SourceMapField("abc.d.ef", () -> map); + assertEquals("mixed", field.get("missing")); + abc.remove("d.ef"); + assertEquals("missing", field.get("missing")); + + field = new SourceMapField("abc.d.ef", () -> map); + // abc is still there + assertEquals("missing", field.get("missing")); + map.remove("abc"); + assertEquals("missing", field.get("missing")); + + field = new SourceMapField("abc.d.ef", () -> map); + assertEquals("flat", field.get("missing")); + } + + public void testExists() { + Map a = new HashMap<>(); + a.put("b.c", null); + assertTrue(new SourceMapField("a.b.c", () -> Map.of("a", a)).exists()); + + a.clear(); + Map level1 = new HashMap<>(); + level1.put("null", null); + a.put("level1", level1); + a.put("null", null); + // SourceMapField.leaf is null + assertFalse(new SourceMapField("missing.leaf", () -> a).exists()); + + // SourceMapField.leaf non-null but missing + assertFalse(new SourceMapField("missing", () -> a).exists()); + + // Check mappings with null values exist + assertTrue(new SourceMapField("level1.null", () -> a).exists()); + assertTrue(new SourceMapField("null", () -> a).exists()); + } + + public void testSizeIsEmpty() { + Map root = new HashMap<>(); + SourceMapField field = new SourceMapField("a.b.c", () -> root); + assertTrue(field.isEmpty()); + assertEquals(0, field.size()); + + root.put("a.b.c", List.of(1, 2)); + field = new SourceMapField("a.b.c", () -> root); + assertFalse(field.isEmpty()); + assertEquals(2, field.size()); + + Map d = new HashMap<>(); + root.put("d", d); + field = new SourceMapField("d.e", () -> root); + assertTrue(field.isEmpty()); + assertEquals(0, field.size()); + d.put("e", "foo"); + assertFalse(field.isEmpty()); + assertEquals(1, field.size()); + } + + public void testIterator() { + Map root = new HashMap<>(); + Map a = new HashMap<>(); + Map b = new HashMap<>(); + a.put("b", b); + root.put("a", a); + + SourceMapField field = new SourceMapField("a.b.c", () -> root); + assertFalse(field.iterator().hasNext()); + + b.put("c", "value"); + Iterator it = field.iterator(); + assertTrue(it.hasNext()); + assertEquals("value", it.next()); + assertFalse(it.hasNext()); + + b.put("c", List.of(1, 2, 3)); + it = field.iterator(); + assertTrue(it.hasNext()); + assertEquals(1, it.next()); + assertTrue(it.hasNext()); + assertEquals(2, it.next()); + assertTrue(it.hasNext()); + assertEquals(3, it.next()); + assertFalse(it.hasNext()); + + assertFalse(new SourceMapField("dne.dne", () -> root).iterator().hasNext()); + } + + @SuppressWarnings("unchecked") + public void testHasValue() { + Map root = new HashMap<>(); + Map a = new HashMap<>(); + Map b = new HashMap<>(); + a.put("b", b); + root.put("a", a); + b.put("c", new ArrayList<>(List.of(10, 11, 12))); + SourceMapField field = new SourceMapField("a.b.c", () -> root); + assertFalse(field.hasValue(v -> (Integer) v < 10)); + assertTrue(field.hasValue(v -> (Integer) v <= 10)); + + root.clear(); + a.clear(); + a.put("null", null); + a.put("b", List.of(1, 2, 3, 4)); + root.put("a", a); + field = new SourceMapField("a.b", () -> root); + assertTrue(field.hasValue(x -> (Integer) x % 2 == 0)); + assertFalse(field.hasValue(x -> (Integer) x > 4)); + assertFalse(new SourceMapField("d.e", () -> root).hasValue(Objects::isNull)); + assertTrue(new SourceMapField("a.null", () -> root).hasValue(Objects::isNull)); + assertFalse(new SourceMapField("a.null2", () -> root).hasValue(Objects::isNull)); + } + + public void testGetIndex() { + Map root = new HashMap<>(); + root.put("a", Map.of("b", List.of(1, 2, 3, 5), "c", "foo")); + SourceMapField field = new SourceMapField("a.b", () -> root); + assertEquals(5, field.get(3, 100)); + assertEquals(100, new SourceMapField("c.d", () -> root).get(3, 100)); + assertEquals("bar", new SourceMapField("a.c", () -> root).get(1, "bar")); + assertEquals("foo", new SourceMapField("a.c", () -> root).get(0, "bar")); + } +} diff --git a/server/src/test/java/org/elasticsearch/script/field/WriteFieldTests.java b/server/src/test/java/org/elasticsearch/script/field/WriteFieldTests.java index d50de79f8d84d..39576a7564124 100644 --- a/server/src/test/java/org/elasticsearch/script/field/WriteFieldTests.java +++ b/server/src/test/java/org/elasticsearch/script/field/WriteFieldTests.java @@ -19,7 +19,6 @@ import java.util.List; import java.util.Map; import java.util.NoSuchElementException; -import java.util.Objects; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.equalTo; @@ -29,68 +28,6 @@ public class WriteFieldTests extends ESTestCase { - public void testResolveDepthFlat() { - Map map = new HashMap<>(); - map.put("abc.d.ef", "flat"); - - Map abc = new HashMap<>(); - map.put("abc", abc); - abc.put("d.ef", "mixed"); - - Map d = new HashMap<>(); - abc.put("d", d); - d.put("ef", "nested"); - - // { "abc.d.ef", "flat", "abc": { "d.ef": "mixed", "d": { "ef": "nested" } } } - WriteField wf = new WriteField("abc.d.ef", () -> map); - assertTrue(wf.exists()); - - assertEquals("nested", wf.get("missing")); - // { "abc.d.ef", "flat", "abc": { "d.ef": "mixed", "d": { } } } - d.remove("ef"); - assertEquals("missing", wf.get("missing")); - // { "abc.d.ef", "flat", "abc": { "d.ef": "mixed" } - // TODO(stu): this should be inaccessible - abc.remove("d"); - assertEquals("missing", wf.get("missing")); - - // resolution at construction time - wf = new WriteField("abc.d.ef", () -> map); - assertEquals("mixed", wf.get("missing")); - abc.remove("d.ef"); - assertEquals("missing", wf.get("missing")); - - wf = new WriteField("abc.d.ef", () -> map); - // abc is still there - assertEquals("missing", wf.get("missing")); - map.remove("abc"); - assertEquals("missing", wf.get("missing")); - - wf = new WriteField("abc.d.ef", () -> map); - assertEquals("flat", wf.get("missing")); - } - - public void testExists() { - Map a = new HashMap<>(); - a.put("b.c", null); - assertTrue(new WriteField("a.b.c", () -> Map.of("a", a)).exists()); - - a.clear(); - Map level1 = new HashMap<>(); - level1.put("null", null); - a.put("level1", level1); - a.put("null", null); - // WriteField.leaf is null - assertFalse(new WriteField("missing.leaf", () -> a).exists()); - - // WriteField.leaf non-null but missing - assertFalse(new WriteField("missing", () -> a).exists()); - - // Check mappings with null values exist - assertTrue(new WriteField("level1.null", () -> a).exists()); - assertTrue(new WriteField("null", () -> a).exists()); - } - public void testMoveString() { String src = "a.b.c"; String dst = "d.e.f"; @@ -397,56 +334,6 @@ public void testAppend() { assertEquals(new ArrayList<>(List.of("bar")), b.get("c")); } - public void testSizeIsEmpty() { - Map root = new HashMap<>(); - WriteField wf = new WriteField("a.b.c", () -> root); - assertTrue(wf.isEmpty()); - assertEquals(0, wf.size()); - - root.put("a.b.c", List.of(1, 2)); - wf = new WriteField("a.b.c", () -> root); - assertFalse(wf.isEmpty()); - assertEquals(2, wf.size()); - - Map d = new HashMap<>(); - root.put("d", d); - wf = new WriteField("d.e", () -> root); - assertTrue(wf.isEmpty()); - assertEquals(0, wf.size()); - d.put("e", "foo"); - assertFalse(wf.isEmpty()); - assertEquals(1, wf.size()); - } - - public void testIterator() { - Map root = new HashMap<>(); - Map a = new HashMap<>(); - Map b = new HashMap<>(); - a.put("b", b); - root.put("a", a); - - WriteField wf = new WriteField("a.b.c", () -> root); - assertFalse(wf.iterator().hasNext()); - - b.put("c", "value"); - Iterator it = wf.iterator(); - assertTrue(it.hasNext()); - assertEquals("value", it.next()); - assertFalse(it.hasNext()); - - b.put("c", List.of(1, 2, 3)); - it = wf.iterator(); - assertTrue(it.hasNext()); - assertEquals(1, it.next()); - assertTrue(it.hasNext()); - assertEquals(2, it.next()); - assertTrue(it.hasNext()); - assertEquals(3, it.next()); - assertFalse(it.hasNext()); - - assertFalse(new WriteField("dne.dne", () -> root).iterator().hasNext()); - } - @SuppressWarnings("unchecked") public void testDeduplicate() { Map root = new HashMap<>(); @@ -529,42 +416,6 @@ public void testRemoveValuesIf() { assertNull(wf.get(null)); } - public void testHasValue() { - Map root = new HashMap<>(); - Map a = new HashMap<>(); - Map b = new HashMap<>(); - a.put("b", b); - root.put("a", a); - b.put("c", new ArrayList<>(List.of(10, 11, 12))); - WriteField wf = new WriteField("a.b.c", () -> root); - assertFalse(wf.hasValue(v -> (Integer) v < 10)); - assertTrue(wf.hasValue(v -> (Integer) v <= 10)); - wf.append(9); - assertTrue(wf.hasValue(v -> (Integer) v < 10)); - - root.clear(); - a.clear(); - a.put("null", null); - a.put("b", List.of(1, 2, 3, 4)); - root.put("a", a); - wf = new WriteField("a.b", () -> root); - assertTrue(wf.hasValue(x -> (Integer) x % 2 == 0)); - assertFalse(wf.hasValue(x -> (Integer) x > 4)); - assertFalse(new WriteField("d.e", () -> root).hasValue(Objects::isNull)); - assertTrue(new WriteField("a.null", () -> root).hasValue(Objects::isNull)); - assertFalse(new WriteField("a.null2", () -> root).hasValue(Objects::isNull)); - } - - public void testGetIndex() { - Map root = new HashMap<>(); - root.put("a", Map.of("b", List.of(1, 2, 3, 5), "c", "foo")); - WriteField wf = new WriteField("a.b", () -> root); - assertEquals(5, wf.get(3, 100)); - assertEquals(100, new WriteField("c.d", () -> root).get(3, 100)); - assertEquals("bar", new WriteField("a.c", () -> root).get(1, "bar")); - assertEquals("foo", new WriteField("a.c", () -> root).get(0, "bar")); - } - @SuppressWarnings("unchecked") public void testDoc() { Map root = new HashMap<>(); diff --git a/test/framework/src/main/java/org/elasticsearch/script/MockScriptEngine.java b/test/framework/src/main/java/org/elasticsearch/script/MockScriptEngine.java index 24d46b99b541b..94828ef147898 100644 --- a/test/framework/src/main/java/org/elasticsearch/script/MockScriptEngine.java +++ b/test/framework/src/main/java/org/elasticsearch/script/MockScriptEngine.java @@ -169,10 +169,13 @@ public void execute() { } else if (context.instanceClazz.equals(AggregationScript.class)) { return context.factoryClazz.cast(new MockAggregationScript(script)); } else if (context.instanceClazz.equals(IngestConditionalScript.class)) { - IngestConditionalScript.Factory factory = parameters -> new IngestConditionalScript(parameters) { + IngestConditionalScript.Factory factory = (parameters, ctxMapWrapper) -> new IngestConditionalScript( + parameters, + ctxMapWrapper + ) { @Override - public boolean execute(Map ctx) { - return (boolean) script.apply(ctx); + public boolean execute() { + return (boolean) script.apply(ctxMapWrapper.getCtxMap()); } }; return context.factoryClazz.cast(factory);