Repository: googleapis/java-storage
Pull request #1637 (2022-09-21 17:16:18)

chore: update handling of BlobInfo and BucketInfo to track which fields are modified
Add new tests to validate expected modified fields match

Related b/247133805


Patch:
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/BlobInfo.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/BlobInfo.java
index 3d8fa6d5..ce3665a4 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/BlobInfo.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/BlobInfo.java
@@ -22,6 +22,7 @@ import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.api.client.util.Data;
 import com.google.api.core.BetaApi;
+import com.google.cloud.storage.Storage.BlobField;
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -97,6 +98,7 @@ public class BlobInfo implements Serializable {
   private final Boolean eventBasedHold;
   private final Boolean temporaryHold;
   private final OffsetDateTime retentionExpirationTime;
+  private final transient ImmutableSet<Storage.BlobField> modifiedFields;
 
   /** This class is meant for internal use only. Users are discouraged from using this class. */
   public static final class ImmutableEmptyMap<K, V> extends AbstractMap<K, V> {
@@ -436,6 +438,7 @@ public class BlobInfo implements Serializable {
     private Boolean eventBasedHold;
     private Boolean temporaryHold;
     private OffsetDateTime retentionExpirationTime;
+    private final ImmutableSet.Builder<Storage.BlobField> modifiedFields = ImmutableSet.builder();
 
     BuilderImpl(BlobId blobId) {
       this.blobId = blobId;
@@ -476,7 +479,19 @@ public class BlobInfo implements Serializable {
 
     @Override
     public Builder setBlobId(BlobId blobId) {
-      this.blobId = checkNotNull(blobId);
+      checkNotNull(blobId);
+      if (!Objects.equals(this.blobId, blobId)) {
+        if (!Objects.equals(this.blobId.getBucket(), blobId.getBucket())) {
+          modifiedFields.add(BlobField.BUCKET);
+        }
+        if (!Objects.equals(this.blobId.getName(), blobId.getName())) {
+          modifiedFields.add(BlobField.NAME);
+        }
+        if (!Objects.equals(this.blobId.getGeneration(), blobId.getGeneration())) {
+          modifiedFields.add(BlobField.GENERATION);
+        }
+      }
+      this.blobId = blobId;
       return this;
     }
 
@@ -488,25 +503,41 @@ public class BlobInfo implements Serializable {
 
     @Override
     public Builder setContentType(String contentType) {
-      this.contentType = firstNonNull(contentType, Data.nullOf(String.class));
+      String tmp = firstNonNull(contentType, Data.nullOf(String.class));
+      if (!Objects.equals(this.contentType, tmp)) {
+        modifiedFields.add(BlobField.CONTENT_TYPE);
+      }
+      this.contentType = tmp;
       return this;
     }
 
     @Override
     public Builder setContentDisposition(String contentDisposition) {
-      this.contentDisposition = firstNonNull(contentDisposition, Data.nullOf(String.class));
+      String tmp = firstNonNull(contentDisposition, Data.nullOf(String.class));
+      if (!Objects.equals(this.contentDisposition, tmp)) {
+        modifiedFields.add(BlobField.CONTENT_DISPOSITION);
+      }
+      this.contentDisposition = tmp;
       return this;
     }
 
     @Override
     public Builder setContentLanguage(String contentLanguage) {
-      this.contentLanguage = firstNonNull(contentLanguage, Data.nullOf(String.class));
+      String tmp = firstNonNull(contentLanguage, Data.nullOf(String.class));
+      if (!Objects.equals(this.contentLanguage, tmp)) {
+        modifiedFields.add(BlobField.CONTENT_LANGUAGE);
+      }
+      this.contentLanguage = tmp;
       return this;
     }
 
     @Override
     public Builder setContentEncoding(String contentEncoding) {
-      this.contentEncoding = firstNonNull(contentEncoding, Data.nullOf(String.class));
+      String tmp = firstNonNull(contentEncoding, Data.nullOf(String.class));
+      if (!Objects.equals(this.contentEncoding, tmp)) {
+        modifiedFields.add(BlobField.CONTENT_ENCODING);
+      }
+      this.contentEncoding = tmp;
       return this;
     }
 
@@ -518,18 +549,36 @@ public class BlobInfo implements Serializable {
 
     @Override
     public Builder setCacheControl(String cacheControl) {
-      this.cacheControl = firstNonNull(cacheControl, Data.nullOf(String.class));
+      String tmp = firstNonNull(cacheControl, Data.nullOf(String.class));
+      if (!Objects.equals(this.cacheControl, tmp)) {
+        modifiedFields.add(BlobField.CACHE_CONTROL);
+      }
+      this.cacheControl = tmp;
       return this;
     }
 
     @Override
     public Builder setAcl(List<Acl> acl) {
-      this.acl = acl != null ? ImmutableList.copyOf(acl) : null;
+      if (!Objects.equals(this.acl, acl)) {
+        modifiedFields.add(BlobField.ACL);
+      }
+      if (acl != null) {
+        if (acl instanceof ImmutableList) {
+          this.acl = acl;
+        } else {
+          this.acl = ImmutableList.copyOf(acl);
+        }
+      } else {
+        this.acl = null;
+      }
       return this;
     }
 
     @Override
     Builder setOwner(Acl.Entity owner) {
+      if (!Objects.equals(this.owner, owner)) {
+        modifiedFields.add(BlobField.OWNER);
+      }
       this.owner = owner;
       return this;
     }
@@ -542,6 +591,9 @@ public class BlobInfo implements Serializable {
 
     @Override
     Builder setEtag(String etag) {
+      if (!Objects.equals(this.etag, etag)) {
+        modifiedFields.add(BlobField.ETAG);
+      }
       this.etag = etag;
       return this;
     }
@@ -554,7 +606,11 @@ public class BlobInfo implements Serializable {
 
     @Override
     public Builder setMd5(String md5) {
-      this.md5 = firstNonNull(md5, Data.nullOf(String.class));
+      String tmp = firstNonNull(md5, Data.nullOf(String.class));
+      if (!Objects.equals(this.md5, tmp)) {
+        modifiedFields.add(BlobField.MD5HASH);
+      }
+      this.md5 = tmp;
       return this;
     }
 
@@ -578,13 +634,16 @@ public class BlobInfo implements Serializable {
         }
         md5ByteBuffer.put((byte) (higherOrderBits << 4 | lowerOrderBits));
       }
-      this.md5 = BaseEncoding.base64().encode(md5ByteBuffer.array());
-      return this;
+      return setMd5(BaseEncoding.base64().encode(md5ByteBuffer.array()));
     }
 
     @Override
     public Builder setCrc32c(String crc32c) {
-      this.crc32c = firstNonNull(crc32c, Data.nullOf(String.class));
+      String tmp = firstNonNull(crc32c, Data.nullOf(String.class));
+      if (!Objects.equals(this.crc32c, tmp)) {
+        modifiedFields.add(BlobField.CRC32C);
+      }
+      this.crc32c = tmp;
       return this;
     }
 
@@ -597,6 +656,9 @@ public class BlobInfo implements Serializable {
 
     @Override
     public Builder setCustomTimeOffsetDateTime(OffsetDateTime customTime) {
+      if (!Objects.equals(this.customTime, customTime)) {
+        modifiedFields.add(BlobField.CUSTOM_TIME);
+      }
       this.customTime = customTime;
       return this;
     }
@@ -622,8 +684,7 @@ public class BlobInfo implements Serializable {
         }
         crc32cByteBuffer.put((byte) (higherOrderBits << 4 | lowerOrderBits));
       }
-      this.crc32c = BaseEncoding.base64().encode(crc32cByteBuffer.array());
-      return this;
+      return setCrc32c(BaseEncoding.base64().encode(crc32cByteBuffer.array()));
     }
 
     @Override
@@ -634,6 +695,9 @@ public class BlobInfo implements Serializable {
 
     @Override
     public Builder setMetadata(Map<String, String> metadata) {
+      if (!Objects.equals(this.metadata, metadata)) {
+        modifiedFields.add(BlobField.METADATA);
+      }
       if (metadata != null) {
         this.metadata = new HashMap<>(metadata);
       } else {
@@ -644,6 +708,9 @@ public class BlobInfo implements Serializable {
 
     @Override
     public Builder setStorageClass(StorageClass storageClass) {
+      if (!Objects.equals(this.storageClass, storageClass)) {
+        modifiedFields.add(BlobField.STORAGE_CLASS);
+      }
       this.storageClass = storageClass;
       return this;
     }
@@ -659,6 +726,9 @@ public class BlobInfo implements Serializable {
     @Override
     public Builder setTimeStorageClassUpdatedOffsetDateTime(
         OffsetDateTime timeStorageClassUpdated) {
+      if (!Objects.equals(this.timeStorageClassUpdated, timeStorageClassUpdated)) {
+        modifiedFields.add(BlobField.TIME_STORAGE_CLASS_UPDATED);
+      }
       this.timeStorageClassUpdated = timeStorageClassUpdated;
       return this;
     }
@@ -678,6 +748,9 @@ public class BlobInfo implements Serializable {
 
     @Override
     Builder setDeleteTimeOffsetDateTime(OffsetDateTime deleteTime) {
+      if (!Objects.equals(this.deleteTime, deleteTime)) {
+        modifiedFields.add(BlobField.TIME_DELETED);
+      }
       this.deleteTime = deleteTime;
       return this;
     }
@@ -690,6 +763,9 @@ public class BlobInfo implements Serializable {
 
     @Override
     Builder setUpdateTimeOffsetDateTime(OffsetDateTime updateTime) {
+      if (!Objects.equals(this.updateTime, updateTime)) {
+        modifiedFields.add(BlobField.UPDATED);
+      }
       this.updateTime = updateTime;
       return this;
     }
@@ -703,6 +779,9 @@ public class BlobInfo implements Serializable {
 
     @Override
     Builder setCreateTimeOffsetDateTime(OffsetDateTime createTime) {
+      if (!Objects.equals(this.createTime, createTime)) {
+        modifiedFields.add(BlobField.TIME_CREATED);
+      }
       this.createTime = createTime;
       return this;
     }
@@ -715,24 +794,36 @@ public class BlobInfo implements Serializable {
 
     @Override
     Builder setCustomerEncryption(CustomerEncryption customerEncryption) {
+      if (!Objects.equals(this.customerEncryption, customerEncryption)) {
+        modifiedFields.add(BlobField.CUSTOMER_ENCRYPTION);
+      }
       this.customerEncryption = customerEncryption;
       return this;
     }
 
     @Override
     Builder setKmsKeyName(String kmsKeyName) {
+      if (!Objects.equals(this.kmsKeyName, kmsKeyName)) {
+        modifiedFields.add(BlobField.KMS_KEY_NAME);
+      }
       this.kmsKeyName = kmsKeyName;
       return this;
     }
 
     @Override
     public Builder setEventBasedHold(Boolean eventBasedHold) {
+      if (!Objects.equals(this.eventBasedHold, eventBasedHold)) {
+        modifiedFields.add(BlobField.EVENT_BASED_HOLD);
+      }
       this.eventBasedHold = eventBasedHold;
       return this;
     }
 
     @Override
     public Builder setTemporaryHold(Boolean temporaryHold) {
+      if (!Objects.equals(this.temporaryHold, temporaryHold)) {
+        modifiedFields.add(BlobField.TEMPORARY_HOLD);
+      }
       this.temporaryHold = temporaryHold;
       return this;
     }
@@ -747,6 +838,9 @@ public class BlobInfo implements Serializable {
 
     @Override
     Builder setRetentionExpirationTimeOffsetDateTime(OffsetDateTime retentionExpirationTime) {
+      if (!Objects.equals(this.retentionExpirationTime, retentionExpirationTime)) {
+        modifiedFields.add(BlobField.RETENTION_EXPIRATION_TIME);
+      }
       this.retentionExpirationTime = retentionExpirationTime;
       return this;
     }
@@ -789,6 +883,7 @@ public class BlobInfo implements Serializable {
     eventBasedHold = builder.eventBasedHold;
     temporaryHold = builder.temporaryHold;
     retentionExpirationTime = builder.retentionExpirationTime;
+    modifiedFields = builder.modifiedFields.build();
   }
 
   /** Returns the blob's identity. */
@@ -1273,6 +1368,10 @@ public class BlobInfo implements Serializable {
         && Objects.equals(retentionExpirationTime, blobInfo.retentionExpirationTime);
   }
 
+  ImmutableSet<BlobField> getModifiedFields() {
+    return modifiedFields;
+  }
+
   /**
    * Attach this instance to an instance of {@link Storage} thereby allowing RPCs to be performed
    * using the methods from the resulting {@link Blob}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/BucketInfo.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/BucketInfo.java
index 0f0c91ca..ac2058eb 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/BucketInfo.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/BucketInfo.java
@@ -29,9 +29,10 @@ import com.google.api.client.util.DateTime;
 import com.google.api.core.BetaApi;
 import com.google.api.services.storage.model.Bucket.Lifecycle.Rule;
 import com.google.cloud.storage.Acl.Entity;
-import com.google.common.base.Function;
+import com.google.cloud.storage.Storage.BucketField;
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Streams;
 import java.io.IOException;
@@ -107,6 +108,7 @@ public class BucketInfo implements Serializable {
   private final String locationType;
   private final Logging logging;
   private final CustomPlacementConfig customPlacementConfig;
+  private final transient ImmutableSet<Storage.BucketField> modifiedFields;
 
   /**
    * non-private for backward compatibility on message class. log messages are now emitted from
@@ -1461,6 +1463,7 @@ public class BucketInfo implements Serializable {
     private String locationType;
     private Logging logging;
     private CustomPlacementConfig customPlacementConfig;
+    private final ImmutableSet.Builder<Storage.BucketField> modifiedFields = ImmutableSet.builder();
 
     BuilderImpl(String name) {
       this.name = name;
@@ -1519,6 +1522,9 @@ public class BucketInfo implements Serializable {
 
     @Override
     Builder setOwner(Acl.Entity owner) {
+      if (!Objects.equals(this.owner, owner)) {
+        modifiedFields.add(BucketField.OWNER);
+      }
       this.owner = owner;
       return this;
     }
@@ -1531,24 +1537,38 @@ public class BucketInfo implements Serializable {
 
     @Override
     public Builder setVersioningEnabled(Boolean enable) {
-      this.versioningEnabled = firstNonNull(enable, Data.<Boolean>nullOf(Boolean.class));
+      Boolean tmp = firstNonNull(enable, Data.<Boolean>nullOf(Boolean.class));
+      if (!Objects.equals(this.versioningEnabled, tmp)) {
+        modifiedFields.add(BucketField.VERSIONING);
+      }
+      this.versioningEnabled = tmp;
       return this;
     }
 
     @Override
     public Builder setRequesterPays(Boolean enable) {
-      this.requesterPays = firstNonNull(enable, Data.<Boolean>nullOf(Boolean.class));
+      Boolean tmp = firstNonNull(enable, Data.<Boolean>nullOf(Boolean.class));
+      if (!Objects.equals(this.requesterPays, tmp)) {
+        modifiedFields.add(BucketField.BILLING);
+      }
+      this.requesterPays = tmp;
       return this;
     }
 
     @Override
     public Builder setIndexPage(String indexPage) {
+      if (!Objects.equals(this.indexPage, indexPage)) {
+        modifiedFields.add(BucketField.WEBSITE);
+      }
       this.indexPage = indexPage;
       return this;
     }
 
     @Override
     public Builder setNotFoundPage(String notFoundPage) {
+      if (!Objects.equals(this.notFoundPage, notFoundPage)) {
+        modifiedFields.add(BucketField.WEBSITE);
+      }
       this.notFoundPage = notFoundPage;
       return this;
     }
@@ -1595,44 +1615,60 @@ public class BucketInfo implements Serializable {
     @SuppressWarnings("unchecked")
     @Override
     public Builder setLifecycleRules(Iterable<? extends LifecycleRule> rules) {
+      final ImmutableList<LifecycleRule> tmp;
       if (rules != null) {
         if (rules instanceof ImmutableList) {
-          this.lifecycleRules = (ImmutableList<LifecycleRule>) rules;
+          tmp = (ImmutableList<LifecycleRule>) rules;
         } else {
-          this.lifecycleRules = ImmutableList.copyOf(rules);
+          tmp = ImmutableList.copyOf(rules);
         }
       } else {
-        this.lifecycleRules = ImmutableList.of();
+        tmp = ImmutableList.of();
+      }
+      if (!Objects.equals(this.lifecycleRules, tmp)) {
+        modifiedFields.add(BucketField.LIFECYCLE);
       }
+      this.lifecycleRules = tmp;
       return this;
     }
 
     @Override
     public Builder deleteLifecycleRules() {
-      setLifecycleRules(null);
-      return this;
+      return setLifecycleRules(null);
     }
 
     @Override
     public Builder setRpo(Rpo rpo) {
+      if (!Objects.equals(this.rpo, rpo)) {
+        modifiedFields.add(BucketField.RPO);
+      }
       this.rpo = rpo;
       return this;
     }
 
     @Override
     public Builder setStorageClass(StorageClass storageClass) {
+      if (!Objects.equals(this.storageClass, storageClass)) {
+        modifiedFields.add(BucketField.STORAGE_CLASS);
+      }
       this.storageClass = storageClass;
       return this;
     }
 
     @Override
     public Builder setLocation(String location) {
+      if (!Objects.equals(this.location, location)) {
+        modifiedFields.add(BucketField.LOCATION);
+      }
       this.location = location;
       return this;
     }
 
     @Override
     Builder setEtag(String etag) {
+      if (!Objects.equals(this.etag, etag)) {
+        modifiedFields.add(BucketField.ETAG);
+      }
       this.etag = etag;
       return this;
     }
@@ -1641,12 +1677,14 @@ public class BucketInfo implements Serializable {
     @Deprecated
     @Override
     Builder setCreateTime(Long createTime) {
-      this.createTime = millisOffsetDateTimeCodec.encode(createTime);
-      return this;
+      return setCreateTimeOffsetDateTime(millisOffsetDateTimeCodec.encode(createTime));
     }
 
     @Override
     Builder setCreateTimeOffsetDateTime(OffsetDateTime createTime) {
+      if (!Objects.equals(this.createTime, createTime)) {
+        modifiedFields.add(BucketField.TIME_CREATED);
+      }
       this.createTime = createTime;
       return this;
     }
@@ -1655,12 +1693,14 @@ public class BucketInfo implements Serializable {
     @Deprecated
     @Override
     Builder setUpdateTime(Long updateTime) {
-      this.updateTime = millisOffsetDateTimeCodec.encode(updateTime);
-      return this;
+      return setUpdateTimeOffsetDateTime(millisOffsetDateTimeCodec.encode(updateTime));
     }
 
     @Override
     Builder setUpdateTimeOffsetDateTime(OffsetDateTime updateTime) {
+      if (!Objects.equals(this.updateTime, updateTime)) {
+        modifiedFields.add(BucketField.UPDATED);
+      }
       this.updateTime = updateTime;
       return this;
     }
@@ -1673,50 +1713,69 @@ public class BucketInfo implements Serializable {
 
     @Override
     public Builder setCors(Iterable<Cors> cors) {
-      this.cors = cors != null ? ImmutableList.copyOf(cors) : ImmutableList.<Cors>of();
+      ImmutableList<Cors> tmp = cors != null ? ImmutableList.copyOf(cors) : ImmutableList.of();
+      if (!Objects.equals(this.cors, tmp)) {
+        modifiedFields.add(BucketField.CORS);
+      }
+      this.cors = tmp;
       return this;
     }
 
     @Override
     public Builder setAcl(Iterable<Acl> acl) {
-      this.acl = acl != null ? ImmutableList.copyOf(acl) : null;
+      List<Acl> tmp = acl != null ? ImmutableList.copyOf(acl) : null;
+      if (!Objects.equals(this.acl, tmp)) {
+        modifiedFields.add(BucketField.ACL);
+      }
+      this.acl = tmp;
       return this;
     }
 
     @Override
     public Builder setDefaultAcl(Iterable<Acl> acl) {
-      this.defaultAcl = acl != null ? ImmutableList.copyOf(acl) : null;
+      List<Acl> tmp = acl != null ? ImmutableList.copyOf(acl) : null;
+      if (!Objects.equals(this.defaultAcl, tmp)) {
+        modifiedFields.add(BucketField.DEFAULT_OBJECT_ACL);
+      }
+      this.defaultAcl = tmp;
       return this;
     }
 
     @Override
     public Builder setLabels(Map<String, String> labels) {
       if (labels != null) {
-        this.labels =
+        Map<String, String> tmp =
             Maps.transformValues(
                 labels,
-                new Function<String, String>() {
-                  @Override
-                  public String apply(String input) {
-                    // replace null values with empty strings
-                    return input == null ? Data.<String>nullOf(String.class) : input;
-                  }
+                input -> {
+                  // replace null values with empty strings
+                  return input == null ? Data.nullOf(String.class) : input;
                 });
+        if (!Objects.equals(this.labels, tmp)) {
+          modifiedFields.add(BucketField.LABELS);
+        }
+        this.labels = tmp;
       }
       return this;
     }
 
     @Override
     public Builder setDefaultKmsKeyName(String defaultKmsKeyName) {
-      this.defaultKmsKeyName =
-          defaultKmsKeyName != null ? defaultKmsKeyName : Data.<String>nullOf(String.class);
+      String tmp = defaultKmsKeyName != null ? defaultKmsKeyName : Data.nullOf(String.class);
+      if (!Objects.equals(this.defaultKmsKeyName, tmp)) {
+        modifiedFields.add(BucketField.ENCRYPTION);
+      }
+      this.defaultKmsKeyName = tmp;
       return this;
     }
 
     @Override
     public Builder setDefaultEventBasedHold(Boolean defaultEventBasedHold) {
-      this.defaultEventBasedHold =
-          firstNonNull(defaultEventBasedHold, Data.<Boolean>nullOf(Boolean.class));
+      Boolean tmp = firstNonNull(defaultEventBasedHold, Data.<Boolean>nullOf(Boolean.class));
+      if (!Objects.equals(this.defaultEventBasedHold, tmp)) {
+        modifiedFields.add(BucketField.DEFAULT_EVENT_BASED_HOLD);
+      }
+      this.defaultEventBasedHold = tmp;
       return this;
     }
 
@@ -1730,14 +1789,20 @@ public class BucketInfo implements Serializable {
 
     @Override
     Builder setRetentionEffectiveTimeOffsetDateTime(OffsetDateTime retentionEffectiveTime) {
+      if (!Objects.equals(this.retentionEffectiveTime, retentionEffectiveTime)) {
+        modifiedFields.add(BucketField.RETENTION_POLICY);
+      }
       this.retentionEffectiveTime = retentionEffectiveTime;
       return this;
     }
 
     @Override
     Builder setRetentionPolicyIsLocked(Boolean retentionPolicyIsLocked) {
-      this.retentionPolicyIsLocked =
-          firstNonNull(retentionPolicyIsLocked, Data.<Boolean>nullOf(Boolean.class));
+      Boolean tmp = firstNonNull(retentionPolicyIsLocked, Data.<Boolean>nullOf(Boolean.class));
+      if (!Objects.equals(this.retentionPolicyIsLocked, retentionPolicyIsLocked)) {
+        modifiedFields.add(BucketField.RETENTION_POLICY);
+      }
+      this.retentionPolicyIsLocked = tmp;
       return this;
     }
 
@@ -1749,30 +1814,46 @@ public class BucketInfo implements Serializable {
 
     @Override
     public Builder setRetentionPeriodDuration(Duration retentionPeriod) {
+      if (!Objects.equals(this.retentionPeriod, retentionPeriod)) {
+        modifiedFields.add(BucketField.RETENTION_POLICY);
+      }
       this.retentionPeriod = retentionPeriod;
       return this;
     }
 
     @Override
     public Builder setIamConfiguration(IamConfiguration iamConfiguration) {
+      if (!Objects.equals(this.iamConfiguration, iamConfiguration)) {
+        modifiedFields.add(BucketField.IAMCONFIGURATION);
+      }
       this.iamConfiguration = iamConfiguration;
       return this;
     }
 
     @Override
     public Builder setLogging(Logging logging) {
-      this.logging = logging != null ? logging : BucketInfo.Logging.newBuilder().build();
+      Logging tmp = logging != null ? logging : Logging.newBuilder().build();
+      if (!Objects.equals(this.logging, tmp)) {
+        modifiedFields.add(BucketField.LOGGING);
+      }
+      this.logging = tmp;
       return this;
     }
 
     @Override
     public Builder setCustomPlacementConfig(CustomPlacementConfig customPlacementConfig) {
-      this.customPlacementConfig = customPlacementConfig != null ? customPlacementConfig : null;
+      if (!Objects.equals(this.customPlacementConfig, customPlacementConfig)) {
+        modifiedFields.add(BucketField.CUSTOM_PLACEMENT_CONFIG);
+      }
+      this.customPlacementConfig = customPlacementConfig;
       return this;
     }
 
     @Override
     Builder setLocationType(String locationType) {
+      if (!Objects.equals(this.locationType, locationType)) {
+        modifiedFields.add(BucketField.LOCATION_TYPE);
+      }
       this.locationType = locationType;
       return this;
     }
@@ -1827,6 +1908,7 @@ public class BucketInfo implements Serializable {
     locationType = builder.locationType;
     logging = builder.logging;
     customPlacementConfig = builder.customPlacementConfig;
+    modifiedFields = builder.modifiedFields.build();
   }
 
   String getProject() {
@@ -2245,6 +2327,10 @@ public class BucketInfo implements Serializable {
     return new Bucket(storage, new BucketInfo.BuilderImpl(this));
   }
 
+  ImmutableSet<BucketField> getModifiedFields() {
+    return modifiedFields;
+  }
+
   /** Creates a {@code BucketInfo} object for the provided bucket name. */
   public static BucketInfo of(String name) {
     return newBuilder(name).build();
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageImpl.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageImpl.java
index a4255ab0..cced65c8 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageImpl.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageImpl.java
@@ -70,7 +70,6 @@ import com.google.iam.v1.SetIamPolicyRequest;
 import com.google.iam.v1.TestIamPermissionsRequest;
 import com.google.protobuf.ByteString;
 import com.google.protobuf.FieldMask;
-import com.google.protobuf.Message;
 import com.google.storage.v2.ComposeObjectRequest;
 import com.google.storage.v2.ComposeObjectRequest.SourceObject;
 import com.google.storage.v2.CreateBucketRequest;
@@ -470,13 +469,15 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
     GrpcCallContext grpcCallContext =
         opts.grpcMetadataMapper().apply(GrpcCallContext.createDefault());
     com.google.storage.v2.Bucket bucket = codecs.bucketInfo().encode(bucketInfo);
-    UpdateBucketRequest.Builder builder = UpdateBucketRequest.newBuilder().setBucket(bucket);
-    UpdateBucketRequest req =
-        opts.updateBucketsRequest()
-            .apply(builder)
-            .setUpdateMask(updateMaskGenerator(bucket))
-            .build();
-
+    UpdateBucketRequest.Builder builder =
+        opts.updateBucketsRequest().apply(UpdateBucketRequest.newBuilder().setBucket(bucket));
+    builder
+        .getUpdateMaskBuilder()
+        .addAllPaths(
+            bucketInfo.getModifiedFields().stream()
+                .map(BucketField::getGrpcFieldName)
+                .collect(ImmutableList.toImmutableList()));
+    UpdateBucketRequest req = builder.build();
     return Retrying.run(
         getOptions(),
         retryAlgorithmManager.getFor(req),
@@ -490,12 +491,15 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
     GrpcCallContext grpcCallContext =
         opts.grpcMetadataMapper().apply(GrpcCallContext.createDefault());
     Object object = codecs.blobInfo().encode(blobInfo);
-    UpdateObjectRequest.Builder builder = UpdateObjectRequest.newBuilder().setObject(object);
-    UpdateObjectRequest req =
-        opts.updateObjectsRequest()
-            .apply(builder)
-            .setUpdateMask(updateMaskGenerator(object))
-            .build();
+    UpdateObjectRequest.Builder builder =
+        opts.updateObjectsRequest().apply(UpdateObjectRequest.newBuilder().setObject(object));
+    builder
+        .getUpdateMaskBuilder()
+        .addAllPaths(
+            blobInfo.getModifiedFields().stream()
+                .map(BlobField::getGrpcFieldName)
+                .collect(ImmutableList.toImmutableList()));
+    UpdateObjectRequest req = builder.build();
     return Retrying.run(
         getOptions(),
         retryAlgorithmManager.getFor(req),
@@ -1352,18 +1356,6 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
         .build();
   }
 
-  private FieldMask updateMaskGenerator(Message message) {
-    // TODO: Filter based on API behavior and not a list
-    return FieldMask.newBuilder()
-        .addAllPaths(
-            message.getAllFields().entrySet().stream()
-                .filter(x -> x.getValue() != null)
-                .map(e -> e.getKey().getName())
-                .filter(f -> updateFields().contains(f))
-                .collect(Collectors.toList()))
-        .build();
-  }
-
   /**
    * When using the retry features of the Gapic client, we are only allowed to provide a {@link
    * Set}{@code <}{@link StatusCode.Code}{@code >}. Given {@link StatusCode.Code} is an enum, we can
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java
index 04ece427..4a657953 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java
@@ -92,60 +92,73 @@ public interface Storage extends Service<StorageOptions>, AutoCloseable {
   }
 
   enum BucketField implements FieldSelector {
-    ID("id"),
+    ID("id", "bucket_id"),
     SELF_LINK("selfLink"),
     NAME("name"),
-    TIME_CREATED("timeCreated"),
+    TIME_CREATED("timeCreated", "time_create"),
     METAGENERATION("metageneration"),
     ACL("acl"),
-    DEFAULT_OBJECT_ACL("defaultObjectAcl"),
+    DEFAULT_OBJECT_ACL("defaultObjectAcl", "default_object_acl"),
     OWNER("owner"),
     LABELS("labels"),
     LOCATION("location"),
-    LOCATION_TYPE("locationType"),
+    LOCATION_TYPE("locationType", "location_type"),
     WEBSITE("website"),
     VERSIONING("versioning"),
     CORS("cors"),
     LIFECYCLE("lifecycle"),
-    STORAGE_CLASS("storageClass"),
+    STORAGE_CLASS("storageClass", "storage_class"),
     ETAG("etag"),
     ENCRYPTION("encryption"),
     BILLING("billing"),
-    DEFAULT_EVENT_BASED_HOLD("defaultEventBasedHold"),
-    RETENTION_POLICY("retentionPolicy"),
-    IAMCONFIGURATION("iamConfiguration"),
+    DEFAULT_EVENT_BASED_HOLD("defaultEventBasedHold", "default_event_based_hold"),
+    RETENTION_POLICY("retentionPolicy", "retention_policy"),
+    IAMCONFIGURATION("iamConfiguration", "iam_config"),
     LOGGING("logging"),
-    UPDATED("updated");
+    UPDATED("updated", "update_time"),
+    RPO("rpo"),
+    CUSTOM_PLACEMENT_CONFIG("customPlacementConfig", "custom_placement_config"),
+    AUTOCLASS("autoclass");
 
     static final List<? extends FieldSelector> REQUIRED_FIELDS = ImmutableList.of(NAME);
 
     private final String selector;
+    private final String grpcFieldName;
 
     BucketField(String selector) {
+      this(selector, selector);
+    }
+
+    BucketField(String selector, String grpcFieldName) {
       this.selector = selector;
+      this.grpcFieldName = grpcFieldName;
     }
 
     @Override
     public String getSelector() {
       return selector;
     }
+
+    String getGrpcFieldName() {
+      return grpcFieldName;
+    }
   }
 
   enum BlobField implements FieldSelector {
     ACL("acl"),
     BUCKET("bucket"),
-    CACHE_CONTROL("cacheControl"),
-    COMPONENT_COUNT("componentCount"),
-    CONTENT_DISPOSITION("contentDisposition"),
-    CONTENT_ENCODING("contentEncoding"),
-    CONTENT_LANGUAGE("contentLanguage"),
-    CONTENT_TYPE("contentType"),
-    CRC32C("crc32c"),
+    CACHE_CONTROL("cacheControl", "cache_control"),
+    COMPONENT_COUNT("componentCount", "component_count"),
+    CONTENT_DISPOSITION("contentDisposition", "content_disposition"),
+    CONTENT_ENCODING("contentEncoding", "content_encoding"),
+    CONTENT_LANGUAGE("contentLanguage", "content_language"),
+    CONTENT_TYPE("contentType", "content_type"),
+    CRC32C("crc32c", "object_checksums.crc32c"),
     ETAG("etag"),
     GENERATION("generation"),
     ID("id"),
     KIND("kind"),
-    MD5HASH("md5Hash"),
+    MD5HASH("md5Hash", "object_checksums.md5_hash"),
     MEDIA_LINK("mediaLink"),
     METADATA("metadata"),
     METAGENERATION("metageneration"),
@@ -153,29 +166,40 @@ public interface Storage extends Service<StorageOptions>, AutoCloseable {
     OWNER("owner"),
     SELF_LINK("selfLink"),
     SIZE("size"),
-    STORAGE_CLASS("storageClass"),
-    TIME_DELETED("timeDeleted"),
-    TIME_CREATED("timeCreated"),
-    KMS_KEY_NAME("kmsKeyName"),
-    EVENT_BASED_HOLD("eventBasedHold"),
-    TEMPORARY_HOLD("temporaryHold"),
-    RETENTION_EXPIRATION_TIME("retentionExpirationTime"),
-    UPDATED("updated"),
-    CUSTOM_TIME("customTime"),
-    TIME_STORAGE_CLASS_UPDATED("timeStorageClassUpdated");
+    STORAGE_CLASS("storageClass", "storage_class"),
+    TIME_DELETED("timeDeleted", "delete_time"),
+    TIME_CREATED("timeCreated", "create_time"),
+    KMS_KEY_NAME("kmsKeyName", "kms_key"),
+    EVENT_BASED_HOLD("eventBasedHold", "event_based_hold"),
+    TEMPORARY_HOLD("temporaryHold", "temporary_hold"),
+    RETENTION_EXPIRATION_TIME("retentionExpirationTime", "retention_expire_time"),
+    UPDATED("updated", "update_time"),
+    CUSTOM_TIME("customTime", "custom_time"),
+    TIME_STORAGE_CLASS_UPDATED("timeStorageClassUpdated", "update_storage_class_time"),
+    CUSTOMER_ENCRYPTION("customerEncryption", "customer_encryption");
 
     static final List<? extends FieldSelector> REQUIRED_FIELDS = ImmutableList.of(BUCKET, NAME);
 
     private final String selector;
+    private final String grpcFieldName;
 
     BlobField(String selector) {
+      this(selector, selector);
+    }
+
+    BlobField(String selector, String grpcFieldName) {
       this.selector = selector;
+      this.grpcFieldName = grpcFieldName;
     }
 
     @Override
     public String getSelector() {
       return selector;
     }
+
+    String getGrpcFieldName() {
+      return grpcFieldName;
+    }
   }
 
   enum UriScheme {


Test patch:
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/UpdateMaskTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/UpdateMaskTest.java
new file mode 100644
index 00000000..89173046
--- /dev/null
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/UpdateMaskTest.java
@@ -0,0 +1,461 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.storage;
+
+import static com.google.cloud.storage.Utils.bucketNameCodec;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.cloud.storage.Acl.Role;
+import com.google.cloud.storage.Acl.User;
+import com.google.cloud.storage.BlobInfo.CustomerEncryption;
+import com.google.cloud.storage.BucketInfo.CustomPlacementConfig;
+import com.google.cloud.storage.BucketInfo.IamConfiguration;
+import com.google.cloud.storage.BucketInfo.LifecycleRule;
+import com.google.cloud.storage.BucketInfo.LifecycleRule.LifecycleAction;
+import com.google.cloud.storage.BucketInfo.LifecycleRule.LifecycleCondition;
+import com.google.cloud.storage.BucketInfo.Logging;
+import com.google.cloud.storage.Storage.BlobField;
+import com.google.cloud.storage.Storage.BucketField;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.protobuf.FieldMask;
+import com.google.storage.v2.Bucket;
+import com.google.storage.v2.Object;
+import com.google.storage.v2.StorageGrpc.StorageImplBase;
+import com.google.storage.v2.UpdateBucketRequest;
+import com.google.storage.v2.UpdateObjectRequest;
+import io.grpc.stub.StreamObserver;
+import java.time.Duration;
+import java.time.OffsetDateTime;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.UnaryOperator;
+import org.junit.Test;
+import org.junit.experimental.runners.Enclosed;
+import org.junit.runner.RunWith;
+
+@RunWith(Enclosed.class)
+public final class UpdateMaskTest {
+
+  public static final class BlobInfoUpdateMask {
+
+    @Test
+    public void updateObjectRequest() throws Exception {
+      Object expectedObject =
+          Object.newBuilder()
+              .setBucket(bucketNameCodec.encode("bucket"))
+              .setName("obj-name")
+              .putMetadata("x", "X")
+              .build();
+      UpdateObjectRequest expected =
+          UpdateObjectRequest.newBuilder()
+              .setObject(expectedObject)
+              .setUpdateMask(FieldMask.newBuilder().addPaths("metadata").build())
+              .build();
+
+      AtomicReference<UpdateObjectRequest> actualRequest = new AtomicReference<>();
+      StorageImplBase service =
+          new StorageImplBase() {
+            @Override
+            public void updateObject(UpdateObjectRequest request, StreamObserver<Object> obs) {
+              try {
+                actualRequest.compareAndSet(null, request);
+                obs.onNext(expectedObject);
+                obs.onCompleted();
+              } catch (Exception e) {
+                obs.onError(e);
+              }
+            }
+          };
+
+      try (FakeServer fake = FakeServer.of(service);
+          Storage s = fake.getGrpcStorageOptions().getService()) {
+        BlobInfo base = base();
+        s.update(base.toBuilder().setMetadata(ImmutableMap.of("x", "X")).build());
+      }
+
+      UpdateObjectRequest actual = actualRequest.get();
+      assertThat(actual).isNotNull();
+      assertThat(actual).isEqualTo(expected);
+    }
+
+    @Test
+    public void blobInfo_field_metadata() {
+      testBlobField(b -> b.setMetadata(ImmutableMap.of("x", "X")), BlobField.METADATA);
+    }
+
+    @Test
+    public void blobInfo_field_acl() {
+      testBlobField(b -> b.setAcl(ImmutableList.of()), BlobField.ACL);
+    }
+
+    @Test
+    public void blobInfo_field_cacheControl() {
+      testBlobField(b -> b.setCacheControl("cc"), BlobField.CACHE_CONTROL);
+    }
+
+    @Test
+    public void blobInfo_field_contentDisposition() {
+      testBlobField(b -> b.setContentDisposition("cd"), BlobField.CONTENT_DISPOSITION);
+    }
+
+    @Test
+    public void blobInfo_field_contentEncoding() {
+      testBlobField(b -> b.setContentEncoding("ce"), BlobField.CONTENT_ENCODING);
+    }
+
+    @Test
+    public void blobInfo_field_contentLanguage() {
+      testBlobField(b -> b.setContentLanguage("cl"), BlobField.CONTENT_LANGUAGE);
+    }
+
+    @Test
+    public void blobInfo_field_contentType() {
+      testBlobField(b -> b.setContentType("ct"), BlobField.CONTENT_TYPE);
+    }
+
+    @Test
+    public void blobInfo_field_crc32c() {
+      testBlobField(b -> b.setCrc32c("c"), BlobField.CRC32C);
+    }
+
+    @Test
+    public void blobInfo_field_crc32cFromHexString() {
+      testBlobField(b -> b.setCrc32cFromHexString("145d34"), BlobField.CRC32C);
+    }
+
+    @Test
+    public void blobInfo_field_etag() {
+      testBlobField(b -> b.setEtag("e"), BlobField.ETAG);
+    }
+
+    @Test
+    public void blobInfo_field_md5() {
+      testBlobField(b -> b.setMd5("m"), BlobField.MD5HASH);
+    }
+
+    @Test
+    public void blobInfo_field_md5FromHexString() {
+      testBlobField(b -> b.setMd5FromHexString("145d34"), BlobField.MD5HASH);
+    }
+
+    @Test
+    public void blobInfo_field_owner() {
+      testBlobField(b -> b.setOwner(new User("x@y.z")), BlobField.OWNER);
+    }
+
+    @Test
+    public void blobInfo_field_storageClass() {
+      testBlobField(b -> b.setStorageClass(StorageClass.COLDLINE), BlobField.STORAGE_CLASS);
+    }
+
+    @Test
+    public void blobInfo_field_timeDeleted() {
+      testBlobField(b -> b.setDeleteTimeOffsetDateTime(OffsetDateTime.MAX), BlobField.TIME_DELETED);
+    }
+
+    @Test
+    public void blobInfo_field_timeCreated() {
+      testBlobField(b -> b.setCreateTimeOffsetDateTime(OffsetDateTime.MAX), BlobField.TIME_CREATED);
+    }
+
+    @Test
+    public void blobInfo_field_kmsKeyName() {
+      testBlobField(b -> b.setKmsKeyName("key"), BlobField.KMS_KEY_NAME);
+    }
+
+    @Test
+    public void blobInfo_field_eventBasedHold() {
+      testBlobField(b -> b.setEventBasedHold(true), BlobField.EVENT_BASED_HOLD);
+    }
+
+    @Test
+    public void blobInfo_field_temporaryHold() {
+      testBlobField(b -> b.setTemporaryHold(true), BlobField.TEMPORARY_HOLD);
+    }
+
+    @Test
+    public void blobInfo_field_retentionExpirationTime() {
+      testBlobField(
+          b -> b.setRetentionExpirationTimeOffsetDateTime(OffsetDateTime.MAX),
+          BlobField.RETENTION_EXPIRATION_TIME);
+    }
+
+    @Test
+    public void blobInfo_field_updated() {
+      testBlobField(b -> b.setUpdateTimeOffsetDateTime(OffsetDateTime.MAX), BlobField.UPDATED);
+    }
+
+    @Test
+    public void blobInfo_field_customTime() {
+      testBlobField(b -> b.setCustomTimeOffsetDateTime(OffsetDateTime.MAX), BlobField.CUSTOM_TIME);
+    }
+
+    @Test
+    public void blobInfo_field_timeStorageClassUpdated() {
+      testBlobField(
+          b -> b.setTimeStorageClassUpdatedOffsetDateTime(OffsetDateTime.MAX),
+          BlobField.TIME_STORAGE_CLASS_UPDATED);
+    }
+
+    @Test
+    public void blobInfo_field_customerEncryption() {
+      testBlobField(
+          b -> b.setCustomerEncryption(new CustomerEncryption("alg", "sha")),
+          BlobField.CUSTOMER_ENCRYPTION);
+    }
+
+    @Test
+    public void blobInfo_field_blobId_changeBucketNameGeneration() {
+      testBlobField(
+          b -> b.setBlobId(BlobId.of("bucket2", "obj2", 3L)),
+          BlobField.BUCKET,
+          BlobField.NAME,
+          BlobField.GENERATION);
+    }
+
+    @Test
+    public void blobInfo_field_blobId_changeName() {
+      testBlobField(b -> b.setBlobId(BlobId.of("bucket", "obj2")), BlobField.NAME);
+    }
+
+    @Test
+    public void blobInfo_field_blobId_changeGeneration() {
+      testBlobField(b -> b.setBlobId(BlobId.of("bucket", "obj-name", 3L)), BlobField.GENERATION);
+    }
+
+    private static void testBlobField(
+        UnaryOperator<BlobInfo.Builder> f, BlobField... expectedModified) {
+      BlobInfo actual1 = f.apply(base().toBuilder()).build();
+      assertThat(actual1.getModifiedFields()).isEqualTo(ImmutableSet.copyOf(expectedModified));
+      // verify that nothing is carried through from a previous state, and that setting the same
+      // value does not mark it as modified.
+      BlobInfo actual2 = f.apply(actual1.toBuilder()).build();
+      assertThat(actual2.getModifiedFields()).isEqualTo(ImmutableSet.of());
+    }
+
+    private static BlobInfo base() {
+      return BlobInfo.newBuilder("bucket", "obj-name").build();
+    }
+  }
+
+  public static final class BucketInfoUpdateMask {
+
+    @Test
+    public void updateBucketRequest() throws Exception {
+      Bucket expectedBucket =
+          Bucket.newBuilder().setName(bucketNameCodec.encode("bucket")).putLabels("x", "X").build();
+      UpdateBucketRequest expected =
+          UpdateBucketRequest.newBuilder()
+              .setBucket(expectedBucket)
+              .setUpdateMask(FieldMask.newBuilder().addPaths("labels").build())
+              .build();
+
+      AtomicReference<UpdateBucketRequest> actualRequest = new AtomicReference<>();
+      StorageImplBase service =
+          new StorageImplBase() {
+            @Override
+            public void updateBucket(UpdateBucketRequest request, StreamObserver<Bucket> obs) {
+              try {
+                actualRequest.compareAndSet(null, request);
+                obs.onNext(expectedBucket);
+                obs.onCompleted();
+              } catch (Exception e) {
+                obs.onError(e);
+              }
+            }
+          };
+
+      try (FakeServer fake = FakeServer.of(service);
+          Storage s = fake.getGrpcStorageOptions().getService()) {
+        BucketInfo base = base();
+        s.update(base.toBuilder().setLabels(ImmutableMap.of("x", "X")).build());
+      }
+
+      UpdateBucketRequest actual = actualRequest.get();
+      assertThat(actual).isNotNull();
+      assertThat(actual).isEqualTo(expected);
+    }
+
+    @Test
+    public void bucketInfo_field_setOwner() {
+      testBucketField(b -> b.setOwner(new User("x@y.z")), BucketField.OWNER);
+    }
+
+    @Test
+    public void bucketInfo_field_setVersioningEnabled() {
+      testBucketField(b -> b.setVersioningEnabled(true), BucketField.VERSIONING);
+    }
+
+    @Test
+    public void bucketInfo_field_setRequesterPays() {
+      testBucketField(b -> b.setRequesterPays(true), BucketField.BILLING);
+    }
+
+    @Test
+    public void bucketInfo_field_setIndexPage() {
+      testBucketField(b -> b.setIndexPage("i"), BucketField.WEBSITE);
+    }
+
+    @Test
+    public void bucketInfo_field_setNotFoundPage() {
+      testBucketField(b -> b.setNotFoundPage("n"), BucketField.WEBSITE);
+    }
+
+    @Test
+    public void bucketInfo_field_setLifecycleRules() {
+      testBucketField(
+          b ->
+              b.setLifecycleRules(
+                  ImmutableList.of(
+                      new LifecycleRule(
+                          LifecycleAction.newDeleteAction(),
+                          LifecycleCondition.newBuilder().setAge(1).build()))),
+          BucketField.LIFECYCLE);
+    }
+
+    @Test
+    public void bucketInfo_field_setRpo() {
+      testBucketField(b -> b.setRpo(Rpo.ASYNC_TURBO), BucketField.RPO);
+    }
+
+    @Test
+    public void bucketInfo_field_setStorageClass() {
+      testBucketField(b -> b.setStorageClass(StorageClass.COLDLINE), BucketField.STORAGE_CLASS);
+    }
+
+    @Test
+    public void bucketInfo_field_setLocation() {
+      testBucketField(b -> b.setLocation("l"), BucketField.LOCATION);
+    }
+
+    @Test
+    public void bucketInfo_field_setEtag() {
+      testBucketField(b -> b.setEtag("e"), BucketField.ETAG);
+    }
+
+    @Test
+    public void bucketInfo_field_setCreateTimeOffsetDateTime() {
+      testBucketField(
+          b -> b.setCreateTimeOffsetDateTime(OffsetDateTime.MAX), BucketField.TIME_CREATED);
+    }
+
+    @Test
+    public void bucketInfo_field_setUpdateTimeOffsetDateTime() {
+      testBucketField(b -> b.setUpdateTimeOffsetDateTime(OffsetDateTime.MAX), BucketField.UPDATED);
+    }
+
+    @Test
+    public void bucketInfo_field_setCors() {
+      testBucketField(
+          b -> b.setCors(ImmutableList.of(Cors.newBuilder().setMaxAgeSeconds(2).build())),
+          BucketField.CORS);
+    }
+
+    @Test
+    public void bucketInfo_field_setAcl() {
+      testBucketField(
+          b -> b.setAcl(ImmutableList.of(Acl.of(new User("x@y.x"), Role.READER))), BucketField.ACL);
+    }
+
+    @Test
+    public void bucketInfo_field_setDefaultAcl() {
+      testBucketField(
+          b -> b.setDefaultAcl(ImmutableList.of(Acl.of(new User("x@y.x"), Role.READER))),
+          BucketField.DEFAULT_OBJECT_ACL);
+    }
+
+    @Test
+    public void bucketInfo_field_setLabels() {
+      testBucketField(b -> b.setLabels(ImmutableMap.of("x", "X")), BucketField.LABELS);
+    }
+
+    @Test
+    public void bucketInfo_field_setDefaultKmsKeyName() {
+      testBucketField(b -> b.setDefaultKmsKeyName("k"), BucketField.ENCRYPTION);
+    }
+
+    @Test
+    public void bucketInfo_field_setDefaultEventBasedHold() {
+      testBucketField(b -> b.setDefaultEventBasedHold(true), BucketField.DEFAULT_EVENT_BASED_HOLD);
+    }
+
+    @Test
+    public void bucketInfo_field_setRetentionEffectiveTimeOffsetDateTime() {
+      testBucketField(
+          b -> b.setRetentionEffectiveTimeOffsetDateTime(OffsetDateTime.MAX),
+          BucketField.RETENTION_POLICY);
+    }
+
+    @Test
+    public void bucketInfo_field_setRetentionPolicyIsLocked() {
+      testBucketField(b -> b.setRetentionPolicyIsLocked(true), BucketField.RETENTION_POLICY);
+    }
+
+    @Test
+    public void bucketInfo_field_setRetentionPeriodDuration() {
+      testBucketField(
+          b -> b.setRetentionPeriodDuration(Duration.ofDays(3)), BucketField.RETENTION_POLICY);
+    }
+
+    @Test
+    public void bucketInfo_field_setIamConfiguration() {
+      testBucketField(
+          b ->
+              b.setIamConfiguration(
+                  IamConfiguration.newBuilder().setIsUniformBucketLevelAccessEnabled(true).build()),
+          BucketField.IAMCONFIGURATION);
+    }
+
+    @Test
+    public void bucketInfo_field_setLogging() {
+      testBucketField(
+          b -> b.setLogging(Logging.newBuilder().setLogBucket("bucket2").build()),
+          BucketField.LOGGING);
+    }
+
+    @Test
+    public void bucketInfo_field_setCustomPlacementConfig() {
+      testBucketField(
+          b ->
+              b.setCustomPlacementConfig(
+                  CustomPlacementConfig.newBuilder()
+                      .setDataLocations(ImmutableList.of("a", "b"))
+                      .build()),
+          BucketField.CUSTOM_PLACEMENT_CONFIG);
+    }
+
+    @Test
+    public void bucketInfo_field_setLocationType() {
+      testBucketField(b -> b.setLocationType("l"), BucketField.LOCATION_TYPE);
+    }
+
+    private static void testBucketField(
+        UnaryOperator<BucketInfo.Builder> f, BucketField... expectedModified) {
+      BucketInfo actual1 = f.apply(base().toBuilder()).build();
+      assertThat(actual1.getModifiedFields()).isEqualTo(ImmutableSet.copyOf(expectedModified));
+      // verify that nothing is carried through from a previous state, and that setting the same
+      // value does not mark it as modified.
+      BucketInfo actual2 = f.apply(actual1.toBuilder()).build();
+      assertThat(actual2.getModifiedFields()).isEqualTo(ImmutableSet.of());
+    }
+
+    private static BucketInfo base() {
+      return BucketInfo.newBuilder("bucket").build();
+    }
+  }
+}