You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@directory.apache.org by bd...@apache.org on 2023/01/12 17:07:08 UTC

[directory-scimple] branch patch_phase1_hacking updated (fa7088a7 -> 19140892)

This is an automated email from the ASF dual-hosted git repository.

bdemers pushed a change to branch patch_phase1_hacking
in repository https://gitbox.apache.org/repos/asf/directory-scimple.git


 discard fa7088a7 Create Patch Operation handlers for each type of OP
     add d7f8dc08 Bump mockito-core from 4.10.0 to 4.11.0
     add 3f1a88e8 Merge pull request #216 from apache/dependabot/maven/org.mockito-mockito-core-4.11.0
     add 9eead64b Bump mockito-junit-jupiter from 4.10.0 to 4.11.0
     add 648e6bdd Merge pull request #215 from apache/dependabot/maven/org.mockito-mockito-junit-jupiter-4.11.0
     add b118e561 Bump dependency-check-maven from 7.4.1 to 7.4.3
     add 25851296 Merge pull request #214 from apache/dependabot/maven/org.owasp-dependency-check-maven-7.4.3
     add 3c091687 Bump version.spring-boot from 3.0.0 to 3.0.1
     add 10670c09 Merge pull request #212 from apache/dependabot/maven/version.spring-boot-3.0.1
     add a40a8a6c Log uncaught client errors at DEBUG level, server errors at WARN
     add 20781411 Merge pull request #223 from apache/log-spam-mapper
     add 48bd7632 Bump swagger-annotations from 2.2.7 to 2.2.8
     add 9b9812f1 Merge pull request #220 from apache/dependabot/maven/io.swagger.core.v3-swagger-annotations-2.2.8
     add 4ea1bb18 Bump assertj-core from 3.23.1 to 3.24.1
     add 4f2e9697 Merge pull request #219 from apache/dependabot/maven/org.assertj-assertj-core-3.24.1
     add df6cd530 Bump dependency-check-maven from 7.4.3 to 7.4.4
     add a2c9c3b8 Merge pull request #218 from apache/dependabot/maven/org.owasp-dependency-check-maven-7.4.4
     add bd74b4d1 Fix typo in SchemaExtensionConfiguration
     add c7b82e77 Merge pull request #224 from kjthorpe18/SchemaExtensionConfiguration-typo
     add 33af8324 Phone number parsing is non-strict by default
     add bcab498a Merge pull request #222 from apache/phone-lax-parsing
     add a52af73f Use UUIDs for examples
     add 600eb503 Merge pull request #225 from apache/simplify-examples
     add 8cd50b2f Merge branch 'develop' into patch_phase1
     new 19140892 Create Patch Operation handlers for each type of OP

This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version.  This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:

 * -- * -- B -- O -- O -- O   (fa7088a7)
            \
             N -- N -- N   refs/heads/patch_phase1_hacking (19140892)

You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.

Any revisions marked "omit" are not gone; other references still
refer to them.  Any revisions marked "discard" are gone forever.

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 pom.xml                                            | 10 ++---
 .../directory/scim/core/schema/SchemaRegistry.java |  4 +-
 .../scim/core/schema/SchemaRegistryTest.java       |  2 +-
 .../jersey/service/InMemoryGroupService.java       | 17 ++++----
 .../jersey/service/InMemoryUserService.java        | 12 ++---
 .../memory/service/InMemoryGroupService.java       | 17 ++++----
 .../memory/service/InMemoryUserService.java        | 12 ++---
 .../spring/ScimpleSpringBootApplication.java       |  9 ++--
 .../spring/service/InMemoryGroupService.java       | 17 ++++----
 .../spring/service/InMemoryUserService.java        | 12 ++---
 .../server/exception/BaseScimExceptionMapper.java  |  7 ++-
 scim-spec/scim-spec-protocol/pom.xml               |  2 +-
 .../directory/scim/spec/resources/PhoneNumber.java | 51 ++++++++++++----------
 .../directory/scim/spec/schema/ResourceType.java   |  4 +-
 .../scim/spec/phonenumber/PhoneNumberTest.java     | 20 +++++----
 15 files changed, 96 insertions(+), 100 deletions(-)


[directory-scimple] 01/01: Create Patch Operation handlers for each type of OP

Posted by bd...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

bdemers pushed a commit to branch patch_phase1_hacking
in repository https://gitbox.apache.org/repos/asf/directory-scimple.git

commit 19140892f7b178d7a6e12efcbc471195efab1c57
Author: Brian Demers <bd...@apache.org>
AuthorDate: Fri Jan 6 18:38:22 2023 -0500

    Create Patch Operation handlers for each type of OP
    
    - FitlerExpressions.inMemory() now returns Predicate<Object> instead of KeyedResource
    - Added tests for complex patch operations based on SCIM spec
---
 .../scim/core/repository/PatchHandlerImpl.java     | 146 +++++++---
 .../scim/core/repository/PatchHandlerTest.java     | 307 ++++++++++++++++++++-
 .../scim/spec/filter/FilterExpressions.java        |   3 +-
 3 files changed, 417 insertions(+), 39 deletions(-)

diff --git a/scim-core/src/main/java/org/apache/directory/scim/core/repository/PatchHandlerImpl.java b/scim-core/src/main/java/org/apache/directory/scim/core/repository/PatchHandlerImpl.java
index c623c6d5..1253c02a 100644
--- a/scim-core/src/main/java/org/apache/directory/scim/core/repository/PatchHandlerImpl.java
+++ b/scim-core/src/main/java/org/apache/directory/scim/core/repository/PatchHandlerImpl.java
@@ -29,7 +29,6 @@ import org.apache.directory.scim.spec.filter.*;
 import org.apache.directory.scim.spec.filter.attribute.AttributeReference;
 import org.apache.directory.scim.spec.patch.PatchOperation;
 import org.apache.directory.scim.spec.patch.PatchOperationPath;
-import org.apache.directory.scim.spec.resources.KeyedResource;
 import org.apache.directory.scim.spec.resources.ScimResource;
 import org.apache.directory.scim.spec.schema.Schema;
 import org.apache.directory.scim.spec.schema.Schema.Attribute;
