From a437edf2e4d38c952cc6ef1d074319e37452aa1b Mon Sep 17 00:00:00 2001
From: Matthew Swift <matthew.swift@forgerock.com>
Date: Thu, 05 Jun 2014 15:01:30 +0000
Subject: [PATCH] Fix OPENDJ-1481: Consider adding method for comparing entries for exact differences, ignoring matching rules

---
 opendj-sdk/opendj-core/src/main/java/org/forgerock/opendj/ldap/Entries.java           |  367 ++++++++++++++++++--
 opendj-sdk/opendj-core/src/main/java/org/forgerock/opendj/ldap/requests/Requests.java |   18 
 opendj-sdk/opendj-core/src/test/java/org/forgerock/opendj/ldap/EntriesTestCase.java   |  604 +++++++++++++++++++++++++----------
 3 files changed, 765 insertions(+), 224 deletions(-)

diff --git a/opendj-sdk/opendj-core/src/main/java/org/forgerock/opendj/ldap/Entries.java b/opendj-sdk/opendj-core/src/main/java/org/forgerock/opendj/ldap/Entries.java
index b976318..b3f0c0c 100644
--- a/opendj-sdk/opendj-core/src/main/java/org/forgerock/opendj/ldap/Entries.java
+++ b/opendj-sdk/opendj-core/src/main/java/org/forgerock/opendj/ldap/Entries.java
@@ -22,15 +22,13 @@
  *
  *
  *      Copyright 2010 Sun Microsystems, Inc.
- *      Portions copyright 2011-2013 ForgeRock AS
+ *      Portions copyright 2011-2014 ForgeRock AS
  */
 
 package org.forgerock.opendj.ldap;
 
-import static org.forgerock.opendj.ldap.AttributeDescription.objectClass;
-
 import static com.forgerock.opendj.ldap.CoreMessages.*;
-
+import static org.forgerock.opendj.ldap.AttributeDescription.objectClass;
 import static org.forgerock.opendj.ldap.ErrorResultException.newErrorResult;
 
 import java.util.ArrayList;
@@ -39,6 +37,7 @@
 import java.util.Comparator;
 import java.util.HashSet;
 import java.util.Iterator;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Set;
 
@@ -63,6 +62,142 @@
  * @see Entry
  */
 public final class Entries {
+    /**
+     * Options for controlling the behavior of the
+     * {@link Entries#diffEntries(Entry, Entry, DiffOptions) diffEntries}
+     * method. {@code DiffOptions} specify which attributes are compared, how
+     * they are compared, and the type of modifications generated.
+     *
+     * @see Entries#diffEntries(Entry, Entry, DiffOptions)
+     */
+    public static final class DiffOptions {
+        /**
+         * Selects which attributes will be compared. By default all user
+         * attributes will be compared.
+         */
+        private AttributeFilter attributeFilter = USER_ATTRIBUTES_ONLY_FILTER;
+
+        /**
+         * When true, attribute values are compared byte for byte, otherwise
+         * they are compared using their matching rules.
+         */
+        private boolean useExactMatching = false;
+
+        /**
+         * When greater than 0, modifications with REPLACE type will be
+         * generated for the new attributes containing at least
+         * "useReplaceMaxValues" attribute values. Otherwise, modifications with
+         * DELETE + ADD types will be generated.
+         */
+        private int useReplaceMaxValues = 0;
+
+        private DiffOptions() {
+            // Nothing to do.
+        }
+
+        /**
+         * Specifies an attribute filter which will be used to determine which
+         * attributes will be compared. By default only user attributes will be
+         * compared.
+         *
+         * @param attributeFilter
+         *            The filter which will be used to determine which
+         *            attributes will be compared.
+         * @return A reference to this set of options.
+         */
+        public DiffOptions attributes(final AttributeFilter attributeFilter) {
+            Reject.ifNull(attributeFilter);
+            this.attributeFilter = attributeFilter;
+            return this;
+        }
+
+        /**
+         * Specifies the list of attributes to be compared. By default only user
+         * attributes will be compared.
+         *
+         * @param attributeDescriptions
+         *            The names of the attributes to be compared.
+         * @return A reference to this set of options.
+         */
+        public DiffOptions attributes(final String... attributeDescriptions) {
+            return attributes(new AttributeFilter(attributeDescriptions));
+        }
+
+        /**
+         * Requests that attribute values should be compared byte for byte,
+         * rather than using their matching rules. This is useful when a client
+         * wishes to perform trivial changes to an attribute value which would
+         * otherwise be ignored by the matching rule, such as removing extra
+         * white space from an attribute, or capitalizing a user's name.
+         *
+         * @return A reference to this set of options.
+         */
+        public DiffOptions useExactMatching() {
+            this.useExactMatching = true;
+            return this;
+        }
+
+        /**
+         * Requests that all generated changes should use the
+         * {@link ModificationType#REPLACE REPLACE} modification type, rather
+         * than a combination of {@link ModificationType#DELETE DELETE} and
+         * {@link ModificationType#ADD ADD}.
+         * <p>
+         * Note that the generated changes will not be reversible, nor will they
+         * be efficient for attributes containing many values (such as groups).
+         * Enabling this option may result in more efficient updates for single
+         * valued attributes and reduce the amount of replication meta-data that
+         * needs to be maintained..
+         *
+         * @return A reference to this set of options.
+         */
+        public DiffOptions alwaysReplaceAttributes() {
+            return replaceMaxValues(Integer.MAX_VALUE);
+        }
+
+        /**
+         * Requests that the generated changes should use the
+         * {@link ModificationType#REPLACE REPLACE} modification type when the
+         * new attribute contains at most one attribute value. All other changes
+         * will use a combination of {@link ModificationType#DELETE DELETE} then
+         * {@link ModificationType#ADD ADD}.
+         * <p>
+         * Specifying this option will usually provide the best overall
+         * performance for single and multi-valued attribute updates, but the
+         * generated changes will probably not be reversible.
+         *
+         * @return A reference to this set of options.
+         */
+        public DiffOptions replaceSingleValuedAttributes() {
+            return replaceMaxValues(1);
+        }
+
+        /**
+         * Requests that the generated changes should use the
+         * {@link ModificationType#REPLACE REPLACE} modification type when the
+         * new attribute contains {@code maxValues} attribute values or less.
+         * All other changes will use a combination of
+         * {@link ModificationType#DELETE DELETE} then
+         * {@link ModificationType#ADD ADD}.
+         *
+         * @param maxValues
+         *            The maximum number of attribute values a modified
+         *            attribute can contain before reversible changes will be
+         *            generated.
+         * @return A reference to this set of options.
+         */
+        private DiffOptions replaceMaxValues(final int maxValues) {
+            // private until we can think of a good use case and better name.
+            Reject.ifFalse(maxValues >= 0, "maxValues must be >= 0");
+            this.useReplaceMaxValues = maxValues;
+            return this;
+        }
+
+        private Entry filter(final Entry entry) {
+            return attributeFilter.filteredViewOf(entry);
+        }
+
+    }
 
     private static final class UnmodifiableEntry implements Entry {
         private final Entry entry;
@@ -269,6 +404,9 @@
         }
     };
 
