gt:                 idMap = HashMultimap.create();
context: /*
   * Copyright (C) 2014-2016 University of Dundee & Open Microscopy Environment.
   * All rights reserved.
   *
   * This program is free software; you can redistribute it and/or modify
   * it under the terms of the GNU General Public License as published by
   * the Free Software Foundation; either version 2 of the License, or
   * (at your option) any later version.
   *
   * This program is distributed in the hope that it will be useful,
   * but WITHOUT ANY WARRANTY; without even the implied warranty of
   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
   * GNU General Public License for more details.
   *
   * You should have received a copy of the GNU General Public License along
   * with this program; if not, write to the Free Software Foundation, Inc.,
   * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
   */
  
  package ome.services.graphs;
  
  import java.util.ArrayList;
  import java.util.Collection;
  import java.util.Collections;
  import java.util.EnumSet;
  import java.util.HashMap;
  import java.util.HashSet;
  import java.util.Iterator;
  import java.util.List;
  import java.util.Map;
  import java.util.Map.Entry;
  import java.util.Set;
  import java.util.SortedSet;
  import java.util.TreeSet;
  
  import org.slf4j.Logger;
  import org.slf4j.LoggerFactory;
  import org.hibernate.Hibernate;
  import org.hibernate.Query;
  import org.hibernate.QueryException;
  import org.hibernate.Session;
  import org.hibernate.proxy.HibernateProxy;
  
  import com.google.common.base.Joiner;
  import com.google.common.base.Objects;
  import com.google.common.collect.HashMultimap;
  import com.google.common.collect.ImmutableList;
  import com.google.common.collect.Iterables;
  import com.google.common.collect.Maps;
  import com.google.common.collect.SetMultimap;
  import com.google.common.collect.Sets;
  
  import ome.model.IObject;
  import ome.model.core.OriginalFile;
  import ome.model.internal.Permissions;
  import ome.model.meta.Experimenter;
  import ome.model.meta.ExperimenterGroup;
  import ome.security.ACLVoter;
  import ome.security.SystemTypes;
  import ome.services.graphs.GraphPathBean.PropertyKind;
  import ome.services.graphs.GraphPolicy.Ability;
  import ome.services.graphs.GraphPolicy.Action;
  import ome.services.graphs.GraphPolicy.Details;
  import ome.services.graphs.GraphPolicy.Orphan;
  import ome.system.EventContext;
  
  /**
   * An alternative implementation of model object graph traversal, relying on SELECTing in advance for making decisions,
   * instead of rolling back to savepoints to recover from failed attempts to act.
   * @author m.t.b.carroll@dundee.ac.uk
   * @since 5.1.0
   */
  public class GraphTraversal {
  
      private static final Logger log = LoggerFactory.getLogger(GraphTraversal.class);
  
      /* all bulk operations are batched; this size should be suitable for IN (:ids) for HQL */
      private static final int BATCH_SIZE = 256;
  
      /* the full name of the model object classes for which subclasses need not be queried */
      private static final Set<String> NO_SUBCLASS_QUERY = Collections.synchronizedSet(new HashSet<String>());
  
      /**
       * A tuple noting the state of a mapped object instance in the current graph traversal.
       * @author m.t.b.carroll@dundee.ac.uk
       * @since 5.1.0
       */
      private static final class DetailsWithCI extends Details {
          /* more useful than IObject for equals and hashCode */
          private final CI subjectAsCI;
  
          /**
           * Construct a note of an object and its details.
           * {@link #equals(Object)} and {@link #hashCode()} consider only the subject, not the action or orphan.
           * @param subject the object whose details these are
           * @param ownerId the ID of the object's owner
           * @param groupId the ID of the object's group
           * @param action the current plan for the object
           * @param orphan the current <q>orphan</q> state of the object
           * @param mayUpdate if the object may be updated
           * @param mayDelete if the object may be deleted
           * @param mayChmod if the object may have its permissions changed
           * @param isOwner if the user owns the object
           * @param isCheckPermissions if the user is expected to have the permissions required to process the object
           */
          DetailsWithCI(IObject subject, Long ownerId, Long groupId, Action action, Orphan orphan,
                  boolean mayUpdate, boolean mayDelete, boolean mayChmod, boolean isOwner, boolean isCheckPermissions) {
              super(subject, ownerId, groupId, action, orphan, mayUpdate, mayDelete, mayChmod, isOwner, isCheckPermissions);
              this.subjectAsCI = new CI(subject);
          }
  
          @Override
          public boolean equals(Object object) {
              if (this == object) {
                  return true;
              } else if (object instanceof DetailsWithCI) {
                  final DetailsWithCI other = (DetailsWithCI) object;
                  return this.subjectAsCI.equals(other.subjectAsCI);
              } else {
                  return false;
              }
          }
  
          @Override
          public int hashCode() {
              return Objects.hashCode(getClass(), subjectAsCI);
          }
  
          @Override
          public String toString() {
              final StringBuffer sb = new StringBuffer();
              sb.append(subjectAsCI);
              sb.append('/');
              sb.append(action == Action.EXCLUDE ? orphan : action);
              if (!isCheckPermissions) {
                  sb.append('*');
              }
              return sb.toString();
          }
      }
  
      /* The tuples immediately below could be elaborately related by a variety of interfaces and builders
       * but their usage does not justify such effort. */
  
      /**
       * An immutable tuple of class name, instance ID.
       * Within this class, equality and hash code is determined wholly by these values.
       * @author m.t.b.carroll@dundee.ac.uk
       * @since 5.1.0
       */
      private static final class CI {
          final String className;
          final long id;
  
          /**
           * Construct an instance with the given field values.
           * @param className a class name
           * @param id an instance ID
           */
          CI(String className, long id) {
              this.className = className;
              this.id = id;
          }
  
          /**
           * Construct an instance corresponding to the given object.
           * @param object a persisted object instance
           */
          CI(IObject object) {
              if (object instanceof HibernateProxy) {
                  this.className = Hibernate.getClass(object).getName();
              } else {
                  this.className = object.getClass().getName();
              }
              this.id = object.getId();
          }
  
          /**
           * Construct a new {@link IObject}
           * @return an unloaded {@link IObject} corresponding to this {@link CI}
           * @throws GraphException if the {@link IObject} could not be constructed
           */
          IObject toIObject() throws GraphException {
              try {
                  final Class<? extends IObject> actualClass = (Class<? extends IObject>) Class.forName(className);
                  return actualClass.getConstructor(Long.class, boolean.class).newInstance(id, false);
              } catch (IllegalArgumentException | ReflectiveOperationException | SecurityException e) {
                  throw new GraphException(
                          "no invocable constructor for: new " + className + "(Long.valueOf(" + id + "L), false)");
              }
          }
  
          @Override
          public boolean equals(Object object) {
              if (this == object) {
                  return true;
              } else if (object instanceof CI) {
                  final CI other = (CI) object;
                  return this.id == other.id &&
                          this.className.equals(other.className);
              } else {
                  return false;
              }
          }
  
          @Override
          public int hashCode() {
              return Objects.hashCode(getClass(), className, id);
          }
  
          @Override
          public String toString() {
              return className + "[" + id + "]";
          }
      }
  
      /**
       * An immutable tuple of class name, property name.
       * Within this class, equality and hash code is determined wholly by these values.
       * @author m.t.b.carroll@dundee.ac.uk
       * @since 5.1.0
       */
      private static final class CP {
          final String className;
          final String propertyName;
  
          /**
           * Construct an instance with the given field values.
           * @param className a class name
           * @param propertyName a property name
           */
          CP(String className, String propertyName) {
              this.className = className;
              this.propertyName = propertyName;
          }
  
          /**
           * Construct a {@link CPI} from this {@link CP} and the given instance ID.
           * @param id an instance ID
           * @return a {@link CPI} with the corresponding values
           */
          CPI toCPI(long id) {
              return new CPI(className, propertyName, id);
          }
  
          @Override
          public boolean equals(Object object) {
              if (this == object) {
                  return true;
              } else if (object instanceof CP) {
                  final CP other = (CP) object;
                  return this.className.equals(other.className) &&
                          this.propertyName.equals(other.propertyName);
              } else {
                  return false;
              }
          }
  
          @Override
          public int hashCode() {
              return Objects.hashCode(getClass(), className, propertyName);
          }
  
          @Override
          public String toString() {
              return(className + "." + propertyName).intern();
          }
      }
  
      /**
       * An immutable tuple of class name, property name, instance ID.
       * Within this class, equality and hash code is determined wholly by these values.
       * @author m.t.b.carroll@dundee.ac.uk
       * @since 5.1.0
       */
      private static final class CPI {
          final String className;
          final String propertyName;
          final long id;
  
          private CP asCP;
  
          /**
           * Construct an instance with the given field values.
           * @param className a class name
           * @param propertyName a property name
           * @param id an instance ID
           */
          CPI(String className, String propertyName, long id) {
              this.className = className;
              this.propertyName = propertyName;
              this.id = id;
          }
  
          /**
           * Construct a {@link CP} from this {@link CPI}.
           * Repeated calls to this method may return the same {@link CP} instance.
           * @param id an instance ID
           * @return a {@link CPI} with the corresponding values
           */
          CP toCP() {
              if (asCP == null) {
                  asCP = new CP(className, propertyName);
              }
              return asCP;
          }
  
          @Override
          public boolean equals(Object object) {
              if (this == object) {
                  return true;
              } else if (object instanceof CPI) {
                  final CPI other = (CPI) object;
                  return this.id == other.id &&
                          this.className.equals(other.className) &&
                          this.propertyName.equals(other.propertyName);
              } else {
                  return false;
              }
          }
  
          @Override
          public int hashCode() {
              return Objects.hashCode(getClass(), className, propertyName, id);
          }
  
          @Override
          public String toString() {
              return className + "[" + id + "]." + propertyName;
          }
      }
  
      /**
       * Track the progress of method calls to ensure that the sequencing makes sense.
       * @author m.t.b.carroll@dundee.ac.uk
       * @since 5.1.3
       */
      private enum Milestone {
          /** operation planned */
          PLANNED,
          /** model objects unlinked */
          UNLINKED,
          /** model objects processed */
          PROCESSED;
      }
  
      /**
       * The state of the graph traversal. Various rules apply:
       * <ol>
       *   <li>An instance may be in no more than one of {@link #included}, {@link #deleted}, {@link #outside},
       *       {@link #findIfLast} and {@link #foundIfLast}.</li>
       *   <li>An instance may be inserted into {@link #included} or {@link #deleted}
       *       whereupon it is also inserted into {@link #toProcess}.</li>
       *   <li>An instance may be inserted into {@link #outside}
       *       whereupon it is also removed from {@link #toProcess}.</li>
       *   <li>An instance may not be removed from {@link #included} or {@link #deleted}
       *       except to be inserted into {@link #included} or {@link #deleted} or {@link #outside}.</li>
       *   <li>An instance may be in {@link #findIfLast} or {@link #foundIfLast} but not both.</li>
       *   <li>An instance may not be removed from {@link #findIfLast} or {@link #foundIfLast}
       *       except to move between them
       *       whereupon it is also inserted into {@link #toProcess}.</li>
       *   <li>An instance may be inserted into {@link #cached}
       *       whereupon it is also inserted into {@link #toProcess}.</li>
       *   <li>{@link #forwardLinksCached}, {@link #backwardLinksCached}, {@link #befores} and {@link #afters}
       *       contain entries for exactly the instances in {@link #cached}.</li>
       *   <li>An instance may be in {@link #included} or {@link #deleted} only if it is in {@link #cached}.</li>
       *   <li>An instance is inserted into {@link #queue} only once.</li>
       *   <li>{@link #queue} contains exactly the instances that are in {@link #included} or {@link #deleted}.</li>
       * </ol>
       * @author m.t.b.carroll@dundee.ac.uk
       * @since 5.1.0
       */
      private static class Planning {
          /* process state */
          final Set<CI> toProcess = new HashSet<CI>();
          final Set<CI> included = new HashSet<CI>();
          final Set<CI> deleted = new HashSet<CI>();
          final Set<CI> outside = new HashSet<CI>();
          /* orphan checks */
          final Set<CI> findIfLast = new HashSet<CI>();
          final Map<CI, Boolean> foundIfLast = new HashMap<CI, Boolean>();
          /* links */
          final Map<CI, CI> aliases = new HashMap<CI, CI>();
          final Set<CI> cached = new HashSet<CI>();
          final SetMultimap<CPI, CI> forwardLinksCached = HashMultimap.create();
          final SetMultimap<CPI, CI> backwardLinksCached = HashMultimap.create();
          final SetMultimap<CI, CI> befores = HashMultimap.create();
          final SetMultimap<CI, CI> afters = HashMultimap.create();
          final Map<CI, Set<CI>> blockedBy = new HashMap<CI, Set<CI>>();
          /* permissions, unused for system users */
          final Map<CI, ome.model.internal.Details> detailsNoted = new HashMap<CI, ome.model.internal.Details>();
          final Set<CI> mayUpdate = new HashSet<CI>();
          final Set<CI> mayDelete = new HashSet<CI>();
          final Set<CI> mayChmod = new HashSet<CI>();
          final Set<CI> owns = new HashSet<CI>();
          final Set<CI> overrides = new HashSet<CI>();
      }
  
      /**
       * Executor that allows callers to actually perform the planned action.
       * @author m.t.b.carroll@dundee.ac.uk
       * @since 5.1.3
       */
      public interface PlanExecutor {
          /**
           * Perform the planned action.
           * @throws GraphException if the action fails
           */
          void execute() throws GraphException;
      }
  
      /**
       * Executes the planned operation.
       * @author m.t.b.carroll@dundee.ac.uk
       * @since 5.1.0
       */
      public interface Processor {
  
          /**
           * Null the given property of the indicated instances.
           * @param className full name of mapped Hibernate class
           * @param propertyName HQL-style property name of class
           * @param ids applicable instances of class, no more than {@link #BATCH_SIZE}
           */
          void nullProperties(String className, String propertyName, Collection<Long> ids);
  
          /**
           * Delete the given instances.
           * @param className full name of mapped Hibernate class
           * @param ids applicable instances of class, no more than {@link #BATCH_SIZE}
           * @throws GraphException if not all the instances could be deleted
           */
          void deleteInstances(String className, Collection<Long> ids) throws GraphException;
  
          /**
           * Process the given instances. They will have been sufficiently unlinked by the other methods.
           * @param className full name of mapped Hibernate class
           * @param ids applicable instances of class, no more than {@link #BATCH_SIZE}
           * @throws GraphException if not all the instances could be processed
           */
          void processInstances(String className, Collection<Long> ids) throws GraphException;
  
          /**
           * @return the permissions required for processing instances with {@link #processInstances(String, Collection)}
           */
          Set<Ability> getRequiredPermissions();
  
          /**
           * Assert that an object with the given details may be processed. Called only if the user is not an administrator.
           * @param className the name of the object's class
           * @param id the ID of the object
           * @param details the object's details
           * @throws GraphException if the object may not be processed
           */
          void assertMayProcess(String className, long id, ome.model.internal.Details details) throws GraphException;
      }
  
      private final Session session;
      private final EventContext eventContext;
      private final ACLVoter aclVoter;
      private final SystemTypes systemTypes;
      private final GraphPathBean model;
      private final SetMultimap<String, String> unnullable;
      private final Set<Milestone> progress = EnumSet.noneOf(Milestone.class);
      private final Planning planning;
      private final GraphPolicy policy;
      private final Processor processor;
  
      /**
       * Construct a new instance of a graph traversal manager.
       * @param session the Hibernate session
       * @param eventContext the current event context
       * @param aclVoter ACL voter for permissions checking
       * @param systemTypes for identifying the system types
       * @param graphPathBean the graph path bean
       * @param unnullable properties that, while nullable, may not be nulled by a graph traversal operation
       * @param policy how to determine which related objects to include in the operation
       * @param processor how to operate on the resulting target object graph
       */
      public GraphTraversal(Session session, EventContext eventContext, ACLVoter aclVoter, SystemTypes systemTypes,
              GraphPathBean graphPathBean, SetMultimap<String, String> unnullable, GraphPolicy policy, Processor processor) {
          this.session = session;
          this.eventContext = eventContext;
          this.aclVoter = aclVoter;
          this.systemTypes = systemTypes;
          this.model = graphPathBean;
          this.unnullable = unnullable;
          this.planning = new Planning();
          this.policy = policy;
          this.processor = log.isDebugEnabled() ? debugWrap(processor) : processor;
      }
  
      /**
       * Traverse model object graph to determine steps for the proposed operation.
       * @param session the Hibernate session to use for HQL queries
       * @param objects the model objects to process
       * @param include if the given model objects are to be included (instead of just deleted)
       * @param applyRules if the given model objects should have the policy rules applied to them
       * @return the model objects included in the operation, and the deleted objects
       * @throws GraphException if the model objects were not as expected
       */
      public Entry<SetMultimap<String, Long>, SetMultimap<String, Long>> planOperation(Session session,
              SetMultimap<String, Long> objects, boolean include, boolean applyRules) throws GraphException {
          if (progress.contains(Milestone.PLANNED)) {
              throw new IllegalStateException("operation already planned");
          }
          final Set<CI> targetSet = include ? planning.included : planning.deleted;
          /* note the object instances for processing */
          targetSet.addAll(objectsToCIs(session, objects));
          if (applyRules) {
              /* actually do the planning of the operation */
              planning.toProcess.addAll(targetSet);
              planOperation(session);
          } else {
              /* act as if the target objects have no links and no rules match them */
              for (final CI targetObject : targetSet) {
                  planning.blockedBy.put(targetObject, new HashSet<CI>());
              }
          }
          progress.add(Milestone.PLANNED);
          /* report which objects are to be included in the operation or deleted so that it can proceed */
          final SetMultimap<String, Long> included = HashMultimap.create();
          for (final CI includedObject : planning.included) {
              included.put(includedObject.className, includedObject.id);
          }
          final SetMultimap<String, Long> deleted = HashMultimap.create();
          for (final CI deletedObject : planning.deleted) {
              deleted.put(deletedObject.className, deletedObject.id);
          }
          return Maps.immutableEntry(included, deleted);
      }
  
      /**
       * Traverse model object graph to determine steps for the proposed operation.
       * @param session the Hibernate session to use for HQL queries
       * @param objectInstances the model objects to process, may be unloaded with ID only
       * @param include if the given model objects are to be included (instead of just deleted)
       * @param applyRules if the given model objects should have the policy rules applied to them
       * @return the model objects included in the operation, and the deleted objects, may be unloaded with ID only
       * @throws GraphException if the model objects were not as expected
       */
      public Entry<Collection<IObject>, Collection<IObject>> planOperation(Session session,
              Collection<? extends IObject> objectInstances, boolean include, boolean applyRules) throws GraphException {
          if (progress.contains(Milestone.PLANNED)) {
              throw new IllegalStateException("operation already planned");
          }
          final Set<CI> targetSet = include ? planning.included : planning.deleted;
          /* note the object instances for processing */
          final SetMultimap<String, Long> objectsToQuery = HashMultimap.create();
          for (final IObject instance : objectInstances) {
              if (instance.isLoaded() && instance.getDetails() != null) {
                  final CI object = new CI(instance);
                  noteDetails(object, instance.getDetails());
                  targetSet.add(object);
              } else {
                  objectsToQuery.put(instance.getClass().getName(), instance.getId());
              }
          }
          targetSet.addAll(objectsToCIs(session, objectsToQuery));
          if (applyRules) {
              /* actually do the planning of the operation */
              planning.toProcess.addAll(targetSet);
              planOperation(session);
          } else {
              /* act as if the target objects have no links and no rules match them */
              for (final CI targetObject : targetSet) {
                  planning.blockedBy.put(targetObject, new HashSet<CI>());
              }
          }
          progress.add(Milestone.PLANNED);
          /* report which objects are to be included in the operation or deleted so that it can proceed */
          final Collection<IObject> included = new ArrayList<IObject>(planning.included.size());
          for (final CI includedObject : planning.included) {
              included.add(includedObject.toIObject());
          }
          final Collection<IObject> deleted = new ArrayList<IObject>(planning.deleted.size());
          for (final CI deletedObject : planning.deleted) {
              deleted.add(deletedObject.toIObject());
          }
          return Maps.immutableEntry(included, deleted);
      }
  
      /**
       * Traverse model object graph to determine steps for the proposed operation.
       * Assumes that the internal {@code planning} field is set up and mutates it accordingly.
       * @param session the Hibernate session to use for HQL queries
       * @throws GraphException if the model objects were not as expected
       */
      private void planOperation(Session session) throws GraphException {
          /* track state to guarantee progress in reprocessing objects whose orphan status is relevant */
          Set<CI> optimisticReprocess = null;
          /* set of not-last objects after latest review */
          Set<CI> isNotLast = null;
          while (true) {
              /* process any pending objects */
              while (!(planning.toProcess.isEmpty() && planning.findIfLast.isEmpty())) {
                  /* first process any cached objects that do not await orphan status determination */
                  final Set<CI> toProcess = new HashSet<CI>(planning.toProcess);
                  toProcess.retainAll(planning.cached);
                  toProcess.removeAll(planning.findIfLast);
                  if (!toProcess.isEmpty()) {
                      if (optimisticReprocess != null && !Sets.difference(planning.toProcess, optimisticReprocess).isEmpty()) {
                          /* processing something beyond optimistic suggestion, so circumstances have changed */
                          optimisticReprocess = null;
                      }
                      for (final CI nextObject : toProcess) {
                          reviewObject(nextObject, false);
                      }
                      continue;
                  }
                  /* if none of the above exist, then fill the cache */
                  final Set<CI> toCache = new HashSet<CI>(planning.toProcess);
                  toCache.removeAll(planning.cached);
                  if (!toCache.isEmpty()) {
                      optimisticReprocess = null;
                      cache(session, toCache);
                      continue;
                  }
                  /* try processing the findIfLast in case of any changes */
                  if (!planning.toProcess.isEmpty()) {
                      final Set<CI> previousToProcess = new HashSet<CI>(planning.toProcess);
                      final Set<CI> previousFindIfLast = new HashSet<CI>(planning.findIfLast);
                      for (final CI nextObject : previousToProcess) {
                          reviewObject(nextObject, false);
                      }
                      /* This condition is tricky. We do want to reprocess objects that are suggested for such, while
                       * avoiding an infinite loop that comes of such processing not resolving any orphan status. */
                      if (!Sets.symmetricDifference(previousFindIfLast, planning.findIfLast).isEmpty() ||
                              (optimisticReprocess == null ||
                               !Sets.symmetricDifference(planning.toProcess, optimisticReprocess).isEmpty()) &&
                              !Sets.symmetricDifference(previousToProcess, planning.toProcess).isEmpty()) {
                          optimisticReprocess = new HashSet<CI>(planning.toProcess);
                          continue;
                      }   
                  }
                  /* if no other processing or caching is needed, then deem outstanding objects orphans */
                  optimisticReprocess = null;
                  for (final CI orphan : planning.findIfLast) {
                      planning.foundIfLast.put(orphan, true);
                      if (log.isDebugEnabled()) {
                          log.debug("marked " + orphan + " as " + Orphan.IS_LAST);
                      }
                  }
                  planning.toProcess.addAll(planning.findIfLast);
                  planning.findIfLast.clear();
              }
              /* determine which objects are now not last */
              final Set<CI> latestIsNotLast = new HashSet<CI>();
              for (final Entry<CI, Boolean> objectAndIsLast : planning.foundIfLast.entrySet()) {
                  if (!objectAndIsLast.getValue()) {
                      latestIsNotLast.add(objectAndIsLast.getKey());
                  }
              }
              if (latestIsNotLast.isEmpty() || (isNotLast != null && Sets.difference(isNotLast, latestIsNotLast).isEmpty())) {
                  /* no fewer not-last objects than before */
                  break;
              }
              /* before completing processing, verify not-last status of objects */
              isNotLast = latestIsNotLast;
              planning.toProcess.addAll(isNotLast);
              planning.findIfLast.addAll(isNotLast);
              for (final CI object : isNotLast) {
                  planning.foundIfLast.remove(object);
                  if (log.isDebugEnabled()) {
                      log.debug("marked " + object + " as " + Orphan.RELEVANT + " to verify " + Orphan.IS_NOT_LAST + " status");
                  }
              }
          }
      }
  
      /**
       * Check that there are no policy violations matched by {@code p:error} policy rules.
       * @throws GraphException if the policy rules are violated
       */
      public void assertNoPolicyViolations() throws GraphException {
          if (!progress.contains(Milestone.PLANNED)) {
              throw new IllegalStateException("operation not yet planned");
          }
          /* review objects for error conditions */
          for (final CI object : planning.cached) {
              reviewObject(object, true);
          }
      }
  
      /**
       * Note the details of the given object.
       * @param object the class and ID of the object instance
       * @param objectDetails the details of the object instance
       * @throws GraphException if the object could not be converted to an unloaded instance
       */
      private void noteDetails(CI object, ome.model.internal.Details objectDetails) throws GraphException {
          final IObject objectInstance = object.toIObject();
  
          if (planning.detailsNoted.put(object, objectDetails) != null) {
              return;
          }
  
          if (!eventContext.isCurrentUserAdmin()) {
              /* allowLoad ensures that BasicEventContext.groupPermissionsMap is populated */
              aclVoter.allowLoad(session, objectInstance.getClass(), objectDetails, object.id);
  
              if (aclVoter.allowUpdate(objectInstance, objectDetails)) {
                  planning.mayUpdate.add(object);
              }
              if (aclVoter.allowDelete(objectInstance, objectDetails)) {
                  planning.mayDelete.add(object);
              }
              if (objectInstance instanceof ExperimenterGroup) {
                  final ExperimenterGroup loadedGroup = (ExperimenterGroup) session.load(ExperimenterGroup.class, object.id);
                  if (aclVoter.allowChmod(loadedGroup)) {
                      planning.mayChmod.add(object);
                  }
              }
              final Experimenter objectOwner = objectDetails.getOwner();
              if (objectOwner != null && eventContext.getCurrentUserId().equals(objectOwner.getId())) {
                  planning.owns.add(object);
              }
          }
  
          policy.noteDetails(session, objectInstance, object.className, object.id);
      }
  
      /**
       * For the given class name and IDs, construct the corresponding {@link CI} instances without loading the persisted objects,
       * and ensure that their {@link ome.model.internal.Details} are noted.
       * @param className a model object class name
       * @param ids IDs of instances of the class
       * @return the {@link CI} instances indexed by object ID
       * @throws GraphException if an object could not be converted to an unloaded instance
       */
      private Map<Long, CI> findObjectDetails(String className, Collection<Long> ids) throws GraphException {
          final Map<Long, CI> objectsById = new HashMap<Long, CI>();
          final Set<Long> idsToQuery = new HashSet<Long>();
  
          for (final Long id : ids) {
              final CI object = new CI(className, id);
              final CI alias = planning.aliases.get(object);
              if (alias == null) {
                  idsToQuery.add(id);
              } else {
                  objectsById.put(object.id, alias);
              }
          }
  
          if (!idsToQuery.isEmpty()) {
              boolean subclassesQueried = false;
  
              if (!NO_SUBCLASS_QUERY.contains(className)) {
                  try {
                      /* determine the class of persisted objects without loading them */
                      final String rootQuery = "SELECT r.id, TYPE(r) FROM " + className + " r WHERE r.id IN (:ids)";
                      for (final List<Long> idsBatch : Iterables.partition(idsToQuery, BATCH_SIZE)) {
                          for (final Object[] result :
                              (List<Object[]>) session.createQuery(rootQuery).setParameterList("ids", idsBatch).list()) {
                              final Long id = (Long) result[0];
                              final Class<? extends IObject> objectClass = (Class<? extends IObject>) result[1];
                              final CI object = new CI(objectClass.getName(), id);
                              objectsById.put(object.id, object);
                              planning.aliases.put(new CI(className, object.id), object);
                          }
                      }
                      subclassesQueried = true;
                  } catch (NullPointerException | QueryException e) {
                      NO_SUBCLASS_QUERY.add(className);
                  }
              }
              if (!subclassesQueried) {
                  /* the class does not have subclasses to determine */
                  for (final Long id : idsToQuery) {
                      final CI object = new CI(className, id);
                      objectsById.put(object.id, object);
                      planning.aliases.put(object, object);
                  }
              }
  
              /* construct query according to which details may be queried */
              final Set<String> linkProperties = new HashSet<String>();
              for (final String superclassName : model.getSuperclassesOfReflexive(className)) {
                  final Set<Entry<String, String>> forwardLinks = model.getLinkedTo(superclassName);
                  for (final Entry<String, String> forwardLink : forwardLinks) {
                      linkProperties.add(forwardLink.getValue());
                  }
              }
              final List<String> soughtProperties = ImmutableList.of("details.owner", "details.group");
              final List<String> selectTerms = new ArrayList<String>(soughtProperties.size() + 1);
              selectTerms.add("root.id");
              for (final String soughtProperty : soughtProperties) {
                  if (linkProperties.contains(soughtProperty)) {
                      selectTerms.add("root." + soughtProperty);
                  } else {
                      selectTerms.add("NULLIF(0,0)");  /* a simple NULL doesn't work in Hibernate 3.5 */
                  }
              }
              selectTerms.add("root.details.permissions");  /* to include among soughtProperties once GraphPathBean knows of it */
              final String detailsQuery =
                      "SELECT " + Joiner.on(',').join(selectTerms) + " FROM " + className +" AS root WHERE root.id IN (:ids)";
  
              /* query and note details of objects */
              for (final List<Long> idsBatch : Iterables.partition(idsToQuery, BATCH_SIZE)) {
                  final Query hibernateQuery = session.createQuery(detailsQuery).setParameterList("ids", idsBatch);
                  for (final Object[] result : (List<Object[]>) hibernateQuery.list()) {
                      final ome.model.internal.Details details = ome.model.internal.Details.create();
                      final Long id = (Long) result[0];
                      details.setOwner((Experimenter) result[1]);
                      details.setGroup((ExperimenterGroup) result[2]);
                      details.setPermissions((Permissions) result[3]);
                      noteDetails(objectsById.get(id), details);
                  }
              }
          }
  
          return objectsById;
      }
  
      /**
       * Convert the indicated objects to {@link CI}s with their actual class identified.
       * @param session a Hibernate session
       * @param objects the objects to query
       * @return {@link CI}s corresponding to the objects
       * @throws GraphException if any of the specified objects could not be queried
       */
      private Collection<CI> objectsToCIs(Session session, SetMultimap<String, Long> objects) throws GraphException {
          final List<CI> returnValue = new ArrayList<CI>(objects.size());
          for (final Entry<String, Collection<Long>> oneQueryClass : objects.asMap().entrySet()) {
              final String className = oneQueryClass.getKey();
              final Collection<Long> ids = oneQueryClass.getValue();
              final Collection<CI> retrieved = findObjectDetails(className, ids).values();
              if (ids.size() != retrieved.size()) {
                  throw new GraphException("cannot read all the specified objects of class " + className);
              }
              returnValue.addAll(retrieved);
          }
          return returnValue;
      }
  
      /**
       * Given a class and a property of that class, determine to which class it links.
       * @param linkProperty a class and property name
       * @return the class linked to
       */
      private String getLinkedClass(CP linkProperty) {
          for (final Entry<String, String> forwardLink : model.getLinkedTo(linkProperty.className)) {
              if (linkProperty.propertyName.equals(forwardLink.getValue())) {
                  return forwardLink.getKey();
              }
          }
          return null;
      }
  
      /**
       * Given a class and a property linking to that class, determine from which class it is linked.
       * @param linkProperty a class and property name
       * @return the linking class
       */
      @SuppressWarnings("unused")
      private String getLinkerClass(CP linkProperty) {
          for (final Entry<String, String> backwardLink : model.getLinkedBy(linkProperty.className)) {
              if (linkProperty.propertyName.equals(backwardLink.getValue())) {
                  return backwardLink.getKey();
              }
          }
          return null;
      }
  
      /**
       * Load a specific link property's object relationships into the various cache fields of {@link Planning}.
       * @param linkProperty the link property being processed
       * @param query the HQL to query the property's object relationships
       * @param ids the IDs of the related objects
       * @return which linker objects are related to which linked objects by the given property
       * @throws GraphException if the objects could not be converted to unloaded instances
       */
      private List<Entry<CI,CI>> getLinksToCache(CP linkProperty, String query, Collection<Long> ids) throws GraphException {
          final String linkedClassName = getLinkedClass(linkProperty);
          final boolean propertyIsAccessible = model.isPropertyAccessible(linkProperty.className, linkProperty.propertyName);
          final SetMultimap<Long, Long> linkerToLinked = HashMultimap.create();
          for (final List<Long> idsBatch : Iterables.partition(ids, BATCH_SIZE)) {
              for (final Object[] result : (List<Object[]>) session.createQuery(query).setParameterList("ids", idsBatch).list()) {
                  linkerToLinked.put((Long) result[0], (Long) result[1]);
              }
          }
          final List<Entry<CI,CI>> linkerLinked = new ArrayList<Entry<CI,CI>>();
          final Map<Long, CI> linkersById = findObjectDetails(linkProperty.className, linkerToLinked.keySet());
          final Map<Long, CI> linkedsById = findObjectDetails(linkedClassName, new HashSet<Long>(linkerToLinked.values()));
          for (final Entry<Long, Long> linkerIdLinkedId : linkerToLinked.entries()) {
              final CI linker = linkersById.get(linkerIdLinkedId.getKey());
              final CI linked = linkedsById.get(linkerIdLinkedId.getValue());
              if (!planning.detailsNoted.containsKey(linker)) {
                  log.warn("failed to query for " + linker);
              } else if (!planning.detailsNoted.containsKey(linked)) {
                  log.warn("failed to query for " + linked);
              } else {
                  linkerLinked.add(Maps.immutableEntry(linker, linked));
                  if (propertyIsAccessible) {
                      planning.befores.put(linked, linker);
                      planning.afters.put(linker, linked);
                  }
                  if (log.isDebugEnabled()) {
                      log.debug(linkProperty.toCPI(linker.id) + " links to " + linked);
                  }
              }
          }
          return linkerLinked;
      }
  
      /**
       * Load object instances and their links into the various cache fields of {@link Planning}.
       * @param session a Hibernate session
       * @param toCache the objects to cache
       * @throws GraphException if the objects could not be converted to unloaded instances
       */
      private void cache(Session session, Collection<CI> toCache) throws GraphException {
          /* note which links to query, organized for batch querying */
          final SetMultimap<CP, Long> forwardLinksWanted = HashMultimap.create();
          final SetMultimap<CP, Long> backwardLinksWanted = HashMultimap.create();
          for (final CI inclusionCandidate : toCache) {
              for (final String inclusionCandidateSuperclassName : model.getSuperclassesOfReflexive(inclusionCandidate.className)) {
                  for (final Entry<String, String> forwardLink : model.getLinkedTo(inclusionCandidateSuperclassName)) {
                      final CP linkProperty = new CP(inclusionCandidateSuperclassName, forwardLink.getValue());
                      forwardLinksWanted.put(linkProperty, inclusionCandidate.id);
                  }
                  for (final Entry<String, String> backwardLink : model.getLinkedBy(inclusionCandidateSuperclassName)) {
                      final CP linkProperty = new CP(backwardLink.getKey(), backwardLink.getValue());
                      backwardLinksWanted.put(linkProperty, inclusionCandidate.id);
                  }
              }
          }
          /* query and cache forward links */
          for (final Entry<CP, Collection<Long>> forwardLink : forwardLinksWanted.asMap().entrySet()) {
              final CP linkProperty = forwardLink.getKey();
              final String query = "SELECT linker.id, linked.id FROM " + linkProperty.className + " AS linker " +
                      "JOIN linker." + linkProperty.propertyName + " AS linked WHERE linker.id IN (:ids)";
              for (final Entry<CI, CI> linkerLinked : getLinksToCache(linkProperty, query, forwardLink.getValue())) {
                  planning.forwardLinksCached.put(linkProperty.toCPI(linkerLinked.getKey().id), linkerLinked.getValue());
              }
          }
          /* query and cache backward links */
          for (final Entry<CP, Collection<Long>> backwardLink : backwardLinksWanted.asMap().entrySet()) {
              final CP linkProperty = backwardLink.getKey();
              final String query = "SELECT linker.id, linked.id FROM " + linkProperty.className + " AS linker " +
                      "JOIN linker." + linkProperty.propertyName + " AS linked WHERE linked.id IN (:ids)";
              for (final Entry<CI, CI> linkerLinked : getLinksToCache(linkProperty, query, backwardLink.getValue())) {
                  planning.backwardLinksCached.put(linkProperty.toCPI(linkerLinked.getValue().id), linkerLinked.getKey());
              }
          }
          /* note cached objects for further processing */
          planning.cached.addAll(toCache);
          planning.toProcess.addAll(toCache);
      }
  
      /**
       * Invalidate {@link Orphan#IS_NOT_LAST} for objects linked to one no longer {@link Action#EXCLUDE}d.
       * @param object the object that is no longer {@link Action#EXCLUDE}d
       */
      private void orphanCheckNoLongerExcluded(CI object) {
          for (final String superclassName : model.getSuperclassesOfReflexive(object.className)) {
              for (final Entry<String, String> forwardLink : model.getLinkedTo(superclassName)) {
                  /* next forward link */
                  final CPI linkSource = new CPI (superclassName, forwardLink.getValue(), object.id);
                  for (final CI linked : planning.forwardLinksCached.get(linkSource)) {
                      /* next object linked by this one */
                      if (Boolean.FALSE.equals(planning.foundIfLast.get(linked))) {
                          planning.findIfLast.add(linked);
                          planning.foundIfLast.remove(linked);
                          planning.toProcess.add(linked);
                      }
                  }
              }
              for (final Entry<String, String> backwardLink : model.getLinkedBy(superclassName)) {
                  /* next backward link */
                  final CPI linkTarget = new CPI (backwardLink.getKey(), backwardLink.getValue(), object.id);
                  for (final CI linker : planning.backwardLinksCached.get(linkTarget)) {
                      /* next object this one links */
                      if (Boolean.FALSE.equals(planning.foundIfLast.get(linker))) {
                          planning.findIfLast.add(linker);
                          planning.foundIfLast.remove(linker);
                          planning.toProcess.add(linker);
                      }
                  }
              }
          }
      }
  
      /**
       * Determine the appropriate value of {@link Action} for the given object.
       * @param object an object
       * @return the object's {@link Action} value
       */
      private Action getAction(CI object) {
          if (planning.included.contains(object)) {
              return Action.INCLUDE;
          } else if (planning.deleted.contains(object)) {
              return Action.DELETE;
          } else if (planning.outside.contains(object)) {
              return Action.OUTSIDE;
          } else {
              return Action.EXCLUDE;
          }
      }
  
      /**
       * Determine the appropriate value of {@link Orphan} for the given object.
       * @param object an object
       * @return the object's {@link Orphan} value
       */
      private Orphan getOrphan(CI object) {
          if (planning.findIfLast.contains(object)) {
              return Orphan.RELEVANT;
          }
          final Boolean isLast = planning.foundIfLast.get(object);
          if (isLast == null) {
              return Orphan.IRRELEVANT;
          } else {
              return isLast ? Orphan.IS_LAST : Orphan.IS_NOT_LAST;
          }
      }
  
      /**
       * Return details of the given model object.
       * Repeated queries for the same model object return exactly the same details object as previously.
       * @param cache the cache of details by object
       * @param object an object
       * @return the details of the object
       * @throws GraphException if the object could not be constructed as an {@link IObject}
       */
      private Details getDetails(Map<CI, Details> cache, CI object) throws GraphException {
          Details details = cache.get(object);
  
          if (details == null) {
              final ome.model.internal.Details objectDetails = planning.detailsNoted.get(object);
              if (objectDetails == null) {
                  throw new GraphException("cannot read " + object);
              }
              final Experimenter owner = objectDetails.getOwner();
              final ExperimenterGroup group = objectDetails.getGroup();
              final Long ownerId = owner == null ? null : owner.getId();
              final Long groupId = group == null ? null : group.getId();
  
              final Action action = getAction(object);
              final Orphan orphan = action == Action.EXCLUDE ? getOrphan(object) : Orphan.IRRELEVANT;
  
              if (eventContext.isCurrentUserAdmin()) {
                  details = new DetailsWithCI(object.toIObject(), ownerId, groupId, action, orphan, true, true, true, true, true);
              } else {
                  details = new DetailsWithCI(object.toIObject(), ownerId, groupId, action, orphan,
                          planning.mayUpdate.contains(object), planning.mayDelete.contains(object),
                          planning.mayChmod.contains(object), planning.owns.contains(object),
                          !planning.overrides.contains(object));
              }
  
              cache.put(object, details);
          }
  
          return details;
      }
  
      /**
       * Process the object, adjusting the planning state accordingly.
       * @param object an object
       * @param isErrorRules if {@link GraphPolicy#review(Map, Details, Map, Set)} should apply final checks instead of normal rules
       * @throws GraphException on detecting the policy attempting an illegal change of {@link Action}
       */
      private void reviewObject(CI object, boolean isErrorRules) throws GraphException {
          /* note the object's details */
          final Map<CI, Details> detailsCache = new HashMap<CI, Details>();
          final Details objectDetails = getDetails(detailsCache, object);
          if (log.isDebugEnabled()) {
              final StringBuffer sb = new StringBuffer();
              sb.append("reviewing ");
              sb.append(objectDetails);
              if (isErrorRules) {
                  sb.append(" for error conditions");
              }
              log.debug(sb.toString());
          }
          /* review the object's links */
          final Map<String, Set<Details>> linkedFromDetails = new HashMap<String, Set<Details>>();
          final Map<String, Set<Details>> linkedToDetails   = new HashMap<String, Set<Details>>();
          final Set<String> notNullable = new HashSet<String>();
          for (final String superclassName : model.getSuperclassesOfReflexive(object.className)) {
              for (final Entry<String, String> forwardLink : model.getLinkedTo(superclassName)) {
                  /* next forward link */
                  final CP linkProperty = new CP(superclassName, forwardLink.getValue());
                  if (model.getPropertyKind(linkProperty.className, linkProperty.propertyName) == PropertyKind.REQUIRED) {
                      notNullable.add(linkProperty.toString());
                  }
                  final Set<Details> linkedsDetails = new HashSet<Details>();
                  linkedToDetails.put(linkProperty.toString(), linkedsDetails);
                  final CPI linkSource = linkProperty.toCPI(object.id);
                  for (final CI linked : planning.forwardLinksCached.get(linkSource)) {
                      /* next object linked by this one */
                      linkedsDetails.add(getDetails(detailsCache, linked));
                  }
              }
              for (final Entry<String, String> backwardLink : model.getLinkedBy(superclassName)) {
                  /* next backward link */
                  final CP linkProperty = new CP(backwardLink.getKey(), backwardLink.getValue());
                  if (model.getPropertyKind(linkProperty.className, linkProperty.propertyName) == PropertyKind.REQUIRED) {
                      notNullable.add(linkProperty.toString());
                  }
                  final Set<Details> linkersDetails = new HashSet<Details>();
                  linkedFromDetails.put(linkProperty.toString(), linkersDetails);
                  final CPI linkTarget = linkProperty.toCPI(object.id);
                  for (final CI linker : planning.backwardLinksCached.get(linkTarget)) {
                      /* next object this one links */
                      linkersDetails.add(getDetails(detailsCache, linker));
                  }
              }
          }
          final Set<Details> changes = policy.review(linkedFromDetails, objectDetails, linkedToDetails, notNullable, isErrorRules);
          /* object is now processed */
          planning.toProcess.remove(object);
          if (changes == null) {
              return;
          }
          /* act on collated policies */
          for (final Details change : changes) {
              final CI instance = new CI(change.subject);
              final Action previousAction = getAction(instance);
              if (previousAction != change.action) {
                  /* undo previous action */
                  switch (previousAction) {
                  case EXCLUDE:
                      /* query orphan status only for EXCLUDEd objects */
                      planning.findIfLast.remove(instance);
                      planning.foundIfLast.remove(instance);
                      /* re-check objects whose IS_NOT_LAST may have depended on this object being excluded */
                      orphanCheckNoLongerExcluded(instance);
                      break;
                  case DELETE:
                      planning.deleted.remove(instance);
                      break;
                  default:
                      throw new GraphException("policy cannot change action from " + previousAction);
                  }
                  /* accept new action */
                  switch (change.action) {
                  case DELETE:
                      planning.deleted.add(instance);
                      planning.toProcess.add(instance);
                      break;
                  case INCLUDE:
                      planning.included.add(instance);
                      planning.toProcess.add(instance);
                      break;
                  case OUTSIDE:
                      planning.outside.add(instance);
                      planning.toProcess.remove(instance);
                      break;
                  default:
                      throw new GraphException("policy cannot change action to " + change.action);
                  }
              } else if ((change.orphan == Orphan.IS_LAST || change.orphan == Orphan.IS_NOT_LAST) &&
                      !planning.foundIfLast.containsKey(instance)) {
                  /* relevant orphan status now determined so object must be processed */
                  planning.findIfLast.remove(instance);
                  planning.foundIfLast.put(instance, change.orphan == Orphan.IS_LAST);
                  planning.toProcess.add(instance);
              } else if (change.action == Action.EXCLUDE && change.orphan == Orphan.RELEVANT &&
                      planning.findIfLast.add(instance) && !planning.cached.contains(instance)) {
                  /* orphan status is relevant; if just now noted as such then ensure the object is or will be cached */
                  planning.toProcess.add(instance);
              } else if (!(change.action == Action.OUTSIDE || instance.equals(object))) {
                  /* probably just needs review */
                  planning.toProcess.add(instance);
              }
              if (!(change.isCheckPermissions || eventContext.isCurrentUserAdmin())) {
                  /* do not check the user's permissions on this object */
                  planning.overrides.add(instance);
              }
              if (log.isDebugEnabled()) {
                  log.debug("adjusted " + change);
              }
          }
          /* if object is now DELETE or INCLUDE then it must be in the queue */
          final Action chosenAction = getAction(object);
          if ((chosenAction == Action.DELETE || chosenAction == Action.INCLUDE) && !planning.blockedBy.containsKey(object)) {
              final Set<CI> queuedItems = planning.blockedBy.keySet();
              planning.blockedBy.put(object, new HashSet<CI>(Sets.intersection(planning.befores.get(object), queuedItems)));
              for (final CI afterItem : Sets.intersection(planning.afters.get(object), queuedItems)) {
                  planning.blockedBy.get(afterItem).add(object);
              }
          }
      }
  
      /**
       * Note a linked object to remove from a linker property's {@link Collection} value.
       * @param linkerToIdToLinked the map from linker property to linker ID to objects in {@link Collection}s
       * @param linker the linker object
       * @param linked the linked object
       */
      private void addRemoval(Map<CP, SetMultimap<Long, Entry<String, Long>>> linkerToIdToLinked, CPI linker, CI linked) {
          if (model.isPropertyAccessible(linker.className, linker.propertyName)) {
              SetMultimap<Long, Entry<String, Long>> idMap = linkerToIdToLinked.get(linker.toCP());
              if (idMap == null) {
  