ObjectIdWithBuilder5909Test.java

package tools.jackson.databind.objectid;

import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIdentityInfo;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.ObjectIdGenerators;

import tools.jackson.databind.*;
import tools.jackson.databind.annotation.JsonDeserialize;
import tools.jackson.databind.annotation.JsonPOJOBuilder;
import tools.jackson.databind.annotation.JsonSerialize;
import tools.jackson.databind.testutil.DatabindTestUtil;

import static org.junit.jupiter.api.Assertions.*;

/**
 * Tests for [databind#5909] (follow-up to [databind#1496]): Forward Object Id
 * references that appear inside a {@link java.util.Collection}, or
 * {@link java.util.Map}-typed property of a Builder-based type must be
 * correctly resolved to the built target object, not the Builder instance.
 * Also covers builder-with-property-creator ({@code @JsonCreator}),                                    
 * delegating-creator ({@code [databind#1706]}), and documents the   
 * typed-array limitation. 
 */
class ObjectIdWithBuilder5909Test extends DatabindTestUtil
{
    @JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
    @JsonDeserialize(builder = EntityBuilder.class)
    static class Entity
    {
        private long id;
        private Entity ref;
        private List<Entity> refs;

        Entity(long id, Entity ref, List<Entity> refs) {
            this.id = id;
            this.ref = ref;
            this.refs = refs;
        }

        public long getId() { return id; }
        public Entity getRef() { return ref; }
        public List<Entity> getRefs() { return refs; }
    }

    @JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
    @JsonPOJOBuilder(withPrefix = "")
    static class EntityBuilder
    {
        private long id;
        private Entity ref;
        private List<Entity> refs;

        public EntityBuilder id(long id) { this.id = id; return this; }
        public EntityBuilder ref(Entity ref) { this.ref = ref; return this; }
        public EntityBuilder refs(List<Entity> refs) { this.refs = refs; return this; }

        public Entity build() {
            return new Entity(id, ref, refs);
        }
    }

    static class EntityContainer
    {
        public List<Entity> entities;
    }

    // ---- Set variant: exercises CollectionReferring non-List branch ----

    @JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
    @JsonDeserialize(builder = EntitySetBuilder.class)
    static class EntitySet
    {
        private final long id;
        private final Set<EntitySet> refs;

        EntitySet(long id, Set<EntitySet> refs) {
            this.id = id;
            this.refs = refs;
        }
        public long getId() { return id; }
        public Set<EntitySet> getRefs() { return refs; }
    }

    @JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
    @JsonPOJOBuilder(withPrefix = "")
    static class EntitySetBuilder
    {
        private long id;
        private Set<EntitySet> refs;

        public EntitySetBuilder id(long id) { this.id = id; return this; }
        public EntitySetBuilder refs(Set<EntitySet> refs) { this.refs = refs; return this; }

        public EntitySet build() {
            return new EntitySet(id, refs == null ? new LinkedHashSet<>() : refs);
        }
    }

    static class EntitySetContainer
    {
        public List<EntitySet> entities;
    }

    // ---- Map variant: exercises MapReferring rebind path ----

    @JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
    @JsonDeserialize(builder = EntityMapBuilder.class)
    static class EntityMap
    {
        private final long id;
        private final Map<String, EntityMap> refs;

        EntityMap(long id, Map<String, EntityMap> refs) {
            this.id = id;
            this.refs = refs;
        }
        public long getId() { return id; }
        public Map<String, EntityMap> getRefs() { return refs; }
    }

    @JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
    @JsonPOJOBuilder(withPrefix = "")
    static class EntityMapBuilder
    {
        private long id;
        private Map<String, EntityMap> refs;

        public EntityMapBuilder id(long id) { this.id = id; return this; }
        public EntityMapBuilder refs(Map<String, EntityMap> refs) { this.refs = refs; return this; }

        public EntityMap build() {
            return new EntityMap(id, refs == null ? new LinkedHashMap<>() : refs);
        }
    }

