// Copyright 2017 The Nomulus Authors. All Rights Reserved. // // 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 google.registry.tools; import static com.google.common.base.CaseFormat.UPPER_CAMEL; import static com.google.common.base.CaseFormat.UPPER_UNDERSCORE; import static com.google.common.base.Functions.toStringFunction; import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; import static com.google.common.base.Preconditions.checkState; import static com.google.common.base.Strings.emptyToNull; import static google.registry.model.ofy.ObjectifyService.ofy; import static google.registry.util.DatastoreServiceUtils.getNameOrId; import static google.registry.util.DiffUtils.prettyPrintEntityDeepDiff; import com.google.common.base.Joiner; import com.google.common.base.MoreObjects; import com.google.common.base.Optional; import com.google.common.collect.FluentIterable; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.googlecode.objectify.Key; import com.googlecode.objectify.VoidWork; import google.registry.model.ImmutableObject; import google.registry.tools.Command.RemoteApiCommand; import java.util.ArrayList; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Objects; import java.util.Set; import javax.annotation.Nullable; /** A {@link ConfirmingCommand} that changes objects in Datastore. */ public abstract class MutatingCommand extends ConfirmingCommand implements RemoteApiCommand { /** * A mutation of a specific entity, represented by an old and a new version of the entity. * Storing the old version is necessary to enable checking that the existing entity has not been * modified when applying a mutation that was created outside the same transaction. */ private static class EntityChange { /** The possible types of mutation that can be performed on an entity. */ public static enum ChangeType { CREATE, DELETE, UPDATE; /** Return the ChangeType corresponding to the given combination of version existences. */ public static ChangeType get(boolean hasOldVersion, boolean hasNewVersion) { checkArgument( hasOldVersion || hasNewVersion, "An entity change must have an old version or a new version (or both)"); return !hasOldVersion ? CREATE : (!hasNewVersion ? DELETE : UPDATE); } } /** The type of mutation being performed on the entity. */ final ChangeType type; /** The old version of the entity, or null if this is a create. */ final ImmutableObject oldEntity; /** The new version of the entity, or null if this is a delete. */ final ImmutableObject newEntity; /** The key that points to the entity being changed. */ final Key key; public EntityChange(ImmutableObject oldEntity, ImmutableObject newEntity) { type = ChangeType.get(oldEntity != null, newEntity != null); checkArgument( type != ChangeType.UPDATE || Key.create(oldEntity).equals(Key.create(newEntity)), "Both entity versions in an update must have the same Key."); this.oldEntity = oldEntity; this.newEntity = newEntity; key = Key.create(MoreObjects.firstNonNull(oldEntity, newEntity)); } /** Returns a human-readable ID string for the entity being changed. */ public String getEntityId() { return String.format( "%s@%s", key.getKind(), // NB: try name before id, since name defaults to null, whereas id defaults to 0. getNameOrId(key.getRaw())); } /** Returns a string representation of this entity change. */ @Override public String toString() { String changeText; if (type == ChangeType.UPDATE) { String diffText = prettyPrintEntityDeepDiff( oldEntity.toDiffableFieldMap(), newEntity.toDiffableFieldMap()); changeText = Optional.fromNullable(emptyToNull(diffText)).or("[no changes]\n"); } else { changeText = MoreObjects.firstNonNull(oldEntity, newEntity) + "\n"; } return String.format( "%s %s\n%s", UPPER_UNDERSCORE.to(UPPER_CAMEL, type.toString()), getEntityId(), changeText); } } /** Map from entity keys to EntityChange objects representing changes to those entities. */ private final LinkedHashMap, EntityChange> changedEntitiesMap = new LinkedHashMap<>(); /** A set of resource keys for which new transactions should be created after. */ private final Set> transactionBoundaries = new HashSet<>(); @Nullable private Key lastAddedKey; /** * Initializes the command. * *

Subclasses override this method to populate {@link #changedEntitiesMap} with updated * entities. The old entity is the key and the new entity is the value; the key is null for newly * created entities and the value is null for deleted entities. */ @Override protected abstract void init() throws Exception; /** * Performs the command and returns a result description. * *

Subclasses can override this method if the command does something besides update entities, * such as running a full flow. */ @Override protected String execute() throws Exception { for (final List batch : getCollatedEntityChangeBatches()) { ofy().transact(new VoidWork() { @Override public void vrun() { for (EntityChange change : batch) { // Load the key of the entity to mutate and double-check that it hasn't been // modified from the version that existed when the change was prepared. ImmutableObject existingEntity = ofy().load().key(change.key).now(); checkState( Objects.equals(change.oldEntity, existingEntity), "Entity changed since init() was called.\n%s", prettyPrintEntityDeepDiff( change.oldEntity == null ? ImmutableMap.of() : change.oldEntity.toDiffableFieldMap(), existingEntity == null ? ImmutableMap.of() : existingEntity.toDiffableFieldMap())); switch (change.type) { case CREATE: // Fall through. case UPDATE: ofy().save().entity(change.newEntity).now(); break; case DELETE: ofy().delete().key(change.key).now(); break; default: throw new UnsupportedOperationException( "Unknown entity change type: " + change.type); } } }}); } return String.format("Updated %d entities.\n", changedEntitiesMap.size()); } /** * Returns a set of lists of EntityChange actions to commit. Each list should be executed in * order inside a single transaction. */ private ImmutableSet> getCollatedEntityChangeBatches() { ImmutableSet.Builder> batches = new ImmutableSet.Builder<>(); ArrayList nextBatch = new ArrayList<>(); for (EntityChange change : changedEntitiesMap.values()) { nextBatch.add(change); if (transactionBoundaries.contains(change.key)) { batches.add(ImmutableList.copyOf(nextBatch)); nextBatch.clear(); } } if (!nextBatch.isEmpty()) { batches.add(ImmutableList.copyOf(nextBatch)); } return batches.build(); } /** * Subclasses can call this to stage a mutation to an entity that will be applied by execute(). * Note that both objects passed must correspond to versions of the same entity with the same key. * * @param oldEntity the existing version of the entity, or null to create a new entity * @param newEntity the new version of the entity to save, or null to delete the entity */ protected void stageEntityChange( @Nullable ImmutableObject oldEntity, @Nullable ImmutableObject newEntity) { EntityChange change = new EntityChange(oldEntity, newEntity); checkArgument( !changedEntitiesMap.containsKey(change.key), "Cannot apply multiple changes for the same entity: %s", change.getEntityId()); changedEntitiesMap.put(change.key, change); lastAddedKey = change.key; } /** * Subclasses can call this to write out all previously requested entity changes since the last * transaction flush in a transaction. */ protected void flushTransaction() { transactionBoundaries.add(checkNotNull(lastAddedKey)); } /** Returns the changes that have been staged thus far. */ @Override protected String prompt() { return changedEntitiesMap.isEmpty() ? "No entity changes to apply." : Joiner.on("\n").join(FluentIterable .from(changedEntitiesMap.values()) .transform(toStringFunction())); } }