Skip to content

feat(#1381): Add a way to specify "inject-only" with @JacksonInject #5175

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

Open
wants to merge 12 commits into
base: 2.x
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions release-notes/CREDITS-2.x
Original file line number Diff line number Diff line change
Expand Up @@ -1945,6 +1945,17 @@ Ryan Schmitt (@rschmitt)
(2.19.0)

Giulio Longfils (@giulong)
* Contributed #1381: Add a way to specify "inject-only" with `@JacksonInject`
(2.20.0)
* Contributed fix for #2678: `@JacksonInject` added to property overrides value
from the JSON even if `useInput` is `OptBoolean.TRUE`
(2.20.0)
* Contributed #3072: Allow specifying `@JacksonInject` does not fail when there's no
corresponding value
(2.20.0)

Plamen Tanov (@ptanov)
* Reported #2678: `@JacksonInject` added to property overrides value from the JSON even if
`useInput` is `OptBoolean.TRUE`
(2.20.0)

6 changes: 6 additions & 0 deletions release-notes/VERSION-2.x
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ Project: jackson-databind

2.20.0 (not yet released)

#1381: Add a way to specify "inject-only" with `@JacksonInject`
(contributed by Giulio L)
#2678: `@JacksonInject` added to property overrides value from the JSON even if
`useInput` is `OptBoolean.TRUE`
(reported by Plamen T)
(fix contributed by Giulio L)
#3072: Allow specifying `@JacksonInject` does not fail when there's no
corresponding value
(requested by Lavender S)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -817,11 +817,23 @@ protected void addInjectables(DeserializationContext ctxt,
for (Map.Entry<Object, AnnotatedMember> entry : raw.entrySet()) {
AnnotatedMember m = entry.getValue();
final JacksonInject.Value injectableValue = introspector.findInjectableValue(m);
final Boolean optional = injectableValue == null ? null : injectableValue.getOptional();
final Boolean optional;
final Boolean useInput;

builder.addInjectable(PropertyName.construct(m.getName()),
m.getType(),
beanDesc.getClassAnnotations(), m, entry.getKey(), optional);
if (injectableValue == null) {
optional = null;
useInput = null;
} else {
optional = injectableValue.getOptional();
useInput = injectableValue.getUseInput();
}

// 04-Jun-2025, tatu: [databind#1381]: default for "useInput" is false
if (!Boolean.TRUE.equals(useInput)) {
builder.addInjectable(PropertyName.construct(m.getName()),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is part I don't fully understand: if injectable is only added if no input is to be used, how do things work with useInput = false, injectable value being present.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As per this test, with no input but injected value present, the injected value is always returned regardless of useInput:

@Test
@DisplayName("input NO, injectable YES, useInput DEFAULT|TRUE|FALSE => injected")
void test2() throws Exception {
    assertEquals("injected", injectedMapper.readValue(empty, InputDefault.class).getField());
    assertEquals("injected", injectedMapper.readValue(empty, InputTrue.class).getField());
    assertEquals("injected", injectedMapper.readValue(empty, InputFalse.class).getField());
}

I read it like "we have no input but we have a value to inject, so we don't care about anything else: let's use the injected value". Is this the expected behavior? Or you meant something else?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic-wise, yes, you are correct (expected behavior I agree with).

But I meant code; meaning... what does addInjectable() do -- to me, it's the thing that injects non-input (injectable) value. So why is that contingent on "useInput"? And I think check here seems wrong in that sense: injectable value may be needed even input is used.
It is not only injected if useInput is false (or missing); can also be injected if useInput is true but input has no value for property.

Copy link
Contributor Author

@giulong giulong Jun 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a way to eagerly discard the injectable if we're going to use the input anyways. I think it's better in terms of performance since any further evaluation is useless, and there are many methods involved down that path.
But I also get this:

injectable value may be needed even input is used

Not now maybe, but in general I get that if a field is marked as injected, we should add it to the internal injectables for whatever future logic.

I can try to change this, which means passing useInput to the builder (last param in the snippet), as we made for optional:

builder.addInjectable(PropertyName.construct(m.getName()),
                            m.getType(),
                            beanDesc.getClassAnnotations(), m, entry.getKey(), optional, useInput);

Maybe we could also think of passing the whole injectableValue to the builder, instead of passing its properties one by one.

But I think this is not going to be easy. What do you think, is it worth it?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, figured it was meant as optimization.
But the problem with " if we're going to use the input anyways" is that there might not be any input.
So it's not "use input if any matching, otherwise null" but rather "use input if present; if not, use injected value". Otherwise why even mark it with @JacksonInject at all, actually.

So we cannot know statically which one to use.

And yes, I think this is necessary to handle properly.

But I understand things are messy, tricky, complicated, esp. when passing things via Constructors (CreatorProperty properties) vs Field/MethodProperty ones.

m.getType(),
beanDesc.getClassAnnotations(), m, entry.getKey(), optional);
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,11 @@ public Object getInjectableValueId() {
return (_injectableValue == null) ? null : _injectableValue.getId();
}

@Override
public JacksonInject.Value getInjectableValue() {
return _injectableValue;
}

@Override
public boolean isInjectionOnly() {
return (_injectableValue != null) && !_injectableValue.willUseInput(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.io.IOException;
import java.lang.annotation.Annotation;

import com.fasterxml.jackson.annotation.JacksonInject;
import com.fasterxml.jackson.core.*;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.deser.impl.FailingDeserializer;
Expand Down Expand Up @@ -458,6 +459,14 @@ public int getCreatorIndex() {
*/
public Object getInjectableValueId() { return null; }

/**
* Accessor for injectable value, if this bean property supports
* value injection.
*
* @since 2.20
*/
public JacksonInject.Value getInjectableValue() { return null; }

/**
* Accessor for checking whether this property is injectable, and if so,
* ONLY injectable (will not bind from input).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.io.IOException;
import java.util.BitSet;

import com.fasterxml.jackson.annotation.JacksonInject;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.deser.SettableAnyProperty;
Expand Down Expand Up @@ -218,6 +219,25 @@ public Object[] getParameters(SettableBeanProperty[] props)
if (_anyParamSetter != null) {
_creatorParameters[_anyParamSetter.getParameterIndex()] = _createAndSetAnySetterValue();
}

for (int ix = 0; ix < props.length; ++ix) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the moment when creator properties are resolved, so I think it's ok to have the logic here, as per the method's javadoc as well:

/**
* Method called to do necessary post-processing such as injection of values
* and verification of values for required properties,
* after either {@link #assignParameter(SettableBeanProperty, Object)}
* returns true (to indicate all creator properties are found), or when
* then whole JSON Object has been processed,
*/
public Object[] getParameters(SettableBeanProperty[] props)

I placed the new part right before the code that checks for null creator properties in case we have an injected value to use, so to not fail. Should I move it after?

final SettableBeanProperty prop = props[ix];
final AnnotatedMember member = prop.getMember();

if (member != null) {
final JacksonInject.Value injectableValue = prop.getInjectableValue();

if (injectableValue != null && !Boolean.TRUE.equals(injectableValue.getUseInput())) {
final Object injectedValue = _context.findInjectableValue(
injectableValue.getId(), prop, member, injectableValue.getOptional());

if (injectedValue != JacksonInject.Value.empty()) {
_creatorParameters[ix] = injectedValue;
}
}
}
}

if (_context.isEnabled(DeserializationFeature.FAIL_ON_NULL_CREATOR_PROPERTIES)) {
for (int ix = 0; ix < props.length; ++ix) {
if (_creatorParameters[ix] == null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
package com.fasterxml.jackson.databind.deser.inject;

import com.fasterxml.jackson.annotation.JacksonInject;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.OptBoolean;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.InjectableValues;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.exc.ValueInstantiationException;
import com.fasterxml.jackson.databind.testutil.DatabindTestUtil;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

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

class JacksonInject1381DeserializationFeatureDisabledTest extends DatabindTestUtil {
static class InputDefault {
@JacksonInject(value = "key")
@JsonProperty("field")
private final String _field;

@JsonCreator
public InputDefault(@JsonProperty("field") final String field) {
_field = field;
}

public String getField() {
return _field;
}
}

static class InputDefaultConstructor {
private final String _field;

@JsonCreator
public InputDefaultConstructor(@JacksonInject(value = "key")
@JsonProperty("field") final String field) {
_field = field;
}

public String getField() {
return _field;
}
}

static class InputTrue {
@JacksonInject(value = "key", useInput = OptBoolean.TRUE)
@JsonProperty("field")
private final String _field;

@JsonCreator
public InputTrue(@JsonProperty("field") final String field) {
_field = field;
}

public String getField() {
return _field;
}
}

static class InputTrueConstructor {
private final String _field;

@JsonCreator
public InputTrueConstructor(@JacksonInject(value = "key", useInput = OptBoolean.TRUE)
@JsonProperty("field") final String field) {
_field = field;
}

public String getField() {
return _field;
}

}

static class InputFalse {
@JacksonInject(value = "key", useInput = OptBoolean.FALSE)
@JsonProperty("field")
private final String _field;

@JsonCreator
public InputFalse(@JsonProperty("field") final String field) {
_field = field;
}

public String getField() {
return _field;
}
}

static class InputFalseConstructor {
private final String _field;

@JsonCreator
public InputFalseConstructor(@JacksonInject(value = "key", useInput = OptBoolean.FALSE)
@JsonProperty("field") final String field) {
_field = field;
}

public String getField() {
return _field;
}
}

private final String empty = "{}";
private final String input = "{\"field\": \"input\"}";

private final ObjectMapper plainMapper = newJsonMapper()
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_INJECT_VALUE);
private final ObjectMapper injectedMapper = jsonMapperBuilder()
.injectableValues(new InjectableValues.Std().addValue("key", "injected"))
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_INJECT_VALUE)
.build();

@Test
@DisplayName("FAIL_ON_UNKNOWN_INJECT_VALUE NO, input NO, injectable NO, useInput DEFAULT|TRUE|FALSE => exception")
void test1() {
assertThrows(ValueInstantiationException.class,
() -> plainMapper.readValue(empty, InputDefault.class));
assertThrows(ValueInstantiationException.class,
() -> plainMapper.readValue(empty, InputDefaultConstructor.class));

assertThrows(ValueInstantiationException.class,
() -> plainMapper.readValue(empty, InputTrue.class));
assertThrows(ValueInstantiationException.class,
() -> plainMapper.readValue(empty, InputTrueConstructor.class));

assertThrows(ValueInstantiationException.class,
() -> plainMapper.readValue(empty, InputFalse.class));
assertThrows(ValueInstantiationException.class,
() -> plainMapper.readValue(empty, InputFalseConstructor.class));
}

@Test
@DisplayName("FAIL_ON_UNKNOWN_INJECT_VALUE NO, input NO, injectable YES, useInput DEFAULT|TRUE|FALSE => injected")
void test2() throws Exception {
assertEquals("injected", injectedMapper.readValue(empty, InputDefault.class).getField());
assertEquals("injected", injectedMapper.readValue(empty, InputDefaultConstructor.class).getField());
assertEquals("injected", injectedMapper.readValue(empty, InputTrue.class).getField());
assertEquals("injected", injectedMapper.readValue(empty, InputTrueConstructor.class).getField());
assertEquals("injected", injectedMapper.readValue(empty, InputFalse.class).getField());
assertEquals("injected", injectedMapper.readValue(empty, InputFalseConstructor.class).getField());
}

@Test
@DisplayName("FAIL_ON_UNKNOWN_INJECT_VALUE NO, input YES, injectable NO, useInput DEFAULT|FALSE => exception")
void test3() throws Exception {
assertEquals("input", plainMapper.readValue(input, InputDefault.class).getField());
assertEquals("input", plainMapper.readValue(input, InputDefaultConstructor.class).getField());
assertEquals("input", plainMapper.readValue(input, InputFalse.class).getField());
assertEquals("input", plainMapper.readValue(input, InputFalseConstructor.class).getField());
}

@Test
@DisplayName("FAIL_ON_UNKNOWN_INJECT_VALUE NO, input YES, injectable NO, useInput TRUE => input")
void test4() throws Exception {
assertEquals("input", plainMapper.readValue(input, InputTrue.class).getField());
assertEquals("input", plainMapper.readValue(input, InputTrueConstructor.class).getField());
}

@Test
@DisplayName("FAIL_ON_UNKNOWN_INJECT_VALUE NO, input YES, injectable YES, useInput DEFAULT|FALSE => injected")
void test5() throws Exception {
assertEquals("injected", injectedMapper.readValue(input, InputDefault.class).getField());
assertEquals("injected", injectedMapper.readValue(input, InputDefaultConstructor.class).getField());
assertEquals("injected", injectedMapper.readValue(input, InputFalse.class).getField());
assertEquals("injected", injectedMapper.readValue(input, InputFalseConstructor.class).getField());
}

@Test
@DisplayName("FAIL_ON_UNKNOWN_INJECT_VALUE NO, input YES, injectable YES, useInput TRUE => input")
void test6() throws Exception {
assertEquals("input", injectedMapper.readValue(input, InputTrue.class).getField());
assertEquals("input", injectedMapper.readValue(input, InputTrueConstructor.class).getField());
}
}
Loading