@@ -41,7 +40,6 @@ import java.util.stream.Collectors;
 @SuppressWarnings("unchecked")
 @Slf4j
 public class PatchHandlerImpl implements PatchHandler {
-
   private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {
   };
 
@@ -49,6 +47,12 @@ public class PatchHandlerImpl implements PatchHandler {
 
   private final SchemaRegistry schemaRegistry;
 
+  private final Map<PatchOperation.Type, PatchOperationHandler> patchOperationHandlers = Map.of(
+    PatchOperation.Type.ADD, new AddOperationHandler(),
+    PatchOperation.Type.REPLACE, new ReplaceOperationHandler(),
+    PatchOperation.Type.REMOVE, new RemoveOperationHandler()
+  );
+
   public PatchHandlerImpl(SchemaRegistry schemaRegistry) {
     this.schemaRegistry = schemaRegistry;
   }
@@ -61,9 +65,10 @@ public class PatchHandlerImpl implements PatchHandler {
       throw new IllegalArgumentException("patchOperations is null. Cannot apply patch.");
     }
 
-    T updatedScimResource;
-    updatedScimResource = SerializationUtils.clone(original);
+    T updatedScimResource = SerializationUtils.clone(original);
     for (PatchOperation patchOperation : patchOperations) {
+
+      // if path is null, break up the value into multiple patch operations
       if (patchOperation.getPath() == null) {
         if (!(patchOperation.getValue() instanceof Map)) {
           throw new IllegalArgumentException("Cannot apply patch. value is required");
@@ -93,37 +98,15 @@ public class PatchHandlerImpl implements PatchHandler {
     final ValuePathExpression valuePathExpression = valuePathExpression(patchOperation);
     final AttributeReference attributeReference = attributeReference(valuePathExpression);
 
-    Schema baseSchema = this.schemaRegistry.getSchema(source.getBaseUrn());
-    Attribute attribute = baseSchema.getAttribute(attributeReference.getAttributeName());
+    Schema schema = this.schemaRegistry.getSchema(source.getBaseUrn());
+    Attribute attribute = schema.getAttribute(attributeReference.getAttributeName());
 
-    Object attributeObject = attribute.getAccessor().get(source);
-    if (attributeObject == null && patchOperation.getOperation().equals(PatchOperation.Type.REPLACE)) {
-      throw new IllegalArgumentException("Cannot apply patch replace on missing property: " + attribute.getName());
-    }
+    PatchOperationHandler patchOperationHandler = patchOperationHandlers.get(patchOperation.getOperation());
 
-    if (valuePathExpression.getAttributeExpression() != null && attributeObject instanceof Collection<?>) {
-      // apply expression filter
-      Collection<Object> items = (Collection<Object>) attributeObject;
-      Collection<Object> updatedCollection = items.stream().map(item -> {
-        KeyedResource keyedResource = (KeyedResource) item;
-        Map<String, Object> keyedResourceAsMap = objectAsMap(item);
-        Predicate<KeyedResource> pred = FilterExpressions.inMemory(valuePathExpression.getAttributeExpression(), baseSchema);
-        if (pred.test(keyedResource)) {
-          String subAttributeName = valuePathExpression.getAttributePath().getSubAttributeName();
-          if (keyedResourceAsMap.get(subAttributeName) == null && patchOperation.getOperation().equals(PatchOperation.Type.REPLACE)) {
-            throw new IllegalArgumentException("Cannot apply patch replace on missing property: " + valuePathExpression.toFilter());
-          }
-          keyedResourceAsMap.put(subAttributeName, patchOperation.getValue());
-          return keyedResourceAsMap;
-        } else {
-          // filter does not apply
-          return item;
-        }
-      }).collect(Collectors.toList());
-      sourceAsMap.put(attribute.getName(), updatedCollection);
+    if (attribute.isMultiValued()) {
+      patchOperationHandler.applyMultiValue(source, sourceAsMap, schema, attribute, valuePathExpression, patchOperation.getValue());
     } else {
-      // no filter expression
-      sourceAsMap.put(attribute.getName(), patchOperation.getValue());
+      patchOperationHandler.applySingleValue(sourceAsMap, attribute, patchOperation.getValue());
     }
     return (T) mapAsScimResource(sourceAsMap, source.getClass());
   }
@@ -156,4 +139,103 @@ public class PatchHandlerImpl implements PatchHandler {
       .orElseThrow(() -> new IllegalArgumentException("Patch operation must have an expression with a valid attribute path"));
   }
 
+  private interface PatchOperationHandler {
+    <T extends ScimResource> void applySingleValue(Map<String, Object> sourceAsMap, Attribute attribute, Object value);
+    <T extends ScimResource> void applyMultiValue(final T source, Map<String, Object> sourceAsMap, Schema schema, Attribute attribute, ValuePathExpression valuePathExpression, Object value);
+  }
+
+  private class AddOperationHandler implements PatchOperationHandler {
+    @Override
+    public <T extends ScimResource> void applySingleValue(Map<String, Object> sourceAsMap, Attribute attribute, Object value) {
+      String attributeName = attribute.getName();
+      if (sourceAsMap.get(attributeName) == null) {
+        sourceAsMap.put(attributeName, value);
+      } else {
+        log.debug("Resource '{}' with attribute '{}' already contains value and cannot be patched with an ADD operation", sourceAsMap.get("id"), attribute.getUrn());
+      }
+    }
+
+    @Override
+    public <T extends ScimResource> void applyMultiValue(T source, Map<String, Object> sourceAsMap, Schema schema, Attribute attribute, ValuePathExpression valuePathExpression, Object value) {
+
+      String subAttributeName = valuePathExpression.getAttributePath().getAttributeName();
+      Collection<Object> items = (Collection<Object>) sourceAsMap.get(subAttributeName);
+      if (items == null) {
+        items = new ArrayList<>();
+        sourceAsMap.put(subAttributeName, items);
+      }
+
+      if (value instanceof Collection) {
+        items.addAll((Collection<Object>) value);
+      } else {
+        items.add(value);
+      }
+    }
+  }
+
+  private class ReplaceOperationHandler implements PatchOperationHandler {
+
+    @Override
+    public <T extends ScimResource> void applySingleValue(Map<String, Object> sourceAsMap, Attribute attribute, Object value) {
+      sourceAsMap.put(attribute.getName(), value);
+    }
+
+    @Override
+    public <T extends ScimResource> void applyMultiValue(T source, Map<String, Object> sourceAsMap, Schema schema, Attribute attribute, ValuePathExpression valuePathExpression, Object value) {
+
+      if (valuePathExpression.getAttributeExpression() != null) {
+
+        // apply expression filter
+        Collection<Object> items = attribute.getAccessor().get(source);
+        Predicate<Object> pred = FilterExpressions.inMemory(valuePathExpression.getAttributeExpression(), schema);
+
+        Collection<Object> updatedCollection = items.stream()
+          .map(item -> {
+            Map<String, Object> resourceAsMap = objectAsMap(item);
+            // find items that need to be updated
+            if (pred.test(item)) {
+              String subAttributeName = valuePathExpression.getAttributePath().getSubAttributeName();
+              if (resourceAsMap.get(subAttributeName) == null) {
+                resourceAsMap = (Map<String, Object>) value;
+              } else {
+                resourceAsMap.put(subAttributeName, value);
+              }
+              return resourceAsMap;
+            } else {
+              // filter does not apply
+              return item;
+            }
+          }).collect(Collectors.toList());
+        sourceAsMap.put(attribute.getName(), updatedCollection);
+
+      // replace collection
+      } else {
+        sourceAsMap.put(attribute.getName(), value);
+      }
+    }
+  }
+
+  private class RemoveOperationHandler implements PatchOperationHandler {
+
+    @Override
+    public <T extends ScimResource> void applySingleValue(Map<String, Object> sourceAsMap, Attribute attribute, Object value) {
+      sourceAsMap.remove(attribute.getName());
+    }
+
+    @Override
+    public <T extends ScimResource> void applyMultiValue(T source, Map<String, Object> sourceAsMap, Schema schema, Attribute attribute, ValuePathExpression valuePathExpression, Object value) {
+
+      if (valuePathExpression.getAttributeExpression() != null) {
+        Collection<Object> items = attribute.getAccessor().get(source);
+        Predicate<Object> pred = FilterExpressions.inMemory(valuePathExpression.getAttributeExpression(), schema);
+
+        Collection<Object> updatedCollection = items.stream()
+          .filter(item -> !(pred.test(item)))
+          .collect(Collectors.toList());
+        sourceAsMap.put(attribute.getName(), updatedCollection);
+      } else {
+        sourceAsMap.remove(attribute.getName());
+      }
+    }
+  }
 }
diff --git a/scim-core/src/test/java/org/apache/directory/scim/core/repository/PatchHandlerTest.java b/scim-core/src/test/java/org/apache/directory/scim/core/repository/PatchHandlerTest.java
index 508eb59f..8fbe8024 100644
--- a/scim-core/src/test/java/org/apache/directory/scim/core/repository/PatchHandlerTest.java
+++ b/scim-core/src/test/java/org/apache/directory/scim/core/repository/PatchHandlerTest.java
@@ -23,15 +23,18 @@ import org.apache.directory.scim.core.schema.SchemaRegistry;
 import org.apache.directory.scim.spec.filter.FilterParseException;
 import org.apache.directory.scim.spec.patch.PatchOperation;
 import org.apache.directory.scim.spec.patch.PatchOperationPath;
+import org.apache.directory.scim.spec.resources.Email;
 import org.apache.directory.scim.spec.resources.ScimUser;
 import org.apache.directory.scim.spec.schema.Schemas;
 import org.junit.jupiter.api.Test;
 
 import static java.util.Map.entry;
+import static org.apache.directory.scim.spec.patch.PatchOperation.Type.*;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
+import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
 
@@ -42,7 +45,7 @@ public class PatchHandlerTest {
   @Test
   public void applyReplaceUserName() throws FilterParseException {
     PatchOperation op = new PatchOperation();
-    op.setOperation(PatchOperation.Type.REPLACE);
+    op.setOperation(REPLACE);
     op.setPath(new PatchOperationPath("userName"));
     op.setValue("testUser_updated");
     ScimUser updatedUser = performPatch(op);
@@ -52,17 +55,311 @@ public class PatchHandlerTest {
   @Test
   public void applyReplaceUserNameWithMappedValue() {
     PatchOperation op = new PatchOperation();
-    op.setOperation(PatchOperation.Type.REPLACE);
+    op.setOperation(REPLACE);
     op.setValue(Map.ofEntries(entry("userName", "testUser_updated")));
     ScimUser updatedUser = performPatch(op);
     assertThat(updatedUser.getUserName()).isEqualTo("testUser_updated");
   }
 
-  private ScimUser performPatch(PatchOperation op) {
+  @Test
+  public void applyWithFilterExpression() throws FilterParseException {
+    PatchOperation op = new PatchOperation();
+    op.setOperation(REPLACE);
+    op.setPath(new PatchOperationPath("emails[type EQ \"home\"].value"));
+    op.setValue("new-home@example.com");
+    ScimUser updatedUser = performPatch(op);
+    List<Email> emails = updatedUser.getEmails();
+    assertThat(emails).isEqualTo(List.of(
+      new Email()
+        .setPrimary(true)
+        .setType("work")
+        .setValue("work@example.com"),
+      new Email()
+        .setType("home")
+        .setValue("new-home@example.com") // updated email
+    ));
+  }
+
+  @Test
+  public void replaceItem() throws FilterParseException {
+    PatchOperation op = new PatchOperation();
+    op.setOperation(REPLACE);
+    op.setPath(new PatchOperationPath("emails[type EQ \"home\"]"));
+    op.setValue(Map.of(
+      "type", "other",
+      "value", "other@example.com"
+    ));
+
+    ScimUser updatedUser = performPatch(op);
+    List<Email> emails = updatedUser.getEmails();
+    assertThat(emails).isEqualTo(List.of(
+      new Email()
+        .setPrimary(true)
+        .setType("work")
+        .setValue("work@example.com"),
+      new Email()
+        .setType("other")
+        .setValue("other@example.com")
+    ));
+  }
+
+  @Test
+  public void replaceMultipleAttributes() {
+    PatchOperation op = new PatchOperation();
+    op.setOperation(REPLACE);
+    op.setValue(Map.of(
+      "emails", List.of(
+        Map.of(
+          "type", "home",
+          "value", "first@example.com"),
+        Map.of(
+          "type", "work",
+          "value", "second@example.com",
+          "primary", true)
+      ),
+      "nickName", "Babs"
+    ));
+
+    ScimUser updatedUser = performPatch(op);
+    List<Email> emails = updatedUser.getEmails();
+    assertThat(emails).isEqualTo(List.of(
+      new Email()
+        .setType("home")
+        .setValue("first@example.com"),
+      new Email()
+        .setPrimary(true)
+        .setType("work")
+        .setValue("second@example.com")
+    ));
+    assertThat(updatedUser.getNickName()).isEqualTo("Babs");
+  }
+
+  @Test
+  public void replaceCollection() throws FilterParseException {
+    PatchOperation op = new PatchOperation();
+    op.setOperation(REPLACE);
+    op.setPath(new PatchOperationPath("emails"));
+    op.setValue(List.of(
+      Map.of(
+        "value", "first@example.com",
+        "type", "home"),
+      Map.of(
+        "primary", true,
+        "value", "second@example.com",
+        "type", "work")
+    ));
+    ScimUser updatedUser = performPatch(op);
+    List<Email> emails = updatedUser.getEmails();
+    assertThat(emails).isEqualTo(List.of(
+      new Email()
+        .setType("home")
+        .setValue("first@example.com"),
+      new Email()
+        .setPrimary(true)
+        .setType("work")
+        .setValue("second@example.com")
+    ));
+  }
+
+  @Test
+  public void deleteItemWithFilter() throws FilterParseException {
+    PatchOperation op = new PatchOperation();
+    op.setOperation(REMOVE);
+    op.setPath(new PatchOperationPath("emails[type EQ \"home\"]"));
+    ScimUser updatedUser = performPatch(op);
+    List<Email> emails = updatedUser.getEmails();
+    assertThat(emails).isEqualTo(List.of(
+      new Email()
+        .setPrimary(true)
+        .setType("work")
+        .setValue("work@example.com")
+    ));
+  }
+
+  @Test
+  public void deleteAttributeWithPath() throws FilterParseException {
+    PatchOperation op = new PatchOperation();
+    op.setOperation(REMOVE);
+    op.setPath(new PatchOperationPath("nickName"));
+    ScimUser updatedUser = performPatch(op);
+    assertThat(updatedUser.getNickName()).isNull();
+  }
+
+  @Test
+  public void deleteCollectionWithPath() throws FilterParseException {
+    PatchOperation op = new PatchOperation();
+    op.setOperation(REMOVE);
+    op.setPath(new PatchOperationPath("emails"));
+    ScimUser updatedUser = performPatch(op);
+    assertThat(updatedUser.getEmails()).isNull();
+  }
+
+  @Test
+  public void deleteItemWithComplexFilter() throws FilterParseException {
+    PatchOperation op = new PatchOperation();
+    op.setOperation(REMOVE);
+    op.setPath(new PatchOperationPath("emails[type EQ \"home\"] and value ew \"example.com\""));
+    ScimUser updatedUser = performPatch(op);
+    assertThat(updatedUser.getEmails()).isEqualTo(List.of(
+      new Email()
+        .setPrimary(true)
+        .setType("work")
+        .setValue("work@example.com")
+    ));
+  }
+
+  @Test
+  public void addAttribute() throws FilterParseException {
+    PatchOperation op = new PatchOperation();
+    op.setOperation(ADD);
+    op.setPath(new PatchOperationPath("profileUrl"));
+    op.setValue("https://profile.example.com");
+
+    ScimUser updatedUser = performPatch(op);
+    assertThat(updatedUser).isEqualTo(new ScimUser()
+      .setUserName("testUser")
+      .setNickName("tester")
+      .setProfileUrl("https://profile.example.com")
+      .setEmails(List.of(
+        new Email()
+          .setPrimary(true)
+          .setType("work")
+          .setValue("work@example.com"),
+        new Email()
+          .setType("home")
+          .setValue("home@example.com")
+      )));
+  }
+
+  @Test
+  public void addItem() throws FilterParseException {
+    PatchOperation op = new PatchOperation();
+    op.setOperation(ADD);
+    op.setPath(new PatchOperationPath("emails"));
+    op.setValue(Map.of(
+      "type", "other",
+      "value", "other@example.com"));
+
+    ScimUser updatedUser = performPatch(op);
+    List<Email> emails = updatedUser.getEmails();
+    assertThat(emails).isEqualTo(List.of(
+        new Email()
+          .setPrimary(true)
+          .setType("work")
+          .setValue("work@example.com"),
+        new Email()
+          .setType("home")
+          .setValue("home@example.com"),
+        new Email()
+          .setType("other")
+          .setValue("other@example.com")
+      ));
+  }
+
+  @Test
+  public void addMultipleProperties() throws FilterParseException {
+    // From Section 3.5.2.1 Add Operation of SCIM Protocol RFC
+    PatchOperation op = new PatchOperation();
+    op.setOperation(ADD);
+    op.setValue(Map.of(
+      "emails", Map.of(
+        "value", "babs@example.com",
+        "type", "other"),
+      "profileUrl", "https://profile.example.com"
+    ));
+
+    ScimUser updatedUser = performPatch(op);
+    List<Email> emails = updatedUser.getEmails();
+    assertThat(emails).isEqualTo(List.of(
+      new Email()
+        .setPrimary(true)
+        .setType("work")
+        .setValue("work@example.com"),
+      new Email()
+        .setType("home")
+        .setValue("home@example.com"),
+      new Email()
+        .setType("other")
+        .setValue("babs@example.com")
+    ));
+    assertThat(updatedUser.getProfileUrl()).isEqualTo("https://profile.example.com");
+  }
+
+  @Test
+  public void multiplePatchOperations() throws FilterParseException {
+    PatchOperation opRm = new PatchOperation();
+    opRm.setOperation(REMOVE);
+    opRm.setPath(new PatchOperationPath("emails[type EQ \"home\"]"));
+
+    PatchOperation opAdd = new PatchOperation();
+    opAdd.setOperation(ADD);
+    opAdd.setPath(new PatchOperationPath("emails"));
+    opAdd.setValue(Map.of(
+      "value", "babs@example.com",
+      "type", "other")
+    );
+
+    ScimUser updatedUser = performPatch(opRm, opAdd);
+    List<Email> emails = updatedUser.getEmails();
+    assertThat(emails).isEqualTo(List.of(
+      new Email()
+        .setPrimary(true)
+        .setType("work")
+        .setValue("work@example.com"),
+      new Email()
+        .setType("other")
+        .setValue("babs@example.com")
+    ));
+  }
+
+  @Test
+  public void replaceCollectionWithMultipleOps() throws FilterParseException {
+    PatchOperation opRm = new PatchOperation();
+    opRm.setOperation(REMOVE);
+    opRm.setPath(new PatchOperationPath("emails"));
+
+    PatchOperation opAdd = new PatchOperation();
+    opAdd.setOperation(ADD);
+    opAdd.setPath(new PatchOperationPath("emails"));
+    opAdd.setValue(List.of(
+      Map.of(
+        "value", "first@example.com",
+        "type", "home"),
+      Map.of(
+        "primary", true,
+        "value", "second@example.com",
+        "type", "work")
+    ));
+
+    ScimUser updatedUser = performPatch(opRm, opAdd);
+    List<Email> emails = updatedUser.getEmails();
+    assertThat(emails).isEqualTo(List.of(
+      new Email()
+        .setType("home")
+        .setValue("first@example.com"),
+      new Email()
+        .setPrimary(true)
+        .setType("work")
+        .setValue("second@example.com")
+    ));
+  }
+
+  private ScimUser performPatch(PatchOperation... op) {
     when(mockSchemaRegistry.getSchema(ScimUser.SCHEMA_URI)).thenReturn(Schemas.schemaFor(ScimUser.class));
-    PatchHandlerImpl patchHandler = new PatchHandlerImpl(mockSchemaRegistry);
+    PatchHandler patchHandler = new PatchHandlerImpl(mockSchemaRegistry);
     ScimUser user = new ScimUser();
     user.setUserName("testUser");
-    return patchHandler.apply(user, List.of(op));
+    user.setNickName("tester");
+    user.setEmails(List.of(
+      new Email()
+        .setPrimary(true)
+        .setType("work")
+        .setValue("work@example.com"),
+      new Email()
+        .setType("home")
+        .setValue("home@example.com")
+    ));
+
+    return patchHandler.apply(user, Arrays.asList(op));
   }
 }
diff --git a/scim-spec/scim-spec-schema/src/main/java/org/apache/directory/scim/spec/filter/FilterExpressions.java b/scim-spec/scim-spec-schema/src/main/java/org/apache/directory/scim/spec/filter/FilterExpressions.java
index e9d2300f..531788dc 100644
--- a/scim-spec/scim-spec-schema/src/main/java/org/apache/directory/scim/spec/filter/FilterExpressions.java
+++ b/scim-spec/scim-spec-schema/src/main/java/org/apache/directory/scim/spec/filter/FilterExpressions.java
@@ -1,6 +1,5 @@
 package org.apache.directory.scim.spec.filter;
 
-import org.apache.directory.scim.spec.resources.KeyedResource;
 import org.apache.directory.scim.spec.resources.ScimResource;
 import org.apache.directory.scim.spec.schema.Schema;
 
@@ -29,7 +28,7 @@ public final class FilterExpressions {
     return InMemoryScimFilterMatcher.toPredicate(expression, schema);
   }
 
-  public static Predicate<KeyedResource> inMemory(FilterExpression expression, Schema schema) {
+  public static Predicate<Object> inMemory(FilterExpression expression, Schema schema) {
     return InMemoryScimFilterMatcher.toPredicate(expression, schema);
   }
 }