    static class EntityMapContainer
    {
        public List<EntityMap> entities;
    }

    // ---- Property-based-creator builder variant: exercises the
    // PropertyValueBuffer.handleIdValue rebind path, distinct from
    // setter-based builders which go via ObjectIdValueProperty.

    @JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
    @JsonDeserialize(builder = EntityCreatorBuilder.class)
    static class EntityCreator
    {
        public final long id;
        public final List<EntityCreator> refs;

        EntityCreator(long id, List<EntityCreator> refs) {
            this.id = id;
            this.refs = refs;
        }
    }

    @JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
    @JsonPOJOBuilder(withPrefix = "")
    static class EntityCreatorBuilder
    {
        private final long id;
        private final List<EntityCreator> refs;

        @JsonCreator
        EntityCreatorBuilder(@JsonProperty("id") long id,
                @JsonProperty("refs") List<EntityCreator> refs) {
            this.id = id;
            this.refs = refs;
        }

        public EntityCreator build() {
            return new EntityCreator(id, refs);
        }
    }

    static class EntityCreatorContainer
    {
        public List<EntityCreator> entities;
    }
    
    // NOTE: An equivalent test for `Entity[]`-typed properties is intentionally
    // omitted: typed arrays of the built type fail earlier with ArrayStoreException
    // when the Builder is stored into `_array` during bindItem, well before the
    // rebind path added here can run. That gap is out of scope for #5909.

    private final ObjectMapper MAPPER = newJsonMapper();

    @Test
    public void forwardReferenceInCollection() throws Exception
    {
        String json = a2q("{'entities':["
                + "{'id':1,'refs':[2]},"
                + "{'id':2,'ref':1,'refs':[]}"
                + "]}");

        EntityContainer container = MAPPER.readValue(json, EntityContainer.class);
        assertNotNull(container);
        assertEquals(2, container.entities.size());

        Entity first = container.entities.get(0);
        Entity second = container.entities.get(1);

        assertEquals(1, first.getId());
        assertEquals(2, second.getId());

        assertSame(first, second.getRef());
        assertEquals(1, first.getRefs().size());
        assertSame(second, first.getRefs().get(0));
    }

    @Test
    public void forwardReferenceInSet() throws Exception
    {
        String json = a2q("{'entities':["
                + "{'id':1,'refs':[2]},"
                + "{'id':2,'refs':[]}"
                + "]}");

        EntitySetContainer container = MAPPER.readValue(json, EntitySetContainer.class);
        assertEquals(2, container.entities.size());

        EntitySet first = container.entities.get(0);
        EntitySet second = container.entities.get(1);

        assertEquals(1, first.getRefs().size());
        assertSame(second, first.getRefs().iterator().next());
    }

    @Test
    public void forwardReferenceInMap() throws Exception
    {
        String json = a2q("{'entities':["
                + "{'id':1,'refs':{'k':2}},"
                + "{'id':2,'refs':{}}"
                + "]}");

        EntityMapContainer container = MAPPER.readValue(json, EntityMapContainer.class);
        assertEquals(2, container.entities.size());

        EntityMap first = container.entities.get(0);
        EntityMap second = container.entities.get(1);

        assertEquals(1, first.getRefs().size());
        assertSame(second, first.getRefs().get("k"));
    }

    // Same forward id appearing multiple times in one List: every slot must be
    // rebound, not just the first.
    @Test
    public void duplicateForwardReferenceInList() throws Exception
    {
        String json = a2q("{'entities':["
                + "{'id':1,'refs':[2,2,2]},"
                + "{'id':2,'refs':[]}"
                + "]}");

        EntityContainer container = MAPPER.readValue(json, EntityContainer.class);
        Entity first = container.entities.get(0);
        Entity second = container.entities.get(1);

        assertEquals(3, first.getRefs().size());
        assertSame(second, first.getRefs().get(0));
        assertSame(second, first.getRefs().get(1));
        assertSame(second, first.getRefs().get(2));
    }

