Skip to content

StdValueInstantiator.createFromInt does not fallback on Object constructor given a int #5030

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
1 task done
blacelle opened this issue Mar 18, 2025 · 7 comments
Closed
1 task done

Comments

@blacelle
Copy link

blacelle commented Mar 18, 2025

Search before asking

  • I searched in the issues and found nothing similar.

Describe the bug

I consider a custom type with an Object property. The object is serialized through its interface with the help of an @JsonTypeInfo, and a custom @JsonSerialize to represent the custom type only given the inner property.

The custom type has an Object constructor, but for some reason, I need to add a constructor for each primitive type (a String constructor, an int constructor, etc).

I would expect the Object constructor to be good-enough to handle any input type.

Relates with:

Version Information

2.18.2

Reproduction

(The reproduction scenario is probably unecessarily cumbersome, I kept some specifities of from actual codebase).

package eu.solven.adhoc.filter.value;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.JsonValue;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;

public class TestIntConstructor {

	@JsonTypeInfo(use = JsonTypeInfo.Id.NAME,
			include = JsonTypeInfo.As.PROPERTY,
			property = "type",
			defaultImpl = FromObject.class)
	@JsonSubTypes({ @JsonSubTypes.Type(value = FromObject.class, name = "from") })
	public interface SomeInterface {
		Object getInner();
	}

	public static class FromObject implements SomeInterface {
		@JsonValue
		Object o;

		public FromObject() {
			System.out.println("empty");
		}

		// @JsonCreator(mode = JsonCreator.Mode.DELEGATING)
		public FromObject(Object o) {
			System.out.println("object");
			this.o = o;
		}

		// public FromObject(int o) {
		// this((Object) o);
		// System.out.println("int");
		// }

		public FromObject(String s) {
			this((Object) s);
			System.out.println("string");
		}

		public void setO(Object o) {
			this.o = o;
		}

		@Override
		public Object getInner() {
			return o;
		}
	}

	@Test
	public void testJackson_integer() throws JsonProcessingException {
		FromObject matcher = new FromObject(3);

		ObjectMapper objectMapper = new ObjectMapper();
		// https://stackoverflow.com/questions/17617370/pretty-printing-json-from-jackson-2-2s-objectmapper
		objectMapper.enable(SerializationFeature.INDENT_OUTPUT);

		String asString = objectMapper.writeValueAsString(matcher);
		Assertions.assertThat(asString).isEqualTo("3");

		SomeInterface fromString = objectMapper.readValue(asString, SomeInterface.class);

		Assertions.assertThat(fromString.getInner()).isEqualTo(3);
	}

	@Test
	public void testJackson_string() throws JsonProcessingException {
		FromObject matcher = new FromObject("foo");

		ObjectMapper objectMapper = new ObjectMapper();
		// https://stackoverflow.com/questions/17617370/pretty-printing-json-from-jackson-2-2s-objectmapper
		objectMapper.enable(SerializationFeature.INDENT_OUTPUT);

		String asString = objectMapper.writeValueAsString(matcher);
		Assertions.assertThat(asString).isEqualTo("\"foo\"");

		SomeInterface fromString = objectMapper.readValue(asString, SomeInterface.class);

		Assertions.assertThat(fromString.getInner()).isEqualTo("foo");
	}

	@Test
	public void testJackson_rawFormat() throws JsonProcessingException {
		ObjectMapper objectMapper = new ObjectMapper();
		// https://stackoverflow.com/questions/17617370/pretty-printing-json-from-jackson-2-2s-objectmapper
		objectMapper.enable(SerializationFeature.INDENT_OUTPUT);

		String asString = "{\"type\": \"from\", \"o\": \"someString\"}";

		SomeInterface fromString = objectMapper.readValue(asString, SomeInterface.class);

		Assertions.assertThat(fromString.getInner()).isEqualTo("someString");
	}

	@Test
	public void testJackson_rawFormat_int() throws JsonProcessingException {
		ObjectMapper objectMapper = new ObjectMapper();
		// https://stackoverflow.com/questions/17617370/pretty-printing-json-from-jackson-2-2s-objectmapper
		objectMapper.enable(SerializationFeature.INDENT_OUTPUT);

		String asString = "{\"type\": \"from\", \"o\": 3}";

		SomeInterface fromString = objectMapper.readValue(asString, SomeInterface.class);

		Assertions.assertThat(fromString.getInner()).isEqualTo(3);
	}
}

Expected behavior

I would expect the Object constructor (if it exists) to be a fallback to all types, including primitive types.

Additional context

No response

@blacelle blacelle added the to-evaluate Issue that has been received but not yet evaluated label Mar 18, 2025
@cowtowncoder
Copy link
Member

You seem to have forgotten (or perhaps be unaware of the need for?) adding @JsonCreator annotation on Object-taking Constructor?

Adding that annotation (possibly as @JsonCreator(mode = JsonCreator.Mode.DELEGATING)) should make deserializer use it.

@cowtowncoder cowtowncoder removed the to-evaluate Issue that has been received but not yet evaluated label Mar 19, 2025
@cowtowncoder
Copy link
Member

Another thing you might want to do: use @JsonValue to reduce/remove need for custom serializer:

                @JsonValue
		@Override
		public Object getInner() {
			return o;
		}

@blacelle
Copy link
Author

blacelle commented Mar 19, 2025

