From 8ab83b9c43aaa3f4bf1d28b76e601903dc2d4430 Mon Sep 17 00:00:00 2001 From: LeeJiWon Date: Sun, 25 Jan 2026 14:35:14 +0900 Subject: [PATCH 1/6] Allow multiple injectable targets per id (#5217) Switch injectables map value to List to support multiple @JacksonInject targets for same id. Keep existing masking rules (creator param masks same-property members, setter masks field) and add explicit failure for setter-setter conflicts on same property/id. Add memoized member->property lookup + tests. --- .../jackson/databind/BeanDescription.java | 2 +- .../deser/BeanDeserializerFactory.java | 30 +- .../introspect/BasicBeanDescription.java | 2 +- .../introspect/POJOPropertiesCollector.java | 172 ++++++++-- .../introspect/POJOPropertyBuilder.java | 41 ++- .../deser/inject/InvalidInjectionTest.java | 45 ++- .../deser/inject/JacksonInject4218Test.java | 2 +- .../deser/inject/JacksonInject5217Test.java | 310 ++++++++++++++++++ 8 files changed, 547 insertions(+), 57 deletions(-) create mode 100644 src/test/java/tools/jackson/databind/deser/inject/JacksonInject5217Test.java diff --git a/src/main/java/tools/jackson/databind/BeanDescription.java b/src/main/java/tools/jackson/databind/BeanDescription.java index e70054a598..b2168de2fe 100644 --- a/src/main/java/tools/jackson/databind/BeanDescription.java +++ b/src/main/java/tools/jackson/databind/BeanDescription.java @@ -243,7 +243,7 @@ public AnnotatedMember findJsonKeyAccessor() { /********************************************************************** */ - public abstract Map findInjectables(); + public abstract Map> findInjectables(); /** * Method called to create a "default instance" of the bean, currently diff --git a/src/main/java/tools/jackson/databind/deser/BeanDeserializerFactory.java b/src/main/java/tools/jackson/databind/deser/BeanDeserializerFactory.java index 0acf2a2ab8..86637ac635 100644 --- a/src/main/java/tools/jackson/databind/deser/BeanDeserializerFactory.java +++ b/src/main/java/tools/jackson/databind/deser/BeanDeserializerFactory.java @@ -806,25 +806,27 @@ protected void addBackReferenceProperties(DeserializationContext ctxt, protected void addInjectables(DeserializationContext ctxt, BeanDescription.Supplier beanDescRef, BeanDeserializerBuilder builder) { - Map raw = beanDescRef.get().findInjectables(); + Map> raw = beanDescRef.get().findInjectables(); if (raw != null) { final AnnotationIntrospector introspector = ctxt.getAnnotationIntrospector(); - for (Map.Entry entry : raw.entrySet()) { - AnnotatedMember m = entry.getValue(); - final JacksonInject.Value injectableValue = introspector.findInjectableValue(ctxt.getConfig(), m); - final Boolean optional, useInput; + // 23-Jan-2026, tatu: [databind#5217] Allow multiple injections of same value + for (Map.Entry> entry : raw.entrySet()) { + for (AnnotatedMember m : entry.getValue()) { + final JacksonInject.Value injectableValue = introspector.findInjectableValue(ctxt.getConfig(), m); + final Boolean optional, useInput; - if (injectableValue == null) { - optional = useInput = null; - } else { - optional = injectableValue.getOptional(); - useInput = injectableValue.getUseInput(); - } + if (injectableValue == null) { + optional = useInput = null; + } else { + optional = injectableValue.getOptional(); + useInput = injectableValue.getUseInput(); + } - builder.addInjectable(PropertyName.construct(m.getName()), - m.getType(), - beanDescRef.getClassAnnotations(), m, entry.getKey(), optional, useInput); + builder.addInjectable(PropertyName.construct(m.getName()), + m.getType(), + beanDescRef.getClassAnnotations(), m, entry.getKey(), optional, useInput); + } } } } diff --git a/src/main/java/tools/jackson/databind/introspect/BasicBeanDescription.java b/src/main/java/tools/jackson/databind/introspect/BasicBeanDescription.java index 19812a78f6..a31bec4cab 100644 --- a/src/main/java/tools/jackson/databind/introspect/BasicBeanDescription.java +++ b/src/main/java/tools/jackson/databind/introspect/BasicBeanDescription.java @@ -299,7 +299,7 @@ public AnnotatedMember findAnySetterAccessor() throws IllegalArgumentException } @Override - public Map findInjectables() { + public Map> findInjectables() { if (_propCollector != null) { return _propCollector.getInjectables(); } diff --git a/src/main/java/tools/jackson/databind/introspect/POJOPropertiesCollector.java b/src/main/java/tools/jackson/databind/introspect/POJOPropertiesCollector.java index 25bf4967de..1b20ee60c8 100644 --- a/src/main/java/tools/jackson/databind/introspect/POJOPropertiesCollector.java +++ b/src/main/java/tools/jackson/databind/introspect/POJOPropertiesCollector.java @@ -131,7 +131,7 @@ public class POJOPropertiesCollector * indicate that they represent mutators for deserializer * value injection. */ - protected LinkedHashMap _injectables; + protected LinkedHashMap> _injectables; /** * Lazily accessed information about POJO format overrides @@ -204,7 +204,7 @@ public PotentialCreators getPotentialCreators() { return _potentialCreators; } - public Map getInjectables() { + public Map> getInjectables() { if (!_collected) { collectAll(); } @@ -1266,36 +1266,46 @@ protected void _addSetterMethod(Map props, _property(props, implName).addSetter(m, pn, nameExplicit, visible, ignore); } + /** + * Collect injectable members using list-based approach with post-processing. + * + * Rules applied: + * - Rule 1: Allow multiple injection targets with the same ID + * - Rule 2: Same property + same ID + field&setter → setter masks field + * - Rule 3: Creator param masks same-property field/setter (#4218) + * + * @since 3.0 + */ protected void _addInjectables(Map props) { - // first fields, then methods, to allow overriding + // Phase 1: Allow multiple injection targets with the same ID (across different properties) for (AnnotatedField f : _classDef.fields()) { _doAddInjectable(_annotationIntrospector.findInjectableValue(_config, f), f); } + // Phase 2: Collect all injectable setters (1-param methods) for (AnnotatedMethod m : _classDef.memberMethods()) { - // for now, only allow injection of a single arg (to be changed in future?) if (m.getParameterCount() != 1) { continue; } _doAddInjectable(_annotationIntrospector.findInjectableValue(_config, m), m); } - // 21-Aug-2025, tatu: [databind#4218] avoid duplicate injectables + // Phase 3: Post-processing if (_injectables != null) { - for (POJOPropertyBuilder creatorProperty : _creatorProperties) { - if (creatorProperty == null) { - continue; - } - final AnnotatedParameter parameter = creatorProperty.getConstructorParameter(); - JacksonInject.Value injectable = _annotationIntrospector.findInjectableValue(_config, parameter); - if (injectable != null) { - _injectables.remove(injectable.getId()); - } + final IdentityHashMap memberToProp = + new IdentityHashMap<>(); + if ((_creatorProperties != null) && !_creatorProperties.isEmpty()) { + _removeCreatorPropertyInjectables(props, memberToProp); // Rule 3 } + _deduplicateSamePropertyInjectables(props, memberToProp); // Rule 2 } } + /** + * Add an injectable member to the collection. + * [databind#5217] Allow multiple members with the same ID. + */ protected void _doAddInjectable(JacksonInject.Value injectable, AnnotatedMember m) { if (injectable == null) { @@ -1305,16 +1315,138 @@ protected void _doAddInjectable(JacksonInject.Value injectable, AnnotatedMember if (_injectables == null) { _injectables = new LinkedHashMap<>(); } - AnnotatedMember prev = _injectables.put(id, m); - if (prev != null) { - // 12-Apr-2017, tatu: Let's allow masking of Field by Method - if (prev.getClass() == m.getClass()) { - reportProblem("Duplicate injectable value with id '%s' (of type %s)", - id, ClassUtil.classNameOf(id)); + // [databind#5217] Allow multiple members with the same ID + _injectables.computeIfAbsent(id, k -> new ArrayList<>()).add(m); + } + + /** + * Remove field/setter injectables that belong to the same property as an + * injectable creator parameter. (Rule 3 - fixes #4218) + * + * Also handles invisible fields (like record fields) by checking field name + * matching the property name. + */ + protected void _removeCreatorPropertyInjectables(Map props, + IdentityHashMap memberToProp) + { + if (_creatorProperties == null) { + return; + } + for (POJOPropertyBuilder creatorProp : _creatorProperties) { + if (creatorProp == null) { + continue; + } + AnnotatedParameter param = creatorProp.getConstructorParameter(); + if (param == null) { + continue; + } + + JacksonInject.Value injectable = _annotationIntrospector.findInjectableValue(_config, param); + if (injectable == null) { + continue; + } + + Object id = injectable.getId(); + List members = _injectables.get(id); + if (members != null) { + // Remove members that belong to the same logical property as creator param. + // Avoid field-name hacks; instead reconcile via property membership/name mapping. + final String creatorPropName = creatorProp.getName(); + members.removeIf(m -> { + // Fast path: property already knows the member + if (creatorProp.containsMember(m)) { + return true; + } + + // Fallback: record / invisible field fallback (field name == logical prop name) + if ((m instanceof AnnotatedField) && creatorPropName.equals(m.getName())) { + return true; + } + + // Fallback: member -> property mapping (handles @JsonProperty rename if in props) + String memberPropName = _findPropertyNameForMember(m, props, memberToProp); + return creatorPropName.equals(memberPropName); + }); + if (members.isEmpty()) { + _injectables.remove(id); + } } } } + /** + * For same ID and same property, if both field and setter exist, keep only setter. + * (Rule 2 - setter masks field) + * + * Note: This method only operates within the same ID. + * Different IDs are treated independently (backward compatibility). + */ + protected void _deduplicateSamePropertyInjectables(Map props, + IdentityHashMap memberToProp) + { + for (Map.Entry> entry : _injectables.entrySet()) { + List members = entry.getValue(); + if (members.size() <= 1) { + continue; + } + + // Group by property (within same ID) + Map byProperty = new LinkedHashMap<>(); + List notInProperty = new ArrayList<>(); + + for (AnnotatedMember m : members) { + String propName = _findPropertyNameForMember(m, props, memberToProp); + if (propName == null) { + notInProperty.add(m); // Keep members not in any property + } else { + AnnotatedMember existing = byProperty.get(propName); + if (existing == null) { + byProperty.put(propName, m); + } else if (_isMethod(m) && !_isMethod(existing)) { + // Setter masks field (same property, same ID) + byProperty.put(propName, m); + } else if (_isMethod(m) == _isMethod(existing)) { + reportProblem("Duplicate injectable value with id '%s' for property '%s' (multiple %s)", + entry.getKey(), propName, _isMethod(m) ? "setters" : "fields"); + } + // else: keep existing (setter already in place or same type) + } + } + + // Reconstruct the list + List result = new ArrayList<>(byProperty.values()); + result.addAll(notInProperty); + entry.setValue(result); + } + } + + private String _findPropertyNameForMember(AnnotatedMember m, Map props, + IdentityHashMap memberToProp) { + if (memberToProp != null) { + final String cached = memberToProp.get(m); + if ((cached != null) || memberToProp.containsKey(m)) { + return cached; + } + } + for (Map.Entry e : props.entrySet()) { + if (e.getValue().containsMember(m)) { + final String name = e.getKey(); + if (memberToProp != null) { + memberToProp.put(m, name); + } + return name; + } + } + if (memberToProp != null) { + memberToProp.put(m, null); + } + return null; + } + + private boolean _isMethod(AnnotatedMember m) { + return m instanceof AnnotatedMethod; + } + private PropertyName _propNameFromSimple(String simpleName) { return PropertyName.construct(simpleName, null); } diff --git a/src/main/java/tools/jackson/databind/introspect/POJOPropertyBuilder.java b/src/main/java/tools/jackson/databind/introspect/POJOPropertyBuilder.java index 578f79be88..48c58d2609 100644 --- a/src/main/java/tools/jackson/databind/introspect/POJOPropertyBuilder.java +++ b/src/main/java/tools/jackson/databind/introspect/POJOPropertyBuilder.java @@ -781,13 +781,50 @@ public boolean hasFieldOrGetter(AnnotatedMember member) { return _hasAccessor(_fields, member) || _hasAccessor(_getters, member); } + /** + * Check if this property contains the given member (field, setter, or getter). + * Uses Member.equals() comparison via underlying JDK Member objects. + *<p> + * Note: Constructor parameters are NOT checked because: + * <ul> + * <li>They are handled separately through _creatorProperties mechanism</li> + * <li>AnnotatedParameter.getMember() returns the owning Constructor/Method, + * not a unique identifier per parameter</li> + * </ul> + * + * @param member the member to check (may be null) + * @return true if this property contains the given member as field, setter, or getter + * @since 3.0 + */ + public boolean containsMember(AnnotatedMember member) { + if (member == null) { + return false; + } + return _hasAccessor(_fields, member) + || _hasAccessor(_setters, member) + || _hasAccessor(_getters, member); + } + private boolean _hasAccessor(Linked node, AnnotatedMember memberToMatch) { - // AnnotatedXxx are not canonical, but underlying JDK Members are: + // Null safety: memberToMatch or its underlying Member could be null + if (memberToMatch == null) { + return false; + } final Member rawMemberToMatch = memberToMatch.getMember(); + if (rawMemberToMatch == null) { + return false; + } + // Note: Use equals() comparison since AnnotatedXxx wrappers may differ + // even for the same underlying Member. Member.equals() compares by + // declaring class, name, and type/parameter types. for (; node != null; node = node.next) { - if (node.value.getMember() == rawMemberToMatch) { + Member nodeMember = node.value.getMember(); + if (nodeMember == null) { + continue; // Skip null members in the chain + } + if (Objects.equals(rawMemberToMatch, nodeMember)) { return true; } } diff --git a/src/test/java/tools/jackson/databind/deser/inject/InvalidInjectionTest.java b/src/test/java/tools/jackson/databind/deser/inject/InvalidInjectionTest.java index 8c6b24c469..fd7173f352 100644 --- a/src/test/java/tools/jackson/databind/deser/inject/InvalidInjectionTest.java +++ b/src/test/java/tools/jackson/databind/deser/inject/InvalidInjectionTest.java @@ -4,21 +4,22 @@ import com.fasterxml.jackson.annotation.JacksonInject; +import tools.jackson.databind.InjectableValues; import tools.jackson.databind.ObjectMapper; -import tools.jackson.databind.exc.InvalidDefinitionException; - -import static org.junit.jupiter.api.Assertions.fail; +import tools.jackson.databind.ObjectReader; +import static org.junit.jupiter.api.Assertions.assertEquals; import static tools.jackson.databind.testutil.DatabindTestUtil.*; +// [databind#5217]: Allow multiple injections of same value public class InvalidInjectionTest { - static class BadBean1 { + static class Bean1 { @JacksonInject protected String prop1; @JacksonInject protected String prop2; } - static class BadBean2 { + static class Bean2 { @JacksonInject("x") protected String prop1; @JacksonInject("x") protected String prop2; } @@ -31,21 +32,29 @@ static class BadBean2 { private final ObjectMapper MAPPER = newJsonMapper(); + // [databind#5217]: multiple injections of same value should work @Test - public void testInvalidDup() throws Exception + public void testDuplicateInjectableFieldsWork() throws Exception { - try { - MAPPER.readValue("{}", BadBean1.class); - fail("Should not pass"); - } catch (InvalidDefinitionException e) { - verifyException(e, "Duplicate injectable value"); - } - try { - MAPPER.readValue("{}", BadBean2.class); - fail("Should not pass"); - } catch (InvalidDefinitionException e) { - verifyException(e, "Duplicate injectable value"); - } + InjectableValues injectables = new InjectableValues.Std() + .addValue(String.class, "injectedValue"); + ObjectReader reader = MAPPER.readerFor(Bean1.class).with(injectables); + + Bean1 bean = reader.readValue("{}"); + assertEquals("injectedValue", bean.prop1); + assertEquals("injectedValue", bean.prop2); } + // [databind#5217]: multiple injections with explicit id should work + @Test + public void testDuplicateInjectableFieldsWithIdWork() throws Exception + { + InjectableValues injectables = new InjectableValues.Std() + .addValue("x", "injectedX"); + ObjectReader reader = MAPPER.readerFor(Bean2.class).with(injectables); + + Bean2 bean = reader.readValue("{}"); + assertEquals("injectedX", bean.prop1); + assertEquals("injectedX", bean.prop2); + } } diff --git a/src/test/java/tools/jackson/databind/deser/inject/JacksonInject4218Test.java b/src/test/java/tools/jackson/databind/deser/inject/JacksonInject4218Test.java index f04399c058..0e35573b46 100644 --- a/src/test/java/tools/jackson/databind/deser/inject/JacksonInject4218Test.java +++ b/src/test/java/tools/jackson/databind/deser/inject/JacksonInject4218Test.java @@ -16,7 +16,7 @@ class JacksonInject4218Test extends DatabindTestUtil { static class Dto { @JacksonInject("id") - String id; + public String id; @JsonCreator Dto(@JacksonInject("id") diff --git a/src/test/java/tools/jackson/databind/deser/inject/JacksonInject5217Test.java b/src/test/java/tools/jackson/databind/deser/inject/JacksonInject5217Test.java new file mode 100644 index 0000000000..0742c195f9 --- /dev/null +++ b/src/test/java/tools/jackson/databind/deser/inject/JacksonInject5217Test.java @@ -0,0 +1,310 @@ +package tools.jackson.databind.deser.inject; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JacksonInject; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +import tools.jackson.databind.InjectableValues; +import tools.jackson.databind.ObjectReader; +import tools.jackson.databind.exc.InvalidDefinitionException; +import tools.jackson.databind.testutil.DatabindTestUtil; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertEquals; + + +// [databind#5217]: Multiple injections of same value should work consistently +class JacksonInject5217Test extends DatabindTestUtil +{ + // Case 1: Field & Field - both fields use same injectable type + static class FieldFieldBean { + @JacksonInject("id") + public String field1; + + @JacksonInject("id") + public String field2; + } + + // Case 2: Parameter & Parameter - both constructor params use same injectable type + static class ParamParamBean { + final String param1; + final String param2; + + @JsonCreator + ParamParamBean( + @JacksonInject("id") @JsonProperty("param1") String param1, + @JacksonInject("id") @JsonProperty("param2") String param2 + ) { + this.param1 = param1; + this.param2 = param2; + } + } + + // Case 3: Parameter & Field - constructor param and field use same injectable type + // When both param and field have same injectable id, the field injection is skipped + // (per issue #4218) and the value comes from constructor assignment + static class ParamFieldBean { + @JacksonInject("id") + public String field; + + final String param; + + @JsonCreator + ParamFieldBean( + @JacksonInject("id") @JsonProperty("param") String param + ) { + this.param = param; + this.field = param; // Constructor assigns param to field + } + } + + // Case 4: Field + Setter (same property, same ID) - should inject via setter only (masking) + // The field and setter are for the same property, so ideally only one should be injected + static class FieldSetterBean { + @JacksonInject("id") + public String value; + + int setterCallCount = 0; + + @JacksonInject("id") + public void setValue(String value) { + this.setterCallCount++; + this.value = value; + } + } + + private final InjectableValues INJECTABLES = new InjectableValues.Std() + .addValue("id", "injectedValue"); + + // [databind#5217]: Field & Field - was failing with "Duplicate injectable value" error + @Test + void testFieldAndFieldInjection() throws Exception + { + ObjectReader reader = newJsonMapper() + .readerFor(FieldFieldBean.class) + .with(INJECTABLES); + + FieldFieldBean bean = reader.readValue("{}"); + assertEquals("injectedValue", bean.field1); + assertEquals("injectedValue", bean.field2); + } + + // [databind#5217]: Parameter & Parameter - was already working + @Test + void testParamAndParamInjection() throws Exception + { + ObjectReader reader = newJsonMapper() + .readerFor(ParamParamBean.class) + .with(INJECTABLES); + + ParamParamBean bean = reader.readValue("{}"); + assertEquals("injectedValue", bean.param1); + assertEquals("injectedValue", bean.param2); + } + + // [databind#5217]: Parameter & Field - was already working + @Test + void testParamAndFieldInjection() throws Exception + { + ObjectReader reader = newJsonMapper() + .readerFor(ParamFieldBean.class) + .with(INJECTABLES); + + ParamFieldBean bean = reader.readValue("{}"); + assertEquals("injectedValue", bean.param); + assertEquals("injectedValue", bean.field); + } + + // [databind#5217]: Field + Setter (same property) - setter should mask field + // This verifies that when both field and setter are annotated with @JacksonInject + // for the same property, only the setter is used (higher precedence) + @Test + void testFieldAndSetterSameProperty() throws Exception + { + ObjectReader reader = newJsonMapper() + .readerFor(FieldSetterBean.class) + .with(INJECTABLES); + + FieldSetterBean bean = reader.readValue("{}"); + assertEquals("injectedValue", bean.value); + assertEquals(1, bean.setterCallCount, "Should inject only once via setter"); + } + + // Case 5: Field + Setter with @JsonProperty custom name + // Verifies that _findPropertyNameForMember correctly handles @JsonProperty + static class FieldSetterWithJsonPropertyBean { + @JacksonInject("id") + @JsonProperty("customName") + public String field; + + int setterCallCount = 0; + + @JacksonInject("id") + public void setCustomName(String value) { + this.setterCallCount++; + this.field = value; + } + } + + // Case 6: Creator param + Field with same ID on DIFFERENT properties + // When on different properties, both should be injected + static class CreatorParamFieldBean { + @JacksonInject("id") + public String field; + + final String param; + + @JsonCreator + CreatorParamFieldBean( + @JacksonInject("id") @JsonProperty("param") String param + ) { + this.param = param; + // Note: field is NOT assigned here to verify injection behavior + } + } + + // Case 7: Different IDs - should inject independently + static class DifferentIdsBean { + @JacksonInject("id1") + public String field1; + + @JacksonInject("id2") + public String field2; + } + + // Case 8: Creator param + DIFFERENT property field share same injectable ID + // Both should be injected (not under-injection) - P0 test for #4218 fix + static class CreatorPlusDifferentPropertyFieldBean { + @JacksonInject("id") + public String fieldB; // Different property from creator param + + final String paramA; + + @JsonCreator + CreatorPlusDifferentPropertyFieldBean( + @JacksonInject("id") @JsonProperty("paramA") String paramA + ) { + this.paramA = paramA; + } + } + + // Case 9: Creator param + SAME property field - should inject only once (#4218 핵심) + static class CreatorSamePropertyFieldBean { + @JacksonInject("id") + public String id; // Same property as creator param! + + @JsonCreator + CreatorSamePropertyFieldBean( + @JacksonInject("id") @JsonProperty("id") String id + ) { + // When field injection is skipped (same property), constructor must assign + this.id = id; + } + } + + // Case 10: Two injectable setters mapped to same logical property -> error + static class TwoSettersSamePropertyBean { + public String value; + + @JacksonInject("id") + @JsonProperty("value") + public void setValue(String v) { + value = v; + } + + @JacksonInject("id") + @JsonProperty("value") + public void setOtherValue(String v) { + value = v; + } + } + + // [databind#5217]: Field + Setter with @JsonProperty - verifies property name lookup + @Test + void testFieldSetterWithJsonProperty() throws Exception + { + ObjectReader reader = newJsonMapper() + .readerFor(FieldSetterWithJsonPropertyBean.class) + .with(INJECTABLES); + + FieldSetterWithJsonPropertyBean bean = reader.readValue("{}"); + assertEquals("injectedValue", bean.field); + assertEquals(1, bean.setterCallCount, "Should inject only once via setter"); + } + + // [databind#4218]: Creator param + field with same ID on DIFFERENT properties + // When creator param and field are on DIFFERENT properties, both should be injected. + // The #4218 fix only prevents duplicate injection for the SAME property. + @Test + void testCreatorParamDoesNotDuplicateFieldInjection() throws Exception + { + ObjectReader reader = newJsonMapper() + .readerFor(CreatorParamFieldBean.class) + .with(INJECTABLES); + + CreatorParamFieldBean bean = reader.readValue("{}"); + assertEquals("injectedValue", bean.param); + // Field is on a DIFFERENT property ("field" vs "param"), so it should also be injected + assertEquals("injectedValue", bean.field, + "Field on different property should also be injected"); + } + + // [databind#5217]: Different IDs - existing functionality should work + @Test + void testDifferentIdsStillWork() throws Exception + { + InjectableValues injectables = new InjectableValues.Std() + .addValue("id1", "value1") + .addValue("id2", "value2"); + + ObjectReader reader = newJsonMapper() + .readerFor(DifferentIdsBean.class) + .with(injectables); + + DifferentIdsBean bean = reader.readValue("{}"); + assertEquals("value1", bean.field1); + assertEquals("value2", bean.field2); + } + + // [databind#4218]: P0 test - Creator param + DIFFERENT property field with same ID + // Both should be injected - verifies the fix doesn't cause under-injection + @Test + void testCreatorAndDifferentPropertyFieldBothInjected() throws Exception + { + ObjectReader reader = newJsonMapper() + .readerFor(CreatorPlusDifferentPropertyFieldBean.class) + .with(INJECTABLES); + + CreatorPlusDifferentPropertyFieldBean bean = reader.readValue("{}"); + + // P0: Both should be injected! + assertEquals("injectedValue", bean.paramA, "Creator param should be injected"); + assertEquals("injectedValue", bean.fieldB, "Different property field should ALSO be injected"); + } + + // [databind#4218]: Creator param + SAME property field - no duplicate injection + @Test + void testCreatorParamAndSamePropertyFieldNoDuplicate() throws Exception + { + ObjectReader reader = newJsonMapper() + .readerFor(CreatorSamePropertyFieldBean.class) + .with(INJECTABLES); + + CreatorSamePropertyFieldBean bean = reader.readValue("{}"); + assertEquals("injectedValue", bean.id, "Value should be injected"); + } + + @Test + void testTwoInjectableSettersSamePropertyFails() throws Exception + { + ObjectReader reader = newJsonMapper() + .readerFor(TwoSettersSamePropertyBean.class) + .with(INJECTABLES); + + InvalidDefinitionException e = assertThrows(InvalidDefinitionException.class, + () -> reader.readValue("{}")); + verifyException(e, "multiple setters"); + } +} From 5bad658c7b17df3644fe97bbdca4cb50ed6bb5d6 Mon Sep 17 00:00:00 2001 From: LeeJiWon Date: Sun, 25 Jan 2026 15:38:41 +0900 Subject: [PATCH 2/6] Add regression tests for #5217 without InjectableValues Verify field-field and param-param don't fail with "Duplicate injectable value" error when InjectableValues is not configured. This directly tests the original issue's expected behavior: consistent error across all cases. --- .../deser/inject/JacksonInject5217Test.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/test/java/tools/jackson/databind/deser/inject/JacksonInject5217Test.java b/src/test/java/tools/jackson/databind/deser/inject/JacksonInject5217Test.java index 0742c195f9..f870139774 100644 --- a/src/test/java/tools/jackson/databind/deser/inject/JacksonInject5217Test.java +++ b/src/test/java/tools/jackson/databind/deser/inject/JacksonInject5217Test.java @@ -13,6 +13,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; // [databind#5217]: Multiple injections of same value should work consistently @@ -307,4 +308,38 @@ void testTwoInjectableSettersSamePropertyFails() throws Exception () -> reader.readValue("{}")); verifyException(e, "multiple setters"); } + + // [databind#5217]: Core regression test - without InjectableValues configured, + // field-field should NOT fail with "Duplicate injectable value" error. + // This directly verifies the original issue's "Expected behavior: The same error + // should be made in all cases." + @Test + void testFieldFieldWithoutInjectableValuesShouldNotFailWithDuplicate() throws Exception + { + ObjectReader reader = newJsonMapper().readerFor(FieldFieldBean.class); + // NO .with(INJECTABLES) - this is the key point + + Exception e = assertThrows(Exception.class, () -> reader.readValue("{}")); + + // Must NOT be "Duplicate injectable value" error - that was the bug + String msg = e.getMessage(); + if (msg != null && msg.contains("Duplicate injectable")) { + fail("Should not fail with 'Duplicate injectable value' error. Got: " + msg); + } + } + + @Test + void testParamParamWithoutInjectableValues() throws Exception + { + ObjectReader reader = newJsonMapper().readerFor(ParamParamBean.class); + // NO .with(INJECTABLES) + + Exception e = assertThrows(Exception.class, () -> reader.readValue("{}")); + + // Should also NOT be "Duplicate injectable value" + String msg = e.getMessage(); + if (msg != null && msg.contains("Duplicate injectable")) { + fail("Should not fail with 'Duplicate injectable value' error. Got: " + msg); + } + } } From 60eaada5d22a8a0ab1421dd4c906bdea582020a7 Mon Sep 17 00:00:00 2001 From: LeeJiWon Date: Sun, 25 Jan 2026 16:10:28 +0900 Subject: [PATCH 3/6] Use specific exception type in regression tests (#5217) Verify field-field and param-param both throw MissingInjectableValueExcepion (same exception type), proving error path convergence per issue's expected behavior: "The same error should be made in all cases." --- .../databind/deser/inject/JacksonInject5217Test.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/test/java/tools/jackson/databind/deser/inject/JacksonInject5217Test.java b/src/test/java/tools/jackson/databind/deser/inject/JacksonInject5217Test.java index f870139774..123b1e10da 100644 --- a/src/test/java/tools/jackson/databind/deser/inject/JacksonInject5217Test.java +++ b/src/test/java/tools/jackson/databind/deser/inject/JacksonInject5217Test.java @@ -9,6 +9,7 @@ import tools.jackson.databind.InjectableValues; import tools.jackson.databind.ObjectReader; import tools.jackson.databind.exc.InvalidDefinitionException; +import tools.jackson.databind.exc.MissingInjectableValueExcepion; import tools.jackson.databind.testutil.DatabindTestUtil; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -310,7 +311,8 @@ void testTwoInjectableSettersSamePropertyFails() throws Exception } // [databind#5217]: Core regression test - without InjectableValues configured, - // field-field should NOT fail with "Duplicate injectable value" error. + // field-field and param-param should fail with the SAME exception type + // (InvalidDefinitionException), NOT "Duplicate injectable value" error. // This directly verifies the original issue's "Expected behavior: The same error // should be made in all cases." @Test @@ -319,7 +321,9 @@ void testFieldFieldWithoutInjectableValuesShouldNotFailWithDuplicate() throws Ex ObjectReader reader = newJsonMapper().readerFor(FieldFieldBean.class); // NO .with(INJECTABLES) - this is the key point - Exception e = assertThrows(Exception.class, () -> reader.readValue("{}")); + // Should throw MissingInjectableValueExcepion (same as param-param case) + MissingInjectableValueExcepion e = assertThrows(MissingInjectableValueExcepion.class, + () -> reader.readValue("{}")); // Must NOT be "Duplicate injectable value" error - that was the bug String msg = e.getMessage(); @@ -334,7 +338,9 @@ void testParamParamWithoutInjectableValues() throws Exception ObjectReader reader = newJsonMapper().readerFor(ParamParamBean.class); // NO .with(INJECTABLES) - Exception e = assertThrows(Exception.class, () -> reader.readValue("{}")); + // Should throw MissingInjectableValueExcepion (same as field-field case) + MissingInjectableValueExcepion e = assertThrows(MissingInjectableValueExcepion.class, + () -> reader.readValue("{}")); // Should also NOT be "Duplicate injectable value" String msg = e.getMessage(); From bc2f392b14861070cd01933e069d8c870dc04871 Mon Sep 17 00:00:00 2001 From: LeeJiWon Date: Sun, 25 Jan 2026 16:50:03 +0900 Subject: [PATCH 4/6] Add record + @JsonProperty rename edge case tests (#5217) - testRecordWithRenamedInjectableProperty: record with renamed injectable - testRecordRenamedPlusDifferentFieldBothInjected: renamed creator + different property field with same ID should both be injected --- .../deser/inject/JacksonInject5217Test.java | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/test/java/tools/jackson/databind/deser/inject/JacksonInject5217Test.java b/src/test/java/tools/jackson/databind/deser/inject/JacksonInject5217Test.java index 123b1e10da..4931aa3b17 100644 --- a/src/test/java/tools/jackson/databind/deser/inject/JacksonInject5217Test.java +++ b/src/test/java/tools/jackson/databind/deser/inject/JacksonInject5217Test.java @@ -348,4 +348,56 @@ void testParamParamWithoutInjectableValues() throws Exception fail("Should not fail with 'Duplicate injectable value' error. Got: " + msg); } } + + // [databind#5217/#4218]: Record with @JsonProperty rename + @JacksonInject + // Verifies Rule 3 works correctly even when logical property name differs from field name + record RecordWithRenamedInject( + @JacksonInject("id") @JsonProperty("renamed") String original + ) {} + + @Test + void testRecordWithRenamedInjectableProperty() throws Exception + { + ObjectReader reader = newJsonMapper() + .readerFor(RecordWithRenamedInject.class) + .with(INJECTABLES); + + RecordWithRenamedInject bean = reader.readValue("{}"); + + // Creator param should be injected via constructor + assertEquals("injectedValue", bean.original(), + "Record component should be injected via creator param"); + } + + // Record with renamed injectable + different property field (same ID) + // Verifies that renaming doesn't break Rule 1 (multiple targets allowed) + static class RecordPlusDifferentFieldBean { + @JacksonInject("id") + public String otherField; + + final String recordValue; + + @JsonCreator + RecordPlusDifferentFieldBean( + @JacksonInject("id") @JsonProperty("renamed") String recordValue + ) { + this.recordValue = recordValue; + } + } + + @Test + void testRecordRenamedPlusDifferentFieldBothInjected() throws Exception + { + ObjectReader reader = newJsonMapper() + .readerFor(RecordPlusDifferentFieldBean.class) + .with(INJECTABLES); + + RecordPlusDifferentFieldBean bean = reader.readValue("{}"); + + // Both should be injected - different properties with same ID + assertEquals("injectedValue", bean.recordValue, + "Renamed creator param should be injected"); + assertEquals("injectedValue", bean.otherField, + "Different property field should ALSO be injected"); + } } From 650622b7aff58d4d5f592c54fe6578752925c946 Mon Sep 17 00:00:00 2001 From: LeeJiWon Date: Tue, 27 Jan 2026 10:27:29 +0900 Subject: [PATCH 5/6] Update @since tag to 3.1 --- .../tools/jackson/databind/introspect/POJOPropertyBuilder.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/tools/jackson/databind/introspect/POJOPropertyBuilder.java b/src/main/java/tools/jackson/databind/introspect/POJOPropertyBuilder.java index 48c58d2609..4ad9155e46 100644 --- a/src/main/java/tools/jackson/databind/introspect/POJOPropertyBuilder.java +++ b/src/main/java/tools/jackson/databind/introspect/POJOPropertyBuilder.java @@ -794,7 +794,7 @@ public boolean hasFieldOrGetter(AnnotatedMember member) { * * @param member the member to check (may be null) * @return true if this property contains the given member as field, setter, or getter - * @since 3.0 + * @since 3.1 */ public boolean containsMember(AnnotatedMember member) { if (member == null) { From 4d6bcc14e932f7c8b0567cf04b96b8e3fc930bd2 Mon Sep 17 00:00:00 2001 From: LeeJiWon Date: Wed, 28 Jan 2026 00:22:55 +0900 Subject: [PATCH 6/6] Restore API compatibility, remove Rule 2 complexity (#5217, #4218) - Restore findInjectables() signature to Map with @Deprecated; add findAllInjectables() as concrete default method - Remove _deduplicateSamePropertyInjectables (setter-masks-field policy) and _isMethod helper to reduce complexity per review feedback - Keep only _removeCreatorPropertyInjectables for #4218 fix - Add getAllInjectables() / getInjectables() pair on POJOPropertiesCollector with defensive copy and freeze of value lists - Switch BeanDeserializerFactory to findAllInjectables() - Add default-id field-field regression, behavior, and API shape tests - Remove Rule 2 tests (testFieldAndSetterSameProperty etc.) --- .../jackson/databind/BeanDescription.java | 37 ++- .../deser/BeanDeserializerFactory.java | 2 +- .../introspect/BasicBeanDescription.java | 17 +- .../introspect/POJOPropertiesCollector.java | 108 ++++---- .../deser/inject/JacksonInject5217Test.java | 238 ++++++++---------- 5 files changed, 214 insertions(+), 188 deletions(-) diff --git a/src/main/java/tools/jackson/databind/BeanDescription.java b/src/main/java/tools/jackson/databind/BeanDescription.java index b2168de2fe..87b8ded0b5 100644 --- a/src/main/java/tools/jackson/databind/BeanDescription.java +++ b/src/main/java/tools/jackson/databind/BeanDescription.java @@ -243,7 +243,42 @@ public AnnotatedMember findJsonKeyAccessor() { /********************************************************************** */ - public abstract Map> findInjectables(); + /** + * Method for finding injectable values. If multiple targets exist for the same + * injectable ID, only one is exposed by this legacy method. + * + * @return Map from injectable ID to single target member + * @deprecated Since 3.1: Use {@link #findAllInjectables()} to access all injection targets. + */ + @Deprecated + public abstract Map findInjectables(); + + /** + * Method for finding all injectable values, where a single injectable ID + * can map to multiple target members. + *

+ * Default implementation wraps single members from {@link #findInjectables()} + * into singleton lists for backward compatibility with custom implementations + * that only override the deprecated {@code findInjectables()} method. + *

+ * Note: Returned {@link Map} is unmodifiable and value {@link List}s are read-only views. + * + * @return Map from injectable ID to list of target members + * @since 3.1 + */ + public Map> findAllInjectables() { + Map single = findInjectables(); + if (single == null || single.isEmpty()) { + return Collections.emptyMap(); + } + LinkedHashMap> result = new LinkedHashMap<>(); + for (Map.Entry entry : single.entrySet()) { + if (entry.getValue() != null) { + result.put(entry.getKey(), Collections.singletonList(entry.getValue())); + } + } + return Collections.unmodifiableMap(result); + } /** * Method called to create a "default instance" of the bean, currently diff --git a/src/main/java/tools/jackson/databind/deser/BeanDeserializerFactory.java b/src/main/java/tools/jackson/databind/deser/BeanDeserializerFactory.java index 86637ac635..12ae231e09 100644 --- a/src/main/java/tools/jackson/databind/deser/BeanDeserializerFactory.java +++ b/src/main/java/tools/jackson/databind/deser/BeanDeserializerFactory.java @@ -806,7 +806,7 @@ protected void addBackReferenceProperties(DeserializationContext ctxt, protected void addInjectables(DeserializationContext ctxt, BeanDescription.Supplier beanDescRef, BeanDeserializerBuilder builder) { - Map> raw = beanDescRef.get().findInjectables(); + Map> raw = beanDescRef.get().findAllInjectables(); if (raw != null) { final AnnotationIntrospector introspector = ctxt.getAnnotationIntrospector(); diff --git a/src/main/java/tools/jackson/databind/introspect/BasicBeanDescription.java b/src/main/java/tools/jackson/databind/introspect/BasicBeanDescription.java index a31bec4cab..c2040b77ec 100644 --- a/src/main/java/tools/jackson/databind/introspect/BasicBeanDescription.java +++ b/src/main/java/tools/jackson/databind/introspect/BasicBeanDescription.java @@ -298,12 +298,21 @@ public AnnotatedMember findAnySetterAccessor() throws IllegalArgumentException return null; } + @Deprecated @Override - public Map> findInjectables() { - if (_propCollector != null) { - return _propCollector.getInjectables(); + public Map findInjectables() { + if (_propCollector == null) { + return Collections.emptyMap(); + } + return _propCollector.getInjectables(); + } + + @Override + public Map> findAllInjectables() { + if (_propCollector == null) { + return Collections.emptyMap(); } - return Collections.emptyMap(); + return _propCollector.getAllInjectables(); } @Override diff --git a/src/main/java/tools/jackson/databind/introspect/POJOPropertiesCollector.java b/src/main/java/tools/jackson/databind/introspect/POJOPropertiesCollector.java index 1b20ee60c8..6be889fe35 100644 --- a/src/main/java/tools/jackson/databind/introspect/POJOPropertiesCollector.java +++ b/src/main/java/tools/jackson/databind/introspect/POJOPropertiesCollector.java @@ -204,11 +204,45 @@ public PotentialCreators getPotentialCreators() { return _potentialCreators; } - public Map> getInjectables() { + /** + * Returns injectable values. If multiple targets exist for the same injectable ID, + * only one representative is returned (last collected member, approximating old + * {@code Map.put()} overwrite semantics; best-effort, not a guarantee). + * + * @deprecated Since 3.1: Use {@link #getAllInjectables()}. + */ + @Deprecated + public Map getInjectables() { + if (!_collected) { + collectAll(); + } + if (_injectables == null || _injectables.isEmpty()) { + return Collections.emptyMap(); + } + LinkedHashMap result = new LinkedHashMap<>(); + for (Map.Entry> entry : _injectables.entrySet()) { + List members = entry.getValue(); + if (members != null && !members.isEmpty()) { + result.put(entry.getKey(), members.get(members.size() - 1)); + } + } + return result; + } + + /** + * Returns all injectable values with support for multiple targets per ID. + * Returns a defensive, unmodifiable copy. + * + * @since 3.1 + */ + public Map> getAllInjectables() { if (!_collected) { collectAll(); } - return _injectables; + if (_injectables == null || _injectables.isEmpty()) { + return Collections.emptyMap(); + } + return Collections.unmodifiableMap(new LinkedHashMap<>(_injectables)); } public AnnotatedMember getJsonKeyAccessor() { @@ -446,6 +480,15 @@ protected void collectAll() // well, almost last: there's still ordering... _sortProperties(props); _properties = props; + + // [databind#5217] Freeze injectable value lists to prevent external mutation + if (_injectables != null && !_injectables.isEmpty()) { + for (Map.Entry> e : _injectables.entrySet()) { + List v = e.getValue(); + e.setValue((v == null) ? Collections.emptyList() : Collections.unmodifiableList(v)); + } + } + _collected = true; } @@ -1071,6 +1114,8 @@ private void _addCreatorParams(Map props, if (!hasImplicit) { // Without name, cannot make use of this creator parameter -- may or may not // be a problem, verified at a later point. + // NOTE: null is intentionally added to maintain positional correspondence; + // callers iterating _creatorProperties MUST handle null entries. creatorProps.add(null); continue; } @@ -1271,7 +1316,6 @@ protected void _addSetterMethod(Map props, * * Rules applied: * - Rule 1: Allow multiple injection targets with the same ID - * - Rule 2: Same property + same ID + field&setter → setter masks field * - Rule 3: Creator param masks same-property field/setter (#4218) * * @since 3.0 @@ -1291,14 +1335,13 @@ protected void _addInjectables(Map props) _doAddInjectable(_annotationIntrospector.findInjectableValue(_config, m), m); } - // Phase 3: Post-processing + // Phase 3: Post-processing — only #4218 fix (creator param masks same-property field/setter) if (_injectables != null) { - final IdentityHashMap memberToProp = - new IdentityHashMap<>(); if ((_creatorProperties != null) && !_creatorProperties.isEmpty()) { + final IdentityHashMap memberToProp = + new IdentityHashMap<>(); _removeCreatorPropertyInjectables(props, memberToProp); // Rule 3 } - _deduplicateSamePropertyInjectables(props, memberToProp); // Rule 2 } } @@ -1333,6 +1376,9 @@ protected void _removeCreatorPropertyInjectables(Map props, - IdentityHashMap memberToProp) - { - for (Map.Entry> entry : _injectables.entrySet()) { - List members = entry.getValue(); - if (members.size() <= 1) { - continue; - } - - // Group by property (within same ID) - Map byProperty = new LinkedHashMap<>(); - List notInProperty = new ArrayList<>(); - - for (AnnotatedMember m : members) { - String propName = _findPropertyNameForMember(m, props, memberToProp); - if (propName == null) { - notInProperty.add(m); // Keep members not in any property - } else { - AnnotatedMember existing = byProperty.get(propName); - if (existing == null) { - byProperty.put(propName, m); - } else if (_isMethod(m) && !_isMethod(existing)) { - // Setter masks field (same property, same ID) - byProperty.put(propName, m); - } else if (_isMethod(m) == _isMethod(existing)) { - reportProblem("Duplicate injectable value with id '%s' for property '%s' (multiple %s)", - entry.getKey(), propName, _isMethod(m) ? "setters" : "fields"); - } - // else: keep existing (setter already in place or same type) - } - } - // Reconstruct the list - List result = new ArrayList<>(byProperty.values()); - result.addAll(notInProperty); - entry.setValue(result); - } - } private String _findPropertyNameForMember(AnnotatedMember m, Map props, IdentityHashMap memberToProp) { @@ -1443,9 +1445,7 @@ private String _findPropertyNameForMember(AnnotatedMember m, Map error - static class TwoSettersSamePropertyBean { - public String value; + private final InjectableValues INJECTABLES = new InjectableValues.Std() + .addValue("id", "injectedValue"); - @JacksonInject("id") - @JsonProperty("value") - public void setValue(String v) { - value = v; - } + // [databind#5217]: Field & Field - was failing with "Duplicate injectable value" error + @Test + void testFieldAndFieldInjection() throws Exception + { + ObjectReader reader = newJsonMapper() + .readerFor(FieldFieldBean.class) + .with(INJECTABLES); - @JacksonInject("id") - @JsonProperty("value") - public void setOtherValue(String v) { - value = v; - } + FieldFieldBean bean = reader.readValue("{}"); + assertEquals("injectedValue", bean.field1); + assertEquals("injectedValue", bean.field2); } - // [databind#5217]: Field + Setter with @JsonProperty - verifies property name lookup + // [databind#5217]: Parameter & Parameter - was already working @Test - void testFieldSetterWithJsonProperty() throws Exception + void testParamAndParamInjection() throws Exception { ObjectReader reader = newJsonMapper() - .readerFor(FieldSetterWithJsonPropertyBean.class) + .readerFor(ParamParamBean.class) .with(INJECTABLES); - FieldSetterWithJsonPropertyBean bean = reader.readValue("{}"); + ParamParamBean bean = reader.readValue("{}"); + assertEquals("injectedValue", bean.param1); + assertEquals("injectedValue", bean.param2); + } + + // [databind#5217]: Parameter & Field - was already working + @Test + void testParamAndFieldInjection() throws Exception + { + ObjectReader reader = newJsonMapper() + .readerFor(ParamFieldBean.class) + .with(INJECTABLES); + + ParamFieldBean bean = reader.readValue("{}"); + assertEquals("injectedValue", bean.param); assertEquals("injectedValue", bean.field); - assertEquals(1, bean.setterCallCount, "Should inject only once via setter"); } // [databind#4218]: Creator param + field with same ID on DIFFERENT properties @@ -298,23 +232,9 @@ void testCreatorParamAndSamePropertyFieldNoDuplicate() throws Exception assertEquals("injectedValue", bean.id, "Value should be injected"); } - @Test - void testTwoInjectableSettersSamePropertyFails() throws Exception - { - ObjectReader reader = newJsonMapper() - .readerFor(TwoSettersSamePropertyBean.class) - .with(INJECTABLES); - - InvalidDefinitionException e = assertThrows(InvalidDefinitionException.class, - () -> reader.readValue("{}")); - verifyException(e, "multiple setters"); - } - // [databind#5217]: Core regression test - without InjectableValues configured, - // field-field and param-param should fail with the SAME exception type - // (InvalidDefinitionException), NOT "Duplicate injectable value" error. - // This directly verifies the original issue's "Expected behavior: The same error - // should be made in all cases." + // field-field and param-param should fail with the SAME exception type, + // NOT "Duplicate injectable value" error. @Test void testFieldFieldWithoutInjectableValuesShouldNotFailWithDuplicate() throws Exception { @@ -400,4 +320,66 @@ void testRecordRenamedPlusDifferentFieldBothInjected() throws Exception assertEquals("injectedValue", bean.otherField, "Different property field should ALSO be injected"); } + + /* + /********************************************************************** + /* New tests per plan: default-id field-field (#5217 original reproduction) + /********************************************************************** + */ + + // Test 1 — Original reproduction (regression prevention) + // Default id (= type name) + field-field same type, NO InjectableValues configured + // Must NOT fail with "Duplicate injectable value" — #5217 inconsistency fix + @Test + void testDefaultIdFieldFieldNoDuplicateError() throws Exception + { + ObjectReader reader = newJsonMapper().readerFor(TwoFieldsSameTypeDto.class); + // InjectableValues not configured — intentional + + MissingInjectableValueExcepion ex = assertThrows( + MissingInjectableValueExcepion.class, + () -> reader.readValue("{}") + ); + + String msg = ex.getMessage(); + assertNotNull(msg); + // Key: must NOT be "Duplicate injectable value" — that was the #5217 bug + assertFalse(msg.contains("Duplicate injectable value"), + "Should not get duplicate injectable error but got: " + msg); + } + + // Test 2 — Behavior verification: both fields injected with same value + @Test + void testDefaultIdFieldFieldBothInjected() throws Exception + { + // Register for both possible default-id formats (Class object and class name string) + InjectableValues injectables = new InjectableValues.Std() + .addValue(String.class, "INJECTED") + .addValue(String.class.getName(), "INJECTED"); + + ObjectReader reader = newJsonMapper() + .readerFor(TwoFieldsSameTypeDto.class) + .with(injectables); + + TwoFieldsSameTypeDto result = reader.readValue("{}"); + + assertEquals("INJECTED", result.first); + assertEquals("INJECTED", result.second); + } + + // Test 3 — API shape: findAllInjectables() returns multiple members for same default id + @Test + void testFindAllInjectablesMultipleMembersForDefaultId() throws Exception + { + ObjectMapper mapper = newJsonMapper(); + BeanDescription desc = ObjectMapperTestAccess.beanDescriptionForDeser(mapper, TwoFieldsSameTypeDto.class); + + Map> all = desc.findAllInjectables(); + assertFalse(all.isEmpty(), "Should have at least one injectable entry"); + + // Key: at least one ID with 2+ members (format-independent count check) + boolean foundMultiple = all.values().stream() + .anyMatch(members -> members.size() >= 2); + assertTrue(foundMultiple, "Expected at least one ID with 2+ members"); + } }