jackson-databind icon indicating copy to clipboard operation
jackson-databind copied to clipboard

Inconsistent behaviour with package private constructors in Jackson 3

Open michael-simons opened this issue 4 weeks ago • 3 comments

Search before asking

  • [x] I searched in the issues and found nothing similar.

Describe the bug

Given

package x.y;

public class ThreeValuedPackagePrivateCtor {

	private final String a;

	private final int b;

	private final Double c;

	ThreeValuedPackagePrivateCtor(String a, int b, Double c) {
		this.a = a;
		this.b = b;
		this.c = c;
	}

	public String getA() {
		return a;
	}

	public int getB() {
		return b;
	}

	public Double getC() {
		return c;
	}

}

I can map this with Jackson 2.20 from another package as this:

@Test
void shouldDeserialize() throws Exception {
	var json = """
			{
			"a": "A String",
			"b": 1,
			"c": 23.42
			}
			""";
	var om = new ObjectMapper();
	om.registerModule(new ParameterNamesModule());
	var z = om.readValue(json, ThreeValuedPackagePrivateCtor.class);
	assertThat(z.getA()).isEqualTo("A String");
	assertThat(z.getB()).isEqualTo(1);
	assertThat(z.getC()).isEqualTo(23.42);
}

Jackson 3 in the same setup fails with

tools.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `x.y.ThreeValuedPackagePrivateCtor` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
 at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); byte offset: #UNKNOWN]

Throwing in a bunch of features

	@Test
	void shouldDeserialize() {
		var json = """
				{
				"a": "A String",
				"b": 1,
				"c": 23.42
				}
				""";
		var om = JsonMapper.builder()
			.enable(MapperFeature.ALLOW_FINAL_FIELDS_AS_MUTATORS)
			.enable(MapperFeature.CAN_OVERRIDE_ACCESS_MODIFIERS)
			.enable(MapperFeature.OVERRIDE_PUBLIC_ACCESS_MODIFIERS)
			.build();
		var z = om.readValue(json, ThreeValuedPackagePrivateCtor.class);
		assertThat(z.getA()).isEqualTo("A String");
		assertThat(z.getB()).isEqualTo(1);
		assertThat(z.getC()).isEqualTo(23.42);
	}

does not help.

I also tried various variants of .constructorDetector() without success.

Looked at those tickets / changes already:

https://github.com/FasterXML/jackson-databind/issues/5246 https://github.com/FasterXML/jackson-databind/pull/5308 https://github.com/FasterXML/jackson-databind/issues/5318

I was able to fix my problem with @JsonCreator (original problem is reading Spring Boot Metrics endpoints, see https://github.com/neo4j/neo4j-jdbc/blob/76201e59488bd2f6fe5031b1c76500f5cfedb915/neo4j-jdbc-it/spring-boot-smoke-tests/src/test/java/org/neo4j/jdbc/it/sb/ApplicationIT.java#L87, for which I know have this… https://github.com/neo4j/neo4j-jdbc/blob/76201e59488bd2f6fe5031b1c76500f5cfedb915/neo4j-jdbc-it/spring-boot-smoke-tests/src/test/java/org/neo4j/jdbc/it/sb/ApplicationIT.java#L121 which is fine for me, but I'm sure this will cause issue somewhere else. The metrics data classes are the same like the ones above: Package private, non default constructor, no other constructor )

Version Information

Working: Jackson 2.20.1, Failing Jackson 3.0.2. Plain Java 25, no Framework. See attached reproducer:

jackson_fun.zip

Reproduction

See reproducer, 2nd commit.

Expected behavior

See reproducer, 1st commit.

Additional context

Funny enough, quite similar class, like this:

package x.y;

public class OneValuedPackagePrivateCtor {

	private final String a;

	OneValuedPackagePrivateCtor(String a) {
		this.a = a;

	}

	public String getA() {
		return a;
	}


}

will map just fine.

michael-simons avatar Nov 24 '25 15:11 michael-simons

I suspect the option that matters is Visibility for CREATORS. By default only public constructors should be auto-detectable so not sure how 2.x would detect (it really shouldn't). I don't remember off-hand method in JsonMapper.builder() for changing visibility; let me know if you can't locate it and I'll go find it. But that should allow auto-detection.

cowtowncoder avatar Nov 24 '25 21:11 cowtowncoder

Ok, this is not obvious:

	var json = """
				{
				"a": "A String",
				"b": 1,
				"c": 23.42
				}
				""";
		var om = JsonMapper.builder()
			.changeDefaultVisibility(i -> i.withCreatorVisibility(JsonAutoDetect.Visibility.NON_PRIVATE))
			.build();
		var z = om.readValue(json, ThreeValuedPackagePrivateCtor.class);
		assertThat(z.getA()).isEqualTo("A String");
		assertThat(z.getB()).isEqualTo(1);
		assertThat(z.getC()).isEqualTo(23.42);

But that works.

However, the inconsistency still holds (it works without that for a ctor taking only one argument), and in Jackson 2 it worked without the checker.

michael-simons avatar Nov 25 '25 06:11 michael-simons

This (multi-argument Constructor) should not have worked in 2.x, as far I can see. I'd typically expect @JsonCreator for such case, although Visibility is an alternative.

So different behavior between 2.x and 3.x in itself is not necessarily considered bug.

cowtowncoder avatar Nov 25 '25 17:11 cowtowncoder

Hi, could I add to the case? for org.springframework.boot.actuate.audit.AuditEvent

@Test
void deserialize() {
    String json = """
        {
            "data":{
                "keyA":"ValueA",
                "KeyB":"ValueB"
            },
            "principal":"user",
            "timestamp":"2025-12-18T14:40:33.333719Z",
            "type":"type"
        }
        """;
        
    AuditEvent auditEvent = jsonMapper.readValue(json, AuditEvent.class);
    Assertions.assertNotNull(auditEvent);
    Assertions.assertEquals("user", auditEvent.getPrincipal());
    Assertions.assertEquals("type", auditEvent.getType());
    Assertions.assertEquals(1, auditEvent.getData().size());
}

Result: tools.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of org.springframework.boot.actuate.audit.AuditEvent (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator) at [Source: REDACTED (StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION disabled); byte offset: #UNKNOWN]

at tools.jackson.databind.exc.InvalidDefinitionException.from(InvalidDefinitionException.java:70)
at tools.jackson.databind.DeserializationContext.reportBadDefinition(DeserializationContext.java:1954)
at tools.jackson.databind.DatabindContext.reportBadDefinition(DatabindContext.java:448)
at tools.jackson.databind.DeserializationContext.handleMissingInstantiator(DeserializationContext.java:1484)
at tools.jackson.databind.deser.bean.BeanDeserializerBase.deserializeFromObjectUsingNonDefault(BeanDeserializerBase.java:1432)
at tools.jackson.databind.deser.bean.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:480)
at tools.jackson.databind.deser.bean.BeanDeserializer.deserialize(BeanDeserializer.java:200)
at tools.jackson.databind.deser.DeserializationContextExt.readRootValue(DeserializationContextExt.java:265)

buksvdl avatar Dec 19 '25 11:12 buksvdl