Skip to content

Commit 0372d6d

Browse files
committed
Allow ObjectParsers to specify required sets of fields (#49661)
ConstructingObjectParser can be used to specify required fields, but it is still difficult to configure "sets" of fields where only one of the set is required (requiring hand-rolled logic in each ConstructingObjectParser, or adding special validation methods to objects that are called after building the object). This commit adds a new method on ObjectParser which allows the parsers to register required sets. E.g. ["foo", "bar"] can be registered, which means "foo", "bar" or both must be configured by the user otherwise an exception is thrown. This pattern crops up in many places in our parsers; a good example are the aggregation "field" and "script" fields. One or both must be configured on all aggregations, omitting both should result in an exception. This was previously handled far downstream resulting in an aggregation exception, when it should be a parse exception.
1 parent 86d5211 commit 0372d6d

File tree

3 files changed

+165
-0
lines changed

3 files changed

+165
-0
lines changed

libs/x-content/src/main/java/org/elasticsearch/common/xcontent/AbstractObjectParser.java

+57
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
public abstract class AbstractObjectParser<Value, Context>
3838
implements BiFunction<XContentParser, Context, Value>, ContextParser<Context, Value> {
3939

40+
final List<String[]> requiredFieldSets = new ArrayList<>();
41+
4042
/**
4143
* Declare some field. Usually it is easier to use {@link #declareString(BiConsumer, ParseField)} or
4244
* {@link #declareObject(BiConsumer, ContextParser, ParseField)} rather than call this directly.
@@ -211,6 +213,61 @@ public <T> void declareFieldArray(BiConsumer<Value, List<T>> consumer, ContextPa
211213
declareField(consumer, (p, c) -> parseArray(p, () -> itemParser.parse(p, c)), field, type);
212214
}
213215

216+
/**
217+
* Declares a set of fields that are required for parsing to succeed. Only one of the values
218+
* provided per String[] must be matched.
219+
*
220+
* E.g. <code>declareRequiredFieldSet("foo", "bar");</code> means at least one of "foo" or
221+
* "bar" fields must be present. If neither of those fields are present, an exception will be thrown.
222+
*
223+
* Multiple required sets can be configured:
224+
*
225+
* <pre><code>
226+
* parser.declareRequiredFieldSet("foo", "bar");
227+
* parser.declareRequiredFieldSet("bizz", "buzz");
228+
* </code></pre>
229+
*
230+
* requires that one of "foo" or "bar" fields are present, and also that one of "bizz" or
231+
* "buzz" fields are present.
232+
*
233+
* In JSON, it means any of these combinations are acceptable:
234+
*
235+
* <ul>
236+
* <li><code>{"foo":"...", "bizz": "..."}</code></li>
237+
* <li><code>{"bar":"...", "bizz": "..."}</code></li>
238+
* <li><code>{"foo":"...", "buzz": "..."}</code></li>
239+
* <li><code>{"bar":"...", "buzz": "..."}</code></li>
240+
* <li><code>{"foo":"...", "bar":"...", "bizz": "..."}</code></li>
241+
* <li><code>{"foo":"...", "bar":"...", "buzz": "..."}</code></li>
242+
* <li><code>{"foo":"...", "bizz":"...", "buzz": "..."}</code></li>
243+
* <li><code>{"bar":"...", "bizz":"...", "buzz": "..."}</code></li>
244+
* <li><code>{"foo":"...", "bar":"...", "bizz": "...", "buzz": "..."}</code></li>
245+
* </ul>
246+
*
247+
* The following would however be rejected:
248+
*
249+
* <table>
250+
* <caption>failure cases</caption>
251+
* <tr><th>Provided JSON</th><th>Reason for failure</th></tr>
252+
* <tr><td><code>{"foo":"..."}</code></td><td>Missing "bizz" or "buzz" field</td></tr>
253+
* <tr><td><code>{"bar":"..."}</code></td><td>Missing "bizz" or "buzz" field</td></tr>
254+
* <tr><td><code>{"bizz": "..."}</code></td><td>Missing "foo" or "bar" field</td></tr>
255+
* <tr><td><code>{"buzz": "..."}</code></td><td>Missing "foo" or "bar" field</td></tr>
256+
* <tr><td><code>{"foo":"...", "bar": "..."}</code></td><td>Missing "bizz" or "buzz" field</td></tr>
257+
* <tr><td><code>{"bizz":"...", "buzz": "..."}</code></td><td>Missing "foo" or "bar" field</td></tr>
258+
* <tr><td><code>{"unrelated":"..."}</code></td> <td>Missing "foo" or "bar" field, and missing "bizz" or "buzz" field</td></tr>
259+
* </table>
260+
*
261+
* @param requiredSet
262+
* A set of required fields, where at least one of the fields in the array _must_ be present
263+
*/
264+
public void declareRequiredFieldSet(String... requiredSet) {
265+
if (requiredSet.length == 0) {
266+
return;
267+
}
268+
this.requiredFieldSets.add(requiredSet);
269+
}
270+
214271
private interface IOSupplier<T> {
215272
T get() throws IOException;
216273
}

libs/x-content/src/main/java/org/elasticsearch/common/xcontent/ObjectParser.java

+24
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import java.util.EnumSet;
2929
import java.util.HashMap;
3030
import java.util.HashSet;
31+
import java.util.Iterator;
3132
import java.util.List;
3233
import java.util.Map;
3334
import java.util.Set;
@@ -272,6 +273,8 @@ public Value parse(XContentParser parser, Value value, Context context) throws I
272273
FieldParser fieldParser = null;
273274
String currentFieldName = null;
274275
XContentLocation currentPosition = null;
276+
List<String[]> requiredFields = new ArrayList<>(this.requiredFieldSets);
277+
275278
while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) {
276279
if (token == XContentParser.Token.FIELD_NAME) {
277280
currentFieldName = parser.currentName();
@@ -285,11 +288,32 @@ public Value parse(XContentParser parser, Value value, Context context) throws I
285288
unknownFieldParser.acceptUnknownField(this, currentFieldName, currentPosition, parser, value, context);
286289
} else {
287290
fieldParser.assertSupports(name, parser, currentFieldName);
291+
292+
// Check to see if this field is a required field, if it is we can
293+
// remove the entry as the requirement is satisfied
294+
Iterator<String[]> iter = requiredFields.iterator();
295+
while (iter.hasNext()) {
296+
String[] requriedFields = iter.next();
297+
for (String field : requriedFields) {
298+
if (field.equals(currentFieldName)) {
299+
iter.remove();
300+
break;
301+
}
302+
}
303+
}
304+
288305
parseSub(parser, fieldParser, currentFieldName, value, context);
289306
}
290307
fieldParser = null;
291308
}
292309
}
310+
if (requiredFields.isEmpty() == false) {
311+
StringBuilder message = new StringBuilder();
312+
for (String[] fields : requiredFields) {
313+
message.append("Required one of fields ").append(Arrays.toString(fields)).append(", but none were specified. ");
314+
}
315+
throw new IllegalArgumentException(message.toString());
316+
}
293317
return value;
294318
}
295319

