Skip to content

GenericData.containsKey() returns true for unset (null) declared fields, violating Map contract #2187

Description

@chaoren

Description

PR #2151 introduced an override for GenericData.containsKey(Object name) that queries classInfo.hasFieldInfo(fieldName). However, ClassInfo.hasFieldInfo checks only whether the @Key field is declared in class reflection metadata, rather than checking whether the field holds a non-null value on the instance.

Impact & Broken Invariants

In GenericData, declared fields with null values are treated as absent from the map:

  1. keySet() / entrySet() Contradiction:
    On new MyData(), containsKey("field") returns true, but get("field") is null, entrySet() has size 0, and keySet().toString() outputs [].
  2. Set.contains vs Iterator Inconsistency:
    Because java.util.AbstractMap.keySet().contains(k) delegates to Map.containsKey(k), model.keySet().contains("field") evaluates to true, while iterating over model.keySet() yields 0 elements.
  3. Client Breakages:
    Code patterns checking for field presence (such as pagination checks like if (response.containsKey("pageToken"))) now evaluate to true even when the server never populated the field.

Reproduction

public class ExampleModel extends GenericData {
  @Key private String optionalField;
}

ExampleModel model = new ExampleModel();

// Prior to 2.2.0:
// model.containsKey("optionalField") == false

// In 2.2.0:
model.containsKey("optionalField"); // returns true!
model.get("optionalField");         // returns null
model.keySet();                     // prints []
model.keySet().contains("optionalField"); // returns true while iterator is empty

Proposed Fix

In com.google.api.client.util.GenericData.java, check whether the declared field value is non-null, matching DataMap.containsKey():

  @Override
  public final boolean containsKey(Object name) {
    if (!(name instanceof String)) {
      return false;
    }
    String fieldName = (String) name;
    FieldInfo fieldInfo = classInfo.getFieldInfo(fieldName);
    if (fieldInfo != null) {
      return fieldInfo.getValue(this) != null;
    }
    if (classInfo.getIgnoreCase()) {
      fieldName = fieldName.toLowerCase(Locale.US);
    }
    return unknownFields.containsKey(fieldName);
  }

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions