JsonInclude1757Test.java

package tools.jackson.databind.ser.filter;

import java.util.List;

import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;

import tools.jackson.core.JsonGenerator;
import tools.jackson.databind.MapperFeature;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.SerializationContext;
import tools.jackson.databind.ValueSerializer;
import tools.jackson.databind.annotation.JsonSerialize;
import tools.jackson.databind.testutil.DatabindTestUtil;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;

// [databind#1757]: Global NON_DEFAULT should use real bean defaults
//  for round-trip consistency when USE_REAL_INCLUDE_NON_DEFAULT is enabled
public class JsonInclude1757Test extends DatabindTestUtil
{
    static class Entity {
        private String someFieldWithDefault = "a default";

        public void setSomeFieldWithDefault(String someFieldWithDefault) {
            this.someFieldWithDefault = someFieldWithDefault;
        }

        public String getSomeFieldWithDefault() {
            return someFieldWithDefault;
        }
    }

    static class IntDefaultEntity {
        public int value = 42;
    }

    private ObjectMapper globalRealNonDefaultMapper() {
        return jsonMapperBuilder()
                .enable(MapperFeature.USE_REAL_INCLUDE_NON_DEFAULT)
                .changeDefaultPropertyInclusion(incl ->
                        incl.withValueInclusion(JsonInclude.Include.NON_DEFAULT))
                .build();
    }

    // [databind#1757]: default values should round-trip with global NON_DEFAULT
    @Test
    public void testGlobalNonDefaultRoundTrip() throws Exception {
        ObjectMapper mapper = globalRealNonDefaultMapper();

        // Serializing a default instance should produce {}
        Entity entity = new Entity();
        String json = mapper.writeValueAsString(entity);
        assertEquals("{}", json);

        // Deserializing {} should restore the default value
        Entity deserialized = mapper.readValue(json, Entity.class);
        assertEquals("a default", deserialized.getSomeFieldWithDefault());
    }

    // [databind#1757]: non-default values should be included
    @Test
    public void testGlobalNonDefaultIncludesNonDefault() throws Exception {
        ObjectMapper mapper = globalRealNonDefaultMapper();

        Entity entity = new Entity();
        entity.setSomeFieldWithDefault("custom value");
        String json = mapper.writeValueAsString(entity);
        assertEquals(a2q("{'someFieldWithDefault':'custom value'}"), json);
    }

    // [databind#1757]: empty string should not be suppressed if it's not the default
    @Test
    public void testGlobalNonDefaultEmptyStringNonDefault() throws Exception {
        ObjectMapper mapper = globalRealNonDefaultMapper();

        Entity entity = new Entity();
        entity.setSomeFieldWithDefault("");
        String json = mapper.writeValueAsString(entity);
        // Empty string is NOT the default ("a default"), so it should be included
        assertEquals(a2q("{'someFieldWithDefault':''}"), json);
    }

    // [databind#6317]: null over non-null default written as explicit null (like per-type),
    //  so that it round-trips
    @Test
    public void globalNonDefaultWritesExplicitNull() throws Exception {
        ObjectMapper mapper = globalRealNonDefaultMapper();

        Entity entity = new Entity();
        entity.setSomeFieldWithDefault(null);
        String json = mapper.writeValueAsString(entity);
        assertEquals("""
                {"someFieldWithDefault":null}""", json);
        assertNull(mapper.readValue(json, Entity.class).getSomeFieldWithDefault());
    }

    // [databind#1757]: global NON_DEFAULT with primitive field having non-zero default
    @Test
    public void testGlobalNonDefaultPrimitiveField() throws Exception {
        ObjectMapper mapper = globalRealNonDefaultMapper();

        // Default value (42) should be suppressed
        IntDefaultEntity entity = new IntDefaultEntity();
        assertEquals("{}", mapper.writeValueAsString(entity));

        // Non-default value should be included
        entity.value = 0;
        assertEquals(a2q("{'value':0}"), mapper.writeValueAsString(entity));

        // Another non-default value
        entity.value = 99;
        assertEquals(a2q("{'value':99}"), mapper.writeValueAsString(entity));
    }

    // [databind#1757]: global NON_DEFAULT should match per-class NON_DEFAULT behavior
    @Test
    public void testGlobalMatchesPerClassBehavior() throws Exception {
        ObjectMapper globalMapper = globalRealNonDefaultMapper();

        ObjectMapper defaultMapper = newJsonMapper();

        // Per-class annotated bean
        AnnotatedEntity annotated = new AnnotatedEntity();
        String perClassJson = defaultMapper.writeValueAsString(annotated);

        // Global NON_DEFAULT bean (same structure, no annotation)
        Entity global = new Entity();
        String globalJson = globalMapper.writeValueAsString(global);

        // Both should produce {}
        assertEquals("{}", perClassJson);
        assertEquals("{}", globalJson);
    }

