BeanDeserializerVanillaTest.java

package tools.jackson.databind.deser.bean;

import java.util.*;

import org.junit.jupiter.api.Test;

import tools.jackson.core.JacksonException;
import tools.jackson.core.JsonParser;
import tools.jackson.core.JsonToken;
import tools.jackson.core.TokenStreamLocation;
import tools.jackson.core.sym.PropertyNameMatcher;
import tools.jackson.core.util.JsonParserDelegate;
import tools.jackson.databind.*;
import tools.jackson.databind.deser.DeserializationContextExt;
import tools.jackson.databind.exc.InvalidFormatException;
import tools.jackson.databind.json.JsonMapper;
import tools.jackson.databind.testutil.DatabindTestUtil;
import tools.jackson.databind.testutil.MockDataInput;

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

/**
 * Tests for the "vanilla" fast path of {@link BeanDeserializer} (which uses
 * {@code JsonParser.nextNameMatchAndToken()}). Note that this path is only
 * used if {@link MapperFeature#DEFAULT_VIEW_INCLUSION} is enabled
 * (it is disabled by default in 3.x).
 */
public class BeanDeserializerVanillaTest extends DatabindTestUtil
{
    @SuppressWarnings("serial")
    static class AccessibleMapper extends JsonMapper {
        AccessibleMapper(JsonMapper.Builder b) {
            super(b);
        }

        DeserializationContextExt deserializationContext() {
            return _deserializationContext();
        }
    }

    static class Point {
        public int x, y;
    }

    // More than 4 properties, to also cover wrap-around of unrolled loop
    static class Wide {
        public String a;
        public int b;
        public boolean c;
        public Double d = -1.0;
        public Point e;
        public List<Point> f;
        public int[] g;
        public Map<String, Object> h;
        public String i;
    }

    // Parser delegate that overrides `nextNameMatch()` (and `currentNameMatch()`)
    // but not `nextNameMatchAndToken()`: overrides must still be honored
    static class RenamingParser extends JsonParserDelegate {
        RenamingParser(JsonParser p) {
            super(p);
        }

        @Override
        public String currentName() {
            String name = delegate.currentName();
            return "legacyA".equals(name) ? "a" : name;
        }

        @Override
        public int currentNameMatch(PropertyNameMatcher matcher) {
            return matcher.matchName(currentName());
        }

        @Override
        public int nextNameMatch(PropertyNameMatcher matcher) {
            JsonToken t = nextToken();
            if (t == JsonToken.PROPERTY_NAME) {
                return matcher.matchName(currentName());
            }
            return (t == JsonToken.END_OBJECT) ? PropertyNameMatcher.MATCH_END_OBJECT
                    : PropertyNameMatcher.MATCH_ODD_TOKEN;
        }
    }

    // All properties of `Wide`, one per line
    private static final String KNOWN_PROPS = """
            "a": "text"
            "b": 42
            "c": true
            "d": null
            "e": { "x": 1, "y": 2 }
            "f": [ { "x": 3, "y": 4 }, { "y": 6, "x": 5 } ]
            "g": [ 7, 8 ]
            "h": { "k": [ 1, { "n": null } ] }
            "i": "last"
            """;

    // Unknown property with structured value, containing names of known properties
    private static final String UNKNOWN_PROP = """
            "unknown": { "a": [ 1, { "e": [ ] } ], "b": "not for Wide.b" }
            """.strip();

    private final AccessibleMapper MAPPER = new AccessibleMapper(jsonMapperBuilder()
            .enable(MapperFeature.DEFAULT_VIEW_INCLUSION));

    // Sanity check: verify tests actually exercise vanilla processing
    @Test
    void vanillaProcessingUsed() throws Exception
    {
        for (Class<?> type : List.of(Wide.class, Point.class)) {
            ValueDeserializer<Object> deser = MAPPER.deserializationContext()
                    .findRootValueDeserializer(MAPPER.constructType(type));
            assertInstanceOf(BeanDeserializer.class, deser);
            assertTrue(((BeanDeserializer) deser)._vanillaProcessing,
                    "Should use vanilla processing for "+type.getSimpleName());
        }
    }

