ObjectIdRebindManyReferencesTest.java
package tools.jackson.databind.objectid;
import java.util.*;
import org.junit.jupiter.api.Test;
import com.fasterxml.jackson.annotation.*;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.annotation.JsonDeserialize;
import tools.jackson.databind.annotation.JsonPOJOBuilder;
import tools.jackson.databind.testutil.DatabindTestUtil;
import static org.junit.jupiter.api.Assertions.*;
/**
* Tests that Object Ids bound to Builders (or delegates) are rebound to built values
* for many values and references: each rebind locates the Object Id entry, and each
* reference replaces just its own slot, without scanning all Object Ids or the whole
* container (which would take O(N^2) time).
*/
public class ObjectIdRebindManyReferencesTest extends DatabindTestUtil
{
@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
@JsonDeserialize(builder = EntityBuilder.class)
static class Entity
{
final int id;
final Entity ref;
final List<Entity> list;
final Set<Entity> set;
final LinkedHashSet<Entity> orderedSet;
final Map<String, Entity> map;
final Entity[] array;
Entity(EntityBuilder b) {
id = b.id;
ref = b.ref;
list = b.list;
set = b.set;
orderedSet = b.orderedSet;
map = b.map;
array = b.array;
}
public int getId() { return id; }
}
@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
@JsonPOJOBuilder(withPrefix = "")
static class EntityBuilder
{
int id;
Entity ref;
List<Entity> list;
Set<Entity> set;
LinkedHashSet<Entity> orderedSet;
Map<String, Entity> map;
Entity[] array;
public EntityBuilder id(int v) { id = v; return this; }
public EntityBuilder ref(Entity v) { ref = v; return this; }
public EntityBuilder list(List<Entity> v) { list = v; return this; }
public EntityBuilder set(Set<Entity> v) { set = v; return this; }
public EntityBuilder orderedSet(LinkedHashSet<Entity> v) { orderedSet = v; return this; }
public EntityBuilder map(Map<String, Entity> v) { map = v; return this; }
public EntityBuilder array(Entity[] v) { array = v; return this; }
public Entity build() {
return new Entity(this);
}
}
static class Container {
public List<Entity> entities;
}
// Delegating-creator variant: bound id'd value is a transient delegate
@JsonIdentityInfo(generator = ObjectIdGenerators.IntSequenceGenerator.class, property = "@id")
@JsonDeserialize(as = ImmutableItem.class)
interface Item {
long getId();
List<Item> getRefs();
}
static class ImmutableItem implements Item {
private final long id;
private final List<Item> refs;
ImmutableItem(long id, List<Item> refs) {
this.id = id;
this.refs = refs;
}
@Override public long getId() { return id; }
@Override public List<Item> getRefs() { return refs; }
@JsonCreator(mode = JsonCreator.Mode.DELEGATING)
static ImmutableItem fromJson(MutableItem m) {
return new ImmutableItem(m.id, m.refs);
}
}
// `@JsonDeserialize` (no `as`) cancels the one inherited from `Item`
@JsonDeserialize
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
static class MutableItem implements Item {
public long id;
public List<Item> refs;
@Override public long getId() { throw new UnsupportedOperationException(); }
@Override public List<Item> getRefs() { throw new UnsupportedOperationException(); }
}
static class ItemContainer {
public List<Item> items;
}
// Builder with hash code that changes as properties get set (after Object Id is bound)
@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
@JsonDeserialize(builder = NamedBuilder.class)
static class Named
{
final int id;
final String name;
final Set<Named> set;
Named(NamedBuilder b) {
id = b.id;
name = b.name;
set = b.set;
}
public int getId() { return id; }
// Value equality: different instances may be equal
@Override
public boolean equals(Object o) {
return (o instanceof Named n) && Objects.equals(n.name, name);
}
@Override
public int hashCode() {
return Objects.hashCode(name);
}
}
@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
@JsonPOJOBuilder(withPrefix = "")
static class NamedBuilder
{
int id;
String name;
Set<Named> set;
public NamedBuilder id(int v) { id = v; return this; }
public NamedBuilder name(String v) { name = v; return this; }
public NamedBuilder set(Set<Named> v) { set = v; return this; }
public Named build() {
return new Named(this);
}
@Override
public boolean equals(Object o) {
return (o instanceof NamedBuilder b) && (b.id == id) && Objects.equals(b.name, name);
}
@Override
public int hashCode() {
return Objects.hash(id, name);
}
}
static class NamedContainer {
public List<Named> entities;
}
private static final int COUNT = 1000;
private final ObjectMapper MAPPER = newJsonMapper();
// Builder added to Set (by reference) before its hash code changes, so
// it cannot be found by hash code any more when rebound
@Test
public void forwardReferencesInSetWithChangingBuilderHashCode()
{
NamedContainer c = MAPPER.readValue(a2q("{'entities':[{'id':0,'set':[1,2,1]},"
+ "{'id':1,'name':'a'},{'id':2,'name':'b'}]}"), NamedContainer.class);
Set<Named> set = c.entities.get(0).set;
assertEquals(HashSet.class, set.getClass());
assertEquals(2, set.size());
assertTrue(set.contains(c.entities.get(1)));
assertTrue(set.contains(c.entities.get(2)));
}
// As above, but with equal built values: must not be mistaken for the
// Builder having been replaced already
@Test
public void forwardReferencesInSetWithChangingBuilderHashCodeEqualValues()
{
NamedContainer c = MAPPER.readValue("""
{"entities":[{"id":0,"set":[1,2]},{"id":1,"name":"a"},{"id":2,"name":"a"}]}
""", NamedContainer.class);
Set<Named> set = c.entities.get(0).set;
assertEquals(HashSet.class, set.getClass());
assertEquals(1, set.size());
assertEquals(Named.class, set.iterator().next().getClass());
}
// Values following rebound ones refer to them: must get the built values
@Test
public void backReferencesToManyBuiltValues()
{
StringBuilder sb = new StringBuilder("{\"entities\":[{\"id\":1}");
for (int i = 2; i <= COUNT; ++i) {
sb.append(",{\"id\":").append(i).append(",\"ref\":").append(i - 1).append('}');
}
Container c = MAPPER.readValue(sb.append("]}").toString(), Container.class);
assertEquals(COUNT, c.entities.size());
for (int i = 1; i < COUNT; ++i) {
assertSame(c.entities.get(i - 1), c.entities.get(i).ref, "Wrong ref for #"+i);
}
}
// Each id referenced twice
@Test
public void forwardReferencesInList()
{
Container c = _readWithRoot("\"list\":[" + _ids(false, 2) + "]");
List<Entity> list = c.entities.get(0).list;
assertEquals(2 * COUNT, list.size());
for (int i = 0; i < list.size(); ++i) {
assertSame(c.entities.get(1 + i / 2), list.get(i), "Wrong entry #"+i);
}
}
// Referenced in reverse order, so that values get rebound while still pending
@Test
public void reverseForwardReferencesInList()
{
Container c = _readWithRoot("\"list\":[" + _ids(true, 1) + "]");
List<Entity> list = c.entities.get(0).list;
assertEquals(COUNT, list.size());
for (int i = 0; i < COUNT; ++i) {
assertSame(c.entities.get(COUNT - i), list.get(i), "Wrong entry #"+i);
}
}
@Test
public void forwardReferencesInSet()
{
Container c = _readWithRoot("\"set\":[" + _ids(false, 2) + "]");
Set<Entity> set = c.entities.get(0).set;
assertEquals(HashSet.class, set.getClass());
assertEquals(COUNT, set.size());
for (int i = 1; i <= COUNT; ++i) {
assertTrue(set.contains(c.entities.get(i)), "Missing entry for id "+i);
}
}
// Ordered Set must retain the order of references
@Test
public void forwardReferencesInOrderedSet()
{
Container c = _readWithRoot("\"orderedSet\":[" + _ids(false, 1) + "]");
LinkedHashSet<Entity> set = c.entities.get(0).orderedSet;
assertEquals(COUNT, set.size());
Iterator<Entity> it = set.iterator();
for (int i = 1; i <= COUNT; ++i) {
assertSame(c.entities.get(i), it.next(), "Wrong entry for id "+i);
}
}
@Test
public void forwardReferencesInMap()
{
StringBuilder sb = new StringBuilder("\"map\":{");
for (int i = 1; i <= COUNT; ++i) {
sb.append("\"k").append(i).append("\":").append(i).append(',');
}
// Duplicate key: later entry overrides earlier one (but retains position)
sb.append("\"k1\":2}");
Container c = _readWithRoot(sb.toString());
Map<String, Entity> map = c.entities.get(0).map;
assertEquals(COUNT, map.size());
assertEquals("k1", map.keySet().iterator().next());
assertSame(c.entities.get(2), map.get("k1"));
for (int i = 2; i <= COUNT; ++i) {
assertSame(c.entities.get(i), map.get("k"+i), "Wrong entry for key k"+i);
}
}
// Duplicate key, with overridden reference rebound only after being overridden
@Test
public void forwardReferencesInMapWithDuplicateKey()
{
Container c = _readWithRoot("\"map\":{\"a\":2,\"a\":1}");
Map<String, Entity> map = c.entities.get(0).map;
assertEquals(1, map.size());
assertSame(c.entities.get(1), map.get("a"));
}
// Each id referenced twice
@Test
public void forwardReferencesInArray()
{
Container c = _readWithRoot("\"array\":[" + _ids(false, 2) + "]");
Entity[] array = c.entities.get(0).array;
assertEquals(2 * COUNT, array.length);
for (int i = 0; i < array.length; ++i) {
assertSame(c.entities.get(1 + i / 2), array[i], "Wrong entry #"+i);
}
}
@Test
public void forwardReferencesWithDelegatingCreator()
{
StringBuilder sb = new StringBuilder("{\"items\":[{\"@id\":0,\"id\":0,\"refs\":[")
.append(_ids(false, 1)).append("]}");
for (int i = 1; i <= COUNT; ++i) {
sb.append(",{\"@id\":").append(i).append(",\"id\":").append(i).append(",\"refs\":[]}");
}
ItemContainer c = MAPPER.readValue(sb.append("]}").toString(), ItemContainer.class);
assertEquals(COUNT + 1, c.items.size());
List<Item> refs = c.items.get(0).getRefs();
assertEquals(COUNT, refs.size());
for (int i = 0; i < COUNT; ++i) {
assertEquals(ImmutableItem.class, c.items.get(i + 1).getClass());
assertSame(c.items.get(i + 1), refs.get(i), "Wrong entry #"+i);
}
}
// Root entity (id 0) with given property, followed by entities with ids 1 to COUNT
private Container _readWithRoot(String rootProperty)
{
StringBuilder sb = new StringBuilder("{\"entities\":[{\"id\":0,")
.append(rootProperty).append('}');
for (int i = 1; i <= COUNT; ++i) {
sb.append(",{\"id\":").append(i).append('}');
}
Container c = MAPPER.readValue(sb.append("]}").toString(), Container.class);
assertEquals(COUNT + 1, c.entities.size());
return c;
}
// Comma-separated ids from 1 to COUNT, in given order, each repeated `times` times
private static String _ids(boolean reverse, int times)
{
StringBuilder sb = new StringBuilder();
for (int i = 1; i <= COUNT; ++i) {
final int id = reverse ? (COUNT + 1 - i) : i;
for (int t = 0; t < times; ++t) {
if (sb.length() > 0) {
sb.append(',');
}
sb.append(id);
}
}
return sb.toString();
}
}