    // [databind#1757]: Without USE_REAL_INCLUDE_NON_DEFAULT, old behavior is preserved
    @Test
    public void testOldBehaviorWithoutFeature() throws Exception {
        ObjectMapper mapper = jsonMapperBuilder()
                // USE_REAL_INCLUDE_NON_DEFAULT is disabled by default
                .changeDefaultPropertyInclusion(incl ->
                        incl.withValueInclusion(JsonInclude.Include.NON_DEFAULT))
                .build();

        // With old behavior (Mode 2), String type default is "".
        // "a default" != "" so the field IS included.
        Entity entity = new Entity();
        String json = mapper.writeValueAsString(entity);
        assertEquals(a2q("{'someFieldWithDefault':'a default'}"), json);
    }

    @JsonInclude(JsonInclude.Include.NON_DEFAULT)
    static class AnnotatedEntity {
        private String someFieldWithDefault = "a default";

        public void setSomeFieldWithDefault(String v) {
            this.someFieldWithDefault = v;
        }

        public String getSomeFieldWithDefault() {
            return someFieldWithDefault;
        }
    }

    // [databind#6275]: per-type NON_DEFAULT, non-null constructor default
    @JsonInclude(JsonInclude.Include.NON_DEFAULT)
    static class PerTypeMode {
        public String mode = "default";
    }

    // [databind#6275]: per-type NON_DEFAULT whose real default is null
    @JsonInclude(JsonInclude.Include.NON_DEFAULT)
    static class PerTypeNullDefault {
        public String mode;
    }

    // [databind#6275]: explicit null over a non-null per-type default must round-trip
    @Test
    public void testPerTypeNonDefaultExplicitNullRoundTrip() throws Exception {
        ObjectMapper mapper = newJsonMapper();

        PerTypeMode bean = new PerTypeMode();
        bean.mode = null;
        String json = mapper.writeValueAsString(bean);
        assertEquals(a2q("{'mode':null}"), json);

        PerTypeMode deserialized = mapper.readValue(json, PerTypeMode.class);
        assertNull(deserialized.mode);

        // Unchanged constructor default is omitted
        assertEquals("{}", mapper.writeValueAsString(new PerTypeMode()));

        // Non-default non-null value is included
        bean.mode = "custom";
        assertEquals(a2q("{'mode':'custom'}"), mapper.writeValueAsString(bean));
    }

    // [databind#6275]: a null real default still suppresses nulls
    @Test
    public void testPerTypeNonDefaultNullDefaultSuppressesNull() throws Exception {
        assertEquals("{}", newJsonMapper().writeValueAsString(new PerTypeNullDefault()));
    }

    static class NullDefaultEntity {
        public String mode;
    }

    static class PropertyOverrideEntity {
        @JsonInclude(JsonInclude.Include.NON_NULL)
        public String mode = "default";
    }

    // [databind#6317]: a null real default still suppresses nulls in global mode
    @Test
    public void globalNonDefaultNullDefaultSuppressesNull() throws Exception {
        ObjectMapper mapper = globalRealNonDefaultMapper();
        assertEquals("{}", mapper.writeValueAsString(new NullDefaultEntity()));
        NullDefaultEntity bean = new NullDefaultEntity();
        bean.mode = "x";
        assertEquals("""
                {"mode":"x"}""", mapper.writeValueAsString(bean));
    }

    // [databind#6317]: per-property NON_NULL overrides global NON_DEFAULT
    @Test
    public void globalNonDefaultPerPropertyOverride() throws Exception {
        ObjectMapper mapper = globalRealNonDefaultMapper();
        PropertyOverrideEntity bean = new PropertyOverrideEntity();
        assertEquals("""
                {"mode":"default"}""", mapper.writeValueAsString(bean));
        bean.mode = null;
        assertEquals("{}", mapper.writeValueAsString(bean));
    }

    @JsonInclude(JsonInclude.Include.NON_NULL)
    static class ClassOverridePropertyNonDefaultEntity {
        @JsonInclude(JsonInclude.Include.NON_DEFAULT)
        public String mode = "default";
    }

    // [databind#6317]: if class-level inclusion overrides global NON_DEFAULT,
    //  per-property NON_DEFAULT uses static defaults, and nulls are suppressed
    @Test
    public void classOverridePerPropertyNonDefaultSuppressesNull() throws Exception {
        ObjectMapper mapper = globalRealNonDefaultMapper();
        ClassOverridePropertyNonDefaultEntity bean = new ClassOverridePropertyNonDefaultEntity();
        // static default for String is "", so real default is not suppressed
        assertEquals("""
                {"mode":"default"}""", mapper.writeValueAsString(bean));
        bean.mode = null;
        assertEquals("{}", mapper.writeValueAsString(bean));
    }

    // [databind#6319]: custom "empty" check must apply when default bean cannot be created
    static class Money {
        final long cents;
        Money(long c) { cents = c; }
    }

    static class MoneySer extends ValueSerializer<Money> {
        @Override
        public void serialize(Money v, JsonGenerator g, SerializationContext ctxt) {
            g.writeNumber(v.cents);
        }

        @Override
        public boolean isEmpty(SerializationContext ctxt, Money v) {
            return v.cents == 0;
        }
    }

    // No default constructor: default bean cannot be created
    static class CreatorOnly {
        @JsonSerialize(using = MoneySer.class)
        public Money amount;

        @JsonCreator
        CreatorOnly(@JsonProperty("amount") Money amount) { this.amount = amount; }
    }

    @JsonInclude(JsonInclude.Include.NON_DEFAULT)
    static class PerTypeCreatorOnly {
        @JsonSerialize(using = MoneySer.class)
        public Money amount;

        @JsonCreator
        PerTypeCreatorOnly(@JsonProperty("amount") Money amount) { this.amount = amount; }
    }

    record MoneyRecord(@JsonSerialize(using = MoneySer.class) Money amount) { }

    private ObjectMapper globalNonDefaultMapper(boolean real) {
        return jsonMapperBuilder()
                .configure(MapperFeature.USE_REAL_INCLUDE_NON_DEFAULT, real)
                .changeDefaultPropertyInclusion(incl ->
                        incl.withValueInclusion(JsonInclude.Include.NON_DEFAULT))
                .build();
    }

    @Test
    public void testNoDefaultBeanGlobalNonDefault() throws Exception {
        for (boolean real : new boolean[] { false, true }) {
            ObjectMapper mapper = globalNonDefaultMapper(real);
            assertEquals("{}", mapper.writeValueAsString(new CreatorOnly(new Money(0))),
                    "real=" + real);
            assertEquals("{\"amount\":5}", mapper.writeValueAsString(new CreatorOnly(new Money(5))),
                    "real=" + real);
        }
    }

    @JsonInclude(JsonInclude.Include.NON_DEFAULT)
    static class PerTypeBuiltInsCreatorOnly {
        public String text;
        public List<String> list;

        @JsonCreator
        PerTypeBuiltInsCreatorOnly(@JsonProperty("text") String text,
                @JsonProperty("list") List<String> list) {
            this.text = text;
            this.list = list;
        }
    }

    @Test
    public void testNoDefaultBeanPerTypeNonDefault() throws Exception {
        for (boolean real : new boolean[] { false, true }) {
            ObjectMapper mapper = jsonMapperBuilder()
                    .configure(MapperFeature.USE_REAL_INCLUDE_NON_DEFAULT, real)
                    .build();
            assertEquals("{}", mapper.writeValueAsString(new PerTypeCreatorOnly(new Money(0))),
                    "real=" + real);
            assertEquals("{\"amount\":5}",
                    mapper.writeValueAsString(new PerTypeCreatorOnly(new Money(5))),
                    "real=" + real);
        }
    }

    // Built-in types: static defaults ("", empty List) apply, as no real default available
    @Test
    public void testNoDefaultBeanPerTypeBuiltIns() throws Exception {
        for (boolean real : new boolean[] { false, true }) {
            ObjectMapper mapper = jsonMapperBuilder()
                    .configure(MapperFeature.USE_REAL_INCLUDE_NON_DEFAULT, real)
                    .build();
            assertEquals("{}",
                    mapper.writeValueAsString(new PerTypeBuiltInsCreatorOnly("", List.of())),
                    "real=" + real);
            assertEquals("{\"text\":\"a\",\"list\":[\"b\"]}",
                    mapper.writeValueAsString(new PerTypeBuiltInsCreatorOnly("a", List.of("b"))),
                    "real=" + real);
        }
    }

    // Records are excluded from "empty" fallback ([databind#5312]), regardless of feature
    @Test
    public void testRecordGlobalNonDefault() throws Exception {
        for (boolean real : new boolean[] { false, true }) {
            ObjectMapper mapper = globalNonDefaultMapper(real);
            assertEquals("{\"amount\":0}", mapper.writeValueAsString(new MoneyRecord(new Money(0))),
                    "real=" + real);
            assertEquals("{\"amount\":5}", mapper.writeValueAsString(new MoneyRecord(new Money(5))),
                    "real=" + real);
        }
    }
}