    @Test
    void allKnownProperties() throws Exception
    {
        for (Wide w : _readAllWays(_object(_knownProps()))) {
            _verifyWide(w);
        }
    }

    @Test
    void emptyObject() throws Exception
    {
        for (Wide w : _readAllWays("{ }")) {
            assertNull(w.a);
            assertEquals(Double.valueOf(-1.0), w.d);
            assertNull(w.i);
        }
    }

    // Unknown property at every position, to hit unknown-name handling from
    // all points of unrolled loop (and its continuation)
    @Test
    void unknownPropertyAtEveryPosition() throws Exception
    {
        final int count = _knownProps().size();
        for (int pos = 0; pos <= count; ++pos) {
            List<String> props = _knownProps();
            props.add(pos, UNKNOWN_PROP);
            final String doc = _object(props);
            for (Wide w : _readAllWays(doc)) {
                try {
                    _verifyWide(w);
                } catch (AssertionError e) {
                    fail("Failed for unknown property at #"+pos+": "+e.getMessage()+"; doc: "+doc);
                }
            }
        }
    }

    // Unknown property before every known one
    @Test
    void unknownPropertiesInterleaved() throws Exception
    {
        List<String> props = new ArrayList<>();
        for (String prop : _knownProps()) {
            props.add(UNKNOWN_PROP);
            props.add(prop);
        }
        props.add(UNKNOWN_PROP);
        for (Wide w : _readAllWays(_object(props))) {
            _verifyWide(w);
        }
    }

    // Overrides of `nextNameMatch()` in a `JsonParserDelegate` sub-class must be
    // honored (was not the case before [core#1710])
    @Test
    void parserDelegateOverridesHonored() throws Exception
    {
        final String doc = """
                {
                  "b": 42,
                  "legacyA": "text",
                  "e": { "x": 1, "y": 2 },
                  "unknown": [ 1 ],
                  "legacyA": "again"
                }
                """;
        try (JsonParser p = new RenamingParser(MAPPER.createParser(doc))) {
            Wide w = MAPPER.readValue(p, Wide.class);
            assertEquals("again", w.a);
            assertEquals(42, w.b);
            assertNotNull(w.e);
            assertEquals(1, w.e.x);
            assertEquals(2, w.e.y);
        }
    }

    /*
    /**********************************************************************
    /* Error reporting: must be same as with non-vanilla processing
    /**********************************************************************
     */

    // Replaces "b": type mismatch for a top-level property
    private static final String BAD_SCALAR_PROP = """
            "b": "notAnInt"
            """.strip();

    // Replaces "e": type mismatch for a property of nested POJO
    private static final String BAD_NESTED_PROP = """
            "e": { "x": 1, "y": "notAnInt" }
            """.strip();

    // Default settings: vanilla processing NOT used (see [databind#6219]; if that
    // changes, need some other way to get non-vanilla processing for reference)
    private final AccessibleMapper REFERENCE_MAPPER = new AccessibleMapper(jsonMapperBuilder());