libs/x-content/src/test/java/org/elasticsearch/common/xcontent/ObjectParserTests.java

+84
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import static org.hamcrest.Matchers.equalTo;
4444
import static org.hamcrest.Matchers.hasSize;
4545
import static org.hamcrest.Matchers.instanceOf;
46+
import static org.hamcrest.Matchers.nullValue;
4647

4748
public class ObjectParserTests extends ESTestCase {
4849

@@ -757,6 +758,89 @@ public void testConsumeUnknownFields() throws IOException {
757758
assertThat(o.fields.get("test_nested"), instanceOf(Map.class));
758759
}
759760

761+
public void testRequiredFieldSet() throws IOException {
762+
class TestStruct {
763+
private Long a;
764+
private Long b;
765+
766+
private void setA(long value) {
767+
this.a = value;
768+
}
769+
770+
private void setB(long value) {
771+
this.b = value;
772+
}
773+
}
774+
775+
XContentParser parser = createParser(JsonXContent.jsonXContent, "{\"a\": \"123\"}");
776+
ObjectParser<TestStruct, Void> objectParser = new ObjectParser<>("foo", true, TestStruct::new);
777+
objectParser.declareLong(TestStruct::setA, new ParseField("a"));
778+
objectParser.declareLong(TestStruct::setB, new ParseField("b"));
779+
objectParser.declareRequiredFieldSet(new String[]{"a", "b"});
780+
781+
TestStruct obj = objectParser.apply(parser, null);
782+
assertThat(obj.a, equalTo(123L));
783+
assertThat(obj.b, nullValue());
784+
785+
parser = createParser(JsonXContent.jsonXContent, "{\"b\": \"123\"}");
786+
objectParser = new ObjectParser<>("foo", true, TestStruct::new);
787+
objectParser.declareLong(TestStruct::setA, new ParseField("a"));
788+
objectParser.declareLong(TestStruct::setB, new ParseField("b"));
789+
objectParser.declareRequiredFieldSet(new String[]{"a", "b"});
790+
791+
obj = objectParser.apply(parser, null);
792+
assertThat(obj.a, nullValue());
793+
assertThat(obj.b, equalTo(123L));
794+
795+
parser = createParser(JsonXContent.jsonXContent, "{\"a\": \"123\", \"b\": \"456\"}");
796+
objectParser = new ObjectParser<>("foo", true, TestStruct::new);
797+
objectParser.declareLong(TestStruct::setA, new ParseField("a"));
798+
objectParser.declareLong(TestStruct::setB, new ParseField("b"));
799+
objectParser.declareRequiredFieldSet(new String[]{"a", "b"});
800+
801+
obj = objectParser.apply(parser, null);
802+
assertThat(obj.a, equalTo(123L));
803+
assertThat(obj.b, equalTo(456L));
804+
}
805+
806+
public void testMultipleRequiredFieldSet() throws IOException {
807+
class TestStruct {
808+
private Long a;
809+
private Long b;
810+
private Long c;
811+
private Long d;
812+
813+
private void setA(long value) {
814+
this.a = value;
815+
}
816+
817+
private void setB(long value) {
818+
this.b = value;
819+
}
820+
821+
private void setC(long value) {
822+
this.c = value;
823+
}
824+
825+
private void setD(long value) {
826+
this.d = value;
827+
}
828+
}
829+
830+
XContentParser parser = createParser(JsonXContent.jsonXContent, "{\"unrelated\": \"123\"}");
831+
ObjectParser<TestStruct, Void> objectParser = new ObjectParser<>("foo", true, TestStruct::new);
832+
objectParser.declareLong(TestStruct::setA, new ParseField("a"));
833+
objectParser.declareLong(TestStruct::setB, new ParseField("b"));
834+
objectParser.declareLong(TestStruct::setC, new ParseField("c"));
835+
objectParser.declareLong(TestStruct::setD, new ParseField("d"));
836+
objectParser.declareRequiredFieldSet(new String[]{"a", "b"});
837+
objectParser.declareRequiredFieldSet(new String[]{"c", "d"});
838+
839+
IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> objectParser.apply(parser, null));
840+
assertThat(e.getMessage(), equalTo("Required one of fields [a, b], but none were specified. " +
841+
"Required one of fields [c, d], but none were specified. "));
842+
}
843+
760844
@Override
761845
protected NamedXContentRegistry xContentRegistry() {
762846
return new NamedXContentRegistry(Arrays.asList(

0 commit comments

Comments
 (0)