RecordCreatorsTest.java

package tools.jackson.databind.records;

import java.util.*;

import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonValue;

import tools.jackson.databind.*;
import tools.jackson.databind.exc.UnrecognizedPropertyException;
import tools.jackson.databind.testutil.DatabindTestUtil;

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

public class RecordCreatorsTest extends DatabindTestUtil
{
    record RecordWithCanonicalCtorOverride(int id, String name) {
        public RecordWithCanonicalCtorOverride(int id, String name) {
            this.id = id;
            this.name = "name";
        }
    }

    record RecordWithAltCtor(int id, String name) {
        @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
        public RecordWithAltCtor(@JsonProperty("id") int id) {
            this(id, "name2");
        }
    }

    // [databind#2980]
    record RecordWithDelegation(String value) {
        @JsonCreator(mode = JsonCreator.Mode.DELEGATING)
        public RecordWithDelegation(String value) {
            this.value = "del:"+value;
        }

        @JsonValue
        public String getValue() {
            return "val:"+value;
        }

        public String accessValueForTest() { return value; }
    }

    // [databind#4452]
    public record PlainTestObject(
            @JsonProperty("strField") String testFieldName,
            @JsonProperty("intField") Integer testOtherField
    ) { }

    public record CreatorTestObject(
            String testFieldName,
            Integer testOtherField
    ) {
        @JsonCreator
        public CreatorTestObject(
                @JsonProperty("strField") String testFieldName,
                @JsonProperty("someOtherIntField") Integer testOtherIntField,
                @JsonProperty("intField") Integer testOtherField)
        {
            this(testFieldName, testOtherField + testOtherIntField);
        }
    }

    // [databind#4724]
    public record Something(String value) {
        public Something {
            if (value == null || value.isEmpty()) {
                throw new IllegalArgumentException("Value cannot be null or empty");
            }
        }

        // should be considered Delegating due to @JsonValue later on
        @JsonCreator
        public static Something of(String value) {
            if (value.isEmpty()) {
                return null;
            }
            return new Something(value);
        }

        @Override
        @JsonValue
        public String toString() {
            return value;
        }
    }

    // [databind#3938]
    private final static String ERROR_3938_PREFIX = "Non-null 'options' not allowed for ";

    interface NoOptionsCommand3938 {
        @JsonProperty("options")
        default void setOptions(JsonNode value) {
          if (value.isNull()) {
             return;
          }
          throw new IllegalArgumentException(ERROR_3938_PREFIX+getClass().getName());
        }
    }

    public record Command3938(int id, String filter) implements NoOptionsCommand3938 { }

    // [databind#562]
    record RecordWithAnySetterCtor562(int id,
            Map<String, Integer> additionalProperties) {
        @JsonCreator
        public RecordWithAnySetterCtor562(@JsonProperty("regular") int id,
                @JsonAnySetter Map<String, Integer> additionalProperties
        ) {
            this.id = id;
            this.additionalProperties = additionalProperties;
        }
    }

    // [databind#3439]
    record TestRecord3439(
            @JsonProperty String field,
            @JsonAnySetter Map<String, Object> anySetter
        ) {}

    // [databind#5952]
    record UserRecordWithAnySetter5952(
            String name,
            @JsonIgnore String sensitiveField,
            @JsonAnySetter Map<String, Object> extras
    ) {}

    // [databind#5923]
    public record Inner5923(@JsonProperty(required = true, value = "innerValue") boolean innerValue) {}

    // [databind#5923]
    public record Outer5923(Inner5923 bools) {
        @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
        public static Outer5923 fromJson(@JsonProperty(required = true, value = "renamed") boolean booleanValue) {
            return new Outer5923(new Inner5923(booleanValue));
        }

        @JsonValue
        public Map<String, Boolean> toJson() {
            return Map.of("renamed", bools.innerValue());
        }
    }

    private final ObjectMapper MAPPER = jsonMapperBuilder().enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).build();

    /*
    /**********************************************************************
    /* Test methods, alternate constructors
    /**********************************************************************
     */

    @Test
    public void testDeserializeWithCanonicalCtorOverride() throws Exception {
        RecordWithCanonicalCtorOverride value = MAPPER.readValue("{\"id\":123,\"name\":\"Bob\"}",
                RecordWithCanonicalCtorOverride.class);
        assertEquals(123, value.id());
        assertEquals("name", value.name());
    }

    @Test
    public void testDeserializeWithAltCtor() throws Exception {
        RecordWithAltCtor value = MAPPER.readValue("{\"id\":2812}",
                RecordWithAltCtor.class);
        assertEquals(2812, value.id());
        assertEquals("name2", value.name());

        // "Implicit" canonical constructor can no longer be used when there's explicit constructor
        try {
            MAPPER.readValue("{\"id\":2812,\"name\":\"Bob\"}",
                    RecordWithAltCtor.class);
            fail("should not pass");
        } catch (UnrecognizedPropertyException e) {
            verifyException(e, "Unrecognized");
            verifyException(e, "\"name\"");
            verifyException(e, "RecordWithAltCtor");
        }
    }

    // [databind#2980]
    @Test
    public void testDeserializeWithDelegatingCtor() throws Exception {
        RecordWithDelegation value = MAPPER.readValue(q("foobar"),
                RecordWithDelegation.class);
        assertEquals("del:foobar", value.accessValueForTest());

        assertEquals(q("val:del:foobar"), MAPPER.writeValueAsString(value));
    }

    /*
    /**********************************************************************
    /* Test methods, @JsonProperty on @JsonCreator [databind#4452]
    /**********************************************************************
     */

    // [databind#4452]
    @Test
    public void testCreatorSerializationWithJsonProperty4452Plain() throws Exception
    {
        String result = MAPPER.writeValueAsString(new PlainTestObject("test", 1));
        assertEquals(a2q("{'strField':'test','intField':1}"), result);
    }

    @Test
    public void testCreatorSerializationWithJsonProperty4452WithCreator() throws Exception
    {
        String json = MAPPER.writeValueAsString(new CreatorTestObject("test", 2, 1));

        @SuppressWarnings("unchecked")
        Map<String, Object> asMap = (Map<String, Object>) MAPPER.readValue(json, Map.class);
        assertEquals(new HashSet<>(Arrays.asList("intField", "strField")), asMap.keySet());
    }

    /*
    /**********************************************************************
    /* Test methods, @JsonCreator with @JsonValue [databind#4724]
    /**********************************************************************
     */

    // [databind#4724]
    @Test
    void testDeserializeWithCreatorAndJsonValue4724() throws Exception {
        newJsonMapper().readValue("\"\"", Something.class);
    }

    /*
    /**********************************************************************
    /* Test methods, failing setter from interface [databind#3938]
    /**********************************************************************
     */

    // [databind#3938]: Should detect and use setters too
    @Test
    public void testFailingSetter3938() throws Exception
    {
        final ObjectMapper mapper = newJsonMapper();
        final ObjectReader R = mapper.readerFor(Command3938.class);

        // First, missing value and `null` are fine, as long as we have all fields
        assertNotNull(R.readValue(a2q("{'id':1, 'filter':'abc'}")));
        assertNotNull(R.readValue(a2q("{'id':2, 'filter':'abc', 'options':null}")));

        // But then failure for non-empty Array (f.ex)
        try {
            R.readValue(a2q("{'id':2,'options':[123]}}"));
            fail("Should not pass");
        } catch (DatabindException e) {
            verifyException(e, ERROR_3938_PREFIX);
        }
    }

    /*
    /**********************************************************************
    /* Test methods, @JsonAnySetter on creator [databind#562, #3439, #5952]
    /**********************************************************************
     */

    // [databind#562]: Allow @JsonAnySetter on Creator constructors
    @Test
    public void testRecordWithAnySetterCtor() throws Exception
    {
        final ObjectMapper mapper = newJsonMapper();
        // First, only regular property mapped
        RecordWithAnySetterCtor562 result = mapper.readValue(a2q("{'regular':13}"),
                RecordWithAnySetterCtor562.class);
        assertEquals(13, result.id);
        assertEquals(0, result.additionalProperties.size());

        // Then with unknown properties
        result = mapper.readValue(a2q("{'regular':13, 'unknown':99, 'extra':-1}"),
                RecordWithAnySetterCtor562.class);
        assertEquals(13, result.id);
        assertEquals(Integer.valueOf(99), result.additionalProperties.get("unknown"));
        assertEquals(Integer.valueOf(-1), result.additionalProperties.get("extra"));
        assertEquals(2, result.additionalProperties.size());
    }

    // [databind#3439] Java Record @JsonAnySetter value is null after deserialization
    @Test
    public void testJsonAnySetterOnRecord() throws Exception {
        final ObjectMapper mapper = newJsonMapper();
        String json = """
            {
                "field": "value",
                "unmapped1": "value1",
                "unmapped2": "value2"
            }
            """;

        TestRecord3439 result = mapper.readValue(json, TestRecord3439.class);

        assertEquals("value", result.field());
        assertEquals(Map.of("unmapped1", "value1", "unmapped2", "value2"),
                result.anySetter());
    }

    // [databind#5952]: per-property @JsonIgnore on a record component must block
    // routing to the any-setter
    @Test
    public void testJsonIgnoreOnRecordComponentNotPassedToAnySetter5952() throws Exception {
        final ObjectMapper mapper = newJsonMapper();
        UserRecordWithAnySetter5952 u = mapper.readValue(
                a2q("{'name':'alice','sensitiveField':'secret','other':'val'}"),
                UserRecordWithAnySetter5952.class);
        assertEquals("alice", u.name());
        assertEquals(Map.of("other", "val"), u.extras());
    }

    /*
    /**********************************************************************
    /* Test methods, @JsonCreator(PROPERTIES) + @JsonValue [databind#5923]
    /**********************************************************************
     */

    // [databind#5923]
    @Test
    public void testDeserialization5923True() throws Exception {
        Outer5923 bw = MAPPER.readValue("{ \"renamed\": true }", Outer5923.class);
        assertEquals(true, bw.bools.innerValue);
    }

    // [databind#5923]
    @Test
    public void testDeserialization5923False() throws Exception {
        Outer5923 bw = MAPPER.readValue("{ \"renamed\": false }", Outer5923.class);
        assertEquals(false, bw.bools.innerValue);
    }

    // [databind#5923]
    @Test
    public void testSerialization5923ViaJsonValue() throws Exception {
        assertEquals("{\"renamed\":true}",
                MAPPER.writeValueAsString(new Outer5923(new Inner5923(true))));
        assertEquals("{\"renamed\":false}",
                MAPPER.writeValueAsString(new Outer5923(new Inner5923(false))));
    }

    // [databind#5923]
    @Test
    public void testRoundTrip5923() throws Exception {
        Outer5923 original = new Outer5923(new Inner5923(true));
        Outer5923 roundTripped = MAPPER.readValue(MAPPER.writeValueAsString(original), Outer5923.class);
        assertEquals(original.bools.innerValue, roundTripped.bools.innerValue);
    }
}