Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
35 changes: 35 additions & 0 deletions src/main/java/tools/jackson/databind/BeanDescription.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<Object, AnnotatedMember> findInjectables();

/**
* Method for finding all injectable values, where a single injectable ID
* can map to multiple target members.
* <p>
* 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.
* <p>
* 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<Object, List<AnnotatedMember>> findAllInjectables() {
Map<Object, AnnotatedMember> single = findInjectables();
if (single == null || single.isEmpty()) {
return Collections.emptyMap();
}
LinkedHashMap<Object, List<AnnotatedMember>> result = new LinkedHashMap<>();
for (Map.Entry<Object, AnnotatedMember> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -803,25 +803,27 @@ protected void addBackReferenceProperties(DeserializationContext ctxt,
protected void addInjectables(DeserializationContext ctxt,
BeanDescription.Supplier beanDescRef, BeanDeserializerBuilder builder)
{
Map<Object, AnnotatedMember> raw = beanDescRef.get().findInjectables();
Map<Object, List<AnnotatedMember>> raw = beanDescRef.get().findAllInjectables();
if (raw != null) {
final AnnotationIntrospector introspector = ctxt.getAnnotationIntrospector();

for (Map.Entry<Object, AnnotatedMember> 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<Object, List<AnnotatedMember>> 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);
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -305,12 +305,21 @@ public AnnotatedMember findAnySetterAccessor() throws IllegalArgumentException
return null;
}

@Deprecated
@Override
public Map<Object, AnnotatedMember> findInjectables() {
if (_propCollector != null) {
return _propCollector.getInjectables();
if (_propCollector == null) {
return Collections.emptyMap();
}
return _propCollector.getInjectables();
}

@Override
public Map<Object, List<AnnotatedMember>> findAllInjectables() {
if (_propCollector == null) {
return Collections.emptyMap();
}
return Collections.emptyMap();
return _propCollector.getAllInjectables();
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ public class POJOPropertiesCollector
* indicate that they represent mutators for deserializer
* value injection.
*/
protected LinkedHashMap<Object, AnnotatedMember> _injectables;
protected LinkedHashMap<Object, List<AnnotatedMember>> _injectables;

/**
* Lazily accessed information about POJO format overrides
Expand Down Expand Up @@ -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<Object, AnnotatedMember> getInjectables() {
if (!_collected) {
collectAll();
}
return _injectables;
if (_injectables == null || _injectables.isEmpty()) {
return Collections.emptyMap();
}
LinkedHashMap<Object, AnnotatedMember> result = new LinkedHashMap<>();
for (Map.Entry<Object, List<AnnotatedMember>> entry : _injectables.entrySet()) {
List<AnnotatedMember> 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<Object, List<AnnotatedMember>> getAllInjectables() {
if (!_collected) {
collectAll();
}
if (_injectables == null || _injectables.isEmpty()) {
return Collections.emptyMap();
}
return Collections.unmodifiableMap(new LinkedHashMap<>(_injectables));
}

public AnnotatedMember getJsonKeyAccessor() {
Expand Down Expand Up @@ -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<Object, List<AnnotatedMember>> e : _injectables.entrySet()) {
List<AnnotatedMember> v = e.getValue();
e.setValue((v == null) ? Collections.emptyList() : Collections.unmodifiableList(v));
}
}

_collected = true;
}

Expand Down Expand Up @@ -1081,6 +1124,8 @@ private void _addCreatorParams(Map<String, POJOPropertyBuilder> 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;
}
Expand Down Expand Up @@ -1276,36 +1321,44 @@ protected void _addSetterMethod(Map<String, POJOPropertyBuilder> 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<String, POJOPropertyBuilder> 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<AnnotatedMember, String> 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) {
Expand All @@ -1315,16 +1368,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<String, POJOPropertyBuilder> props,
IdentityHashMap<AnnotatedMember, String> 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) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Does this happen?

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<AnnotatedMember> 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<String, POJOPropertyBuilder> props,
IdentityHashMap<AnnotatedMember, String> memberToProp) {
if (memberToProp != null) {
final String cached = memberToProp.get(m);
if ((cached != null) || memberToProp.containsKey(m)) {
return cached;
}
}
for (Map.Entry<String, POJOPropertyBuilder> 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);
}
Expand Down
Loading
Loading