Browse Source

Reinterpret dots in field names as object structure (#79922)

DocumentParser parses documents by following their object hierarchy, and
using a parallel hierarchy of ObjectMappers to work out how to map leaf fields.
Field names that contain dots complicate this, meaning that many methods
need to reverse-engineer the object hierarchy to check that the current parent
object mapper is the correct one; this is particularly complex when objects
are being created dynamically.

To simplify this logic, this commit introduces a DotExpandingXContentParser,
which wraps another XContentParser and re-interprets any field name containing
dots as a series of objects. So for example, `"foo.bar.baz":{ ... }` is represented
as `"foo":{"bar":{"baz":{...}}}`. DocumentParser uses this to automatically 
expand all field names containing dots when parsing the source.
Alan Woodward 3 years ago
parent
commit
2e8a973d4a

+ 244 - 0
libs/x-content/src/main/java/org/elasticsearch/xcontent/DelegatingXContentParser.java

@@ -0,0 +1,244 @@
+/*
+ * 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 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 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.xcontent;
+
+import org.elasticsearch.core.CheckedFunction;
+import org.elasticsearch.core.RestApiVersion;
+
+import java.io.IOException;
+import java.nio.CharBuffer;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Supplier;
+
+public abstract class DelegatingXContentParser implements XContentParser {
+
+    protected abstract XContentParser delegate();
+
+    @Override
+    public XContentType contentType() {
+        return delegate().contentType();
+    }
+
+    @Override
+    public void allowDuplicateKeys(boolean allowDuplicateKeys) {
+        delegate().allowDuplicateKeys(allowDuplicateKeys);
+    }
+
+    @Override
+    public Token nextToken() throws IOException {
+        return delegate().nextToken();
+    }
+
+    @Override
+    public void skipChildren() throws IOException {
+        delegate().skipChildren();
+    }
+
+    @Override
+    public Token currentToken() {
+        return delegate().currentToken();
+    }
+
+    @Override
+    public String currentName() throws IOException {
+        return delegate().currentName();
+    }
+
+    @Override
+    public Map<String, Object> map() throws IOException {
+        return delegate().map();
+    }
+
+    @Override
+    public Map<String, Object> mapOrdered() throws IOException {
+        return delegate().mapOrdered();
+    }
+
+    @Override
+    public Map<String, String> mapStrings() throws IOException {
+        return delegate().mapStrings();
+    }
+
+    @Override
+    public <T> Map<String, T> map(Supplier<Map<String, T>> mapFactory, CheckedFunction<XContentParser, T, IOException> mapValueParser)
+        throws IOException {
+        return delegate().map(mapFactory, mapValueParser);
+    }
+
+    @Override
+    public List<Object> list() throws IOException {
+        return delegate().list();
+    }
+
+    @Override
+    public List<Object> listOrderedMap() throws IOException {
+        return delegate().listOrderedMap();
+    }
+
+    @Override
+    public String text() throws IOException {
+        return delegate().text();
+    }
+
+    @Override
+    public String textOrNull() throws IOException {
+        return delegate().textOrNull();
+    }
+
+    @Override
+    public CharBuffer charBufferOrNull() throws IOException {
+        return delegate().charBufferOrNull();
+    }
+
+    @Override
+    public CharBuffer charBuffer() throws IOException {
+        return delegate().charBuffer();
+    }
+
+    @Override
+    public Object objectText() throws IOException {
+        return delegate().objectText();
+    }
+
+    @Override
+    public Object objectBytes() throws IOException {
+        return delegate().objectBytes();
+    }
+
+    @Override
+    public boolean hasTextCharacters() {
+        return delegate().hasTextCharacters();
+    }
+
+    @Override
+    public char[] textCharacters() throws IOException {
+        return delegate().textCharacters();
+    }
+
+    @Override
+    public int textLength() throws IOException {
+        return delegate().textLength();
+    }
+
+    @Override
+    public int textOffset() throws IOException {
+        return delegate().textOffset();
+    }
+
+    @Override
+    public Number numberValue() throws IOException {
+        return delegate().numberValue();
+    }
+
+    @Override
+    public NumberType numberType() throws IOException {
+        return delegate().numberType();
+    }
+
+    @Override
+    public short shortValue(boolean coerce) throws IOException {
+        return delegate().shortValue(coerce);
+    }
+
+    @Override
+    public int intValue(boolean coerce) throws IOException {
+        return delegate().intValue(coerce);
+    }
+
+    @Override
+    public long longValue(boolean coerce) throws IOException {
+        return delegate().longValue(coerce);
+    }
+
+    @Override
+    public float floatValue(boolean coerce) throws IOException {
+        return delegate().floatValue(coerce);
+    }
+
+    @Override
+    public double doubleValue(boolean coerce) throws IOException {
+        return delegate().doubleValue(coerce);
+    }
+
+    @Override
+    public short shortValue() throws IOException {
+        return delegate().shortValue();
+    }
+
+    @Override
+    public int intValue() throws IOException {
+        return delegate().intValue();
+    }
+
+    @Override
+    public long longValue() throws IOException {
+        return delegate().longValue();
+    }
+
+    @Override
+    public float floatValue() throws IOException {
+        return delegate().floatValue();
+    }
+
+    @Override
+    public double doubleValue() throws IOException {
+        return delegate().doubleValue();
+    }
+
+    @Override
+    public boolean isBooleanValue() throws IOException {
+        return delegate().isBooleanValue();
+    }
+
+    @Override
+    public boolean booleanValue() throws IOException {
+        return delegate().booleanValue();
+    }
+
+    @Override
+    public byte[] binaryValue() throws IOException {
+        return delegate().binaryValue();
+    }
+
+    @Override
+    public XContentLocation getTokenLocation() {
+        return delegate().getTokenLocation();
+    }
+
+    @Override
+    public <T> T namedObject(Class<T> categoryClass, String name, Object context) throws IOException {
+        return delegate().namedObject(categoryClass, name, context);
+    }
+
+    @Override
+    public NamedXContentRegistry getXContentRegistry() {
+        return delegate().getXContentRegistry();
+    }
+
+    @Override
+    public boolean isClosed() {
+        return delegate().isClosed();
+    }
+
+    @Override
+    public RestApiVersion getRestApiVersion() {
+        return delegate().getRestApiVersion();
+    }
+
+    @Override
+    public DeprecationHandler getDeprecationHandler() {
+        return delegate().getDeprecationHandler();
+    }
+
+    @Override
+    public void close() throws IOException {
+        delegate().close();
+    }
+}

+ 201 - 0
libs/x-content/src/main/java/org/elasticsearch/xcontent/DotExpandingXContentParser.java

@@ -0,0 +1,201 @@
+/*
+ * 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 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 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.xcontent;
+
+import java.io.IOException;
+import java.util.ArrayDeque;
+import java.util.Deque;
+
+/**
+ * An XContentParser that reinterprets field names containing dots as an object structure.
+ *
+ * A fieldname named {@code "foo.bar.baz":...} will be parsed instead as {@code 'foo':{'bar':{'baz':...}}}
+ */
+public class DotExpandingXContentParser extends FilterXContentParser {
+
+    private static class WrappingParser extends DelegatingXContentParser {
+
+        final Deque<XContentParser> parsers = new ArrayDeque<>();
+
+        WrappingParser(XContentParser in) throws IOException {
+            parsers.push(in);
+            if (in.currentToken() == Token.FIELD_NAME) {
+                expandDots();
+            }
+        }
+
+        @Override
+        public Token nextToken() throws IOException {
+            Token token;
+            while ((token = delegate().nextToken()) == null) {
+                parsers.pop();
+                if (parsers.isEmpty()) {
+                    return null;
+                }
+            }
+            if (token != Token.FIELD_NAME) {
+                return token;
+            }
+            expandDots();
+            return Token.FIELD_NAME;
+        }
+
+        private void expandDots() throws IOException {
+            String field = delegate().currentName();
+            String[] subpaths = field.split("\\.");
+            if (subpaths.length == 0) {
+                throw new IllegalArgumentException("field name cannot contain only dots: [" + field + "]");
+            }
+            if (subpaths.length == 1) {
+                return;
+            }
+            Token token = delegate().nextToken();
+            if (token == Token.START_OBJECT || token == Token.START_ARRAY) {
+                parsers.push(new DotExpandingXContentParser(new XContentSubParser(delegate()), delegate(), subpaths));
+            } else if (token == Token.END_OBJECT || token == Token.END_ARRAY) {
+                throw new IllegalStateException("Expecting START_OBJECT or START_ARRAY or VALUE but got [" + token + "]");
+            } else {
+                parsers.push(new DotExpandingXContentParser(new SingletonValueXContentParser(delegate()), delegate(), subpaths));
+            }
+        }
+
+        @Override
+        protected XContentParser delegate() {
+            return parsers.peek();
+        }
+    }
+
+    /**
+     * Wraps an XContentParser such that it re-interprets dots in field names as an object structure
+     * @param in    the parser to wrap
+     * @return  the wrapped XContentParser
+     */
+    public static XContentParser expandDots(XContentParser in) throws IOException {
+        return new WrappingParser(in);
+    }
+
+    private enum State {
+        PRE,
+        DURING,
+        POST
+    }
+
+    final String[] subPaths;
+    final XContentParser subparser;
+
+    int level = 0;
+    private State state = State.PRE;
+
+    private DotExpandingXContentParser(XContentParser subparser, XContentParser root, String[] subPaths) {
+        super(root);
+        this.subPaths = subPaths;
+        this.subparser = subparser;
+    }
+
+    @Override
+    public Token nextToken() throws IOException {
+        if (state == State.PRE) {
+            level++;
+            if (level == subPaths.length * 2 - 1) {
+                state = State.DURING;
+                return in.currentToken();
+            }
+            if (level % 2 == 0) {
+                return Token.FIELD_NAME;
+            }
+            return Token.START_OBJECT;
+        }
+        if (state == State.DURING) {
+            Token token = subparser.nextToken();
+            if (token != null) {
+                return token;
+            }
+            state = State.POST;
+        }
+        assert state == State.POST;
+        if (level >= 1) {
+            level -= 2;
+        }
+        return level < 0 ? null : Token.END_OBJECT;
+    }
+
+    @Override
+    public Token currentToken() {
+        if (state == State.PRE) {
+            return level % 2 == 1 ? Token.START_OBJECT : Token.FIELD_NAME;
+        }
+        if (state == State.POST) {
+            if (level > 1) {
+                return Token.END_OBJECT;
+            }
+        }
+        return in.currentToken();
+    }
+
+    @Override
+    public String currentName() throws IOException {
+        if (state == State.DURING) {
+            return in.currentName();
+        }
+        if (state == State.POST) {
+            if (level <= 1) {
+                return in.currentName();
+            }
+            throw new IllegalStateException("Can't get current name during END_OBJECT");
+        }
+        return subPaths[level / 2];
+    }
+
+    @Override
+    public void skipChildren() throws IOException {
+        if (state == State.PRE) {
+            in.skipChildren();
+            state = State.POST;
+        }
+        if (state == State.DURING) {
+            subparser.skipChildren();
+        }
+    }
+
+    @Override
+    public String textOrNull() throws IOException {
+        if (state == State.PRE) {
+            throw new IllegalStateException("Can't get text on a " + currentToken() + " at " + getTokenLocation());
+        }
+        return super.textOrNull();
+    }
+
+    @Override
+    public Number numberValue() throws IOException {
+        if (state == State.PRE) {
+            throw new IllegalStateException("Can't get numeric value on a " + currentToken() + " at " + getTokenLocation());
+        }
+        return super.numberValue();
+    }
+
+    @Override
+    public boolean booleanValue() throws IOException {
+        if (state == State.PRE) {
+            throw new IllegalStateException("Can't get boolean value on a " + currentToken() + " at " + getTokenLocation());
+        }
+        return super.booleanValue();
+    }
+
+    private static class SingletonValueXContentParser extends FilterXContentParser {
+
+        protected SingletonValueXContentParser(XContentParser in) {
+            super(in);
+        }
+
+        @Override
+        public Token nextToken() throws IOException {
+            return null;
+        }
+    }
+}

+ 84 - 0
libs/x-content/src/test/java/org/elasticsearch/xcontent/DotExpandingXContentParserTests.java

@@ -0,0 +1,84 @@
+/*
+ * 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 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 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.xcontent;
+
+import org.elasticsearch.common.Strings;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.xcontent.json.JsonXContent;
+
+import java.io.IOException;
+
+public class DotExpandingXContentParserTests extends ESTestCase {
+
+    private void assertXContentMatches(String expected, String actual) throws IOException {
+        XContentParser inputParser = createParser(JsonXContent.jsonXContent, actual);
+        XContentParser expandedParser = DotExpandingXContentParser.expandDots(inputParser);
+
+        XContentBuilder actualOutput = XContentBuilder.builder(JsonXContent.jsonXContent).copyCurrentStructure(expandedParser);
+        assertEquals(expected, Strings.toString(actualOutput));
+    }
+
+    public void testEmbeddedObject() throws IOException {
+
+        assertXContentMatches(
+            "{\"test\":{\"with\":{\"dots\":{\"field\":\"value\"}}},\"nodots\":\"value2\"}",
+            "{\"test.with.dots\":{\"field\":\"value\"},\"nodots\":\"value2\"}"
+        );
+    }
+
+    public void testEmbeddedArray() throws IOException {
+
+        assertXContentMatches(
+            "{\"test\":{\"with\":{\"dots\":[\"field\",\"value\"]}},\"nodots\":\"value2\"}",
+            "{\"test.with.dots\":[\"field\",\"value\"],\"nodots\":\"value2\"}"
+        );
+
+    }
+
+    public void testEmbeddedValue() throws IOException {
+
+        assertXContentMatches(
+            "{\"test\":{\"with\":{\"dots\":\"value\"}},\"nodots\":\"value2\"}",
+            "{\"test.with.dots\":\"value\",\"nodots\":\"value2\"}"
+        );
+
+    }
+
+    public void testSkipChildren() throws IOException {
+        XContentParser parser = DotExpandingXContentParser.expandDots(
+            createParser(JsonXContent.jsonXContent, "{ \"test.with.dots\" : \"value\", \"nodots\" : \"value2\" }")
+        );
+
+        parser.nextToken();     // start object
+        assertEquals(XContentParser.Token.FIELD_NAME, parser.nextToken());
+        assertEquals("test", parser.currentName());
+        assertEquals(XContentParser.Token.START_OBJECT, parser.nextToken());
+        assertEquals(XContentParser.Token.START_OBJECT, parser.currentToken());
+        assertEquals(XContentParser.Token.FIELD_NAME, parser.nextToken());
+        assertEquals("with", parser.currentName());
+        assertEquals(XContentParser.Token.START_OBJECT, parser.nextToken());
+        assertEquals(XContentParser.Token.START_OBJECT, parser.currentToken());
+        parser.skipChildren();
+        assertEquals(XContentParser.Token.END_OBJECT, parser.currentToken());
+        assertEquals(XContentParser.Token.END_OBJECT, parser.nextToken());
+        assertEquals(XContentParser.Token.FIELD_NAME, parser.nextToken());
+        assertEquals("nodots", parser.currentName());
+        assertEquals(XContentParser.Token.VALUE_STRING, parser.nextToken());
+        assertEquals("value2", parser.text());
+        assertEquals(XContentParser.Token.END_OBJECT, parser.nextToken());
+        assertNull(parser.nextToken());
+    }
+
+    public void testNestedExpansions() throws IOException {
+        assertXContentMatches(
+            "{\"first\":{\"dot\":{\"second\":{\"dot\":\"value\"},\"third\":\"value\"}},\"nodots\":\"value\"}",
+            "{\"first.dot\":{\"second.dot\":\"value\",\"third\":\"value\"},\"nodots\":\"value\"}"
+        );
+    }
+}

+ 1 - 0
rest-api-spec/build.gradle

@@ -49,6 +49,7 @@ tasks.named("yamlRestTestV7CompatTransform").configure { task ->
   task.skipTestsByFilePattern("**/indices.upgrade/*.yml", "upgrade api will only get a dummy endpoint returning an exception suggesting to use _reindex")
   task.skipTestsByFilePattern("**/indices.stats/60_field_usage/*/*.yml", "field usage results will be different between lucene versions")
 
+  task.skipTest("bulk/11_dynamic_templates/Dynamic templates", "Error message has changed")
   task.skipTest("indices.create/20_mix_typeless_typeful/Implicitly create a typed index while there is a typeless template", "Type information about the type is removed and not passed down. The logic to check for this is also removed.")
   task.skipTest("indices.create/20_mix_typeless_typeful/Implicitly create a typeless index while there is a typed template", "Type information about the type is removed and not passed down. The logic to check for this is also removed.")
   task.skipTest("delete/70_mix_typeless_typeful/DELETE with typeless API on an index that has types", "Type information about the type is removed and not passed down. The logic to check for this is also removed.");

+ 3 - 3
rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/bulk/11_dynamic_templates.yml

@@ -1,8 +1,8 @@
 ---
 "Dynamic templates":
   - skip:
-      version: " - 7.12.99"
-      reason: "Dynamic templates parameter is added to bulk requests in 7.13"
+      version: " - 8.1.0"
+      reason: "Error message has changed in 8.1.0"
 
   - do:
       indices.create:
@@ -166,6 +166,6 @@
   - match: { errors: true }
   - match: { items.0.index.status: 400 }
   - match: { items.0.index.error.type: mapper_parsing_exception }
-  - match: { items.0.index.error.reason: "Field [foo] must be an object; but it's configured as [keyword] in dynamic template [string]"}
+  - match: { items.0.index.error.reason: "failed to parse field [foo] of type [keyword] in document with id 'id_11'. Preview of field's value: '{bar=hello world}'"}
   - match: { items.1.index.status: 201 }
   - match: { items.1.index.result: created }

+ 59 - 225
server/src/main/java/org/elasticsearch/index/mapper/DocumentParser.java

@@ -19,13 +19,13 @@ import org.elasticsearch.common.regex.Regex;
 import org.elasticsearch.common.time.DateFormatter;
 import org.elasticsearch.common.xcontent.LoggingDeprecationHandler;
 import org.elasticsearch.common.xcontent.XContentHelper;
-import org.elasticsearch.core.Tuple;
 import org.elasticsearch.index.IndexSettings;
 import org.elasticsearch.index.analysis.IndexAnalyzers;
 import org.elasticsearch.index.fielddata.IndexFieldDataCache;
 import org.elasticsearch.index.query.SearchExecutionContext;
 import org.elasticsearch.indices.breaker.NoneCircuitBreakerService;
 import org.elasticsearch.search.lookup.SearchLookup;
+import org.elasticsearch.xcontent.DotExpandingXContentParser;
 import org.elasticsearch.xcontent.NamedXContentRegistry;
 import org.elasticsearch.xcontent.XContentBuilder;
 import org.elasticsearch.xcontent.XContentParser;
@@ -85,10 +85,10 @@ public final class DocumentParser {
             )
         ) {
             context = new InternalDocumentParserContext(mappingLookup, indexSettings, indexAnalyzers, dateParserContext, source, parser);
-            validateStart(parser);
+            validateStart(context.parser());
             MetadataFieldMapper[] metadataFieldsMappers = mappingLookup.getMapping().getSortedMetadataMappers();
-            internalParseDocument(mappingLookup.getMapping().getRoot(), metadataFieldsMappers, context, parser);
-            validateEnd(parser);
+            internalParseDocument(mappingLookup.getMapping().getRoot(), metadataFieldsMappers, context);
+            validateEnd(context.parser());
         } catch (Exception e) {
             throw wrapInMapperParsingException(source, e);
         }
@@ -109,28 +109,13 @@ public final class DocumentParser {
         );
     }
 
-    private static boolean containsDisabledObjectMapper(ObjectMapper objectMapper, String[] subfields) {
-        for (int i = 0; i < subfields.length - 1; ++i) {
-            Mapper mapper = objectMapper.getMapper(subfields[i]);
-            if (mapper instanceof ObjectMapper == false) {
-                break;
-            }
-            objectMapper = (ObjectMapper) mapper;
-            if (objectMapper.isEnabled() == false) {
-                return true;
-            }
-        }
-        return false;
-    }
-
     private static void internalParseDocument(
         RootObjectMapper root,
         MetadataFieldMapper[] metadataFieldsMappers,
-        DocumentParserContext context,
-        XContentParser parser
+        DocumentParserContext context
     ) throws IOException {
 
-        final boolean emptyDoc = isEmptyDoc(root, parser);
+        final boolean emptyDoc = isEmptyDoc(root, context.parser());
 
         for (MetadataFieldMapper metadataMapper : metadataFieldsMappers) {
             metadataMapper.preParse(context);
@@ -138,7 +123,7 @@ public final class DocumentParser {
 
         if (root.isEnabled() == false) {
             // entire type is disabled
-            parser.skipChildren();
+            context.parser().skipChildren();
         } else if (emptyDoc == false) {
             parseObjectOrNested(context, root);
         }
@@ -457,39 +442,32 @@ public final class DocumentParser {
         }
         if (token == XContentParser.Token.START_OBJECT) {
             // if we are just starting an OBJECT, advance, this is the object we are parsing, we need the name first
-            token = parser.nextToken();
+            parser.nextToken();
         }
 
-        innerParseObject(context, mapper, parser, currentFieldName, token);
+        innerParseObject(context, mapper);
         // restore the enable path flag
         if (mapper.isNested()) {
             nested(context, (NestedObjectMapper) mapper);
         }
     }
 
-    private static void innerParseObject(
-        DocumentParserContext context,
-        ObjectMapper mapper,
-        XContentParser parser,
-        String currentFieldName,
-        XContentParser.Token token
-    ) throws IOException {
+    private static void innerParseObject(DocumentParserContext context, ObjectMapper mapper) throws IOException {
+
+        XContentParser.Token token = context.parser().currentToken();
+        String currentFieldName = context.parser().currentName();
         assert token == XContentParser.Token.FIELD_NAME || token == XContentParser.Token.END_OBJECT;
-        String[] paths = null;
+
         while (token != XContentParser.Token.END_OBJECT) {
             if (token == XContentParser.Token.FIELD_NAME) {
-                currentFieldName = parser.currentName();
-                paths = splitAndValidatePath(currentFieldName);
-                if (containsDisabledObjectMapper(mapper, paths)) {
-                    parser.nextToken();
-                    parser.skipChildren();
-                }
+                currentFieldName = context.parser().currentName();
+                splitAndValidatePath(currentFieldName);
             } else if (token == XContentParser.Token.START_OBJECT) {
-                parseObject(context, mapper, currentFieldName, paths);
+                parseObject(context, mapper, currentFieldName);
             } else if (token == XContentParser.Token.START_ARRAY) {
-                parseArray(context, mapper, currentFieldName, paths);
+                parseArray(context, mapper, currentFieldName);
             } else if (token == XContentParser.Token.VALUE_NULL) {
-                parseNullValue(context, mapper, currentFieldName, paths);
+                parseNullValue(context, mapper, currentFieldName);
             } else if (token == null) {
                 throw new MapperParsingException(
                     "object mapping for ["
@@ -499,9 +477,9 @@ public final class DocumentParser {
                         + "] as object, but got EOF, has a concrete value been provided to it?"
                 );
             } else if (token.isValue()) {
-                parseValue(context, mapper, currentFieldName, token, paths);
+                parseValue(context, mapper, currentFieldName, token);
             }
-            token = parser.nextToken();
+            token = context.parser().nextToken();
         }
     }
 
@@ -577,7 +555,8 @@ public final class DocumentParser {
                 parseCopyFields(context, copyToFields);
             }
         } else if (mapper instanceof FieldAliasMapper) {
-            throw new IllegalArgumentException("Cannot write to a field alias [" + mapper.name() + "].");
+            String verb = context.isWithinCopyTo() ? "copy" : "write";
+            throw new MapperParsingException("Cannot " + verb + " to a field alias [" + mapper.name() + "].");
         } else {
             throw new IllegalStateException(
                 "The provided mapper [" + mapper.name() + "] has an unrecognized type [" + mapper.getClass().getSimpleName() + "]."
@@ -585,23 +564,19 @@ public final class DocumentParser {
         }
     }
 
-    private static void parseObject(final DocumentParserContext context, ObjectMapper mapper, String currentFieldName, String[] paths)
-        throws IOException {
+    private static void parseObject(final DocumentParserContext context, ObjectMapper mapper, String currentFieldName) throws IOException {
         assert currentFieldName != null;
-        Mapper objectMapper = getMapper(context, mapper, currentFieldName, paths);
+        Mapper objectMapper = getMapper(context, mapper, currentFieldName);
         if (objectMapper != null) {
             context.path().add(currentFieldName);
             parseObjectOrField(context, objectMapper);
             context.path().remove();
         } else {
-            currentFieldName = paths[paths.length - 1];
-            Tuple<Integer, ObjectMapper> parentMapperTuple = getDynamicParentMapper(context, paths, mapper);
-            ObjectMapper parentMapper = parentMapperTuple.v2();
-            ObjectMapper.Dynamic dynamic = dynamicOrDefault(parentMapper, context);
+            ObjectMapper.Dynamic dynamic = dynamicOrDefault(mapper, context);
             if (dynamic == ObjectMapper.Dynamic.STRICT) {
                 throw new StrictDynamicMappingException(mapper.fullPath(), currentFieldName);
             } else if (dynamic == ObjectMapper.Dynamic.FALSE) {
-                failIfMatchesRoutingPath(context, parentMapper, currentFieldName);
+                failIfMatchesRoutingPath(context, mapper, currentFieldName);
                 // not dynamic, read everything up to end object
                 context.parser().skipChildren();
             } else {
@@ -614,21 +589,20 @@ public final class DocumentParser {
                     dynamicObjectMapper = dynamic.getDynamicFieldsBuilder().createDynamicObjectMapper(context, currentFieldName);
                     context.addDynamicMapper(dynamicObjectMapper);
                 }
+                if (dynamicObjectMapper instanceof NestedObjectMapper && context.isWithinCopyTo()) {
+                    throw new MapperParsingException(
+                        "It is forbidden to create dynamic nested objects ([" + dynamicObjectMapper.name() + "]) through `copy_to`"
+                    );
+                }
                 context.path().add(currentFieldName);
                 parseObjectOrField(context, dynamicObjectMapper);
                 context.path().remove();
             }
-            for (int i = 0; i < parentMapperTuple.v1(); i++) {
-                context.path().remove();
-            }
         }
     }
 
-    private static void parseArray(DocumentParserContext context, ObjectMapper parentMapper, String lastFieldName, String[] paths)
-        throws IOException {
-        String arrayFieldName = lastFieldName;
-
-        Mapper mapper = getLeafMapper(context, parentMapper, lastFieldName, paths);
+    private static void parseArray(DocumentParserContext context, ObjectMapper parentMapper, String lastFieldName) throws IOException {
+        Mapper mapper = getLeafMapper(context, parentMapper, lastFieldName);
         if (mapper != null) {
             // There is a concrete mapper for this field already. Need to check if the mapper
             // expects an array, if so we pass the context straight to the mapper and if not
@@ -636,38 +610,31 @@ public final class DocumentParser {
             if (parsesArrayValue(mapper)) {
                 parseObjectOrField(context, mapper);
             } else {
-                parseNonDynamicArray(context, parentMapper, lastFieldName, arrayFieldName);
+                parseNonDynamicArray(context, parentMapper, lastFieldName, lastFieldName);
             }
         } else {
-            arrayFieldName = paths[paths.length - 1];
-            lastFieldName = arrayFieldName;
-            Tuple<Integer, ObjectMapper> parentMapperTuple = getDynamicParentMapper(context, paths, parentMapper);
-            parentMapper = parentMapperTuple.v2();
             ObjectMapper.Dynamic dynamic = dynamicOrDefault(parentMapper, context);
             if (dynamic == ObjectMapper.Dynamic.STRICT) {
-                throw new StrictDynamicMappingException(parentMapper.fullPath(), arrayFieldName);
+                throw new StrictDynamicMappingException(parentMapper.fullPath(), lastFieldName);
             } else if (dynamic == ObjectMapper.Dynamic.FALSE) {
                 // TODO: shouldn't this skip, not parse?
-                parseNonDynamicArray(context, parentMapper, lastFieldName, arrayFieldName);
+                parseNonDynamicArray(context, parentMapper, lastFieldName, lastFieldName);
             } else {
-                Mapper objectMapperFromTemplate = dynamic.getDynamicFieldsBuilder().createObjectMapperFromTemplate(context, arrayFieldName);
+                Mapper objectMapperFromTemplate = dynamic.getDynamicFieldsBuilder().createObjectMapperFromTemplate(context, lastFieldName);
                 if (objectMapperFromTemplate == null) {
-                    parseNonDynamicArray(context, parentMapper, lastFieldName, arrayFieldName);
+                    parseNonDynamicArray(context, parentMapper, lastFieldName, lastFieldName);
                 } else {
                     if (parsesArrayValue(objectMapperFromTemplate)) {
                         context.addDynamicMapper(objectMapperFromTemplate);
-                        context.path().add(arrayFieldName);
+                        context.path().add(lastFieldName);
                         parseObjectOrField(context, objectMapperFromTemplate);
                         context.path().remove();
                     } else {
-                        parseNonDynamicArray(context, parentMapper, lastFieldName, arrayFieldName);
+                        parseNonDynamicArray(context, parentMapper, lastFieldName, lastFieldName);
                     }
                 }
 
             }
-            for (int i = 0; i < parentMapperTuple.v1(); i++) {
-                context.path().remove();
-            }
         }
     }
 
@@ -683,14 +650,14 @@ public final class DocumentParser {
     ) throws IOException {
         XContentParser parser = context.parser();
         XContentParser.Token token;
-        final String[] paths = splitAndValidatePath(lastFieldName);
+        splitAndValidatePath(lastFieldName);
         while ((token = parser.nextToken()) != XContentParser.Token.END_ARRAY) {
             if (token == XContentParser.Token.START_OBJECT) {
-                parseObject(context, mapper, lastFieldName, paths);
+                parseObject(context, mapper, lastFieldName);
             } else if (token == XContentParser.Token.START_ARRAY) {
-                parseArray(context, mapper, lastFieldName, paths);
+                parseArray(context, mapper, lastFieldName);
             } else if (token == XContentParser.Token.VALUE_NULL) {
-                parseNullValue(context, mapper, lastFieldName, paths);
+                parseNullValue(context, mapper, lastFieldName);
             } else if (token == null) {
                 throw new MapperParsingException(
                     "object mapping for ["
@@ -701,7 +668,7 @@ public final class DocumentParser {
                 );
             } else {
                 assert token.isValue();
-                parseValue(context, mapper, lastFieldName, token, paths);
+                parseValue(context, mapper, lastFieldName, token);
             }
         }
     }
@@ -710,8 +677,7 @@ public final class DocumentParser {
         final DocumentParserContext context,
         ObjectMapper parentMapper,
         String currentFieldName,
-        XContentParser.Token token,
-        String[] paths
+        XContentParser.Token token
     ) throws IOException {
         if (currentFieldName == null) {
             throw new MapperParsingException(
@@ -723,24 +689,17 @@ public final class DocumentParser {
                     + "]"
             );
         }
-        Mapper mapper = getLeafMapper(context, parentMapper, currentFieldName, paths);
+        Mapper mapper = getLeafMapper(context, parentMapper, currentFieldName);
         if (mapper != null) {
             parseObjectOrField(context, mapper);
         } else {
-            currentFieldName = paths[paths.length - 1];
-            Tuple<Integer, ObjectMapper> parentMapperTuple = getDynamicParentMapper(context, paths, parentMapper);
-            parentMapper = parentMapperTuple.v2();
             parseDynamicValue(context, parentMapper, currentFieldName, token);
-            for (int i = 0; i < parentMapperTuple.v1(); i++) {
-                context.path().remove();
-            }
         }
     }
 
-    private static void parseNullValue(DocumentParserContext context, ObjectMapper parentMapper, String lastFieldName, String[] paths)
-        throws IOException {
+    private static void parseNullValue(DocumentParserContext context, ObjectMapper parentMapper, String lastFieldName) throws IOException {
         // we can only handle null values if we have mappings for them
-        Mapper mapper = getLeafMapper(context, parentMapper, lastFieldName, paths);
+        Mapper mapper = getLeafMapper(context, parentMapper, lastFieldName);
         if (mapper != null) {
             // TODO: passing null to an object seems bogus?
             parseObjectOrField(context, mapper);
@@ -782,7 +741,6 @@ public final class DocumentParser {
      * Creates instances of the fields that the current field should be copied to
      */
     private static void parseCopyFields(DocumentParserContext context, List<String> copyToFields) throws IOException {
-        context = context.createCopyToContext();
         for (String field : copyToFields) {
             // In case of a hierarchy of nested documents, we need to figure out
             // which document the field should go to
@@ -794,112 +752,11 @@ public final class DocumentParser {
                 }
             }
             assert targetDoc != null;
-            final DocumentParserContext copyToContext;
-            if (targetDoc == context.doc()) {
-                copyToContext = context;
-            } else {
-                copyToContext = context.switchDoc(targetDoc);
-            }
-            parseCopy(field, copyToContext);
+            final DocumentParserContext copyToContext = context.createCopyToContext(field, targetDoc);
+            innerParseObject(copyToContext, context.root());
         }
     }
 
