diff --git a/src/main/java/tools/jackson/databind/BeanDescription.java b/src/main/java/tools/jackson/databind/BeanDescription.java index 4f6b36885f..078a056510 100644 --- a/src/main/java/tools/jackson/databind/BeanDescription.java +++ b/src/main/java/tools/jackson/databind/BeanDescription.java @@ -262,8 +262,43 @@ public AnnotatedMember findJsonKeyAccessor() { /********************************************************************** */ + /** + * 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 * only needed for obtaining default field values which may be used for diff --git a/src/main/java/tools/jackson/databind/deser/BeanDeserializerFactory.java b/src/main/java/tools/jackson/databind/deser/BeanDeserializerFactory.java index 7e15155aff..f8ddcb450d 100644 --- a/src/main/java/tools/jackson/databind/deser/BeanDeserializerFactory.java +++ b/src/main/java/tools/jackson/databind/deser/BeanDeserializerFactory.java @@ -832,25 +832,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().findAllInjectables(); 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 785c6c704f..6188c74091 100644 --- a/src/main/java/tools/jackson/databind/introspect/BasicBeanDescription.java +++ b/src/main/java/tools/jackson/databind/introspect/BasicBeanDescription.java @@ -305,12 +305,21 @@ public AnnotatedMember findAnySetterAccessor() throws IllegalArgumentException return null; } + @Deprecated @Override public Map findInjectables() { - if (_propCollector != null) { - return _propCollector.getInjectables(); + 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 ab0787714e..547e914fb2 100644 --- a/src/main/java/tools/jackson/databind/introspect/POJOPropertiesCollector.java +++ b/src/main/java/tools/jackson/databind/introspect/POJOPropertiesCollector.java @@ -148,7 +148,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 @@ -221,11 +221,45 @@ public PotentialCreators getPotentialCreators() { return _potentialCreators; } + /** + * 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(); } - return _injectables; + 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(); + } + if (_injectables == null || _injectables.isEmpty()) { + return Collections.emptyMap(); + } + return Collections.unmodifiableMap(new LinkedHashMap<>(_injectables)); } public AnnotatedMember getJsonKeyAccessor() { @@ -483,6 +517,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; } @@ -1084,6 +1127,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; } @@ -1279,36 +1324,44 @@ 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 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 — only #4218 fix (creator param masks same-property field/setter) 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()); - } + if ((_creatorProperties != null) && !_creatorProperties.isEmpty()) { + final IdentityHashMap memberToProp = + new IdentityHashMap<>(); + _removeCreatorPropertyInjectables(props, memberToProp); // Rule 3 } } } + /** + * 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) { @@ -1318,16 +1371,95 @@ 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) { + // [databind#5217] creatorProp can be null when creator parameter lacks + // explicit/implicit name (see _addCreatorParams where null is intentionally + // added as placeholder to maintain positional correspondence). + 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); + } + } + } + } + + + + 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 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 ae6e44257f..867c403a95 100644 --- a/src/main/java/tools/jackson/databind/introspect/POJOPropertyBuilder.java +++ b/src/main/java/tools/jackson/databind/introspect/POJOPropertyBuilder.java @@ -802,13 +802,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.1 + */ + 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..8725974902 --- /dev/null +++ b/src/test/java/tools/jackson/databind/deser/inject/JacksonInject5217Test.java @@ -0,0 +1,385 @@ +package tools.jackson.databind.deser.inject; + +import java.util.List; +import java.util.Map; + +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.*; +import tools.jackson.databind.exc.InvalidDefinitionException; +import tools.jackson.databind.exc.MissingInjectableValueExcepion; +import tools.jackson.databind.introspect.AnnotatedMember; +import tools.jackson.databind.testutil.DatabindTestUtil; + +import static org.junit.jupiter.api.Assertions.*; + + +// [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 + } + } + + // Two fields with same default injectable ID (type-based) + static class TwoFieldsSameTypeDto { + @JacksonInject + public String first; + + @JacksonInject + public String second; + } + + // 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 + } + } + + // Different IDs - should inject independently + static class DifferentIdsBean { + @JacksonInject("id1") + public String field1; + + @JacksonInject("id2") + public String field2; + } + + // 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; + } + } + + // 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; + } + } + + 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#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"); + } + + // [databind#5217]: Core regression test - without InjectableValues configured, + // field-field and param-param should fail with the SAME exception type, + // NOT "Duplicate injectable value" error. + @Test + void testFieldFieldWithoutInjectableValuesShouldNotFailWithDuplicate() throws Exception + { + ObjectReader reader = newJsonMapper().readerFor(FieldFieldBean.class); + // NO .with(INJECTABLES) - this is the key point + + // 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(); + 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) + + // 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(); + if (msg != null && msg.contains("Duplicate injectable")) { + 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"); + } + + /* + /********************************************************************** + /* 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"); + } +}