+    private static final AttributeFilter USER_ATTRIBUTES_ONLY_FILTER = new AttributeFilter();
+    private static final DiffOptions DEFAULT_DIFF_OPTIONS = new DiffOptions();
+
     private static final Function<Attribute, Attribute, Void> UNMODIFIABLE_ATTRIBUTE_FUNCTION =
             new Function<Attribute, Attribute, Void>() {
 
@@ -409,12 +547,15 @@
      * Creates a new modify request containing a list of modifications which can
      * be used to transform {@code fromEntry} into entry {@code toEntry}.
      * <p>
-     * The modify request is reversible: it will contain only modifications of
-     * type {@link ModificationType#ADD ADD} and {@link ModificationType#DELETE
-     * DELETE}.
+     * The changes will be generated using a default set of {@link DiffOptions
+     * options}. More specifically, only user attributes will be compared,
+     * attributes will be compared using their matching rules, and all generated
+     * changes will be reversible: it will contain only modifications of type
+     * {@link ModificationType#DELETE DELETE} then {@link ModificationType#ADD
+     * ADD}.
      * <p>
      * Finally, the modify request will use the distinguished name taken from
-     * {@code fromEntry}. Moreover, this method will not check to see if both
+     * {@code fromEntry}. This method will not check to see if both
      * {@code fromEntry} and {@code toEntry} have the same distinguished name.
      * <p>
      * This method is equivalent to:
@@ -423,35 +564,62 @@
      * ModifyRequest request = Requests.newModifyRequest(fromEntry, toEntry);
      * </pre>
      *
+     * Or:
+     *
+     * <pre>
+     * ModifyRequest request = diffEntries(fromEntry, toEntry, Entries.diffOptions());
+     * </pre>
+     *
      * @param fromEntry
      *            The source entry.
      * @param toEntry
      *            The destination entry.
      * @return A modify request containing a list of modifications which can be
      *         used to transform {@code fromEntry} into entry {@code toEntry}.
+     *         The returned request will always be non-{@code null} but may not
+     *         contain any modifications.
      * @throws NullPointerException
      *             If {@code fromEntry} or {@code toEntry} were {@code null}.
      * @see Requests#newModifyRequest(Entry, Entry)
      */
     public static ModifyRequest diffEntries(final Entry fromEntry, final Entry toEntry) {
-        Reject.ifNull(fromEntry, toEntry);
+        return diffEntries(fromEntry, toEntry, DEFAULT_DIFF_OPTIONS);
+    }
+
+    /**
+     * Creates a new modify request containing a list of modifications which can
+     * be used to transform {@code fromEntry} into entry {@code toEntry}.
+     * <p>
+     * The changes will be generated using the provided set of
+     * {@link DiffOptions}.
+     * <p>
+     * Finally, the modify request will use the distinguished name taken from
+     * {@code fromEntry}. This method will not check to see if both
+     * {@code fromEntry} and {@code toEntry} have the same distinguished name.
+     *
+     * @param fromEntry
+     *            The source entry.
+     * @param toEntry
+     *            The destination entry.
+     * @param options
+     *            The set of options which will control which attributes are
+     *            compared, how they are compared, and the type of modifications
+     *            generated.
+     * @return A modify request containing a list of modifications which can be
+     *         used to transform {@code fromEntry} into entry {@code toEntry}.
+     *         The returned request will always be non-{@code null} but may not
+     *         contain any modifications.
+     * @throws NullPointerException
+     *             If {@code fromEntry}, {@code toEntry}, or {@code options}
+     *             were {@code null}.
+     */
+    public static ModifyRequest diffEntries(final Entry fromEntry, final Entry toEntry,
+            final DiffOptions options) {
+        Reject.ifNull(fromEntry, toEntry, options);
 
         final ModifyRequest request = Requests.newModifyRequest(fromEntry.getName());
-
-        final TreeMapEntry tfrom;
-        if (fromEntry instanceof TreeMapEntry) {
-            tfrom = (TreeMapEntry) fromEntry;
-        } else {
-            tfrom = new TreeMapEntry(fromEntry);
-        }
-
-        final TreeMapEntry tto;
-        if (toEntry instanceof TreeMapEntry) {
-            tto = (TreeMapEntry) toEntry;
-        } else {
-            tto = new TreeMapEntry(toEntry);
-        }
-
+        final Entry tfrom = toFilteredTreeMapEntry(fromEntry, options);
+        final Entry tto = toFilteredTreeMapEntry(toEntry, options);
         final Iterator<Attribute> ifrom = tfrom.getAllAttributes().iterator();
         final Iterator<Attribute> ito = tto.getAllAttributes().iterator();
 
@@ -461,62 +629,101 @@
         while (afrom != null && ato != null) {
             final AttributeDescription adfrom = afrom.getAttributeDescription();
             final AttributeDescription adto = ato.getAttributeDescription();
+
             final int cmp = adfrom.compareTo(adto);
             if (cmp == 0) {
                 /*
-                 * Attribute is in both entries. Compute the set of values to be
-                 * added and removed. We won't replace the attribute because
-                 * this is not reversible.
+                 * Attribute is in both entries so compute the differences
+                 * between the old and new.
                  */
-                final Attribute addedValues = new LinkedAttribute(ato);
-                addedValues.removeAll(afrom);
-                if (!addedValues.isEmpty()) {
-                    request.addModification(new Modification(ModificationType.ADD, addedValues));
-                }
+                if (options.useReplaceMaxValues > ato.size()) {
+                    // This attribute is a candidate for replacing.
+                    if (diffAttributeNeedsReplacing(afrom, ato, options)) {
+                        request.addModification(new Modification(ModificationType.REPLACE, ato));
+                    }
+                } else if (afrom.size() == 1 && ato.size() == 1) {
+                    // Fast-path for single valued attributes.
+                    if (diffFirstValuesAreDifferent(options, afrom, ato)) {
+                        diffDeleteValues(request, afrom);
+                        diffAddValues(request, ato);
+                    }
+                } else if (options.useExactMatching) {
+                    /*
+                     * Compare multi-valued attributes using exact matching. Use
+                     * a hash sets for membership checking rather than the
+                     * attributes in order to avoid matching rule based
+                     * comparisons.
+                     */
+                    final Set<ByteString> oldValues = new LinkedHashSet<ByteString>(afrom);
+                    final Set<ByteString> newValues = new LinkedHashSet<ByteString>(ato);
 
-                final Attribute deletedValues = new LinkedAttribute(afrom);
-                deletedValues.removeAll(ato);
-                if (!deletedValues.isEmpty()) {
-                    request.addModification(new Modification(ModificationType.DELETE, deletedValues));
+                    final Set<ByteString> deletedValues = new LinkedHashSet<ByteString>(oldValues);
+                    deletedValues.removeAll(newValues);
+                    diffDeleteValues(request, deletedValues.size() == afrom.size() ? afrom
+                            : new LinkedAttribute(adfrom, deletedValues));
+
+                    final Set<ByteString> addedValues = newValues;
+                    addedValues.removeAll(oldValues);
+                    diffAddValues(request, addedValues.size() == ato.size() ? ato
+                            : new LinkedAttribute(adto, addedValues));
+                } else {
+                    // Compare multi-valued attributes using matching rules.
+                    final Attribute deletedValues = new LinkedAttribute(afrom);
+                    deletedValues.removeAll(ato);
+                    diffDeleteValues(request, deletedValues);
+
+                    final Attribute addedValues = new LinkedAttribute(ato);
+                    addedValues.removeAll(afrom);
+                    diffAddValues(request, addedValues);
                 }
 
                 afrom = ifrom.hasNext() ? ifrom.next() : null;
                 ato = ito.hasNext() ? ito.next() : null;
             } else if (cmp < 0) {
                 // afrom in source, but not destination.
-                request.addModification(new Modification(ModificationType.DELETE, afrom));
+                diffDeleteAttribute(request, afrom, options);
                 afrom = ifrom.hasNext() ? ifrom.next() : null;
             } else {
                 // ato in destination, but not in source.
-                request.addModification(new Modification(ModificationType.ADD, ato));
+                diffAddAttribute(request, ato, options);
                 ato = ito.hasNext() ? ito.next() : null;
             }
         }
 
         // Additional attributes in source entry: these must be deleted.
         if (afrom != null) {
-            request.addModification(new Modification(ModificationType.DELETE, afrom));
+            diffDeleteAttribute(request, afrom, options);
         }
-
         while (ifrom.hasNext()) {
-            final Attribute a = ifrom.next();
-            request.addModification(new Modification(ModificationType.DELETE, a));
+            diffDeleteAttribute(request, ifrom.next(), options);
         }
 
         // Additional attributes in destination entry: these must be added.
         if (ato != null) {
-            request.addModification(new Modification(ModificationType.ADD, ato));
+            diffAddAttribute(request, ato, options);
         }
-
         while (ito.hasNext()) {
-            final Attribute a = ito.next();
-            request.addModification(new Modification(ModificationType.ADD, a));
+            diffAddAttribute(request, ito.next(), options);
         }
 
         return request;
     }
 
     /**
+     * Returns a new set of options which may be used to control how entries are
+     * compared and changes generated using
+     * {@link #diffEntries(Entry, Entry, DiffOptions)}. By default only user
+     * attributes will be compared, matching rules will be used for comparisons,
+     * and all generated changes will be reversible.
+     *
+     * @return A new set of options which may be used to control how entries are
+     *         compared and changes generated.
+     */
+    public static DiffOptions diffOptions() {
+        return new DiffOptions();
+    }
+
+    /**
      * Returns an unmodifiable set containing the object classes associated with
      * the provided entry. This method will ignore unrecognized object classes.
      * <p>
@@ -617,7 +824,7 @@
      * <p>
      * Sample usage:
      * <pre>
-     * List<Entry> smiths = TestCaseUtils.makeEntries(
+     * List&lt;Entry&gt; smiths = TestCaseUtils.makeEntries(
      *   "dn: cn=John Smith,dc=example,dc=com",
      *   "objectclass: inetorgperson",
      *   "cn: John Smith",
@@ -816,6 +1023,64 @@
         }
     }
 
+    private static void diffAddAttribute(final ModifyRequest request, final Attribute ato,
+            final DiffOptions diffOptions) {
+        if (diffOptions.useReplaceMaxValues > 0) {
+            request.addModification(new Modification(ModificationType.REPLACE, ato));
+        } else {
+            request.addModification(new Modification(ModificationType.ADD, ato));
+        }
+    }
+
+    private static void diffAddValues(final ModifyRequest request, final Attribute addedValues) {
+        if (addedValues != null && !addedValues.isEmpty()) {
+            request.addModification(new Modification(ModificationType.ADD, addedValues));
+        }
+    }
+
+    private static boolean diffAttributeNeedsReplacing(final Attribute afrom, final Attribute ato,
+            final DiffOptions options) {
+        if (afrom.size() != ato.size()) {
+            return true;
+        } else if (afrom.size() == 1) {
+            return diffFirstValuesAreDifferent(options, afrom, ato);
+        } else if (options.useExactMatching) {
+            /*
+             * Use a hash set for membership checking rather than the attribute
+             * in order to avoid matching rule based comparisons.
+             */
+            final Set<ByteString> oldValues = new LinkedHashSet<ByteString>(afrom);
+            return !oldValues.containsAll(ato);
+        } else {
+            return !afrom.equals(ato);
+        }
+    }
+
+    private static void diffDeleteAttribute(final ModifyRequest request, final Attribute afrom,
+            final DiffOptions diffOptions) {
+        if (diffOptions.useReplaceMaxValues > 0) {
+            request.addModification(new Modification(ModificationType.REPLACE, Attributes
+                    .emptyAttribute(afrom.getAttributeDescription())));
+        } else {
+            request.addModification(new Modification(ModificationType.DELETE, afrom));
+        }
+    }
+
+    private static void diffDeleteValues(final ModifyRequest request, final Attribute deletedValues) {
+        if (deletedValues != null && !deletedValues.isEmpty()) {
+            request.addModification(new Modification(ModificationType.DELETE, deletedValues));
+        }
+    }
+
+    private static boolean diffFirstValuesAreDifferent(final DiffOptions diffOptions,
+            final Attribute afrom, final Attribute ato) {
+        if (diffOptions.useExactMatching) {
+            return !afrom.firstValue().equals(ato.firstValue());
+        } else {
+            return !afrom.contains(ato.firstValue());
+        }
+    }
+
     private static void incrementAttribute(final Entry entry, final Attribute change)
             throws ErrorResultException {
         // First parse the change.
@@ -893,6 +1158,14 @@
         return entry;
     }
 
