diff --git a/qa/smoke-test-watcher-with-shield/pom.xml b/qa/smoke-test-watcher-with-shield/pom.xml
index 6a327ebe5ed..4e439668e08 100644
--- a/qa/smoke-test-watcher-with-shield/pom.xml
+++ b/qa/smoke-test-watcher-with-shield/pom.xml
@@ -37,7 +37,7 @@
${project.basedir}/integration-tests.xml
false
license,shield,watcher
- hijack/10_basic/*
+ hijack/10_basic/*,array_compare_watch/10_basic/Basic array_compare watch
diff --git a/watcher/docs/reference/condition.asciidoc b/watcher/docs/reference/condition.asciidoc
index 8a9c5ca41a8..534aeef9d64 100644
--- a/watcher/docs/reference/condition.asciidoc
+++ b/watcher/docs/reference/condition.asciidoc
@@ -8,8 +8,8 @@ Watcher supports four condition types: <>, <> and <> conditions can use the data in
-the payload to determine whether or not the necessary conditions have been met.
+The <>, <> and <>
+conditions can use the data in the payload to determine whether or not the necessary conditions have been met.
include::condition/always.asciidoc[]
@@ -18,3 +18,5 @@ include::condition/never.asciidoc[]
include::condition/script.asciidoc[]
include::condition/compare.asciidoc[]
+
+include::condition/array-compare.asciidoc[]
diff --git a/watcher/docs/reference/condition/array-compare.asciidoc b/watcher/docs/reference/condition/array-compare.asciidoc
new file mode 100644
index 00000000000..dc885a83402
--- /dev/null
+++ b/watcher/docs/reference/condition/array-compare.asciidoc
@@ -0,0 +1,68 @@
+[[array-condition-compare]]
+==== Array Compare Condition
+
+A watch <> that compares an array of values in the <>
+to a given value. The values in the model are identified by a path within that model.
+
+==== Using an Array Compare Condition
+
+The following snippet configures an `array_compare` condition that returns `true` if there is at least one bucket in the
+aggregations buckets that has a `doc_count` higher than or equal to 25:
+
+[source,json]
+--------------------------------------------------
+{
+ ...
+
+ "condition": {
+ "array_compare": {
+ "ctx.payload.aggregations.top_tweeters.buckets" : { <1>
+ "path": "doc_count" <2>,
+ "gte": { <3>
+ "value": 25, <4>
+ "quantifier": "some" <5>
+ }
+ }
+ }
+ }
+ ...
+}
+--------------------------------------------------
+<1> The field name is the path to the array (array path) in the execution context model
+<2> The value of the field `path` (here `doc_count`) is the path to the value for each element of the array that the
+comparison operator will be applied to
+<3> The field name (here `gte`) is the name of the comparison operator
+<4> The value of the field `value` in the comparison operator object is the comparison value
+<5> The value of the field `quantifier` (`all` or `some`) specifies whether the comparison must be true for all or for
+at least one of the values to evaluate the comparison to true
+
+NOTE: The `path` element is optional and will default to `""` if not specified.
+
+NOTE: The `quantifier` element is optional and will default to `"some"` if not specified.
+
+The array path is a "dot-notation" expression that can reference the following variables in the watch context:
+
+[options="header"]
+|======
+| Name | Description
+| `ctx.metadata.*` | Any metadata associated with the watch.
+| `ctx.payload.*` | The payload data loaded by the watch's input.
+|======
+
+This array path must resolve to an array.
+
+The comparison operator can be any one of the operators from [[condition-compare]].
+
+The quantifier operator can be any one of the following:
+
+[options="header"]
+|======
+| Name | Description
+| `all` | Returns `true` when the resolved value compares `true` according to the comparison operator for all the elements in the array
+| `some` | Returns `true` when the resolved value compares `true` according to the comparison operator for at least one element in the array
+|======
+
+NOTE: If the array is empty, `all` causes the comparison operator to evaluate to `true` and `some` causes the comparison
+operator to evaluate to `false`.
+
+NOTE: It is also possible to use date math expressions and values in the context model as in [[condition-compare]].
diff --git a/watcher/rest-api-spec/test/array_compare_watch/10_basic.yaml b/watcher/rest-api-spec/test/array_compare_watch/10_basic.yaml
new file mode 100644
index 00000000000..0538ba869a4
--- /dev/null
+++ b/watcher/rest-api-spec/test/array_compare_watch/10_basic.yaml
@@ -0,0 +1,148 @@
+---
+"Basic array_compare watch":
+ - do:
+ cluster.health:
+ wait_for_status: green
+
+
+ - do: {watcher.stats:{}}
+ - match: { "watcher_state": "started" }
+ - match: { "watch_count": 0 }
+
+ - do:
+ watcher.put_watch:
+ id: "array-compare-watch"
+ body: >
+ {
+ "trigger": {
+ "schedule": {
+ "interval": "1s"
+ }
+ },
+ "input": {
+ "search": {
+ "request": {
+ "indices": [ "test_1" ],
+ "body": {
+ "query": {
+ "filtered": {
+ "query": {
+ "match_all": {}
+ }
+ }
+ },
+ "aggs": {
+ "top_foos": {
+ "terms": {
+ "field": "foo",
+ "size": 1
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "condition": {
+ "array_compare": {
+ "ctx.payload.aggregations.top_foos.buckets": {
+ "path": "doc_count",
+ "gte": {
+ "value": 3,
+ "quantifier": "some"
+ }
+ }
+ }
+ },
+ "actions": {
+ "log": {
+ "logging": {
+ "text": "executed at {{ctx.execution_time}}"
+ }
+ }
+ }
+ }
+ - match: { _id: "array-compare-watch" }
+ - match: { created: true }
+
+ - do:
+ index:
+ index: test_1
+ type: test
+ id: 1
+ body: { foo: bar }
+
+ - do:
+ index:
+ index: test_1
+ type: test
+ id: 2
+ body: { foo: bar }
+
+ - do:
+ index:
+ index: test_1
+ type: test
+ id: 3
+ body: { foo: bar }
+
+ - do:
+ index:
+ index: test_1
+ type: test
+ id: 4
+ body: { foo: baz }
+ - do:
+ indices.refresh: {}
+
+ - do: {watcher.stats:{}}
+ - match: { "watch_count": 1 }
+
+ # Simulate a Thread.sleep()
+ - do:
+ catch: request_timeout
+ cluster.health:
+ wait_for_nodes: 99
+ timeout: 10s
+ - match: { "timed_out": true }
+
+ - do:
+ indices.refresh:
+ index: .watch_history-*
+
+ - do:
+ search:
+ index: .watch_history-*
+ body: >
+ {
+ "query": {
+ "bool": {
+ "must" : [
+ {
+ "term": {
+ "watch_id": {
+ "value": "array-compare-watch"
+ }
+ }
+ },
+ {
+ "term": {
+ "result.condition.met": {
+ "value": "true"
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ - gte: { hits.total: 1 }
+
+ - do:
+ watcher.delete_watch:
+ id: "array-compare-watch"
+ - match: { found: true }
+
+
+ - do: {watcher.stats:{}}
+ - match: { "watch_count": 0 }
diff --git a/watcher/src/main/java/org/elasticsearch/watcher/condition/ConditionBuilders.java b/watcher/src/main/java/org/elasticsearch/watcher/condition/ConditionBuilders.java
index f21ef41a13f..0daf985521b 100644
--- a/watcher/src/main/java/org/elasticsearch/watcher/condition/ConditionBuilders.java
+++ b/watcher/src/main/java/org/elasticsearch/watcher/condition/ConditionBuilders.java
@@ -6,6 +6,7 @@
package org.elasticsearch.watcher.condition;
import org.elasticsearch.watcher.condition.always.AlwaysCondition;
+import org.elasticsearch.watcher.condition.compare.array.ArrayCompareCondition;
import org.elasticsearch.watcher.condition.never.NeverCondition;
import org.elasticsearch.watcher.condition.script.ScriptCondition;
import org.elasticsearch.watcher.support.Script;
@@ -37,4 +38,8 @@ public final class ConditionBuilders {
public static ScriptCondition.Builder scriptCondition(Script script) {
return ScriptCondition.builder(script);
}
+
+ public static ArrayCompareCondition.Builder arrayCompareCondition(String arrayPath, String path, ArrayCompareCondition.Op op, Object value, ArrayCompareCondition.Quantifier quantifier) {
+ return ArrayCompareCondition.builder(arrayPath, path, op, value, quantifier);
+ }
}
diff --git a/watcher/src/main/java/org/elasticsearch/watcher/condition/ConditionModule.java b/watcher/src/main/java/org/elasticsearch/watcher/condition/ConditionModule.java
index 67b3d460fad..adcebe726df 100644
--- a/watcher/src/main/java/org/elasticsearch/watcher/condition/ConditionModule.java
+++ b/watcher/src/main/java/org/elasticsearch/watcher/condition/ConditionModule.java
@@ -11,6 +11,8 @@ import org.elasticsearch.watcher.condition.always.AlwaysCondition;
import org.elasticsearch.watcher.condition.always.AlwaysConditionFactory;
import org.elasticsearch.watcher.condition.compare.CompareCondition;
import org.elasticsearch.watcher.condition.compare.CompareConditionFactory;
+import org.elasticsearch.watcher.condition.compare.array.ArrayCompareCondition;
+import org.elasticsearch.watcher.condition.compare.array.ArrayCompareConditionFactory;
import org.elasticsearch.watcher.condition.never.NeverCondition;
import org.elasticsearch.watcher.condition.never.NeverConditionFactory;
import org.elasticsearch.watcher.condition.script.ScriptCondition;
@@ -47,6 +49,9 @@ public class ConditionModule extends AbstractModule {
bind(CompareConditionFactory.class).asEagerSingleton();
factoriesBinder.addBinding(CompareCondition.TYPE).to(CompareConditionFactory.class);
+ bind(ArrayCompareConditionFactory.class).asEagerSingleton();
+ factoriesBinder.addBinding(ArrayCompareCondition.TYPE).to(ArrayCompareConditionFactory.class);
+
for (Map.Entry> entry : factories.entrySet()) {
bind(entry.getValue()).asEagerSingleton();
factoriesBinder.addBinding(entry.getKey()).to(entry.getValue());
diff --git a/watcher/src/main/java/org/elasticsearch/watcher/condition/compare/AbstractExecutableCompareCondition.java b/watcher/src/main/java/org/elasticsearch/watcher/condition/compare/AbstractExecutableCompareCondition.java
new file mode 100644
index 00000000000..87b01b7cef1
--- /dev/null
+++ b/watcher/src/main/java/org/elasticsearch/watcher/condition/compare/AbstractExecutableCompareCondition.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+package org.elasticsearch.watcher.condition.compare;
+
+import org.elasticsearch.common.logging.ESLogger;
+import org.elasticsearch.watcher.condition.Condition;
+import org.elasticsearch.watcher.condition.ExecutableCondition;
+import org.elasticsearch.watcher.execution.WatchExecutionContext;
+import org.elasticsearch.watcher.support.Variables;
+import org.elasticsearch.watcher.support.WatcherDateTimeUtils;
+import org.elasticsearch.watcher.support.clock.Clock;
+import org.elasticsearch.watcher.support.xcontent.ObjectPath;
+import org.joda.time.DateTime;
+import org.joda.time.DateTimeZone;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public abstract class AbstractExecutableCompareCondition extends ExecutableCondition {
+ static final Pattern DATE_MATH_PATTERN = Pattern.compile("<\\{(.+)\\}>");
+ static final Pattern PATH_PATTERN = Pattern.compile("\\{\\{(.+)\\}\\}");
+
+ private final Clock clock;
+
+ public AbstractExecutableCompareCondition(C condition, ESLogger logger, Clock clock) {
+ super(condition, logger);
+ this.clock = clock;
+ }
+
+ @Override
+ public R execute(WatchExecutionContext ctx) {
+ Map resolvedValues = new HashMap<>();
+ try {
+ Map model = Variables.createCtxModel(ctx, ctx.payload());
+ return doExecute(model, resolvedValues);
+ } catch (Exception e) {
+ logger.error("failed to execute [{}] condition for [{}]", e, type(), ctx.id());
+ if (resolvedValues.isEmpty()) {
+ resolvedValues = null;
+ }
+ return doFailure(resolvedValues, e);
+ }
+ }
+
+ protected Object resolveConfiguredValue(Map resolvedValues, Map model, Object configuredValue) {
+ if (configuredValue instanceof String) {
+
+ // checking if the given value is a date math expression
+ Matcher matcher = DATE_MATH_PATTERN.matcher((String) configuredValue);
+ if (matcher.matches()) {
+ String dateMath = matcher.group(1);
+ configuredValue = WatcherDateTimeUtils.parseDateMath(dateMath, DateTimeZone.UTC, clock);
+ resolvedValues.put(dateMath, WatcherDateTimeUtils.formatDate((DateTime) configuredValue));
+ } else {
+ // checking if the given value is a path expression
+ matcher = PATH_PATTERN.matcher((String) configuredValue);
+ if (matcher.matches()) {
+ String configuredPath = matcher.group(1);
+ configuredValue = ObjectPath.eval(configuredPath, model);
+ resolvedValues.put(configuredPath, configuredValue);
+ }
+ }
+ }
+ return configuredValue;
+ }
+
+ protected abstract R doExecute(Map model, Map resolvedValues) throws Exception;
+
+ protected abstract R doFailure(Map resolvedValues, Exception e);
+}
diff --git a/watcher/src/main/java/org/elasticsearch/watcher/condition/compare/CompareCondition.java b/watcher/src/main/java/org/elasticsearch/watcher/condition/compare/CompareCondition.java
index 39a9b07ec41..6248c44babc 100644
--- a/watcher/src/main/java/org/elasticsearch/watcher/condition/compare/CompareCondition.java
+++ b/watcher/src/main/java/org/elasticsearch/watcher/condition/compare/CompareCondition.java
@@ -11,15 +11,11 @@ import org.elasticsearch.common.ParseField;
import org.elasticsearch.common.xcontent.XContentBuilder;
import org.elasticsearch.common.xcontent.XContentParser;
import org.elasticsearch.watcher.condition.Condition;
-import org.elasticsearch.watcher.support.WatcherDateTimeUtils;
import org.elasticsearch.watcher.support.xcontent.WatcherXContentUtils;
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeZone;
import java.io.IOException;
import java.util.Locale;
import java.util.Map;
-import java.util.Objects;
/**
*
@@ -134,9 +130,9 @@ public class CompareCondition implements Condition {
this.resolveValues = resolveValues;
}
- Result(@Nullable Map resolveValues, Exception e) {
+ Result(@Nullable Map resolvedValues, Exception e) {
super(TYPE, e);
- this.resolveValues = resolveValues;
+ this.resolveValues = resolvedValues;
}
public Map getResolveValues() {
@@ -159,7 +155,7 @@ public class CompareCondition implements Condition {
EQ() {
@Override
public boolean eval(Object v1, Object v2) {
- Integer compVal = compare(v1, v2);
+ Integer compVal = LenientCompare.compare(v1, v2);
return compVal != null && compVal == 0;
}
@@ -171,7 +167,7 @@ public class CompareCondition implements Condition {
NOT_EQ() {
@Override
public boolean eval(Object v1, Object v2) {
- Integer compVal = compare(v1, v2);
+ Integer compVal = LenientCompare.compare(v1, v2);
return compVal == null || compVal != 0;
}
@@ -183,28 +179,28 @@ public class CompareCondition implements Condition {
LT() {
@Override
public boolean eval(Object v1, Object v2) {
- Integer compVal = compare(v1, v2);
+ Integer compVal = LenientCompare.compare(v1, v2);
return compVal != null && compVal < 0;
}
},
LTE() {
@Override
public boolean eval(Object v1, Object v2) {
- Integer compVal = compare(v1, v2);
+ Integer compVal = LenientCompare.compare(v1, v2);
return compVal != null && compVal <= 0;
}
},
GT() {
@Override
public boolean eval(Object v1, Object v2) {
- Integer compVal = compare(v1, v2);
+ Integer compVal = LenientCompare.compare(v1, v2);
return compVal != null && compVal > 0;
}
},
GTE() {
@Override
public boolean eval(Object v1, Object v2) {
- Integer compVal = compare(v1, v2);
+ Integer compVal = LenientCompare.compare(v1, v2);
return compVal != null && compVal >= 0;
}
};
@@ -215,73 +211,6 @@ public class CompareCondition implements Condition {
return false;
}
- // this method performs lenient comparison, potentially between different types. The second argument
- // type (v2) determines the type of comparison (this is because the second argument is configured by the
- // user while the first argument is the dynamic path that is evaluated at runtime. That is, if the user configures
- // a number, it expects a number, therefore the comparison will be based on numeric comparison). If the
- // comparison is numeric, other types (e.g. strings) will converted to numbers if possible, if not, the comparison
- // will fail and `false` will be returned.
- //
- // may return `null` indicating v1 simply doesn't equal v2 (without any order association)
- static Integer compare(Object v1, Object v2) {
- if (Objects.equals(v1, v2)) {
- return 0;
- }
- if (v1 == null || v2 == null) {
- return null;
- }
-
- // special case for numbers. If v1 is not a number, we'll try to convert it to a number
- if (v2 instanceof Number) {
- if (!(v1 instanceof Number)) {
- try {
- v1 = Double.valueOf(String.valueOf(v1));
- } catch (NumberFormatException nfe) {
- // could not convert to number
- return null;
- }
- }
- return ((Number) v1).doubleValue() > ((Number) v2).doubleValue() ? 1 :
- ((Number) v1).doubleValue() < ((Number) v2).doubleValue() ? -1 : 0;
- }
-
- // special case for strings. If v1 is not a string, we'll convert it to a string
- if (v2 instanceof String) {
- v1 = String.valueOf(v1);
- return ((String) v1).compareTo((String) v2);
- }
-
- // special case for date/times. If v1 is not a dateTime, we'll try to convert it to a datetime
- if (v2 instanceof DateTime) {
- if (v1 instanceof DateTime) {
- return ((DateTime) v1).compareTo((DateTime) v2);
- }
- if (v1 instanceof String) {
- try {
- v1 = WatcherDateTimeUtils.parseDate((String) v1);
- } catch (Exception e) {
- return null;
- }
- } else if (v1 instanceof Number){
- v1 = new DateTime(((Number) v1).longValue(), DateTimeZone.UTC);
- } else {
- // cannot convert to date...
- return null;
- }
- return ((DateTime) v1).compareTo((DateTime) v2);
- }
-
- if (v1.getClass() != v2.getClass() || Comparable.class.isAssignableFrom(v1.getClass())) {
- return null;
- }
-
- try {
- return ((Comparable) v1).compareTo(v2);
- } catch (Exception e) {
- return null;
- }
- }
-
public String id() {
return name().toLowerCase(Locale.ROOT);
}
diff --git a/watcher/src/main/java/org/elasticsearch/watcher/condition/compare/ExecutableCompareCondition.java b/watcher/src/main/java/org/elasticsearch/watcher/condition/compare/ExecutableCompareCondition.java
index 1f856705a45..90fd7a4a57c 100644
--- a/watcher/src/main/java/org/elasticsearch/watcher/condition/compare/ExecutableCompareCondition.java
+++ b/watcher/src/main/java/org/elasticsearch/watcher/condition/compare/ExecutableCompareCondition.java
@@ -5,81 +5,33 @@
*/
package org.elasticsearch.watcher.condition.compare;
-import org.joda.time.DateTime;
import org.elasticsearch.common.logging.ESLogger;
-import org.elasticsearch.watcher.actions.email.DataAttachment;
-import org.elasticsearch.watcher.condition.ExecutableCondition;
-import org.elasticsearch.watcher.execution.WatchExecutionContext;
-import org.elasticsearch.watcher.support.Variables;
-import org.elasticsearch.watcher.support.WatcherDateTimeUtils;
import org.elasticsearch.watcher.support.clock.Clock;
import org.elasticsearch.watcher.support.xcontent.ObjectPath;
-import org.joda.time.DateTimeZone;
-import java.io.IOException;
-import java.util.HashMap;
import java.util.Map;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
/**
*
*/
-public class ExecutableCompareCondition extends ExecutableCondition {
-
- static final Pattern DATE_MATH_PATTERN = Pattern.compile("<\\{(.+)\\}>");
- static final Pattern PATH_PATTERN = Pattern.compile("\\{\\{(.+)\\}\\}");
-
-
- private final Clock clock;
-
+public class ExecutableCompareCondition extends AbstractExecutableCompareCondition {
public ExecutableCompareCondition(CompareCondition condition, ESLogger logger, Clock clock) {
- super(condition, logger);
- this.clock = clock;
+ super(condition, logger, clock);
}
@Override
- public CompareCondition.Result execute(WatchExecutionContext ctx) {
- Map resolvedValues = new HashMap<>();
- try {
- return doExecute(ctx, resolvedValues);
- } catch (Exception e) {
- logger.error("failed to execute [{}] condition for [{}]", e, CompareCondition.TYPE, ctx.id());
- if (resolvedValues.isEmpty()) {
- resolvedValues = null;
- }
- return new CompareCondition.Result(resolvedValues, e);
- }
- }
-
- public CompareCondition.Result doExecute(WatchExecutionContext ctx, Map resolvedValues) throws Exception {
- Map model = Variables.createCtxModel(ctx, ctx.payload());
-
- Object configuredValue = condition.getValue();
-
- if (configuredValue instanceof String) {
-
- // checking if the given value is a date math expression
- Matcher matcher = DATE_MATH_PATTERN.matcher((String) configuredValue);
- if (matcher.matches()) {
- String dateMath = matcher.group(1);
- configuredValue = WatcherDateTimeUtils.parseDateMath(dateMath, DateTimeZone.UTC, clock);
- resolvedValues.put(dateMath, WatcherDateTimeUtils.formatDate((DateTime) configuredValue));
- } else {
- // checking if the given value is a path expression
- matcher = PATH_PATTERN.matcher((String) configuredValue);
- if (matcher.matches()) {
- String configuredPath = matcher.group(1);
- configuredValue = ObjectPath.eval(configuredPath, model);
- resolvedValues.put(configuredPath, configuredValue);
- }
- }
- }
+ protected CompareCondition.Result doExecute(Map model, Map resolvedValues) throws Exception {
+ Object configuredValue = resolveConfiguredValue(resolvedValues, model, condition.getValue());
Object resolvedValue = ObjectPath.eval(condition.getPath(), model);
resolvedValues.put(condition.getPath(), resolvedValue);
return new CompareCondition.Result(resolvedValues, condition.getOp().eval(resolvedValue, configuredValue));
}
+
+ @Override
+ protected CompareCondition.Result doFailure(Map resolvedValues, Exception e) {
+ return new CompareCondition.Result(resolvedValues, e);
+ }
}
diff --git a/watcher/src/main/java/org/elasticsearch/watcher/condition/compare/LenientCompare.java b/watcher/src/main/java/org/elasticsearch/watcher/condition/compare/LenientCompare.java
new file mode 100644
index 00000000000..9419eeb865c
--- /dev/null
+++ b/watcher/src/main/java/org/elasticsearch/watcher/condition/compare/LenientCompare.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+package org.elasticsearch.watcher.condition.compare;
+
+import org.elasticsearch.watcher.support.WatcherDateTimeUtils;
+import org.joda.time.DateTime;
+import org.joda.time.DateTimeZone;
+
+import java.util.Objects;
+
+public class LenientCompare {
+ // this method performs lenient comparison, potentially between different types. The second argument
+ // type (v2) determines the type of comparison (this is because the second argument is configured by the
+ // user while the first argument is the dynamic path that is evaluated at runtime. That is, if the user configures
+ // a number, it expects a number, therefore the comparison will be based on numeric comparison). If the
+ // comparison is numeric, other types (e.g. strings) will converted to numbers if possible, if not, the comparison
+ // will fail and `false` will be returned.
+ //
+ // may return `null` indicating v1 simply doesn't equal v2 (without any order association)
+ public static Integer compare(Object v1, Object v2) {
+ if (Objects.equals(v1, v2)) {
+ return 0;
+ }
+ if (v1 == null || v2 == null) {
+ return null;
+ }
+
+ // special case for numbers. If v1 is not a number, we'll try to convert it to a number
+ if (v2 instanceof Number) {
+ if (!(v1 instanceof Number)) {
+ try {
+ v1 = Double.valueOf(String.valueOf(v1));
+ } catch (NumberFormatException nfe) {
+ // could not convert to number
+ return null;
+ }
+ }
+ return ((Number) v1).doubleValue() > ((Number) v2).doubleValue() ? 1 :
+ ((Number) v1).doubleValue() < ((Number) v2).doubleValue() ? -1 : 0;
+ }
+
+ // special case for strings. If v1 is not a string, we'll convert it to a string
+ if (v2 instanceof String) {
+ v1 = String.valueOf(v1);
+ return ((String) v1).compareTo((String) v2);
+ }
+
+ // special case for date/times. If v1 is not a dateTime, we'll try to convert it to a datetime
+ if (v2 instanceof DateTime) {
+ if (v1 instanceof DateTime) {
+ return ((DateTime) v1).compareTo((DateTime) v2);
+ }
+ if (v1 instanceof String) {
+ try {
+ v1 = WatcherDateTimeUtils.parseDate((String) v1);
+ } catch (Exception e) {
+ return null;
+ }
+ } else if (v1 instanceof Number) {
+ v1 = new DateTime(((Number) v1).longValue(), DateTimeZone.UTC);
+ } else {
+ // cannot convert to date...
+ return null;
+ }
+ return ((DateTime) v1).compareTo((DateTime) v2);
+ }
+
+ if (v1.getClass() != v2.getClass() || Comparable.class.isAssignableFrom(v1.getClass())) {
+ return null;
+ }
+
+ try {
+ return ((Comparable) v1).compareTo(v2);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+}
diff --git a/watcher/src/main/java/org/elasticsearch/watcher/condition/compare/array/ArrayCompareCondition.java b/watcher/src/main/java/org/elasticsearch/watcher/condition/compare/array/ArrayCompareCondition.java
new file mode 100644
index 00000000000..fcbbf6a45ed
--- /dev/null
+++ b/watcher/src/main/java/org/elasticsearch/watcher/condition/compare/array/ArrayCompareCondition.java
@@ -0,0 +1,347 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+package org.elasticsearch.watcher.condition.compare.array;
+
+import org.elasticsearch.ElasticsearchParseException;
+import org.elasticsearch.common.Nullable;
+import org.elasticsearch.common.ParseField;
+import org.elasticsearch.common.ParseFieldMatcher;
+import org.elasticsearch.common.xcontent.XContentBuilder;
+import org.elasticsearch.common.xcontent.XContentParser;
+import org.elasticsearch.watcher.condition.Condition;
+import org.elasticsearch.watcher.condition.compare.LenientCompare;
+import org.elasticsearch.watcher.support.xcontent.WatcherXContentUtils;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+
+public class ArrayCompareCondition implements Condition {
+ public static final String TYPE = "array_compare";
+
+ private String arrayPath;
+ private String path;
+ private Op op;
+ private Object value;
+ private Quantifier quantifier;
+
+ public ArrayCompareCondition(String arrayPath, String path, Op op, Object value, Quantifier quantifier) {
+ this.arrayPath = arrayPath;
+ this.path = path;
+ this.op = op;
+ this.value = value;
+ this.quantifier = quantifier;
+ }
+
+ @Override
+ public String type() {
+ return TYPE;
+ }
+
+ public String getArrayPath() {
+ return arrayPath;
+ }
+
+ public String getPath() {
+ return path;
+ }
+
+ public Op getOp() {
+ return op;
+ }
+
+ public Object getValue() {
+ return value;
+ }
+
+ public Quantifier getQuantifier() {
+ return quantifier;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ ArrayCompareCondition that = (ArrayCompareCondition) o;
+ return Objects.equals(getArrayPath(), that.getArrayPath()) &&
+ Objects.equals(getPath(), that.getPath()) &&
+ Objects.equals(getOp(), that.getOp()) &&
+ Objects.equals(getValue(), that.getValue()) &&
+ Objects.equals(getQuantifier(), that.getQuantifier());
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(getArrayPath(), getPath(), getOp(), getValue(), getQuantifier());
+ }
+
+ @Override
+ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+ return
+ builder
+ .startObject()
+ .startObject(arrayPath)
+ .field(Field.PATH.getPreferredName(), path)
+ .startObject(op.id())
+ .field(Field.VALUE.getPreferredName(), value)
+ .field(Field.QUANTIFIER.getPreferredName(), quantifier.id())
+ .endObject()
+ .endObject()
+ .endObject();
+ }
+
+ public static ArrayCompareCondition parse(String watchId, XContentParser parser) throws IOException {
+ if (parser.currentToken() != XContentParser.Token.START_OBJECT) {
+ throw new ElasticsearchParseException("could not parse [{}] condition for watch [{}]. expected an object but found [{}] instead", TYPE, watchId, parser.currentToken());
+ }
+ String arrayPath = null;
+ String path = null;
+ Op op = null;
+ Object value = null;
+ boolean haveValue = false;
+ Quantifier quantifier = null;
+
+ XContentParser.Token token;
+ while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) {
+ if (token == XContentParser.Token.FIELD_NAME) {
+ arrayPath = parser.currentName();
+ } else if (arrayPath == null) {
+ throw new ElasticsearchParseException("could not parse [{}] condition for watch [{}]. expected a field indicating the compared path, but found [{}] instead", TYPE, watchId, token);
+ } else if (token == XContentParser.Token.START_OBJECT) {
+ while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) {
+ if (token == XContentParser.Token.FIELD_NAME) {
+ if (ParseFieldMatcher.STRICT.match(parser.currentName(), Field.PATH)) {
+ parser.nextToken();
+ path = parser.text();
+ } else {
+ if (op != null) {
+ throw new ElasticsearchParseException("could not parse [{}] condition for watch [{}]. encountered duplicate comparison operator, but already saw [{}].", TYPE, watchId, parser.currentName(), op.id());
+ }
+ try {
+ op = Op.resolve(parser.currentName());
+ } catch (IllegalArgumentException iae) {
+ throw new ElasticsearchParseException("could not parse [{}] condition for watch [{}]. unknown comparison operator [{}]", TYPE, watchId, parser.currentName(), iae);
+ }
+ token = parser.nextToken();
+ if (token == XContentParser.Token.START_OBJECT) {
+ while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) {
+ if (token == XContentParser.Token.FIELD_NAME) {
+ if (ParseFieldMatcher.STRICT.match(parser.currentName(), Field.VALUE)) {
+ if (haveValue) {
+ throw new ElasticsearchParseException("could not parse [{}] condition for watch [{}]. encountered duplicate field \"value\", but already saw value [{}].", TYPE, watchId, value);
+ }
+ token = parser.nextToken();
+ if (!op.supportsStructures() && !token.isValue() && token != XContentParser.Token.VALUE_NULL) {
+ throw new ElasticsearchParseException("could not parse [{}] condition for watch [{}]. compared value for [{}] with operation [{}] must either be a numeric, string, boolean or null value, but found [{}] instead", TYPE, watchId, path, op.name().toLowerCase(Locale.ROOT), token);
+ }
+ value = WatcherXContentUtils.readValue(parser, token);
+ haveValue = true;
+ } else if (ParseFieldMatcher.STRICT.match(parser.currentName(), Field.QUANTIFIER)) {
+ if (quantifier != null) {
+ throw new ElasticsearchParseException("could not parse [{}] condition for watch [{}]. encountered duplicate field \"quantifier\", but already saw quantifier [{}].", TYPE, watchId, quantifier.id());
+ }
+ parser.nextToken();
+ try {
+ quantifier = Quantifier.resolve(parser.text());
+ } catch (IllegalArgumentException iae) {
+ throw new ElasticsearchParseException("could not parse [{}] condition for watch [{}]. unknown comparison quantifier [{}]", TYPE, watchId, parser.text(), iae);
+ }
+ } else {
+ throw new ElasticsearchParseException("could not parse [{}] condition for watch [{}]. expected a field indicating the comparison value or comparison quantifier, but found [{}] instead", TYPE, watchId, parser.currentName());
+ }
+ } else {
+ throw new ElasticsearchParseException("could not parse [{}] condition for watch [{}]. expected a field indicating the comparison value or comparison quantifier, but found [{}] instead", TYPE, watchId, token);
+ }
+ }
+ } else {
+ throw new ElasticsearchParseException("could not parse [{}] condition for watch [{}]. expected an object for field [{}] but found [{}] instead", TYPE, watchId, op.id(), token);
+ }
+ }
+ } else {
+ throw new ElasticsearchParseException("could not parse [{}] condition for watch [{}]. expected a field indicating the compared path or a comparison operator, but found [{}] instead", TYPE, watchId, token);
+ }
+ }
+ } else {
+ throw new ElasticsearchParseException("could not parse [{}] condition for watch [{}]. expected an object for field [{}] but found [{}] instead", TYPE, watchId, path, token);
+ }
+ }
+
+ if (path == null) {
+ path = "";
+ }
+ if (quantifier == null) {
+ quantifier = Quantifier.SOME;
+ }
+
+ return new ArrayCompareCondition(arrayPath, path, op, value, quantifier);
+ }
+
+ public static class Result extends Condition.Result {
+ private final @Nullable Map resolvedValues;
+
+ Result(Map resolvedValues, boolean met) {
+ super(TYPE, met);
+ this.resolvedValues = resolvedValues;
+ }
+
+ Result(@Nullable Map resolvedValues, Exception e) {
+ super(TYPE, e);
+ this.resolvedValues = resolvedValues;
+ }
+
+ public Map getResolvedValues() {
+ return resolvedValues;
+ }
+
+ @Override
+ protected XContentBuilder typeXContent(XContentBuilder builder, Params params) throws IOException {
+ if (resolvedValues == null) {
+ return builder;
+ }
+ return builder.startObject(type)
+ .field(Field.RESOLVED_VALUES.getPreferredName(), resolvedValues)
+ .endObject();
+ }
+
+ public interface Field extends Condition.Field {
+ ParseField RESOLVED_VALUES = new ParseField("resolved_values");
+ }
+ }
+
+ public enum Op {
+ EQ() {
+ @Override
+ public boolean comparison(int x) {
+ return x == 0;
+ }
+
+ @Override
+ public boolean supportsStructures() {
+ return true;
+ }
+ },
+ NOT_EQ() {
+ @Override
+ public boolean comparison(int x) {
+ return x != 0;
+ }
+
+ @Override
+ public boolean supportsStructures() {
+ return true;
+ }
+ },
+ GTE() {
+ @Override
+ public boolean comparison(int x) {
+ return x >= 0;
+ }
+ },
+ GT() {
+ @Override
+ public boolean comparison(int x) {
+ return x > 0;
+ }
+ },
+ LTE() {
+ @Override
+ public boolean comparison(int x) {
+ return x <= 0;
+ }
+ },
+ LT() {
+ @Override
+ public boolean comparison(int x) {
+ return x < 0;
+ }
+ };
+
+ public abstract boolean comparison(int x);
+
+ public boolean supportsStructures() {
+ return false;
+ }
+
+ public String id() {
+ return name().toLowerCase(Locale.ROOT);
+ }
+
+ public static Op resolve(String id) {
+ return Op.valueOf(id.toUpperCase(Locale.ROOT));
+ }
+ }
+
+ public enum Quantifier {
+ ALL() {
+ @Override
+ public boolean eval(List