    // Multiple distinct forward ids in one List, all needing rebind.
    @Test
    public void multipleForwardReferencesInList() throws Exception
    {
        String json = a2q("{'entities':["
                + "{'id':1,'refs':[2,3]},"
                + "{'id':2,'refs':[]},"
                + "{'id':3,'refs':[]}"
                + "]}");

        EntityContainer container = MAPPER.readValue(json, EntityContainer.class);
        assertEquals(3, container.entities.size());

        Entity first = container.entities.get(0);
        Entity second = container.entities.get(1);
        Entity third = container.entities.get(2);

        assertEquals(2, first.getRefs().size());
        assertSame(second, first.getRefs().get(0));
        assertSame(third, first.getRefs().get(1));
    }

    // Multi-element Set with two forward refs to distinct ids: every entry
    // must end up rebound to its built object (none left as Builder), and the
    // snapshot/clear/replay path must not drop or duplicate entries.
    @Test
    public void multipleForwardReferencesInSet() throws Exception
    {
        String json = a2q("{'entities':["
                + "{'id':1,'refs':[2,3]},"
                + "{'id':2,'refs':[]},"
                + "{'id':3,'refs':[]}"
                + "]}");

        EntitySetContainer container = MAPPER.readValue(json, EntitySetContainer.class);
        EntitySet first = container.entities.get(0);
        EntitySet second = container.entities.get(1);
        EntitySet third = container.entities.get(2);

        Set<EntitySet> refs = first.getRefs();
        assertEquals(2, refs.size());
        // Every entry must be a built EntitySet, not a leftover Builder.
        for (EntitySet item : refs) {
            assertEquals(EntitySet.class, item.getClass(),
                    "entry should have been rebound from Builder to built object");
        }
        assertTrue(refs.contains(second));
        assertTrue(refs.contains(third));
    }

    // Multi-entry Map: forward and resolved refs in the same Map, distinct keys.
    @Test
    public void multipleForwardReferencesInMap() throws Exception
    {
        String json = a2q("{'entities':["
                + "{'id':1,'refs':{'a':2,'b':3,'c':2}},"
                + "{'id':2,'refs':{}},"
                + "{'id':3,'refs':{}}"
                + "]}");

        EntityMapContainer container = MAPPER.readValue(json, EntityMapContainer.class);
        EntityMap first = container.entities.get(0);
        EntityMap second = container.entities.get(1);
        EntityMap third = container.entities.get(2);

        assertEquals(3, first.getRefs().size());
        assertSame(second, first.getRefs().get("a"));
        assertSame(third, first.getRefs().get("b"));
        assertSame(second, first.getRefs().get("c"));
    }

    @Test
    public void forwardReferenceInCollectionWithPropertyCreatorBuilder() throws Exception
    {
        String json = a2q("{'entities':["
                + "{'id':1,'refs':[2]},"
                + "{'id':2,'refs':[]}"
                + "]}");

        EntityCreatorContainer container = MAPPER.readValue(json, EntityCreatorContainer.class);
        EntityCreator first = container.entities.get(0);
        EntityCreator second = container.entities.get(1);

        assertEquals(1, first.refs.size());
        assertSame(second, first.refs.get(0),
                "forward ref must be rebound from Builder to built object via PropertyValueBuffer path");
    }

    // ---- Typed array variant ([databind#5946], follow-up to [databind#5909]):
    // Forward Object Id refs inside a typed `Entity[]` of a Builder-based type
    // must be rebound from the Builder to the built object. The Builder cannot
    // be stored into `Entity[]` directly (would `ArrayStoreException`), so
    // ObjectArrayDeserializer defers writing during `resolveForwardReference`
    // when the bound value isn't assignable to the array's component type, and
    // writes the rebuilt object via `replaceResolvedItem` once `updateObjectId`
    // fires.

    @JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
    @JsonDeserialize(builder = EntityArrayBuilder.class)
    static class EntityArray
    {
        public final long id;
        public final EntityArray[] refs;