    // Errors within vanilla processing (at every position of unrolled loop, and
    // within continuation after unknown property) must be reported same as with
    // non-vanilla processing: same exception type, message, path and location
    @Test
    void errorsReportedSameAsNonVanilla() throws Exception
    {
        for (Class<?> type : List.of(Wide.class, Point.class)) {
            ValueDeserializer<Object> deser = REFERENCE_MAPPER.deserializationContext()
                    .findRootValueDeserializer(REFERENCE_MAPPER.constructType(type));
            assertFalse(((BeanDeserializer) deser)._vanillaProcessing,
                    "Reference mapper should NOT use vanilla processing for "+type.getSimpleName());
        }

        final int count = _knownProps().size();
        for (String badProp : List.of(BAD_SCALAR_PROP, BAD_NESTED_PROP)) {
            final String badName = badProp.substring(0, badProp.indexOf(':'));
            final List<String> expPath = (badProp == BAD_SCALAR_PROP)
                    ? List.of("b") : List.of("e", "y");
            for (boolean unknownFirst : new boolean[] { false, true }) {
                for (int pos = 0; pos < count; ++pos) {
                    List<String> props = _knownProps();
                    props.removeIf(prop -> prop.startsWith(badName));
                    props.add(pos, badProp);
                    if (unknownFirst) {
                        props.add(0, UNKNOWN_PROP);
                    }
                    // One property per line: bad one is on line (index + 2)
                    final String doc = "{\n" + String.join(",\n", props) + "\n}";
                    final int expLine = props.indexOf(badProp) + 2;

                    for (int mode = 0; mode < 3; ++mode) {
                        final String desc = "input mode #"+mode+", doc:\n"+doc;
                        InvalidFormatException vanilla = _readFail(MAPPER, doc, mode);
                        InvalidFormatException reference = _readFail(REFERENCE_MAPPER, doc, mode);

                        assertEquals("notAnInt", vanilla.getValue(), "value: "+desc);
                        assertEquals(int.class, vanilla.getTargetType(), "target type: "+desc);

                        assertEquals(expPath, vanilla.getPath().stream()
                                .map(JacksonException.Reference::getPropertyName).toList(),
                                "path: "+desc);
                        // DataInput-backed parser does not track token location accurately,
                        // so only compare it with reference (below)
                        if (mode != 2) {
                            assertEquals(expLine, vanilla.getLocation().getLineNr(), "line: "+desc);
                        }

                        assertEquals(reference.getClass(), vanilla.getClass(), desc);
                        assertEquals(reference.getOriginalMessage(), vanilla.getOriginalMessage(), desc);
                        assertEquals(reference.getPathReference(), vanilla.getPathReference(), desc);
                        _assertSameLocation(reference.getLocation(), vanilla.getLocation(), desc);
                    }
                }
            }
        }
    }

    /*
    /**********************************************************************
    /* Helper methods
    /**********************************************************************
     */

    // Read using one of JSON parser implementations, expecting failure:
    // 0 = byte-backed, 1 = char-backed, 2 = DataInput-backed
    private InvalidFormatException _readFail(ObjectMapper mapper, String doc, int mode) {
        return assertThrows(InvalidFormatException.class, () -> {
            switch (mode) {
            case 0 -> mapper.readValue(utf8Bytes(doc), Wide.class);
            case 1 -> mapper.readValue(doc, Wide.class);
            default -> mapper.readValue(new MockDataInput(doc), Wide.class);
            }
        });
    }

    private void _assertSameLocation(TokenStreamLocation exp, TokenStreamLocation act, String desc) {
        assertEquals(exp.getLineNr(), act.getLineNr(), "line: "+desc);
        assertEquals(exp.getColumnNr(), act.getColumnNr(), "column: "+desc);
        assertEquals(exp.getByteOffset(), act.getByteOffset(), "byte offset: "+desc);
        assertEquals(exp.getCharOffset(), act.getCharOffset(), "char offset: "+desc);
    }

    private List<String> _knownProps() {
        return new ArrayList<>(KNOWN_PROPS.lines().toList());
    }

    private String _object(List<String> props) {
        return "{" + String.join(",", props) + "}";
    }

    // Read using all 3 JSON parser implementations: byte-, char- and DataInput-backed
    private List<Wide> _readAllWays(String doc) throws Exception {
        return List.of(MAPPER.readValue(utf8Bytes(doc), Wide.class),
                MAPPER.readValue(doc, Wide.class),
                MAPPER.readValue(new MockDataInput(doc), Wide.class));
    }

    private void _verifyWide(Wide w) {
        assertEquals("text", w.a);
        assertEquals(42, w.b);
        assertTrue(w.c);
        assertNull(w.d);
        assertNotNull(w.e);
        assertEquals(1, w.e.x);
        assertEquals(2, w.e.y);
        assertNotNull(w.f);
        assertEquals(2, w.f.size());
        assertEquals(3, w.f.get(0).x);
        assertEquals(4, w.f.get(0).y);
        assertEquals(5, w.f.get(1).x);
        assertEquals(6, w.f.get(1).y);
        assertArrayEquals(new int[] { 7, 8 }, w.g);
        assertEquals(Map.of("k", List.of(1, Collections.singletonMap("n", null))), w.h);
        assertEquals("last", w.i);
    }
}