SOLR-17274: allow JSON atomic updates to use multiple modifiers or a modifier like 'set' as a field name if child docs not enabled  (#2451)

* fix two problems with json atomic update [SOLR-17274]

 - this modifies `isChildDoc` to always return false if child docs are
   not possible according to the schema, and restores part of the
   logic of `parseObjectFieldValue` so that it can handle
   multiple modifiers
 - re-enables the ability to use multiple modifiers in a single
   update, like `{"add": "foo", "remove": "bar"}`
 - re-enables the ability to have a field with a name like
   `set` or `remove` and be able to use updates like
   `{"set": {"set": "foo"}`
 - both of these are possible now but only if the schema
   doesn't support child/nested docs; if the schema does
   support them, then functionality is unchanged and either
   will continue throwing an exception

---------

Co-authored-by: Calvin Smith <casmith@lucasfilm.com>
Co-authored-by: David Smiley <dsmiley@apache.org>
diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt
index a98bc9b..d231a3d 100644
--- a/solr/CHANGES.txt
+++ b/solr/CHANGES.txt
@@ -113,6 +113,9 @@
 
 * SOLR-14115: Add linkconfig, cluster, and updateacls as commands to SolrCLI.  Allow bin/solr commands to have parity with zkcli.sh commands. (Eric Pugh)
 
+* SOLR-17274: Allow JSON atomic updates to use multiple modifiers or a modifier like 'set' as a field name
+  if child docs are not enabled. (Calvin Smith, David Smiley)
+
 Optimizations
 ---------------------
 * SOLR-17257: Both Minimize Cores and the Affinity replica placement strategies would over-gather
diff --git a/solr/core/src/java/org/apache/solr/handler/loader/JsonLoader.java b/solr/core/src/java/org/apache/solr/handler/loader/JsonLoader.java
index 82231d0..48e095a 100644
--- a/solr/core/src/java/org/apache/solr/handler/loader/JsonLoader.java
+++ b/solr/core/src/java/org/apache/solr/handler/loader/JsonLoader.java
@@ -29,7 +29,6 @@
 import java.lang.invoke.MethodHandles;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Collections;
 import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -43,6 +42,7 @@
 import org.apache.solr.common.params.CommonParams;
 import org.apache.solr.common.params.SolrParams;
 import org.apache.solr.common.params.UpdateParams;
+import org.apache.solr.common.util.CollectionUtil;
 import org.apache.solr.common.util.ContentStream;
 import org.apache.solr.common.util.JsonRecordReader;
 import org.apache.solr.common.util.StrUtils;
@@ -50,6 +50,7 @@
 import org.apache.solr.handler.UpdateRequestHandler;
 import org.apache.solr.request.SolrQueryRequest;
 import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.schema.IndexSchema;
 import org.apache.solr.schema.SchemaField;
 import org.apache.solr.update.AddUpdateCommand;
 import org.apache.solr.update.CommitUpdateCommand;
@@ -657,21 +658,31 @@
       // Is this a child doc (true) or a partial update (false)
       if (isChildDoc(extendedSolrDocument)) {
         return extendedSolrDocument;
-      } else { // partial update
-        assert extendedSolrDocument.size() == 1;
-        final SolrInputField pair = extendedSolrDocument.iterator().next();
-        return Collections.singletonMap(pair.getName(), pair.getValue());
+      } else { // partial update: can include multiple modifiers (e.g. 'add', 'remove')
+        Map<String, Object> map = CollectionUtil.newLinkedHashMap(extendedSolrDocument.size());
+        for (SolrInputField inputField : extendedSolrDocument) {
+          map.put(inputField.getName(), inputField.getValue());
+        }
+        return map;
       }
     }
 
     /** Is this a child doc (true) or a partial update (false)? */
     private boolean isChildDoc(SolrInputDocument extendedFieldValue) {
-      if (extendedFieldValue.size() != 1) {
+      IndexSchema schema = req.getSchema();
+      // If schema doesn't support child docs, return false immediately, which
+      // allows people to do atomic updates with multiple modifiers (like 'add'
+      // and 'remove' for a single doc) and to do single-modifier updates for a
+      // field with a name like 'set' that is defined in the schema, both of
+      // which would otherwise fail.
+      if (!schema.isUsableForChildDocs()) {
+        return false;
+      } else if (extendedFieldValue.size() != 1) {
         return true;
       }
       // if the only key is a field in the schema, assume it's a child doc
       final String fieldName = extendedFieldValue.iterator().next().getName();
-      return req.getSchema().getFieldOrNull(fieldName) != null;
+      return schema.getFieldOrNull(fieldName) != null;
       // otherwise, assume it's "set" or some other verb for a partial update.
       // NOTE: it's fundamentally ambiguous with JSON; this is a best effort try.
     }
diff --git a/solr/core/src/test-files/solr/collection1/conf/atomic-update-json-test.xml b/solr/core/src/test-files/solr/collection1/conf/atomic-update-json-test.xml
new file mode 100644
index 0000000..820c13e
--- /dev/null
+++ b/solr/core/src/test-files/solr/collection1/conf/atomic-update-json-test.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<schema name="id-version-and-multivalued-name-field" version="1.1">
+  <fieldType name="string" class="solr.StrField" sortMissingLast="true" omitNorms="true"/>
+  <field name="id" type="string" indexed="true" stored="true"/>
+  <field name="_version_" type="string" indexed="true" stored="true"/>
+  <field name="name" type="string" indexed="true" stored="true" multiValued="true"/>
+    <!-- two fields (one multivalued, one not) for verifying correct behavior when
+         an atomic update modifier is used as a field name. -->
+  <field name="set" type="string" indexed="true" stored="true"/>
+  <field name="add" type="string" indexed="true" stored="true" multiValued="true"/>
+  <!-- If the following is uncommented, then the schema will support
+    nested docs, and all but one of the tests in AtomicUpdatesJsonTest.java
+    will fail. -->
+  <!-- <field name="_root_" type="string" indexed="true" stored="true"/> -->
+  <uniqueKey>id</uniqueKey>
+</schema>
diff --git a/solr/core/src/test/org/apache/solr/update/processor/AtomicUpdateJsonTest.java b/solr/core/src/test/org/apache/solr/update/processor/AtomicUpdateJsonTest.java
new file mode 100644
index 0000000..699e01c
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/update/processor/AtomicUpdateJsonTest.java
@@ -0,0 +1,184 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.solr.update.processor;
+
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.solr.SolrTestCaseJ4;
+import org.apache.solr.common.SolrInputDocument;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+// Tests atomic updates using JSON loader, since the existing
+// tests all use XML format, and there have been some atomic update
+// issues that were specific to the JSON format.
+public class AtomicUpdateJsonTest extends SolrTestCaseJ4 {
+
+  @BeforeClass
+  public static void beforeTests() throws Exception {
+    System.setProperty("enable.update.log", "true");
+    initCore("solrconfig.xml", "atomic-update-json-test.xml");
+  }
+
+  @Before
+  public void before() {
+    assertU(delQ("*:*"));
+    assertU(commit());
+  }
+
+  @Test
+  public void testSchemaIsNotUsableForChildDocs() throws Exception {
+    // the schema we loaded shouldn't be usable for child docs,
+    // since we're testing JSON loader functionality that only
+    // works in that case and is ambiguous if nested docs are supported.
+    assertFalse(h.getCore().getLatestSchema().isUsableForChildDocs());
+  }
+
+  @Test
+  public void testAddOne() throws Exception {
+    SolrInputDocument doc = new SolrInputDocument();
+    doc.setField("id", "1");
+    doc.setField("name", new String[] {"aaa"});
+    updateJ(jsonAdd(doc), null);
+    assertU(commit());
+    assertQ(req("q", "name:bbb"), "//result[@numFound = '0']");
+
+    doc = new SolrInputDocument();
+    doc.setField("id", "1");
+    doc.setField("name", Map.of("add", "bbb"));
+    updateJ(jsonAdd(doc), null);
+    assertU(commit());
+    assertQ(req("q", "name:bbb"), "//result[@numFound = '1']");
+  }
+
+  @Test
+  public void testRemoveOne() throws Exception {
+    SolrInputDocument doc = new SolrInputDocument();
+    doc.setField("id", "1");
+    doc.setField("name", new String[] {"aaa", "bbb"});
+    updateJ(jsonAdd(doc), null);
+    assertU(commit());
+    assertQ(req("q", "name:bbb"), "//result[@numFound = '1']");
+
+    doc = new SolrInputDocument();
+    doc.setField("id", "1");
+    doc.setField("name", Map.of("remove", "bbb"));
+    updateJ(jsonAdd(doc), null);
+    assertU(commit());
+    assertQ(req("q", "name:bbb"), "//result[@numFound = '0']");
+    assertQ(req("q", "name:aaa"), "//result[@numFound = '1']");
+  }
+
+  @Test
+  public void testRemoveMultiple() throws Exception {
+    SolrInputDocument doc = new SolrInputDocument();
+    doc.setField("id", "1");
+    doc.setField("name", new String[] {"aaa", "bbb", "ccc"});
+    updateJ(jsonAdd(doc), null);
+    assertU(commit());
+    assertQ(req("q", "name:bbb"), "//result[@numFound = '1']");
+
+    doc = new SolrInputDocument();
+    doc.setField("id", "1");
+    doc.setField(
+        "name", Map.of("add", new String[] {"ddd", "eee"}, "remove", new String[] {"aaa", "ccc"}));
+    updateJ(jsonAdd(doc), null);
+    assertU(commit());
+    assertQ(req("q", "name:aaa"), "//result[@numFound = '0']");
+    assertQ(req("q", "name:ccc"), "//result[@numFound = '0']");
+    assertQ(req("q", "name:bbb"), "//result[@numFound = '1']");
+    assertQ(req("q", "name:ddd"), "//result[@numFound = '1']");
+    assertQ(req("q", "name:eee"), "//result[@numFound = '1']");
+  }
+
+  @Test
+  public void testAddAndRemove() throws Exception {
+    SolrInputDocument doc = new SolrInputDocument();
+    doc.setField("id", "1");
+    doc.setField("name", new String[] {"aaa", "bbb", "ccc"});
+    updateJ(jsonAdd(doc), null);
+    assertU(commit());
+    assertQ(req("q", "name:aaa"), "//result[@numFound = '1']");
+    assertQ(req("q", "name:bbb"), "//result[@numFound = '1']");
+    assertQ(req("q", "name:ccc"), "//result[@numFound = '1']");
+
+    doc = new SolrInputDocument();
+    doc.setField("id", "1");
+    doc.setField("name", Map.of("add", "ddd", "remove", "bbb"));
+    updateJ(jsonAdd(doc), null);
+    assertU(commit());
+    assertQ(req("q", "name:ddd"), "//result[@numFound = '1']");
+    assertQ(req("q", "name:bbb"), "//result[@numFound = '0']");
+    assertQ(req("q", "name:ccc"), "//result[@numFound = '1']");
+    assertQ(req("q", "name:aaa"), "//result[@numFound = '1']");
+  }
+
+  @Test
+  public void testAtomicUpdateModifierNameSingleValued() throws Exception {
+    // Testing atomic update with a single-valued field named 'set'
+    SolrInputDocument doc = new SolrInputDocument();
+    doc.setField("id", "1");
+    doc.setField("set", "setval");
+    doc.setField("name", new String[] {"aaa"});
+    updateJ(jsonAdd(doc), null);
+    assertU(commit());
+    assertQ(req("q", "set:setval"), "//result[@numFound = '1']");
+
+    doc = new SolrInputDocument();
+    doc.setField("id", "1");
+    doc.setField("set", Map.of("set", "modval"));
+    updateJ(jsonAdd(doc), null);
+    assertU(commit());
+    assertQ(req("q", "set:modval"), "//result[@numFound = '1']");
+    assertQ(req("q", "set:setval"), "//result[@numFound = '0']");
+
+    doc = new SolrInputDocument();
+    doc.setField("id", "1");
+    Map<String, String> removeSetField = new HashMap<>();
+    removeSetField.put("set", null);
+    doc.setField("set", removeSetField);
+    updateJ(jsonAdd(doc), null);
+    assertU(commit());
+    assertQ(req("q", "set:modval"), "//result[@numFound = '0']");
+    assertQ(req("q", "name:aaa"), "//result[@numFound = '1']");
+  }
+
+  @Test
+  public void testAtomicUpdateModifierNameMultiValued() throws Exception {
+    // Testing atomic update with a multi-valued field named 'add'
+    SolrInputDocument doc = new SolrInputDocument();
+    doc.setField("id", "1");
+    doc.setField("name", new String[] {"aaa"});
+    doc.setField("add", new String[] {"aaa", "bbb", "ccc"});
+    updateJ(jsonAdd(doc), null);
+    assertU(commit());
+    assertQ(req("q", "add:bbb"), "//result[@numFound = '1']");
+
+    doc = new SolrInputDocument();
+    doc.setField("id", "1");
+    doc.setField(
+        "add", Map.of("add", new String[] {"ddd", "eee"}, "remove", new String[] {"bbb", "ccc"}));
+    updateJ(jsonAdd(doc), null);
+    assertU(commit());
+    assertQ(req("q", "add:ddd"), "//result[@numFound = '1']");
+    assertQ(req("q", "add:eee"), "//result[@numFound = '1']");
+    assertQ(req("q", "add:aaa"), "//result[@numFound = '1']");
+    assertQ(req("q", "add:bbb"), "//result[@numFound = '0']");
+    assertQ(req("q", "add:ccc"), "//result[@numFound = '0']");
+  }
+}