You seem to have forgotten (or perhaps be unaware of the need for?) adding @JsonCreator annotation on Object-taking Constructor?

Yes, but no.

Yes, as I indeed barely never used @JsonCreator (as it is generally not needed in my usages) and I did not thought it would help in this case.

No, because it not fully satisfactory. From a functional perspective :

  • I have a set of types (following matching logic: equals, in, like, ...)
  • I want each of them to have a common serialization format: {'type': 'equals', 'operand': anyObject}, {'type': 'like', 'like': 'someLikeExpression'}, etc. This takes use of @JsonSubTypes.
  • I also want the most common case to have a human-friendly format: I want equals to be (de)-serialized by default as anyObject (instead of {'type': 'equals', 'operand': anyObject}). This is why I added defaultImpl = FromObject.class and @JsonSerialize(using = HasWrappedSerializer.class).

I adjusted the reproduction case to reflect current implementation of this need.

Building the reproduction case, I note:

  • for the "someString" format, Jackson relies on constructors. @JsonCreator would cover the int case but it breaks the {"type": "from", "o": "someString"} format.
  • for the {"type": "from", "o": "someString"} format, Jackson relies on setters. A single setO(Object o) covers all types,

Another thing you might want to do: use @JsonValue to reduce/remove need for custom serializer:

Indeed. Thanks. I sinmplified the reproduction case (and my own code) with this.

@cowtowncoder
Copy link
Member

cowtowncoder commented Mar 19, 2025

Well, use of @JsonCreator is not and will not be optional for cases where you have multiple public Constructors, or when you want to have "Delegating" constructor aside from a small-set of "well-known" types (String, long/Long, int/Integer, double/Double). Support for "well-known types" is bit of a legacy thing and not meant to be extended.

So to use constructor that takes single java.lang.Object argument, you will need to use @JsonCreator. This will not be changed; nor is the omission considered a bug.

@blacelle
Copy link
Author

blacelle commented Mar 19, 2025

Thanks. I understand that @JsonCreator would the way to go, except for my specific case where I want both a explicit and implicit format (i.e. 2 different JSON formats to deserialize into my type). Then, it looks legit to provide a small-set of constructor for well-know types.

One advantage of my considered design is to enable .convertToMap (which is legit for {"type": "from", "o": "someString"}, but not for someString).

While working on this (trying hard not to add the few additional constructors), I've hit some weird/unexpected behavior:

  • Similar case as worked through this ticket (specific reproduction scenario at the end of this comment)
  • The type wraps a String (original case was wrapping an Object)
  • Using ObjectMapper.convertTo(..., Map.class) (original case was focused on writeValueAsString )
  • I receive a Map like c -> List.of("from", "foo") where from is the name from @JsonSubTypes and foo is the wrapped String

Should I open a specific ticket?

org.opentest4j.AssertionFailedError: 
expected: "{c=foo}"
 but was: "{c=[from, foo]}"
import java.util.Map;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.JsonValue;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;

import lombok.Builder;
import lombok.Value;
import lombok.extern.jackson.Jacksonized;

public class TestIntConstructor_WeirdSerialization {

	@JsonTypeInfo(use = JsonTypeInfo.Id.NAME,
			include = JsonTypeInfo.As.PROPERTY,
			property = "type",
			defaultImpl = AroundString.class)
	@JsonSubTypes({ @JsonSubTypes.Type(value = AroundString.class, name = "from") })
	public interface AroundSomething {
		Object getInner();
	}

	@Builder
	@Value
	@Jacksonized
	public static class AroundString implements AroundSomething {
		@JsonValue
		String inner;

		public AroundString(String s) {
			this.inner = s;
			System.out.println("string");
		}

		@Override
		public Object getInner() {
			return inner;
		}

		public static class AroundStringBuilder {

			public AroundStringBuilder() {
			}

			// Enable Jackson deserialization given a plain String
			public AroundStringBuilder(String column) {
				this.inner(column);
			}
		}
	}

	@Value
	@Builder
	@Jacksonized
	public static class HasFromObject {
		AroundSomething c;
	}

	@Test
	public void testJackson_convertToWrappingPojo_string() throws JsonProcessingException {
		AroundString matcher = new AroundString("foo");

		ObjectMapper objectMapper = new ObjectMapper();

		Map asMap = objectMapper.convertValue(HasFromObject.builder().c(matcher).build(), Map.class);
		Assertions.assertThat(asMap.toString()).isEqualTo("{c=foo}");
	}
}

@cowtowncoder
Copy link
Member

I am not sure understand the ask here; example is getting way too complicated.

But as to a new ticket: you can if you want to. I doubt we'll be adding support for Yet More Constructor detection as in general the goal is to support use of just 2 Creators (constructor / factory methods):

  • A properties-based Creator (Mode.PROPERTIES) -- may be auto-detected if the only visible (public) constructor
  • A delegating creator (Mode.DELEGATING) -- not auto-detected, needs @JsonCreator.

but due to legacy reasons, there are those String/int/long/double/boolean -taking delegating constructors that are additionally detected.

As such, use of general-purpose delegating Creator that takes intermediate/general type like JsonNode or Object is recommended, to handle digestion from whatever JSON shape is encountered.

@blacelle
Copy link
Author

Thanks @cowtowncoder . My case in indeed quite specific. I open #5035 for an unexpected behavior, popped by this issue, but unrelated with StdValueInstantiator.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants