RecordBasicsTest.java

package tools.jackson.databind.records;

import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;

import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.annotation.*;

import tools.jackson.core.JsonGenerator;
import tools.jackson.core.JsonParser;
import tools.jackson.databind.*;
import tools.jackson.databind.annotation.JsonDeserialize;
import tools.jackson.databind.annotation.JsonNaming;
import tools.jackson.databind.annotation.JsonSerialize;
import tools.jackson.databind.deser.std.StdScalarDeserializer;
import tools.jackson.databind.json.JsonMapper;
import tools.jackson.databind.ser.std.StdScalarSerializer;
import tools.jackson.databind.testutil.DatabindTestUtil;
import tools.jackson.databind.type.TypeFactory;
import tools.jackson.databind.util.Converter;

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

public class RecordBasicsTest extends DatabindTestUtil
{
    record EmptyRecord() { }

    record SimpleRecord(int id, String name) { }

    record RecordOfRecord(SimpleRecord record) { }

    record RecordWithRename(int id, @JsonProperty("rename")String name) { }

    record RecordWithHeaderInject(int id, @JacksonInject String name) { }

    record RecordWithConstructorInject(int id, String name) {

        RecordWithConstructorInject(int id, @JacksonInject String name) {
            this.id = id;
            this.name = name;
        }
    }

    // [databind#2992]
    @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
    record SnakeRecord(String myId, String myValue){}

    record RecordWithJsonDeserialize(int id, @JsonDeserialize(converter = StringTrimmer.class) String name) { }

    record RecordSingleWriteOnly(@JsonProperty(access = JsonProperty.Access.WRITE_ONLY) int id) { }

