This is an automated email from the ASF dual-hosted git repository.

rombert pushed a commit to branch master
in repository 
https://gitbox.apache.org/repos/asf/sling-org-apache-sling-graphql-core.git


The following commit(s) were added to refs/heads/master by this push:
     new d4f779c  SLING-13175 - [GraphQL] Duplicate field aliases lose 
sub-fields present only in the second alias (#46)
d4f779c is described below

commit d4f779c44068fdc1069b4a3e1db8987e0259087f
Author: Martin Schulze <[email protected]>
AuthorDate: Thu Apr 23 09:49:05 2026 +0200

    SLING-13175 - [GraphQL] Duplicate field aliases lose sub-fields present 
only in the second alias (#46)
    
    ---------
    
    Co-authored-by: Martin Schulze <[email protected]>
---
 .../graphql/core/engine/SelectedFieldWrapper.java  |  29 ++-
 .../core/engine/SelectedFieldWrapperTest.java      | 241 ++++++++++++++-------
 .../core/engine/SelectionSetWrapperTest.java       | 157 ++++++++++++++
 3 files changed, 334 insertions(+), 93 deletions(-)

diff --git 
a/src/main/java/org/apache/sling/graphql/core/engine/SelectedFieldWrapper.java 
b/src/main/java/org/apache/sling/graphql/core/engine/SelectedFieldWrapper.java
index 9f399fb..0deaf39 100644
--- 
a/src/main/java/org/apache/sling/graphql/core/engine/SelectedFieldWrapper.java
+++ 
b/src/main/java/org/apache/sling/graphql/core/engine/SelectedFieldWrapper.java
@@ -71,15 +71,16 @@ public class SelectedFieldWrapper implements SelectedField {
         if (selectionSet != null) {
             selectionSet.getImmediateFields().forEach(sf -> {
                 SelectedFieldWrapper selectedChildField = new 
SelectedFieldWrapper(sf);
+                // Always add to the by-name multimap so 
hasDuplicateFieldByName() works
+                subFieldMap.put(sf.getName(), selectedChildField);
                 String fqn = sf.getFullyQualifiedName();
                 SelectedField existing = subFQNFieldMap.get(fqn);
                 if (existing instanceof SelectedFieldWrapper) {
-                    // Merge sub-fields from duplicate entries (e.g. aliased 
selections
+                    // Merge sub-fields from duplicate FQN entries (e.g. 
aliased selections
                     // of the same field with different inline fragments)
                     ((SelectedFieldWrapper) 
existing).mergeSubFields(selectedChildField);
                 } else {
                     subFQNFieldMap.put(fqn, selectedChildField);
-                    subFieldMap.put(sf.getName(), selectedChildField);
                 }
             });
         }
@@ -91,17 +92,27 @@ public class SelectedFieldWrapper implements SelectedField {
      * Merge sub-fields from another SelectedFieldWrapper into this one.
      * This handles the case where the same field is selected multiple times
      * (e.g. via aliases with different inline fragments), and the 
sub-selections
-     * need to be combined.
+     * need to be combined. The merge is recursive: if both wrappers contain a
+     * child with the same FQN, their sub-fields are merged in turn rather than
+     * silently dropping one.
      */
+    // S3824: computeIfAbsent cannot be used here because the existing-value 
branch
+    // must trigger a recursive merge rather than a replacement.
+    @SuppressWarnings("java:S3824")
     void mergeSubFields(SelectedFieldWrapper other) {
         for (SelectedField otherSub : other.getSubSelectedFields()) {
             String fqn = otherSub.getFullyQualifiedName();
-            if (fqn != null) {
-                subFQNFieldMap.computeIfAbsent(fqn, k -> {
-                    subFieldMap.put(otherSub.getName(), otherSub);
-                    subFields.add(otherSub);
-                    return otherSub;
-                });
+            if (fqn == null) {
+                continue;
+            }
+            SelectedField existingSub = subFQNFieldMap.get(fqn);
+            if (existingSub instanceof SelectedFieldWrapper && otherSub 
instanceof SelectedFieldWrapper) {
+                // Recursively merge deeper levels
+                ((SelectedFieldWrapper) 
existingSub).mergeSubFields((SelectedFieldWrapper) otherSub);
+            } else if (existingSub == null) {
+                subFQNFieldMap.put(fqn, otherSub);
+                subFieldMap.put(otherSub.getName(), otherSub);
+                subFields.add(otherSub);
             }
         }
     }
diff --git 
a/src/test/java/org/apache/sling/graphql/core/engine/SelectedFieldWrapperTest.java
 
b/src/test/java/org/apache/sling/graphql/core/engine/SelectedFieldWrapperTest.java
index 66f1e53..902b9cb 100644
--- 
a/src/test/java/org/apache/sling/graphql/core/engine/SelectedFieldWrapperTest.java
+++ 
b/src/test/java/org/apache/sling/graphql/core/engine/SelectedFieldWrapperTest.java
@@ -181,22 +181,18 @@ public class SelectedFieldWrapperTest {
     }
 
     /**
-     * Tests the mergeSubFields behavior (SITES-42449): when graphql-java 
returns
-     * two immediate children with the same FQN (e.g. two "items" entries from 
aliased
-     * inline fragments), their sub-fields must be merged rather than the 
first being
-     * overwritten by the second.
-     *
-     * Simulates: { parentList { items { ... on ModelA { fieldA } ... on 
ModelB { fieldB } } } }
-     * where graphql-java produces two "items" entries with FQN 
"Parent.items", each
-     * carrying different sub-fields (ModelA.fieldA vs ModelB.fieldB).
+     * Tests merge when two immediate children share the same FQN but carry
+     * different sub-fields (e.g. two aliased selections of the same field with
+     * different inline fragments). The merged wrapper must contain sub-fields
+     * from both entries, and hasDuplicateFieldByName must still report the
+     * duplicate.
      */
     @Test
     public void testMergeSubFieldsForDuplicateFQN() {
-        // FQN shared by both "items" entries
         String itemsFqn = "Parent.items";
         String itemsName = "items";
 
-        // Sub-fields from the first inline fragment (ModelA)
+        // First "items" entry carries sub-field fieldA
         String subFqnA = "ModelA.fieldA";
         String subNameA = "fieldA";
         graphql.schema.SelectedField sourceSubA = 
mock(graphql.schema.SelectedField.class);
@@ -213,7 +209,7 @@ public class SelectedFieldWrapperTest {
         doReturn(itemsFqn).when(sourceItems1).getFullyQualifiedName();
         doReturn(selSetItems1).when(sourceItems1).getSelectionSet();
 
-        // Sub-fields from the second inline fragment (ModelB)
+        // Second "items" entry carries sub-field fieldB
         String subFqnB = "ModelB.fieldB";
         String subNameB = "fieldB";
         graphql.schema.SelectedField sourceSubB = 
mock(graphql.schema.SelectedField.class);
@@ -230,7 +226,7 @@ public class SelectedFieldWrapperTest {
         doReturn(itemsFqn).when(sourceItems2).getFullyQualifiedName();
         doReturn(selSetItems2).when(sourceItems2).getSelectionSet();
 
-        // Parent field with both "items" entries as immediate children
+        // Parent with both items entries
         graphql.schema.SelectedField sourceParent = 
mock(graphql.schema.SelectedField.class);
         doReturn("parent").when(sourceParent).getName();
         doReturn("parent").when(sourceParent).getQualifiedName();
@@ -242,98 +238,175 @@ public class SelectedFieldWrapperTest {
 
         SelectedFieldWrapper parent = new SelectedFieldWrapper(sourceParent);
 
-        // The parent should have exactly one "items" child (merged, not 
duplicated)
+        // hasDuplicateFieldByName must still detect both entries
+        assertTrue("items should be reported as duplicate by name",
+                parent.hasDuplicateFieldByName(itemsName));
+
+        // Merged items must contain sub-fields from BOTH entries
         SelectedField itemsField = parent.getSubSelectedFieldByFQN(itemsFqn);
         assertNotNull("Items field not found by FQN", itemsField);
-
-        // The merged "items" field should contain sub-fields from BOTH inline 
fragments
-        assertTrue("Sub-field from first inline fragment (ModelA.fieldA) 
missing",
-                itemsField.hasSubSelectedFieldsByFQN(subFqnA));
-        assertTrue("Sub-field from second inline fragment (ModelB.fieldB) 
missing",
-                itemsField.hasSubSelectedFieldsByFQN(subFqnB));
-
-        // Verify by direct lookup
-        SelectedField foundA = itemsField.getSubSelectedFieldByFQN(subFqnA);
-        assertNotNull("fieldA not found by FQN", foundA);
-        assertEquals("Wrong name for fieldA", subNameA, foundA.getName());
-
-        SelectedField foundB = itemsField.getSubSelectedFieldByFQN(subFqnB);
-        assertNotNull("fieldB not found by FQN", foundB);
-        assertEquals("Wrong name for fieldB", subNameB, foundB.getName());
-
-        // Total sub-fields count should be 2 (one from each inline fragment)
+        assertTrue("fieldA missing", 
itemsField.hasSubSelectedFieldsByFQN(subFqnA));
+        assertTrue("fieldB missing", 
itemsField.hasSubSelectedFieldsByFQN(subFqnB));
         assertEquals("Expected 2 merged sub-fields", 2, 
itemsField.getSubSelectedFields().size());
     }
 
     /**
-     * Verifies that when two immediate children share the same FQN (and 
simple name),
-     * the merged-away duplicate does NOT remain in subFieldMap.
+     * Tests recursive merge: two aliased selections of the same field where
+     * both contain a child with the same FQN (e.g. policyCategories) but with
+     * different deeper sub-fields. The merge must recurse so that the deeper
+     * sub-fields from both branches are visible.
      *
-     * Before the fix, subFieldMap.put() ran unconditionally before the FQN 
dedup merge,
-     * leaving a stale wrapper in the map. This caused 
hasDuplicateFieldByName() to report
-     * a false positive and getSubSelectedFieldByName() to return both the 
merged wrapper
-     * and the stale one.
+     * Simulates: policyTabList: policy { policyCategories { id, name } }
+     *            policyTabDetailList: policy { policyCategories { id, name, 
policies { ... } } }
+     */
+    @Test
+    public void testRecursiveMergeForSharedChildFQN() {
+        String policyFqn = "PoliciesModel.policy";
+        String policyName = "policy";
+        String catFqn = "PolicyModel.policyCategories";
+        String catName = "policyCategories";
+
+        // --- Shared leaf fields under policyCategories ---
+        graphql.schema.SelectedField idField = 
mock(graphql.schema.SelectedField.class);
+        doReturn("policyCategoryId").when(idField).getName();
+        doReturn("policyCategoryId").when(idField).getQualifiedName();
+        
doReturn("CategoryModel.policyCategoryId").when(idField).getFullyQualifiedName();
+
+        graphql.schema.SelectedField nameField = 
mock(graphql.schema.SelectedField.class);
+        doReturn("policyCategoryName").when(nameField).getName();
+        doReturn("policyCategoryName").when(nameField).getQualifiedName();
+        
doReturn("CategoryModel.policyCategoryName").when(nameField).getFullyQualifiedName();
+
+        // --- Extra deep field only in second alias ---
+        graphql.schema.SelectedField policiesField = 
mock(graphql.schema.SelectedField.class);
+        doReturn("policies").when(policiesField).getName();
+        doReturn("policies").when(policiesField).getQualifiedName();
+        
doReturn("CategoryModel.policies").when(policiesField).getFullyQualifiedName();
+
+        // First policyCategories (shallow: id + name)
+        DataFetchingFieldSelectionSet catSelSet1 = 
mock(DataFetchingFieldSelectionSet.class);
+        doReturn(Arrays.asList(idField, 
nameField)).when(catSelSet1).getImmediateFields();
+        graphql.schema.SelectedField cat1 = 
mock(graphql.schema.SelectedField.class);
+        doReturn(catName).when(cat1).getName();
+        doReturn(catName).when(cat1).getQualifiedName();
+        doReturn(catFqn).when(cat1).getFullyQualifiedName();
+        doReturn(catSelSet1).when(cat1).getSelectionSet();
+
+        // Second policyCategories (deep: id + name + policies)
+        DataFetchingFieldSelectionSet catSelSet2 = 
mock(DataFetchingFieldSelectionSet.class);
+        doReturn(Arrays.asList(idField, nameField, 
policiesField)).when(catSelSet2).getImmediateFields();
+        graphql.schema.SelectedField cat2 = 
mock(graphql.schema.SelectedField.class);
+        doReturn(catName).when(cat2).getName();
+        doReturn(catName).when(cat2).getQualifiedName();
+        doReturn(catFqn).when(cat2).getFullyQualifiedName();
+        doReturn(catSelSet2).when(cat2).getSelectionSet();
+
+        // First policy entry (alias policyTabList)
+        DataFetchingFieldSelectionSet policySelSet1 = 
mock(DataFetchingFieldSelectionSet.class);
+        doReturn(Arrays.asList(cat1)).when(policySelSet1).getImmediateFields();
+        graphql.schema.SelectedField policy1 = 
mock(graphql.schema.SelectedField.class);
+        doReturn(policyName).when(policy1).getName();
+        doReturn(policyName).when(policy1).getQualifiedName();
+        doReturn(policyFqn).when(policy1).getFullyQualifiedName();
+        doReturn(policySelSet1).when(policy1).getSelectionSet();
+
+        // Second policy entry (alias policyTabDetailList)
+        DataFetchingFieldSelectionSet policySelSet2 = 
mock(DataFetchingFieldSelectionSet.class);
+        doReturn(Arrays.asList(cat2)).when(policySelSet2).getImmediateFields();
+        graphql.schema.SelectedField policy2 = 
mock(graphql.schema.SelectedField.class);
+        doReturn(policyName).when(policy2).getName();
+        doReturn(policyName).when(policy2).getQualifiedName();
+        doReturn(policyFqn).when(policy2).getFullyQualifiedName();
+        doReturn(policySelSet2).when(policy2).getSelectionSet();
+
+        // Parent item containing both policy selections
+        graphql.schema.SelectedField parentField = 
mock(graphql.schema.SelectedField.class);
+        doReturn("item").when(parentField).getName();
+        doReturn("item").when(parentField).getQualifiedName();
+        doReturn("Query.item").when(parentField).getFullyQualifiedName();
+        DataFetchingFieldSelectionSet parentSelSet = 
mock(DataFetchingFieldSelectionSet.class);
+        doReturn(Arrays.asList(policy1, 
policy2)).when(parentSelSet).getImmediateFields();
+        doReturn(parentSelSet).when(parentField).getSelectionSet();
+
+        SelectedFieldWrapper parent = new SelectedFieldWrapper(parentField);
+
+        // policy should be flagged as duplicate
+        assertTrue("policy should be duplicate by name",
+                parent.hasDuplicateFieldByName(policyName));
+
+        // Merged policy → policyCategories must contain all 3 sub-fields
+        SelectedField policyField = parent.getSubSelectedFieldByFQN(policyFqn);
+        assertNotNull("policy not found by FQN", policyField);
+
+        SelectedField catField = policyField.getSubSelectedFieldByFQN(catFqn);
+        assertNotNull("policyCategories not found", catField);
+
+        assertTrue("policyCategoryId missing",
+                
catField.hasSubSelectedFieldsByFQN("CategoryModel.policyCategoryId"));
+        assertTrue("policyCategoryName missing",
+                
catField.hasSubSelectedFieldsByFQN("CategoryModel.policyCategoryName"));
+        assertTrue("policies missing (recursive merge failed)",
+                catField.hasSubSelectedFieldsByFQN("CategoryModel.policies"));
+        assertEquals("Expected 3 sub-fields after recursive merge",
+                3, catField.getSubSelectedFields().size());
+    }
+
+    /**
+     * Tests that mergeSubFields silently skips sub-fields whose FQN is null.
+     * Such entries must not be added to the merged wrapper.
      */
     @Test
-    public void testNoDuplicateInSubFieldMapAfterFQNMerge() {
+    public void testMergeSubFieldsSkipsNullFqn() {
         String itemsFqn = "Parent.items";
         String itemsName = "items";
 
-        // First "items" child with one sub-field
-        graphql.schema.SelectedField sourceSubA = 
mock(graphql.schema.SelectedField.class);
-        doReturn("fieldA").when(sourceSubA).getName();
-        doReturn("fieldA").when(sourceSubA).getQualifiedName();
-        doReturn("ModelA.fieldA").when(sourceSubA).getFullyQualifiedName();
-
-        DataFetchingFieldSelectionSet selSetItems1 = 
mock(DataFetchingFieldSelectionSet.class);
-        
doReturn(Arrays.asList(sourceSubA)).when(selSetItems1).getImmediateFields();
-
-        graphql.schema.SelectedField sourceItems1 = 
mock(graphql.schema.SelectedField.class);
-        doReturn(itemsName).when(sourceItems1).getName();
-        doReturn(itemsName).when(sourceItems1).getQualifiedName();
-        doReturn(itemsFqn).when(sourceItems1).getFullyQualifiedName();
-        doReturn(selSetItems1).when(sourceItems1).getSelectionSet();
-
-        // Second "items" child (same name + same FQN) with a different 
sub-field
-        graphql.schema.SelectedField sourceSubB = 
mock(graphql.schema.SelectedField.class);
-        doReturn("fieldB").when(sourceSubB).getName();
-        doReturn("fieldB").when(sourceSubB).getQualifiedName();
-        doReturn("ModelB.fieldB").when(sourceSubB).getFullyQualifiedName();
-
-        DataFetchingFieldSelectionSet selSetItems2 = 
mock(DataFetchingFieldSelectionSet.class);
-        
doReturn(Arrays.asList(sourceSubB)).when(selSetItems2).getImmediateFields();
-
-        graphql.schema.SelectedField sourceItems2 = 
mock(graphql.schema.SelectedField.class);
-        doReturn(itemsName).when(sourceItems2).getName();
-        doReturn(itemsName).when(sourceItems2).getQualifiedName();
-        doReturn(itemsFqn).when(sourceItems2).getFullyQualifiedName();
-        doReturn(selSetItems2).when(sourceItems2).getSelectionSet();
-
-        // Parent with both "items" entries
+        // First "items" entry with a real sub-field
+        graphql.schema.SelectedField realSub = 
mock(graphql.schema.SelectedField.class);
+        doReturn("realField").when(realSub).getName();
+        doReturn("realField").when(realSub).getQualifiedName();
+        doReturn("Model.realField").when(realSub).getFullyQualifiedName();
+
+        DataFetchingFieldSelectionSet selSet1 = 
mock(DataFetchingFieldSelectionSet.class);
+        doReturn(Arrays.asList(realSub)).when(selSet1).getImmediateFields();
+
+        graphql.schema.SelectedField items1 = 
mock(graphql.schema.SelectedField.class);
+        doReturn(itemsName).when(items1).getName();
+        doReturn(itemsName).when(items1).getQualifiedName();
+        doReturn(itemsFqn).when(items1).getFullyQualifiedName();
+        doReturn(selSet1).when(items1).getSelectionSet();
+
+        // Second "items" entry with a sub-field whose FQN is null
+        graphql.schema.SelectedField nullFqnSub = 
mock(graphql.schema.SelectedField.class);
+        doReturn("ghostField").when(nullFqnSub).getName();
+        doReturn("ghostField").when(nullFqnSub).getQualifiedName();
+        doReturn(null).when(nullFqnSub).getFullyQualifiedName();
+
+        DataFetchingFieldSelectionSet selSet2 = 
mock(DataFetchingFieldSelectionSet.class);
+        doReturn(Arrays.asList(nullFqnSub)).when(selSet2).getImmediateFields();
+
+        graphql.schema.SelectedField items2 = 
mock(graphql.schema.SelectedField.class);
+        doReturn(itemsName).when(items2).getName();
+        doReturn(itemsName).when(items2).getQualifiedName();
+        doReturn(itemsFqn).when(items2).getFullyQualifiedName();
+        doReturn(selSet2).when(items2).getSelectionSet();
+
+        // Parent with both items entries
         graphql.schema.SelectedField sourceParent = 
mock(graphql.schema.SelectedField.class);
         doReturn("parent").when(sourceParent).getName();
         doReturn("parent").when(sourceParent).getQualifiedName();
         doReturn("Query.parent").when(sourceParent).getFullyQualifiedName();
-
         DataFetchingFieldSelectionSet parentSelSet = 
mock(DataFetchingFieldSelectionSet.class);
-        doReturn(Arrays.asList(sourceItems1, 
sourceItems2)).when(parentSelSet).getImmediateFields();
+        doReturn(Arrays.asList(items1, 
items2)).when(parentSelSet).getImmediateFields();
         doReturn(parentSelSet).when(sourceParent).getSelectionSet();
 
         SelectedFieldWrapper parent = new SelectedFieldWrapper(sourceParent);
 
-        // subFieldMap must contain exactly one entry for "items" (the merged 
wrapper),
-        // NOT two (merged + stale)
-        assertFalse("Same-FQN children must not be reported as duplicates by 
simple name",
-                parent.hasDuplicateFieldByName(itemsName));
-        Collection<SelectedField> byName = 
parent.getSubSelectedFieldByName(itemsName);
-        assertEquals("Expected exactly 1 entry in subFieldMap for same-FQN 
children", 1, byName.size());
-
-        // The single entry must be the merged wrapper containing sub-fields 
from both fragments
-        SelectedField merged = byName.iterator().next();
-        assertEquals("Expected 2 sub-fields in merged wrapper", 2, 
merged.getSubSelectedFields().size());
-        assertTrue("fieldA missing from merged wrapper",
-                merged.hasSubSelectedFieldsByFQN("ModelA.fieldA"));
-        assertTrue("fieldB missing from merged wrapper",
-                merged.hasSubSelectedFieldsByFQN("ModelB.fieldB"));
+        SelectedField itemsField = parent.getSubSelectedFieldByFQN(itemsFqn);
+        assertNotNull("items not found by FQN", itemsField);
+
+        // realField must be present
+        assertTrue("realField missing after merge", 
itemsField.hasSubSelectedFieldsByFQN("Model.realField"));
+        // null-FQN ghost must be silently skipped — only 1 sub-field in 
merged wrapper
+        assertEquals("Null-FQN sub-field must be skipped during merge", 1, 
itemsField.getSubSelectedFields().size());
     }
 }
diff --git 
a/src/test/java/org/apache/sling/graphql/core/engine/SelectionSetWrapperTest.java
 
b/src/test/java/org/apache/sling/graphql/core/engine/SelectionSetWrapperTest.java
new file mode 100644
index 0000000..957f723
--- /dev/null
+++ 
b/src/test/java/org/apache/sling/graphql/core/engine/SelectionSetWrapperTest.java
@@ -0,0 +1,157 @@
+/*
+ * 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.sling.graphql.core.engine;
+
+import graphql.schema.DataFetchingFieldSelectionSet;
+import org.apache.sling.graphql.api.SelectedField;
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+
+public class SelectionSetWrapperTest {
+
+    /** Null selectionSet must yield an empty wrapper without NPE. */
+    @Test
+    public void testNullSelectionSet() {
+        SelectionSetWrapper wrapper = new SelectionSetWrapper(null);
+        assertTrue("fields must be empty for null input", 
wrapper.getFields().isEmpty());
+    }
+
+    /** Two fields with different names are both kept independently. */
+    @Test
+    public void testDistinctFields() {
+        graphql.schema.SelectedField sfA = mockField("fieldA", "Model.fieldA");
+        graphql.schema.SelectedField sfB = mockField("fieldB", "Model.fieldB");
+
+        DataFetchingFieldSelectionSet selSet = 
mock(DataFetchingFieldSelectionSet.class);
+        doReturn(Arrays.asList(sfA, sfB)).when(selSet).getImmediateFields();
+
+        SelectionSetWrapper wrapper = new SelectionSetWrapper(selSet);
+
+        List<SelectedField> fields = wrapper.getFields();
+        assertEquals("Expected 2 top-level fields", 2, fields.size());
+        assertNotNull("fieldA must be accessible by name", 
wrapper.get("fieldA"));
+        assertNotNull("fieldB must be accessible by name", 
wrapper.get("fieldB"));
+        assertTrue("contains(fieldA)", wrapper.contains("fieldA"));
+        assertTrue("contains(fieldB)", wrapper.contains("fieldB"));
+    }
+
+    /**
+     * Two top-level entries with the same simple name (different aliases 
pointing at the same field).
+     * The second entry must be merged into the first, not added as a second 
top-level field.
+     * Sub-fields present in only one of the entries must be visible after the 
merge.
+     */
+    @Test
+    public void testDuplicateFieldNameMergesSubFields() {
+        // Sub-field present only in the first alias
+        graphql.schema.SelectedField subA = mockField("subA", 
"ItemModel.subA");
+        // Sub-field present only in the second alias
+        graphql.schema.SelectedField subB = mockField("subB", 
"ItemModel.subB");
+
+        // First alias selects only subA
+        DataFetchingFieldSelectionSet selSet1 = 
mock(DataFetchingFieldSelectionSet.class);
+        doReturn(Arrays.asList(subA)).when(selSet1).getImmediateFields();
+        graphql.schema.SelectedField item1 = mockFieldWithSelectionSet("item", 
"HolderModel.item", selSet1);
+
+        // Second alias selects only subB
+        DataFetchingFieldSelectionSet selSet2 = 
mock(DataFetchingFieldSelectionSet.class);
+        doReturn(Arrays.asList(subB)).when(selSet2).getImmediateFields();
+        graphql.schema.SelectedField item2 = mockFieldWithSelectionSet("item", 
"HolderModel.item", selSet2);
+
+        DataFetchingFieldSelectionSet root = 
mock(DataFetchingFieldSelectionSet.class);
+        doReturn(Arrays.asList(item1, item2)).when(root).getImmediateFields();
+
+        SelectionSetWrapper wrapper = new SelectionSetWrapper(root);
+
+        // Only one top-level field named "item"
+        assertEquals("Duplicate-named entries must be merged to a single 
top-level field",
+                1, wrapper.getFields().size());
+
+        // The merged field must expose sub-fields from BOTH aliases
+        SelectedField merged = wrapper.get("item");
+        assertNotNull("merged item field not found", merged);
+        assertTrue("subA (from first alias) must be present after merge",
+                merged.hasSubSelectedFieldsByFQN("ItemModel.subA"));
+        assertTrue("subB (from second alias) must be present after merge",
+                merged.hasSubSelectedFieldsByFQN("ItemModel.subB"));
+        assertEquals("merged item must have 2 sub-fields", 2, 
merged.getSubSelectedFields().size());
+    }
+
+    /**
+     * A field whose name contains "/" is treated as a flat-map path entry 
only and must
+     * not appear in the top-level fields list.
+     */
+    @Test
+    public void testSlashFieldExcludedFromTopLevel() {
+        graphql.schema.SelectedField slashField = mockField("parent/child", 
"Model.parentChild");
+
+        DataFetchingFieldSelectionSet selSet = 
mock(DataFetchingFieldSelectionSet.class);
+        doReturn(Arrays.asList(slashField)).when(selSet).getImmediateFields();
+
+        SelectionSetWrapper wrapper = new SelectionSetWrapper(selSet);
+
+        assertTrue("Slash-named field must not appear in top-level fields 
list",
+                wrapper.getFields().isEmpty());
+    }
+
+    /** Nested sub-fields must be accessible via slash-qualified flat-map 
paths. */
+    @Test
+    public void testNestedFieldAccessibleByQualifiedPath() {
+        graphql.schema.SelectedField leaf = mockField("leaf", 
"ChildModel.leaf");
+        DataFetchingFieldSelectionSet childSelSet = 
mock(DataFetchingFieldSelectionSet.class);
+        doReturn(Arrays.asList(leaf)).when(childSelSet).getImmediateFields();
+
+        graphql.schema.SelectedField parent = 
mockFieldWithSelectionSet("parent", "Model.parent", childSelSet);
+
+        DataFetchingFieldSelectionSet root = 
mock(DataFetchingFieldSelectionSet.class);
+        doReturn(Arrays.asList(parent)).when(root).getImmediateFields();
+
+        SelectionSetWrapper wrapper = new SelectionSetWrapper(root);
+
+        assertNotNull("parent must be found by simple name", 
wrapper.get("parent"));
+        assertNotNull("leaf must be found by flat qualified path parent/leaf",
+                wrapper.get("parent/leaf"));
+        assertNull("unknown path must return null", 
wrapper.get("parent/unknown"));
+    }
+
+    // --- helpers ---
+
+    private static graphql.schema.SelectedField mockField(String name, String 
fqn) {
+        graphql.schema.SelectedField sf = 
mock(graphql.schema.SelectedField.class);
+        doReturn(name).when(sf).getName();
+        doReturn(name).when(sf).getQualifiedName();
+        doReturn(fqn).when(sf).getFullyQualifiedName();
+        return sf;
+    }
+
+    private static graphql.schema.SelectedField mockFieldWithSelectionSet(
+            String name, String fqn, DataFetchingFieldSelectionSet selSet) {
+        graphql.schema.SelectedField sf = mockField(name, fqn);
+        doReturn(selSet).when(sf).getSelectionSet();
+        return sf;
+    }
+}
\ No newline at end of file

Reply via email to