        EntityArray(long id, EntityArray[] refs) {
            this.id = id;
            this.refs = refs;
        }
    }

    @JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
    @JsonPOJOBuilder(withPrefix = "")
    static class EntityArrayBuilder
    {
        private long id;
        private EntityArray[] refs;

        public EntityArrayBuilder id(long id) { this.id = id; return this; }
        public EntityArrayBuilder refs(EntityArray[] refs) { this.refs = refs; return this; }

        public EntityArray build() { return new EntityArray(id, refs); }
    }

    static class EntityArrayContainer
    {
        public List<EntityArray> entities;
    }

    @Test
    public void forwardReferenceInTypedArrayWithBuilder() throws Exception
    {
        String json = a2q("{'entities':["
                + "{'id':1,'refs':[2]},"
                + "{'id':2,'refs':[]}"
                + "]}");

        EntityArrayContainer container = MAPPER.readValue(json, EntityArrayContainer.class);
        EntityArray first = container.entities.get(0);
        EntityArray second = container.entities.get(1);

        assertEquals(1, first.refs.length);
        assertSame(second, first.refs[0],
                "forward ref must be rebound from Builder to built object in typed array");
    }

    // ---- Delegating-creator variant ([databind#1706] + collection forward
    // refs): the bound id'd instance is a transient delegate (Mutable*) which
    // is replaced via updateObjectId after createUsingDelegate. Forward refs
    // inside a Collection-typed property must be rebound to the final bean,
    // not left pointing at the discarded delegate.

    @JsonIdentityInfo(generator = ObjectIdGenerators.IntSequenceGenerator.class, property = "@id")
    @JsonSerialize(as = ImmutableEntity1706.class)
    @JsonDeserialize(as = ImmutableEntity1706.class)
    interface Entity1706 {
        long getId();
        List<Entity1706> getRefs();
    }

    static class ImmutableEntity1706 implements Entity1706 {
        private final long id;
        private final List<Entity1706> refs;

        ImmutableEntity1706(long id, List<Entity1706> refs) {
            this.id = id;
            this.refs = refs;
        }

        @Override public long getId() { return id; }
        @Override public List<Entity1706> getRefs() { return refs; }

        @JsonCreator(mode = JsonCreator.Mode.DELEGATING)
        static ImmutableEntity1706 fromJson(MutableEntity1706 m) {
            return new ImmutableEntity1706(m.id, m.refs);
        }
    }

    // @JsonDeserialize (no `as`) override on MutableEntity1706 cancels the
    // `as=ImmutableEntity1706` inherited from Entity1706 ��� needed so the
    // delegate type isn't (mis)narrowed during delegate-deserializer lookup.
    @JsonDeserialize
    @JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
    static class MutableEntity1706 implements Entity1706 {
        public long id;
        public List<Entity1706> refs;

        @Override public long getId() { throw new UnsupportedOperationException(); }
        @Override public List<Entity1706> getRefs() { throw new UnsupportedOperationException(); }
    }

    static class Entity1706Container {
        public List<Entity1706> entities;
    }

    @Test
    public void forwardReferenceInCollectionWithDelegatingCreator() throws Exception
    {
        // First entity refs the second by @id forward; second is materialized later.
        String json = a2q("{'entities':["
                + "{'@id':1,'id':10,'refs':[2]},"
                + "{'@id':2,'id':20,'refs':[]}"
                + "]}");

        Entity1706Container container = MAPPER.readValue(json, Entity1706Container.class);
        Entity1706 first = container.entities.get(0);
        Entity1706 second = container.entities.get(1);

        // Both must be the *built* immutable bean, not the leftover Mutable delegate
        assertEquals(ImmutableEntity1706.class, first.getClass());
        assertEquals(ImmutableEntity1706.class, second.getClass());

        assertEquals(1, first.getRefs().size());
        assertSame(second, first.getRefs().get(0),
                "forward ref must be rebound from delegate to built bean");
    }

}