    record RecordSomeWriteOnly(
            @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) int id,
            @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) String name,
            String email) {
    }

    record RecordAllWriteOnly(
            @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) int id,
            @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) String name,
            @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) String email) {
    }

    // [databind#4175]
    private static record PrivateTextRecord4175(String text) { }

    // [databind#5967]
    public record RenamedRecord5967(@JsonProperty("renamedProp") String prop) {}

    // [jackson#188]
    record Animal188(
            @JsonDeserialize(using = PrefixStringDeserializer.class)
            @JsonSerialize(using = PrefixStringSerializer.class)
            String name,
            Integer age
    ) { }

    static class PrefixStringSerializer extends StdScalarSerializer<String> {
        protected PrefixStringSerializer() {
            super(String.class);
        }

        @Override
        public void serialize(String value, JsonGenerator jgen, SerializationContext provider) {
            jgen.writeString("custom " + value);
        }
    }

    static class PrefixStringDeserializer extends StdScalarDeserializer<String> {
        protected PrefixStringDeserializer() {
            super(String.class);
        }

        @Override
        public String deserialize(JsonParser jp, DeserializationContext ctxt) {
            return "custom-deser" + jp.getString();
        }
    }

    private final ObjectMapper MAPPER = newJsonMapper();

    /*
    /**********************************************************************
    /* Test methods, Record type introspection
    /**********************************************************************
     */

    @Test
    public void testRecordJavaType() {
        assertFalse(MAPPER.constructType(getClass()).isRecordType());

        assertTrue(MAPPER.constructType(SimpleRecord.class).isRecordType());
        assertTrue(MAPPER.constructType(RecordOfRecord.class).isRecordType());
        assertTrue(MAPPER.constructType(RecordWithRename.class).isRecordType());
    }

    /*
    /**********************************************************************
    /* Test methods, default reading/writing Record values
    /**********************************************************************
     */

    @Test
    public void testSerializeSimpleRecord() throws Exception {
        String json = MAPPER.writeValueAsString(new SimpleRecord(123, "Bob"));
        final Object EXP = map("id", Integer.valueOf(123), "name", "Bob");
        assertEquals(EXP, MAPPER.readValue(json, Object.class));
    }

    @Test
    public void testDeserializeSimpleRecord() throws Exception {
        assertEquals(new SimpleRecord(123, "Bob"),
                MAPPER.readValue("{\"id\":123,\"name\":\"Bob\"}", SimpleRecord.class));
    }

    @Test
    public void testSerializeEmptyRecord() throws Exception {
        assertEquals("{}", MAPPER.writeValueAsString(new EmptyRecord()));
    }

    @Test
    public void testDeserializeEmptyRecord() throws Exception {
        assertEquals(new EmptyRecord(),
                MAPPER.readValue("{}", EmptyRecord.class));
    }

    @Test
    public void testSerializeRecordOfRecord() throws Exception {
        RecordOfRecord record = new RecordOfRecord(new SimpleRecord(123, "Bob"));
        String json = MAPPER.writeValueAsString(record);
        final Object EXP = Collections.singletonMap("record",
                map("id", Integer.valueOf(123), "name", "Bob"));
        assertEquals(EXP, MAPPER.readValue(json, Object.class));
    }

    @Test
    public void testDeserializeRecordOfRecord() throws Exception {
        assertEquals(new RecordOfRecord(new SimpleRecord(123, "Bob")),
                MAPPER.readValue("{\"record\":{\"id\":123,\"name\":\"Bob\"}}",
                        RecordOfRecord.class));
    }

    /*
    /**********************************************************************
    /* Test methods, reading/writing Record values with different config
    /**********************************************************************
     */

    @Test
    public void testSerializeSimpleRecord_DisableAnnotationIntrospector() throws Exception {
        SimpleRecord record = new SimpleRecord(123, "Bob");

        JsonMapper mapper = JsonMapper.builder()
                .configure(MapperFeature.USE_ANNOTATIONS, false)
                .build();
        String json = mapper.writeValueAsString(record);

        assertEquals("{\"id\":123,\"name\":\"Bob\"}", json);
    }

    @Test
    public void testDeserializeSimpleRecord_DisableAnnotationIntrospector() throws Exception {
        JsonMapper mapper = JsonMapper.builder()
                .configure(MapperFeature.USE_ANNOTATIONS, false)
                .build();
        SimpleRecord value = mapper.readValue("{\"id\":123,\"name\":\"Bob\"}", SimpleRecord.class);

        assertEquals(new SimpleRecord(123, "Bob"), value);
    }

    /*
    /**********************************************************************
    /* Test methods, renames, injects
    /**********************************************************************
     */

    @Test
    public void testSerializeJsonRename() throws Exception {
        String json = MAPPER.writeValueAsString(new RecordWithRename(123, "Bob"));
        final Object EXP = map("id", Integer.valueOf(123), "rename", "Bob");
        assertEquals(EXP, MAPPER.readValue(json, Object.class));
    }

    @Test
    public void testDeserializeJsonRename() throws Exception {
        RecordWithRename value = MAPPER.readValue("{\"id\":123,\"rename\":\"Bob\"}",
                RecordWithRename.class);
        assertEquals(new RecordWithRename(123, "Bob"), value);
    }

    // Confirmation of fix of [databind#4218]
    @Test
    public void testDeserializeHeaderInjectRecord4218() throws Exception {
        ObjectReader reader = MAPPER.readerFor(RecordWithHeaderInject.class)
                .with(new InjectableValues.Std().addValue(String.class, "Bob"));
        assertNotNull(reader.readValue("{\"id\":123}"));
    }

    @Test
    public void testDeserializeConstructorInjectRecord4218() throws Exception {
        ObjectReader reader = MAPPER.readerFor(RecordWithConstructorInject.class)
                .with(new InjectableValues.Std().addValue(String.class, "Bob"));
        RecordWithConstructorInject value = reader.readValue("{\"id\":123}");
        assertEquals(new RecordWithConstructorInject(123, "Bob"), value);
    }

    /*
    /**********************************************************************
    /* Test methods, naming strategy
    /**********************************************************************
     */

    // [databind#2992]
    @Test
    public void testNamingStrategy() throws Exception
    {
        SnakeRecord input = new SnakeRecord("123", "value");

        String json = MAPPER.writeValueAsString(input);
        assertEquals("{\"my_id\":\"123\",\"my_value\":\"value\"}", json);

        SnakeRecord output = MAPPER.readValue(json, SnakeRecord.class);
        assertEquals(input, output);
    }

    /*
    /**********************************************************************
    /* Test methods, JsonDeserialize
    /**********************************************************************
     */

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

        assertEquals(new RecordWithJsonDeserialize(123, "Bob"), value);
    }

    /*
    /**********************************************************************
    /* Test methods, JsonProperty(access=WRITE_ONLY)
    /**********************************************************************
     */

    @Test
    public void testSerialize_SingleWriteOnlyParameter() throws Exception {
        String json = MAPPER.writeValueAsString(new RecordSingleWriteOnly(123));

        assertEquals("{}", json);
    }

    // [databind#3897]
    @Test
    public void testDeserialize_SingleWriteOnlyParameter() throws Exception {
        RecordSingleWriteOnly value = MAPPER.readValue("{\"id\":123}", RecordSingleWriteOnly.class);

        assertEquals(new RecordSingleWriteOnly(123), value);
    }

    @Test
    public void testSerialize_SomeWriteOnlyParameter() throws Exception {
        String json = MAPPER.writeValueAsString(new RecordSomeWriteOnly(123, "Bob", "bob@example.com"));

        assertEquals("{\"email\":\"bob@example.com\"}", json);
    }

    @Test
    public void testDeserialize_SomeWriteOnlyParameter() throws Exception {
        RecordSomeWriteOnly value = MAPPER.readValue(
                "{\"id\":123,\"name\":\"Bob\",\"email\":\"bob@example.com\"}",
                RecordSomeWriteOnly.class);

        assertEquals(new RecordSomeWriteOnly(123, "Bob", "bob@example.com"), value);
    }

    @Test
    public void testSerialize_AllWriteOnlyParameter() throws Exception {
        String json = MAPPER.writeValueAsString(new RecordAllWriteOnly(123, "Bob", "bob@example.com"));

        assertEquals("{}", json);
    }

    @Test
    public void testDeserialize_AllWriteOnlyParameter() throws Exception {
        RecordAllWriteOnly value = MAPPER.readValue(
                "{\"id\":123,\"name\":\"Bob\",\"email\":\"bob@example.com\"}",
                RecordAllWriteOnly.class);

        assertEquals(new RecordAllWriteOnly(123, "Bob", "bob@example.com"), value);
    }

    /*
    /**********************************************************************
    /* Test method(s), MapperFeature.REQUIRE_SETTERS_FOR_GETTERS
    /**********************************************************************
     */

    // [databind#4678]
    @Test
    public void testSerializeWithSettersForGetters() throws Exception {
        ObjectMapper mapperWithSetters = JsonMapper.builder()
                .configure(MapperFeature.REQUIRE_SETTERS_FOR_GETTERS, true)
                .build();
        var input = new SimpleRecord(123, "Bob");
        assertEquals(MAPPER.writeValueAsString(input),
                mapperWithSetters.writeValueAsString(input));
    }

    /*
    /**********************************************************************
    /* Test methods, private records [databind#4175]
    /**********************************************************************
     */

    // [databind#4175]
    @Test
    public void testSerializePrivateTextRecord() throws Exception {
        PrivateTextRecord4175 textRecord = new PrivateTextRecord4175("anything");
        String json = MAPPER.writeValueAsString(textRecord);
        final Object EXP = Collections.singletonMap("text", "anything");
        assertEquals(EXP, MAPPER.readValue(json, Object.class));
    }

    // [databind#4175]
    @Test
    public void testDeserializePrivateTextRecord() throws Exception {
        assertEquals(new PrivateTextRecord4175("anything"),
                MAPPER.readValue("{\"text\":\"anything\"}", PrivateTextRecord4175.class));
    }

    /*
    /**********************************************************************
    /* Test methods, renamed-property regression [databind#5967]
    /**********************************************************************
     */

    // [databind#5967] Records take a different path in
    // POJOPropertiesCollector#_renameProperties (always skipped by isRecordType()
    // when the property name is in _ignoredPropertyNames), so the field-stripping
    // fix added for non-records must not regress record renaming.
    @Test
    public void recordWithRenamedPropertyRoundTrips() throws Exception
    {
        RenamedRecord5967 original = new RenamedRecord5967("someValue");
        String json = MAPPER.writeValueAsString(original);
        assertEquals("{\"renamedProp\":\"someValue\"}", json);

        RenamedRecord5967 result = MAPPER.readValue(json, RenamedRecord5967.class);
        assertEquals("someValue", result.prop());
    }

    /*
    /**********************************************************************
    /* Test methods, custom @JsonSerialize/@JsonDeserialize [jackson#188]
    /**********************************************************************
     */

    // [jackson#188]
    @Test
    void testJsonSerializeOnRecord() throws Exception
    {
        Animal188 input = new Animal188("dog", 3);
        String JSON = MAPPER.writeValueAsString(input);
        assertEquals(a2q("{'name':'custom dog','age':3}"), JSON);
    }

    // [jackson#188]
    @Test
    void testJsonDeserializeOnRecord() throws Exception
    {
        String JSON = a2q("{'name':'cat','age':4}");
        Animal188 result = MAPPER.readValue(JSON, Animal188.class);
        assertEquals("custom-desercat", result.name());
        assertEquals(4, result.age());
    }

    /*
    /**********************************************************************
    /* Internal helper methods
    /**********************************************************************
     */

    private Map<String,Object> map(String key1, Object value1,
            String key2, Object value2) {
        final Map<String, Object> result = new LinkedHashMap<>();
        result.put(key1, value1);
        result.put(key2, value2);
        return result;
    }

    public static class StringTrimmer implements Converter<String, String>
    {
        @Override
        public String convert(DeserializationContext ctxt, String value) {
            return _convert(value);
        }

        @Override
        public String convert(SerializationContext ctxt, String value) {
            return _convert(value);
        }

        String _convert(String value) {
            return value.trim();
        }

        @Override
        public JavaType getInputType(TypeFactory typeFactory) {
            return typeFactory.constructType(String.class);
        }

        @Override
        public JavaType getOutputType(TypeFactory typeFactory) {
            return typeFactory.constructType(String.class);
        }
    }
}