+    private static Entry toFilteredTreeMapEntry(final Entry entry, final DiffOptions options) {
+        if (entry instanceof TreeMapEntry) {
+            return options.filter(entry);
+        } else {
+            return new TreeMapEntry(options.filter(entry));
+        }
+    }
+
     // Prevent instantiation.
     private Entries() {
         // Nothing to do.
diff --git a/opendj-sdk/opendj-core/src/main/java/org/forgerock/opendj/ldap/requests/Requests.java b/opendj-sdk/opendj-core/src/main/java/org/forgerock/opendj/ldap/requests/Requests.java
index 238ce1f..39ddc63 100644
--- a/opendj-sdk/opendj-core/src/main/java/org/forgerock/opendj/ldap/requests/Requests.java
+++ b/opendj-sdk/opendj-core/src/main/java/org/forgerock/opendj/ldap/requests/Requests.java
@@ -893,9 +893,13 @@
      * Creates a new modify request containing a list of modifications which can
      * be used to transform {@code fromEntry} into entry {@code toEntry}.
      * <p>
-     * The modify request is reversible: it will contain only modifications of
-     * type {@link ModificationType#ADD ADD} and {@link ModificationType#DELETE
-     * DELETE}.
+     * The changes will be generated using a default set of
+     * {@link org.forgerock.opendj.ldap.Entries.DiffOptions options}. More
+     * specifically, only user attributes will be compared, attributes will be
+     * compared using their matching rules, and all generated changes will be
+     * reversible: it will contain only modifications of type
+     * {@link ModificationType#DELETE DELETE} then {@link ModificationType#ADD
+     * ADD}.
      * <p>
      * Finally, the modify request will use the distinguished name taken from
      * {@code fromEntry}. Moreover, this method will not check to see if both
@@ -907,12 +911,20 @@
      * ModifyRequest request = Entries.diffEntries(fromEntry, toEntry);
      * </pre>
      *
+     * Or:
+     *
+     * <pre>
+     * ModifyRequest request = Entries.diffEntries(fromEntry, toEntry, Entries.diffOptions());
+     * </pre>
+     *
      * @param fromEntry
      *            The source entry.
      * @param toEntry
      *            The destination entry.
      * @return A modify request containing a list of modifications which can be
      *         used to transform {@code fromEntry} into entry {@code toEntry}.
+     *         The returned request will always be non-{@code null} but may not
+     *         contain any modifications.
      * @throws NullPointerException
      *             If {@code fromEntry} or {@code toEntry} were {@code null}.
      * @see Entries#diffEntries(Entry, Entry)
diff --git a/opendj-sdk/opendj-core/src/test/java/org/forgerock/opendj/ldap/EntriesTestCase.java b/opendj-sdk/opendj-core/src/test/java/org/forgerock/opendj/ldap/EntriesTestCase.java
index aa92855..0126560 100644
--- a/opendj-sdk/opendj-core/src/test/java/org/forgerock/opendj/ldap/EntriesTestCase.java
+++ b/opendj-sdk/opendj-core/src/test/java/org/forgerock/opendj/ldap/EntriesTestCase.java
@@ -22,10 +22,14 @@
  *
  *
  *      Copyright 2009-2010 Sun Microsystems, Inc.
+ *      Portions copyright 2014 ForgeRock AS.
  */
 
 package org.forgerock.opendj.ldap;
 
+import static org.fest.assertions.Assertions.assertThat;
+import static org.forgerock.opendj.ldap.Entries.diffEntries;
+import static org.forgerock.opendj.ldap.Entries.diffOptions;
 import static org.testng.AssertJUnit.assertFalse;
 import static org.testng.AssertJUnit.assertTrue;
 
@@ -34,8 +38,6 @@
 import org.forgerock.opendj.ldap.requests.ModifyRequest;
 import org.forgerock.opendj.ldap.requests.Requests;
 import org.forgerock.opendj.ldap.schema.Schema;
-import org.testng.Assert;
-import org.testng.annotations.DataProvider;
 import org.testng.annotations.Test;
 
 /**
@@ -43,188 +45,442 @@
  */
 @SuppressWarnings("javadoc")
 public final class EntriesTestCase extends SdkTestCase {
-    /**
-     * Creates test data for {@link #testDiffEntries}.
-     *
-     * @return The test data.
-     */
-    @DataProvider(name = "createTestDiffEntriesData")
-    public Object[][] createTestDiffEntriesData() {
-        // @formatter:off
-        Entry empty = new LinkedHashMapEntry(
-            "dn: cn=test",
-            "objectClass: top",
-            "objectClass: test"
-        );
 
-        Entry from = new LinkedHashMapEntry(
-            "dn: cn=test",
-            "objectClass: top",
-            "objectClass: test",
-            "fromOnly: fromOnlyValue",
-            "bothSame: one",
-            "bothSame: two",
-            "bothSame: three",
-            "bothDifferentDeletes: common",
-            "bothDifferentDeletes: fromOnly1",
-            "bothDifferentDeletes: fromOnly2",
-            "bothDifferentAdds: common",
-            "bothDifferentAddsAndDeletes: common",
-            "bothDifferentAddsAndDeletes: fromOnly",
-            "bothDifferentReplace: fromOnly1",
-            "bothDifferentReplace: fromOnly2"
-        );
+    @Test
+    public void testContainsObjectClass() throws Exception {
+        Entry entry =
+                new LinkedHashMapEntry("dn: cn=test", "objectClass: top", "objectClass: person");
+        Schema schema = Schema.getDefaultSchema();
 
-        Entry to = new LinkedHashMapEntry(
-            "dn: cn=test",
-            "objectClass: top",
-            "objectClass: test",
-            "toOnly: toOnlyValue",
-            "bothSame: one",
-            "bothSame: two",
-            "bothSame: three",
-            "bothDifferentDeletes: common",
-            "bothDifferentAdds: common",
-            "bothDifferentAdds: toOnly1",
-            "bothDifferentAdds: toOnly2",
-            "bothDifferentAddsAndDeletes: common",
-            "bothDifferentAddsAndDeletes: toOnly",
-            "bothDifferentReplace: toOnly1",
-            "bothDifferentReplace: toOnly2"
-        );
-
-        ModifyRequest diffFromEmpty = Requests.newModifyRequest(
-            "dn: cn=test",
-            "changetype: modify",
-            "delete: bothDifferentAdds",
-            "bothDifferentAdds: common",
-            "-",
-            "delete: bothDifferentAddsAndDeletes",
-            "bothDifferentAddsAndDeletes: common",
-            "bothDifferentAddsAndDeletes: fromOnly",
-            "-",
-            "delete: bothDifferentDeletes",
-            "bothDifferentDeletes: common",
-            "bothDifferentDeletes: fromOnly1",
-            "bothDifferentDeletes: fromOnly2",
-            "-",
-            "delete: bothDifferentReplace",
-            "bothDifferentReplace: fromOnly1",
-            "bothDifferentReplace: fromOnly2",
-            "-",
-            "delete: bothSame",
-            "bothSame: one",
-            "bothSame: two",
-            "bothSame: three",
-            "-",
-            "delete: fromOnly",
-            "fromOnly: fromOnlyValue"
-        );
-
-        ModifyRequest diffEmptyTo = Requests.newModifyRequest(
-            "dn: cn=test",
-            "changetype: modify",
-            "add: bothDifferentAdds",
-            "bothDifferentAdds: common",
-            "bothDifferentAdds: toOnly1",
-            "bothDifferentAdds: toOnly2",
-            "-",
-            "add: bothDifferentAddsAndDeletes",
-            "bothDifferentAddsAndDeletes: common",
-            "bothDifferentAddsAndDeletes: toOnly",
-            "-",
-            "add: bothDifferentDeletes",
-            "bothDifferentDeletes: common",
-            "-",
-            "add: bothDifferentReplace",
-            "bothDifferentReplace: toOnly1",
-            "bothDifferentReplace: toOnly2",
-            "-",
-            "add: bothSame",
-            "bothSame: one",
-            "bothSame: two",
-            "bothSame: three",
-            "-",
-            "add: toOnly",
-            "toOnly: toOnlyValue"
-        );
-
-        ModifyRequest diffFromTo = Requests.newModifyRequest(
-            "dn: cn=test",
-            "changetype: modify",
-            "add: bothDifferentAdds",
-            "bothDifferentAdds: toOnly1",
-            "bothDifferentAdds: toOnly2",
-            "-",
-            "add: bothDifferentAddsAndDeletes",
-            "bothDifferentAddsAndDeletes: toOnly",
-            "-",
-            "delete: bothDifferentAddsAndDeletes",
-            "bothDifferentAddsAndDeletes: fromOnly",
-            "-",
-            "delete: bothDifferentDeletes",
-            "bothDifferentDeletes: fromOnly1",
-            "bothDifferentDeletes: fromOnly2",
-            "-",
-            "add: bothDifferentReplace",
-            "bothDifferentReplace: toOnly1",
-            "bothDifferentReplace: toOnly2",
-            "-",
-            "delete: bothDifferentReplace",
-            "bothDifferentReplace: fromOnly1",
-            "bothDifferentReplace: fromOnly2",
-            "-",
-            "delete: fromOnly",
-            "fromOnly: fromOnlyValue",
-            "-",
-            "add: toOnly",
-            "toOnly: toOnlyValue"
-        );
-
-        // From, to, diff.
-        return new Object[][] {
-            { from,  empty, diffFromEmpty },
-            { empty, to,    diffEmptyTo   },
-            { from,  to,    diffFromTo    }
-        };
-
-        // @formatter:on
+        assertTrue("should contain top", Entries.containsObjectClass(entry, schema
+                .getObjectClass("top")));
+        assertTrue("should contain person", Entries.containsObjectClass(entry, schema
+                .getObjectClass("person")));
+        assertFalse("should not contain country", Entries.containsObjectClass(entry, schema
+                .getObjectClass("country")));
     }
 
-    /**
-     * Tests {@link Entries#diffEntries(Entry, Entry)}.
-     *
-     * @param from
-     *            Source entry.
-     * @param to
-     *            Destination entry.
-     * @param expected
-     *            Expected modifications.
-     */
-    @Test(dataProvider = "createTestDiffEntriesData")
-    public void testDiffEntries(final Entry from, final Entry to, final ModifyRequest expected) {
-        ModifyRequest actual = Entries.diffEntries(from, to);
+    @Test
+    public void testDiffEntriesAddDeleteAddIntermediateAttribute() {
+        // @formatter:off
+        Entry from = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "sn: ignore");
+        Entry to = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "description: value",
+            "sn: ignore");
+        ModifyRequest expected = Requests.newModifyRequest(
+            "dn: cn=test",
+            "changetype: modify",
+            "add: description",
+            "description: value");
+        // @formatter:on
+        assertEquals(diffEntries(from, to), expected);
+    }
 
-        Assert.assertEquals(from.getName(), actual.getName());
-        Assert.assertEquals(actual.getModifications().size(), expected.getModifications().size());
+    @Test
+    public void testDiffEntriesAddDeleteAddTrailingAttributes() {
+        // @formatter:off
+        Entry from = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "cn: ignore");
+        Entry to = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "cn: ignore",
+            "description: value",
+            "sn: value");
+        ModifyRequest expected = Requests.newModifyRequest(
+            "dn: cn=test",
+            "changetype: modify",
+            "add: description",
+            "description: value",
+            "-",
+            "add: sn",
+            "sn: value");
+        // @formatter:on
+        assertEquals(diffEntries(from, to), expected);
+    }
+
+    @Test
+    public void testDiffEntriesAddDeleteDeleteIntermediateAttribute() {
+        // @formatter:off
+        Entry from = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "description: value",
+            "sn: ignore");
+        Entry to = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "sn: ignore");
+        ModifyRequest expected = Requests.newModifyRequest(
+            "dn: cn=test",
+            "changetype: modify",
+            "delete: description",
+            "description: value");
+        // @formatter:on
+        assertEquals(diffEntries(from, to), expected);
+    }
+
+    @Test
+    public void testDiffEntriesAddDeleteDeleteTrailingAttributes() {
+        // @formatter:off
+        Entry from = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "cn: ignore",
+            "description: value",
+            "sn: value");
+        Entry to = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "cn: ignore");
+        ModifyRequest expected = Requests.newModifyRequest(
+            "dn: cn=test",
+            "changetype: modify",
+            "delete: description",
+            "description: value",
+            "-",
+            "delete: sn",
+            "sn: value");
+        // @formatter:on
+        assertEquals(diffEntries(from, to), expected);
+    }
+
+    @Test
+    public void testDiffEntriesAddDeleteMultiValueAddSingleValue() {
+        // @formatter:off
+        Entry from = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "description: value1");
+        Entry to = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "description: value1",
+            "description: value2");
+        ModifyRequest expected = Requests.newModifyRequest(
+            "dn: cn=test",
+            "changetype: modify",
+            "add: description",
+            "description: value2");
+        // @formatter:on
+        assertEquals(diffEntries(from, to), expected);
+    }
+
+    @Test
+    public void testDiffEntriesAddDeleteMultiValueDeleteSingleValue() {
+        // @formatter:off
+        Entry from = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "description: value1",
+            "description: value2");
+        Entry to = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "description: value1");
+        ModifyRequest expected = Requests.newModifyRequest(
+            "dn: cn=test",
+            "changetype: modify",
+            "delete: description",
+            "description: value2");
+        // @formatter:on
+        assertEquals(diffEntries(from, to), expected);
+    }
+
+    @Test
+    public void testDiffEntriesAddDeleteMultiValueSameSizeDifferentValues() {
+        // @formatter:off
+        Entry from = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "description: value1",
+            "description: value2");
+        Entry to = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "description: VALUE2",
+            "description: VALUE3");
+        ModifyRequest expected = Requests.newModifyRequest(
+            "dn: cn=test",
+            "changetype: modify",
+            "delete: description",
+            "description: value1",
+            "-",
+            "add: description",
+            "description: VALUE3");
+        // @formatter:on
+        assertEquals(diffEntries(from, to), expected);
+    }
+
+    @Test
+    public void testDiffEntriesAddDeleteMultiValueSameSizeDifferentValuesExact() {
+        // @formatter:off
+        Entry from = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "description: value1",
+            "description: value2");
+        Entry to = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "description: VALUE2",
+            "description: VALUE3");
+        ModifyRequest expected = Requests.newModifyRequest(
+            "dn: cn=test",
+            "changetype: modify",
+            "delete: description",
+            "description: value1",
+            "description: value2",
+
+            "-",
+            "add: description",
+            "description: VALUE2",
+            "description: VALUE3");
+        // @formatter:on
+        assertEquals(diffEntries(from, to, diffOptions().useExactMatching()), expected);
+    }
+
+    @Test
+    public void testDiffEntriesAddDeleteSingleValue() {
+        // @formatter:off
+        Entry from = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "description: from");
+        Entry to = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "description: to");
+        ModifyRequest expected = Requests.newModifyRequest(
+            "dn: cn=test",
+            "changetype: modify",
+            "delete: description",
+            "description: from",
+            "-",
+            "add: description",
+            "description: to");
+        // @formatter:on
+        assertEquals(diffEntries(from, to), expected);
+    }
+
+    @Test
+    public void testDiffEntriesAddDeleteSingleValueExactMatch() {
+        // @formatter:off
+        Entry from = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "description: value");
+        Entry to = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "description: VALUE");
+        ModifyRequest expected = Requests.newModifyRequest(
+            "dn: cn=test",
+            "changetype: modify",
+            "delete: description",
+            "description: value",
+            "-",
+            "add: description",
+            "description: VALUE");
+        // @formatter:on
+        assertEquals(diffEntries(from, to, diffOptions().useExactMatching()), expected);
+    }
+
+    @Test
+    public void testDiffEntriesAddDeleteSingleValueNoChange() {
+        // @formatter:off
+        Entry from = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "description: value");
+        Entry to = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "description: VALUE");
+        ModifyRequest expected = Requests.newModifyRequest(
+            "dn: cn=test",
+            "changetype: modify");
+        // @formatter:on
+        assertEquals(diffEntries(from, to), expected);
+    }
+
+    @Test
+    public void testDiffEntriesReplaceAddTrailingAttributes() {
+        // @formatter:off
+        Entry from = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "cn: ignore");
+        Entry to = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "cn: ignore",
+            "description: value",
+            "sn: value");
+        ModifyRequest expected = Requests.newModifyRequest(
+            "dn: cn=test",
+            "changetype: modify",
+            "replace: description",
+            "description: value",
+            "-",
+            "replace: sn",
+            "sn: value");
+        // @formatter:on
+        assertEquals(diffEntries(from, to, diffOptions().alwaysReplaceAttributes()), expected);
+    }
+
+    @Test
+    public void testDiffEntriesReplaceDeleteTrailingAttributes() {
+        // @formatter:off
+        Entry from = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "cn: ignore",
+            "description: value",
+            "sn: value");
+        Entry to = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "cn: ignore");
+        ModifyRequest expected = Requests.newModifyRequest(
+            "dn: cn=test",
+            "changetype: modify",
+            "replace: description",
+            "-",
+            "replace: sn");
+        // @formatter:on
+        assertEquals(diffEntries(from, to, diffOptions().alwaysReplaceAttributes()), expected);
+    }
+
+    @Test
+    public void testDiffEntriesReplaceFilteredAttributes() {
+        // @formatter:off
+        Entry from = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "cn: from");
+        Entry to = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "cn: to",
+            "description: value",
+            "sn: value");
+        ModifyRequest expected = Requests.newModifyRequest(
+            "dn: cn=test",
+            "changetype: modify",
+            "replace: cn",
+            "cn: to",
+            "-",
+            "replace: sn",
+            "sn: value");
+        // @formatter:on
+        assertEquals(diffEntries(from, to, diffOptions().alwaysReplaceAttributes().attributes("cn", "sn")),
+                expected);
+    }
+
+    @Test
+    public void testDiffEntriesReplaceMultiValueChangeSize() {
+        // @formatter:off
+        Entry from = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "description: value1");
+        Entry to = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "description: value1",
+            "description: value2");
+        ModifyRequest expected = Requests.newModifyRequest(
+            "dn: cn=test",
+            "changetype: modify",
+            "replace: description",
+            "description: value1",
+            "description: value2");
+        // @formatter:on
+        assertEquals(diffEntries(from, to, diffOptions().alwaysReplaceAttributes()), expected);
+    }
+
+    @Test
+    public void testDiffEntriesReplaceMultiValueSameSize() {
+        // @formatter:off
+        Entry from = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "description: value1",
+            "description: value2");
+        Entry to = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "description: VALUE2",
+            "description: VALUE3");
+        ModifyRequest expected = Requests.newModifyRequest(
+            "dn: cn=test",
+            "changetype: modify",
+            "replace: description",
+            "description: VALUE2",
+            "description: VALUE3");
+        // @formatter:on
+        assertEquals(diffEntries(from, to, diffOptions().alwaysReplaceAttributes()), expected);
+    }
+
+    @Test
+    public void testDiffEntriesReplaceMultiValueSameSizeExact() {
+        // @formatter:off
+        Entry from = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "description: value1",
+            "description: value2");
+        Entry to = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "description: value2",
+            "description: value3");
+        ModifyRequest expected = Requests.newModifyRequest(
+            "dn: cn=test",
+            "changetype: modify",
+            "replace: description",
+            "description: value2",
+            "description: value3");
+        // @formatter:on
+        assertEquals(diffEntries(from, to, diffOptions().alwaysReplaceAttributes().useExactMatching()), expected);
+    }
+
+    @Test
+    public void testDiffEntriesReplaceSingleValue() {
+        // @formatter:off
+        Entry from = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "description: from");
+        Entry to = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "description: to");
+        ModifyRequest expected = Requests.newModifyRequest(
+            "dn: cn=test",
+            "changetype: modify",
+            "replace: description",
+            "description: to");
+        // @formatter:on
+        assertEquals(diffEntries(from, to, diffOptions().alwaysReplaceAttributes()), expected);
+    }
+
+    @Test
+    public void testDiffEntriesReplaceSingleValueExactMatch() {
+        // @formatter:off
+        Entry from = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "description: value");
+        Entry to = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "description: VALUE");
+        ModifyRequest expected = Requests.newModifyRequest(
+            "dn: cn=test",
+            "changetype: modify",
+            "replace: description",
+            "description: VALUE");
+        // @formatter:on
+        assertEquals(diffEntries(from, to, diffOptions().alwaysReplaceAttributes().useExactMatching()), expected);
+    }
+
+    @Test
+    public void testDiffEntriesReplaceSingleValueNoChange() {
+        // @formatter:off
+        Entry from = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "description: value");
+        Entry to = new LinkedHashMapEntry(
+            "dn: cn=test",
+            "description: VALUE");
+        ModifyRequest expected = Requests.newModifyRequest(
+            "dn: cn=test",
+            "changetype: modify");
+        // @formatter:on
+        assertEquals(diffEntries(from, to, diffOptions().alwaysReplaceAttributes()), expected);
+    }
+
+    private void assertEquals(ModifyRequest actual, ModifyRequest expected) {
+        assertThat((Object) actual.getName()).isEqualTo(expected.getName());
+        assertThat(actual.getModifications()).hasSize(expected.getModifications().size());
         Iterator<Modification> i1 = actual.getModifications().iterator();
         Iterator<Modification> i2 = expected.getModifications().iterator();
         while (i1.hasNext()) {
             Modification m1 = i1.next();
             Modification m2 = i2.next();
-
-            Assert.assertEquals(m1.getModificationType(), m2.getModificationType());
-            Assert.assertEquals(m1.getAttribute(), m2.getAttribute());
+            assertThat(m1.getModificationType()).isEqualTo(m2.getModificationType());
+            assertThat(m1.getAttribute()).isEqualTo(m2.getAttribute());
         }
     }
-
-    @Test
-    public void testContainsObjectClass() throws Exception {
-        Entry entry = new LinkedHashMapEntry("dn: cn=test", "objectClass: top", "objectClass: person");
-        Schema schema = Schema.getDefaultSchema();
-
-        assertTrue("should contain top", Entries.containsObjectClass(entry, schema.getObjectClass("top")));
-        assertTrue("should contain person", Entries.containsObjectClass(entry, schema.getObjectClass("person")));
-        assertFalse("should not contain country", Entries.containsObjectClass(entry, schema.getObjectClass("country")));
-    }
 }

--
Gitblit v1.10.0