-    /**
-     * Creates an copy of the current field with given field name and boost
-     */
-    private static void parseCopy(String field, DocumentParserContext context) throws IOException {
-        Mapper mapper = context.mappingLookup().getMapper(field);
-        if (mapper != null) {
-            if (mapper instanceof FieldMapper) {
-                ((FieldMapper) mapper).parse(context);
-            } else if (mapper instanceof FieldAliasMapper) {
-                throw new IllegalArgumentException("Cannot copy to a field alias [" + mapper.name() + "].");
-            } else {
-                throw new IllegalStateException(
-                    "The provided mapper [" + mapper.name() + "] has an unrecognized type [" + mapper.getClass().getSimpleName() + "]."
-                );
-            }
-        } else {
-            // The path of the dest field might be completely different from the current one so we need to reset it
-            context = context.overridePath(new ContentPath(0));
-
-            final String[] paths = splitAndValidatePath(field);
-            final String fieldName = paths[paths.length - 1];
-            Tuple<Integer, ObjectMapper> parentMapperTuple = getDynamicParentMapper(context, paths, null);
-            ObjectMapper objectMapper = parentMapperTuple.v2();
-            parseDynamicValue(context, objectMapper, fieldName, context.parser().currentToken());
-            for (int i = 0; i < parentMapperTuple.v1(); i++) {
-                context.path().remove();
-            }
-        }
-    }
-
-    private static Tuple<Integer, ObjectMapper> getDynamicParentMapper(
-        DocumentParserContext context,
-        final String[] paths,
-        ObjectMapper currentParent
-    ) {
-        ObjectMapper mapper = currentParent == null ? context.root() : currentParent;
-        int pathsAdded = 0;
-        ObjectMapper parent = mapper;
-        for (int i = 0; i < paths.length - 1; i++) {
-            String name = paths[i];
-            String currentPath = context.path().pathAsText(name);
-            Mapper existingFieldMapper = context.mappingLookup().getMapper(currentPath);
-            if (existingFieldMapper != null) {
-                throw new MapperParsingException(
-                    "Could not dynamically add mapping for field [{}]. Existing mapping for [{}] must be of type object but found [{}].",
-                    null,
-                    String.join(".", paths),
-                    currentPath,
-                    existingFieldMapper.typeName()
-                );
-            }
-            mapper = context.mappingLookup().objectMappers().get(currentPath);
-            if (mapper == null) {
-                // One mapping is missing, check if we are allowed to create a dynamic one.
-                ObjectMapper.Dynamic dynamic = dynamicOrDefault(parent, context);
-                if (dynamic == ObjectMapper.Dynamic.STRICT) {
-                    throw new StrictDynamicMappingException(parent.fullPath(), name);
-                } else if (dynamic == ObjectMapper.Dynamic.FALSE) {
-                    // Should not dynamically create any more mappers so return the last mapper
-                    return new Tuple<>(pathsAdded, parent);
-                } else if (dynamic == ObjectMapper.Dynamic.RUNTIME) {
-                    mapper = new NoOpObjectMapper(name, currentPath);
-                } else {
-                    final Mapper fieldMapper = dynamic.getDynamicFieldsBuilder().createDynamicObjectMapper(context, name);
-                    if (fieldMapper instanceof ObjectMapper == false) {
-                        assert context.sourceToParse().dynamicTemplates().containsKey(currentPath)
-                            : "dynamic templates [" + context.sourceToParse().dynamicTemplates() + "]";
-                        throw new MapperParsingException(
-                            "Field ["
-                                + currentPath
-                                + "] must be an object; "
-                                + "but it's configured as ["
-                                + fieldMapper.typeName()
-                                + "] in dynamic template ["
-                                + context.sourceToParse().dynamicTemplates().get(currentPath)
-                                + "]"
-                        );
-                    }
-                    mapper = (ObjectMapper) fieldMapper;
-                    if (mapper.isNested()) {
-                        throw new MapperParsingException(
-                            "It is forbidden to create dynamic nested objects (["
-                                + currentPath
-                                + "]) through `copy_to` or dots in field names"
-                        );
-                    }
-                    context.addDynamicMapper(mapper);
-                }
-            }
-            context.path().add(paths[i]);
-            pathsAdded++;
-            parent = mapper;
-        }
-        return new Tuple<>(pathsAdded, mapper);
-    }
-
     // find what the dynamic setting is given the current parse context and parent
     private static ObjectMapper.Dynamic dynamicOrDefault(ObjectMapper parentMapper, DocumentParserContext context) {
         ObjectMapper.Dynamic dynamic = parentMapper.dynamic();
@@ -927,48 +784,25 @@ public final class DocumentParser {
         return dynamic;
     }
 
-    // looks up a child mapper, but takes into account field names that expand to objects
+    // looks up a child mapper
     // returns null if no such child mapper exists - note that unlike getLeafMapper,
     // we do not check for shadowing runtime fields because they only apply to leaf
     // fields
-    private static Mapper getMapper(final DocumentParserContext context, ObjectMapper objectMapper, String fieldName, String[] subfields) {
+    private static Mapper getMapper(final DocumentParserContext context, ObjectMapper objectMapper, String fieldName) {
         String fieldPath = context.path().pathAsText(fieldName);
         // Check if mapper is a metadata mapper first
         Mapper mapper = context.getMetadataMapper(fieldPath);
         if (mapper != null) {
             return mapper;
         }
-
-        for (int i = 0; i < subfields.length - 1; ++i) {
-            mapper = objectMapper.getMapper(subfields[i]);
-            if (mapper instanceof ObjectMapper == false) {
-                return null;
-            }
-            objectMapper = (ObjectMapper) mapper;
-            if (objectMapper.isNested()) {
-                throw new MapperParsingException(
-                    "Cannot add a value for field ["
-                        + fieldName
-                        + "] since one of the intermediate objects is mapped as a nested object: ["
-                        + mapper.name()
-                        + "]"
-                );
-            }
-        }
-        String leafName = subfields[subfields.length - 1];
-        return objectMapper.getMapper(leafName);
+        return objectMapper.getMapper(fieldName);
     }
 
     // looks up a child mapper, taking into account field names that expand to objects
     // if no mapper is found, checks to see if a runtime field with the specified
     // field name exists and if so returns a no-op mapper to prevent indexing
-    private static Mapper getLeafMapper(
-        final DocumentParserContext context,
-        ObjectMapper objectMapper,
-        String fieldName,
-        String[] subfields
-    ) {
-        Mapper mapper = getMapper(context, objectMapper, fieldName, subfields);
+    private static Mapper getLeafMapper(final DocumentParserContext context, ObjectMapper objectMapper, String fieldName) {
+        Mapper mapper = getMapper(context, objectMapper, fieldName);
         if (mapper != null) {
             return mapper;
         }
@@ -1086,9 +920,9 @@ public final class DocumentParser {
             Function<DateFormatter, MappingParserContext> parserContext,
             SourceToParse source,
             XContentParser parser
-        ) {
+        ) throws IOException {
             super(mappingLookup, indexSettings, indexAnalyzers, parserContext, source);
-            this.parser = parser;
+            this.parser = DotExpandingXContentParser.expandDots(parser);
             this.document = new LuceneDocument();
             this.documents.add(document);
             this.maxAllowedNumNestedDocs = indexSettings().getMappingNestedDocsLimit();

+ 74 - 16
server/src/main/java/org/elasticsearch/index/mapper/DocumentParserContext.java

@@ -12,8 +12,11 @@ import org.apache.lucene.document.Field;
 import org.elasticsearch.common.time.DateFormatter;
 import org.elasticsearch.index.IndexSettings;
 import org.elasticsearch.index.analysis.IndexAnalyzers;
+import org.elasticsearch.xcontent.DotExpandingXContentParser;
+import org.elasticsearch.xcontent.FilterXContentParser;
 import org.elasticsearch.xcontent.XContentParser;
 
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -247,18 +250,6 @@ public abstract class DocumentParserContext {
      */
     public abstract Iterable<LuceneDocument> nonRootDocuments();
 
-    /**
-     * Return a new context that will be within a copy-to operation.
-     */
-    public final DocumentParserContext createCopyToContext() {
-        return new Wrapper(this) {
-            @Override
-            public boolean isWithinCopyTo() {
-                return true;
-            }
-        };
-    }
-
     public boolean isWithinCopyTo() {
         return false;
     }
@@ -267,6 +258,10 @@ public abstract class DocumentParserContext {
      * Return a new context that will be used within a nested document.
      */
     public final DocumentParserContext createNestedContext(String fullPath) {
+        if (isWithinCopyTo()) {
+            // nested context will already have been set up for copy_to fields
+            return this;
+        }
         final LuceneDocument doc = new LuceneDocument(fullPath, doc());
         addDoc(doc);
         return switchDoc(doc);
@@ -285,20 +280,42 @@ public abstract class DocumentParserContext {
     }
 
     /**
-     * Return a new context that will have the provided path.
+     * Return a context for copy_to directives
+     * @param copyToField   the name of the field to copy to
+     * @param doc           the document to target
      */
-    public final DocumentParserContext overridePath(final ContentPath path) {
+    public final DocumentParserContext createCopyToContext(String copyToField, LuceneDocument doc) throws IOException {
+        ContentPath path = new ContentPath(0);
+        XContentParser parser = DotExpandingXContentParser.expandDots(new CopyToParser(copyToField, parser()));
         return new Wrapper(this) {
             @Override
             public ContentPath path() {
                 return path;
             }
+
+            @Override
+            public XContentParser parser() {
+                return parser;
+            }
+
+            @Override
+            public boolean isWithinCopyTo() {
+                return true;
+            }
+
+            @Override
+            public LuceneDocument doc() {
+                return doc;
+            }
         };
     }
 
     /**
-     * @deprecated we are actively deprecating and removing the ability to pass
-     *             complex objects to multifields, so try and avoid using this method
+     *  @deprecated we are actively deprecating and removing the ability to pass
+     *              complex objects to multifields, so try and avoid using this method
+     * Replace the XContentParser used by this context
+     * @param parser    the replacement parser
+     * @return  a new context with a replaced parser
      */
     @Deprecated
     public final DocumentParserContext switchParser(XContentParser parser) {
@@ -343,4 +360,45 @@ public abstract class DocumentParserContext {
         }
         return null;
     }
+
+    // XContentParser that wraps an existing parser positioned on a value,
+    // and a field name, and returns a stream that looks like { 'field' : 'value' }
+    private static class CopyToParser extends FilterXContentParser {
+
+        enum State {
+            FIELD,
+            VALUE
+        }
+
+        private State state = State.FIELD;
+        private final String field;
+
+        CopyToParser(String fieldName, XContentParser in) {
+            super(in);
+            this.field = fieldName;
+            assert in.currentToken().isValue() || in.currentToken() == Token.VALUE_NULL;
+        }
+
+        @Override
+        public Token nextToken() throws IOException {
+            if (state == State.FIELD) {
+                state = State.VALUE;
+                return in.currentToken();
+            }
+            return Token.END_OBJECT;
+        }
+
+        @Override
+        public Token currentToken() {
+            if (state == State.FIELD) {
+                return Token.FIELD_NAME;
+            }
+            return in.currentToken();
+        }
+
+        @Override
+        public String currentName() throws IOException {
+            return field;
+        }
+    }
 }

+ 3 - 0
server/src/main/java/org/elasticsearch/index/mapper/NumberFieldMapper.java

@@ -1384,6 +1384,9 @@ public class NumberFieldMapper extends FieldMapper {
         if (coerce && parser.currentToken() == Token.VALUE_STRING && parser.textLength() == 0) {
             return nullValue;
         }
+        if (parser.currentToken() == Token.START_OBJECT) {
+            throw new IllegalArgumentException("Cannot parse object as number");
+        }
         return numberType.parse(parser, coerce);
     }
 

+ 22 - 65
server/src/test/java/org/elasticsearch/index/mapper/DocumentParserTests.java

@@ -315,32 +315,32 @@ public class DocumentParserTests extends MapperServiceTestCase {
     public void testDotsWithFieldDisabled() throws IOException {
         DocumentMapper mapper = createDocumentMapper(fieldMapping(b -> b.field("enabled", false)));
         {
-            ParsedDocument doc = mapper.parse(source(b -> b.field("field.bar", 111)));
+            ParsedDocument doc = mapper.parse(source(b -> {
+                b.field("field.bar", "string value");
+                b.field("blub", 222);
+            }));
             assertNull(doc.rootDoc().getField("field"));
             assertNull(doc.rootDoc().getField("bar"));
             assertNull(doc.rootDoc().getField("field.bar"));
+            assertNotNull(doc.rootDoc().getField("blub"));
         }
         {
-            ParsedDocument doc = mapper.parse(source(b -> b.field("field.bar", new int[] { 1, 2, 3 })));
+            ParsedDocument doc = mapper.parse(source(b -> b.field("field.bar", 111)));
             assertNull(doc.rootDoc().getField("field"));
             assertNull(doc.rootDoc().getField("bar"));
             assertNull(doc.rootDoc().getField("field.bar"));
         }
         {
-            ParsedDocument doc = mapper.parse(source(b -> b.field("field.bar", Collections.singletonMap("key", "value"))));
+            ParsedDocument doc = mapper.parse(source(b -> b.field("field.bar", new int[] { 1, 2, 3 })));
             assertNull(doc.rootDoc().getField("field"));
             assertNull(doc.rootDoc().getField("bar"));
             assertNull(doc.rootDoc().getField("field.bar"));
         }
         {
-            ParsedDocument doc = mapper.parse(source(b -> {
-                b.field("field.bar", "string value");
-                b.field("blub", 222);
-            }));
+            ParsedDocument doc = mapper.parse(source(b -> b.field("field.bar", Collections.singletonMap("key", "value"))));
             assertNull(doc.rootDoc().getField("field"));
             assertNull(doc.rootDoc().getField("bar"));
             assertNull(doc.rootDoc().getField("field.bar"));
-            assertNotNull(doc.rootDoc().getField("blub"));
         }
     }
 
@@ -400,11 +400,8 @@ public class DocumentParserTests extends MapperServiceTestCase {
             b.endObject();
         }));
 
-        MapperParsingException e = expectThrows(MapperParsingException.class, () -> mapper.parse(source(b -> b.field("field.bar", 123))));
-        assertEquals(
-            "Cannot add a value for field [field.bar] since one of the intermediate objects is mapped as a nested object: [field]",
-            e.getMessage()
-        );
+        ParsedDocument doc = mapper.parse(source(b -> b.field("field.bar", 123)));
+        assertEquals(123, doc.docs().get(0).getNumericValue("field.bar"));
     }
 
     public void testUnexpectedFieldMappingType() throws Exception {
@@ -440,8 +437,8 @@ public class DocumentParserTests extends MapperServiceTestCase {
             b.endArray();
         }));
 
-        MapperParsingException e = expectThrows(MapperParsingException.class, () -> mapper.parse(source(b -> b.field("foo.bar", 42))));
-        assertEquals("It is forbidden to create dynamic nested objects ([foo]) through `copy_to` or dots in field names", e.getMessage());
+        ParsedDocument doc = mapper.parse(source(b -> b.field("foo.bar", 42)));
+        assertEquals(42L, doc.docs().get(0).getNumericValue("foo.bar"));
     }
 
     public void testNestedHaveIdAndTypeFields() throws Exception {
@@ -1199,10 +1196,7 @@ public class DocumentParserTests extends MapperServiceTestCase {
             MapperParsingException.class,
             () -> mapper.parse(source("1", b -> b.field(field, "true"), null, Map.of("foo", "booleans")))
         );
-        assertThat(
-            error.getMessage(),
-            containsString("Field [foo] must be an object; but it's configured as [boolean] in dynamic template [booleans]")
-        );
+        assertThat(error.getMessage(), containsString("failed to parse field [foo] of type [boolean]"));
 
         ParsedDocument doc = mapper.parse(source("1", b -> b.field(field, "true"), null, Map.of(field, "booleans")));
         IndexableField[] fields = doc.rootDoc().getFields(field);
@@ -1232,11 +1226,7 @@ public class DocumentParserTests extends MapperServiceTestCase {
             MapperParsingException.class,
             () -> mapper.parse(source(b -> b.startArray("field.bar.baz").value(0).value(1).endArray()))
         );
-        assertEquals(
-            "Could not dynamically add mapping for field [field.bar.baz]. "
-                + "Existing mapping for [field] must be of type object but found [long].",
-            exception.getMessage()
-        );
+        assertThat(exception.getMessage(), containsString("failed to parse field [field] of type [long]"));
     }
 
     public void testDynamicFalseDottedFieldNameLongArray() throws Exception {
@@ -1321,11 +1311,7 @@ public class DocumentParserTests extends MapperServiceTestCase {
             MapperParsingException.class,
             () -> mapper.parse(source(b -> b.field("field.bar.baz", 0)))
         );
-        assertEquals(
-            "Could not dynamically add mapping for field [field.bar.baz]. "
-                + "Existing mapping for [field] must be of type object but found [long].",
-            exception.getMessage()
-        );
+        assertThat(exception.getMessage(), containsString("failed to parse field [field] of type [long]"));
     }
 
     public void testDynamicFalseDottedFieldNameLong() throws Exception {
@@ -1420,11 +1406,7 @@ public class DocumentParserTests extends MapperServiceTestCase {
             MapperParsingException.class,
             () -> mapper.parse(source(b -> b.startObject("field.bar.baz").field("a", 0).endObject()))
         );
-        assertEquals(
-            "Could not dynamically add mapping for field [field.bar.baz]. "
-                + "Existing mapping for [field] must be of type object but found [long].",
-            exception.getMessage()
-        );
+        assertThat(exception.getMessage(), containsString("failed to parse field [field] of type [long]"));
     }
 
     public void testDynamicFalseDottedFieldNameObject() throws Exception {
@@ -1785,22 +1767,6 @@ public class DocumentParserTests extends MapperServiceTestCase {
 
     public void testDynamicFieldsStartingAndEndingWithDot() throws Exception {
         MapperService mapperService = createMapperService(mapping(b -> {}));
-        merge(mapperService, dynamicMapping(mapperService.documentMapper().parse(source(b -> {
-            b.startArray("top.");
-            {
-                b.startObject();
-                {
-                    b.startArray("foo.");
-                    {
-                        b.startObject().field("thing", "bah").endObject();
-                    }
-                    b.endArray();
-                }
-                b.endObject();
-            }
-            b.endArray();
-        })).dynamicMappingsUpdate()));
-
         IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> mapperService.documentMapper().parse(source(b -> {
             b.startArray("top.");
             {
@@ -1827,7 +1793,7 @@ public class DocumentParserTests extends MapperServiceTestCase {
 
         assertThat(
             e.getMessage(),
-            containsString("object field starting or ending with a [.] makes object resolution ambiguous: [top..foo..bar]")
+            containsString("object field starting or ending with a [.] makes object resolution ambiguous: [top..foo.]")
         );
     }
 
@@ -1835,7 +1801,7 @@ public class DocumentParserTests extends MapperServiceTestCase {
         DocumentMapper mapper = createDocumentMapper(mapping(b -> {}));
 
         IllegalArgumentException emptyFieldNameException = expectThrows(IllegalArgumentException.class, () -> mapper.parse(source(b -> {
-            b.startArray("top.");
+            b.startArray("top");
             {
                 b.startObject();
                 {
@@ -1889,7 +1855,7 @@ public class DocumentParserTests extends MapperServiceTestCase {
             () -> mapper.parse(source(b -> b.field("alias-field", "value")))
         );
 
-        assertEquals("Cannot write to a field alias [alias-field].", exception.getCause().getMessage());
+        assertEquals("Cannot write to a field alias [alias-field].", exception.getMessage());
     }
 
     public void testCopyToFieldAlias() throws Exception {
@@ -1914,7 +1880,7 @@ public class DocumentParserTests extends MapperServiceTestCase {
             () -> mapper.parse(source(b -> b.field("text-field", "value")))
         );
 
-        assertEquals("Cannot copy to a field alias [alias-field].", exception.getCause().getMessage());
+        assertEquals("Cannot copy to a field alias [alias-field].", exception.getMessage());
     }
 
     public void testDynamicDottedFieldNameWithFieldAlias() throws Exception {
@@ -1933,11 +1899,7 @@ public class DocumentParserTests extends MapperServiceTestCase {
             () -> mapper.parse(source(b -> b.startObject("alias-field.dynamic-field").field("type", "keyword").endObject()))
         );
 
-        assertEquals(
-            "Could not dynamically add mapping for field [alias-field.dynamic-field]. "
-                + "Existing mapping for [alias-field] must be of type object but found [alias].",
-            exception.getMessage()
-        );
+        assertEquals("Cannot write to a field alias [alias-field].", exception.getMessage());
     }
 
     public void testMultifieldOverwriteFails() throws Exception {
@@ -1962,12 +1924,7 @@ public class DocumentParserTests extends MapperServiceTestCase {
             MapperParsingException.class,
             () -> mapper.parse(source(b -> b.field("message", "original").field("message.text", "overwrite")))
         );
-
-        assertEquals(
-            "Could not dynamically add mapping for field [message.text]. "
-                + "Existing mapping for [message] must be of type object but found [keyword].",
-            exception.getMessage()
-        );
+        assertThat(exception.getMessage(), containsString("failed to parse field [message] of type [keyword]"));
     }
 
     public void testTypeless() throws IOException {

+ 1 - 2
server/src/test/java/org/elasticsearch/index/mapper/NumberFieldMapperTests.java

@@ -199,8 +199,7 @@ public abstract class NumberFieldMapperTests extends MapperTestCase {
                 b.field("ignore_malformed", ignoreMalformed);
             }));
             MapperParsingException e = expectThrows(MapperParsingException.class, () -> mapper.parse(malformed));
-            assertThat(e.getCause().getMessage(), containsString("Current token"));
-            assertThat(e.getCause().getMessage(), containsString("not numeric, can not use numeric value accessors"));
+            assertThat(e.getCause().getMessage(), containsString("Cannot parse object as number"));
         }
     }
 

+ 22 - 0
server/src/test/java/org/elasticsearch/index/mapper/flattened/FlattenedFieldMapperTests.java

@@ -399,4 +399,26 @@ public class FlattenedFieldMapperTests extends MapperTestCase {
         assumeFalse("Test implemented in a follow up", true);
         return null;
     }
+
+    public void testDynamicTemplateAndDottedPaths() throws IOException {
+        DocumentMapper mapper = createDocumentMapper(topMapping(b -> {
+            b.startArray("dynamic_templates");
+            b.startObject();
+            b.startObject("no_deep_objects");
+            b.field("path_match", "*.*.*");
+            b.field("match_mapping_type", "object");
+            b.startObject("mapping");
+            b.field("type", "flattened");
+            b.endObject();
+            b.endObject();
+            b.endObject();
+            b.endArray();
+        }));
+
+        ParsedDocument doc = mapper.parse(source(b -> b.field("a.b.c.d", "value")));
+        IndexableField[] fields = doc.rootDoc().getFields("a.b.c");
+        assertEquals(new BytesRef("value"), fields[0].binaryValue());
+        IndexableField[] keyed = doc.rootDoc().getFields("a.b.c._keyed");
+        assertEquals(new BytesRef("d\0value"), keyed[0].binaryValue());
+    }
 }