Add in memory Filter matcher

This implementation can be used for demo purposes.  Actual production implementations will need to translate a FilterExpresion into a query language.
diff --git a/scim-spec/scim-spec-protocol/src/main/java/org/apache/directory/scim/spec/protocol/filter/FilterExpression.java b/scim-spec/scim-spec-protocol/src/main/java/org/apache/directory/scim/spec/protocol/filter/FilterExpression.java
index 0e95ae6..2fd1b2d 100644
--- a/scim-spec/scim-spec-protocol/src/main/java/org/apache/directory/scim/spec/protocol/filter/FilterExpression.java
+++ b/scim-spec/scim-spec-protocol/src/main/java/org/apache/directory/scim/spec/protocol/filter/FilterExpression.java
@@ -19,6 +19,8 @@
 
 package org.apache.directory.scim.spec.protocol.filter;
 
+import java.util.function.Function;
+
 public interface FilterExpression {
   
   String toFilter();
@@ -26,4 +28,8 @@
   void setAttributePath(String urn, String parentAttributeName);
 
   String toUnqualifiedFilter();
+
+  default <U> U map(Function<? super FilterExpression, U> mapper) {
+    return mapper.apply(this);
+  }
 }
diff --git a/scim-spec/scim-spec-protocol/src/main/java/org/apache/directory/scim/spec/protocol/filter/FilterExpressions.java b/scim-spec/scim-spec-protocol/src/main/java/org/apache/directory/scim/spec/protocol/filter/FilterExpressions.java
new file mode 100644
index 0000000..3356355
--- /dev/null
+++ b/scim-spec/scim-spec-protocol/src/main/java/org/apache/directory/scim/spec/protocol/filter/FilterExpressions.java
@@ -0,0 +1,31 @@
+package org.apache.directory.scim.spec.protocol.filter;
+
+import org.apache.directory.scim.spec.protocol.search.Filter;
+import org.apache.directory.scim.spec.resources.ScimResource;
+import org.apache.directory.scim.spec.schema.Schema;
+
+import java.util.function.Predicate;
+
+
+public final class FilterExpressions {
+
+  private FilterExpressions() {}
+
+  /**
+   * Converts a filter into a Predicate used for in-memory evaluation. Production implementations should translate the Filter into
+   * the appropriate query language.
+   * <p>
+   *
+   * <b>This implementation should only be used for demo proposes.</b>
+   */
+  public static Predicate<ScimResource> inMemory(Filter filter, Schema schema) {
+    if (filter == null) {
+      return x -> true;
+    }
+    FilterExpression expression = filter.getExpression();
+    if (expression == null) {
+      return x -> true;
+    }
+    return InMemoryScimFilterMatcher.toPredicate(expression, schema);
+  }
+}
diff --git a/scim-spec/scim-spec-protocol/src/main/java/org/apache/directory/scim/spec/protocol/filter/InMemoryScimFilterMatcher.java b/scim-spec/scim-spec-protocol/src/main/java/org/apache/directory/scim/spec/protocol/filter/InMemoryScimFilterMatcher.java
new file mode 100644
index 0000000..5faec26
--- /dev/null
+++ b/scim-spec/scim-spec-protocol/src/main/java/org/apache/directory/scim/spec/protocol/filter/InMemoryScimFilterMatcher.java
@@ -0,0 +1,300 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+
+ * http://www.apache.org/licenses/LICENSE-2.0
+
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.directory.scim.spec.protocol.filter;
+
+import org.apache.directory.scim.spec.exception.ScimResourceInvalidException;
+import org.apache.directory.scim.spec.protocol.attribute.AttributeReference;
+import org.apache.directory.scim.spec.schema.AttributeContainer;
+import org.apache.directory.scim.spec.schema.Schema;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.function.Predicate;
+
+final class InMemoryScimFilterMatcher {
+
+  private static final Logger log = LoggerFactory.getLogger(InMemoryScimFilterMatcher.class);
+
+  private InMemoryScimFilterMatcher() {}
+
+  /**
+   * Converts a FilterExpression to a Predicate that can be used to later test a ScimResource (or child attribute).
+   * <p>
+   * The {@code attributeContainer} should be a top level Schema for the initial call, but recursive calls to this method
+   * will pass the AttributeContainer (a child-schema).  For example, if given a
+   * {@link org.apache.directory.scim.spec.resources.ScimUser ScimUser} expression of {@code emails.value co "example.org"},
+   * The initial call to this method pass the ScimUser Schema object.  When this method is called recursively, the
+   * {@link org.apache.directory.scim.spec.resources.Email Email}'s AttributeContainer will be used.
+   *
+   * @param expression the FilterExpression to build a predicate from.
+   * @param attributeContainer the Schema (or other sub attribute container) to build the predicate from.
+   * @return A Predicate that can be used to later test a ScimResource (or child attribute).
+   */
+  static <R> Predicate<R> toPredicate(FilterExpression expression, AttributeContainer attributeContainer) {
+
+    // attribute EQ "something"
+    if (expression instanceof AttributeComparisonExpression) {
+      return new AttributeComparisonPredicate<>((AttributeComparisonExpression) expression, attributeContainer);
+    }
+    // (attribute EQ "something") AND (otherAttribute EQ "something else")
+    else if (expression instanceof LogicalExpression) {
+      LogicalExpression logicalExpression = (LogicalExpression) expression;
+      Predicate<R> left = toPredicate(logicalExpression.getLeft(), attributeContainer);
+      Predicate<R> right = toPredicate(logicalExpression.getRight(), attributeContainer);
+
+      LogicalOperator op = logicalExpression.getOperator();
+      if (op == LogicalOperator.AND) {
+        return left.and(right);
+      } else {
+        return left.or(right);
+      }
+    }
+    // NOT (attribute EQ "something")
+    else if (expression instanceof GroupExpression) {
+      GroupExpression groupExpression = (GroupExpression) expression;
+      Predicate<R> predicate = toPredicate(groupExpression.getFilterExpression(), attributeContainer);
+      return groupExpression.isNot()
+        ? predicate.negate()
+        : predicate;
+    }
+    // attribute PR
+    else if (expression instanceof AttributePresentExpression) {
+      return new AttributePresentPredicate<>((AttributePresentExpression) expression, attributeContainer);
+    }
+    // addresses[type EQ "work"]
+    else if (expression instanceof ValuePathExpression) {
+      ValuePathExpression valuePathExpression = (ValuePathExpression) expression;
+      Predicate<Object> nestedPredicate = toPredicate(valuePathExpression.getAttributeExpression(), attribute(attributeContainer, valuePathExpression.getAttributePath()));
+      return new ValuePathPredicate<>(valuePathExpression, attributeContainer, nestedPredicate);
+    }
+    log.debug("Unsupported Filter expression of type: " + expression.getClass());
+    return scimResource -> false;
+  }
+
+
+  private static boolean isStringExpression(Schema.Attribute attribute, Object compareValue) {
+    if (attribute.getType() != Schema.Attribute.Type.STRING) {
+      log.debug("Invalid query, non String value for expression : " + attribute.getType());
+      return false;
+    }
+    if (compareValue == null) {
+      log.debug("Invalid query, empty value for expression : " + attribute.getType());
+      return false;
+    }
+    return true;
+  }
+
+  private static Schema.Attribute attribute(AttributeContainer attributeContainer, AttributeReference attributeReference) {
+    String baseAttributeName = attributeReference.getAttributeName();
+
+    Schema.Attribute schemaAttribute = attributeContainer.getAttribute(baseAttributeName);
+    if (schemaAttribute == null) {
+      log.warn("Invalid filter: attribute '" + baseAttributeName + "' is NOT a valid SCIM attribute.");
+      return null;
+    }
+
+    String subAttribute = attributeReference.getSubAttributeName();
+    if (subAttribute != null) {
+      schemaAttribute = schemaAttribute.getAttribute(subAttribute);
+      if (schemaAttribute == null) {
+        log.warn("Invalid filter: attribute '" + attributeReference.getFullyQualifiedAttributeName() + "' is NOT a valid SCIM attribute.");
+        return null;
+      }
+    }
+
+    // filter out fields like passwords that should not be queried against
+    if (schemaAttribute.getReturned() == Schema.Attribute.Returned.NEVER) {
+      log.warn("Invalid filter: attribute '" + attributeReference.getAttributeName() + "' is not filterable.");
+      return null;
+    }
+
+    return schemaAttribute;
+  }
+
+  private static abstract class AbstractAttributePredicate<T extends FilterExpression, R> implements Predicate<R> {
+
+    private static final Logger log = LoggerFactory.getLogger(InMemoryScimFilterMatcher.class);
+
+    final T expression;
+
+    final AttributeContainer attributeContainer;
+
+    final AttributeReference attributeReference;
+
+    private AbstractAttributePredicate(T expression, AttributeContainer attributeContainer, AttributeReference attributeReference) {
+      this.expression = expression;
+      this.attributeContainer = attributeContainer;
+      this.attributeReference = attributeReference;
+    }
+
+    protected abstract boolean test(Schema.Attribute attribute, Object actualValue);
+
+    @Override
+    public boolean test(R actual) {
+
+      try {
+        // get and validate attribute
+        Schema.Attribute resolvedAttribute = attribute(attributeContainer, attributeReference);
+        if (resolvedAttribute != null) {
+
+          // now walk the attribute path again to get the accessor and value
+          Schema.Attribute schemaAttribute = attributeContainer.getAttribute(attributeReference.getAttributeName());
+          actual = schemaAttribute.getAccessor().get(actual);
+
+          // if the attribute has a sub-level, continue on
+          String subAttribute = attributeReference.getSubAttributeName();
+          if (subAttribute != null) {
+            schemaAttribute = schemaAttribute.getAttribute(subAttribute);
+            actual = schemaAttribute.getAccessor().get(actual);
+          }
+          return test(schemaAttribute, actual);
+        }
+      } catch (Exception e) {
+        // The SCIM spec states to ignore the query instead of rejecting it - rfc7644 - 3.4.2
+        log.debug("Invalid SCIM filter received", e);
+      }
+
+      return false;
+    }
+  }
+
+  private static class ValuePathPredicate<R> extends AbstractAttributePredicate<ValuePathExpression, R> {
+
+    final private Predicate<Object> nestedPredicate;
+
+    ValuePathPredicate(ValuePathExpression expression, AttributeContainer attributeContainer, Predicate<Object> nestedPredicate) {
+      super(expression, attributeContainer, expression.getAttributePath());
+      this.nestedPredicate = nestedPredicate;
+    }
+
+    @Override
+    protected boolean test(Schema.Attribute attribute, Object actualValue) {
+      // actualValue must be a Collection
+      if (attribute.isMultiValued()) {
+        return ((Collection<?>) actualValue).stream().anyMatch(nestedPredicate);
+      }
+
+      return false;
+    }
+  }
+
+  private static class AttributeComparisonPredicate<R> extends AbstractAttributePredicate<AttributeComparisonExpression, R> {
+
+    private AttributeComparisonPredicate(AttributeComparisonExpression expression, AttributeContainer attributeContainer) {
+      super(expression, attributeContainer, expression.getAttributePath());
+    }
+
+    @Override
+    protected boolean test(Schema.Attribute attribute, Object actualValue) {
+
+      if (actualValue == null) {
+        return false;
+      }
+
+      if (attribute.isMultiValued()) {
+        log.warn("Invalid expression, target is collection");
+        return false;
+      }
+
+      CompareOperator op = expression.getOperation();
+      Object compareValue = expression.getCompareValue();
+
+      if (op == CompareOperator.EQ) {
+        return compareValue.equals(actualValue);
+      }
+      if (op == CompareOperator.NE) {
+        return !compareValue.equals(actualValue);
+      }
+      if (op == CompareOperator.SW) {
+        return isStringExpression(attribute, compareValue)
+          && actualValue.toString().startsWith(compareValue.toString());
+      }
+      if (op == CompareOperator.EW) {
+        return isStringExpression(attribute, compareValue)
+          && actualValue.toString().endsWith(compareValue.toString());
+      }
+      if (op == CompareOperator.CO) {
+        return isStringExpression(attribute, compareValue)
+          && actualValue.toString().contains(compareValue.toString());
+      }
+
+      if (actualValue instanceof Comparable) {
+        Comparable actual = (Comparable) actualValue;
+        Comparable compare = (Comparable) compareValue;
+        return CompareOperatorPredicate.naturalOrder(op, compare).test(actual);
+      }
+
+      throw new ScimResourceInvalidException("Unsupported operation in filter: " + op.name());
+    }
+  }
+
+  private static class CompareOperatorPredicate<T> implements Predicate<T> {
+    private final CompareOperator op;
+
+    private final Comparator<T> comparator;
+
+    private final T comparedValue;
+
+
+    private CompareOperatorPredicate(CompareOperator op, T comparedValue, Comparator<T> comparator) {
+      this.op = op;
+      this.comparator = comparator;
+      this.comparedValue = comparedValue;
+    }
+
+    @Override
+    public boolean test(T actualValue) {
+
+      int compareResult = comparator.compare(actualValue, comparedValue);
+
+      if (op == CompareOperator.LT) {
+        return compareResult < 0;
+      } else if (op == CompareOperator.GT) {
+        return compareResult > 0;
+      } else if (op == CompareOperator.LE) {
+        return compareResult <= 0;
+      } else if (op == CompareOperator.GE) {
+        return compareResult >= 0;
+      }
+      return false;
+    }
+
+    static <T extends Comparable<T>> CompareOperatorPredicate<T> naturalOrder(CompareOperator op, T comparedValue) {
+      return new CompareOperatorPredicate<>(op, comparedValue, Comparator.naturalOrder());
+    }
+  }
+
+  private static class AttributePresentPredicate<R> extends AbstractAttributePredicate<AttributePresentExpression, R> {
+    private AttributePresentPredicate(AttributePresentExpression expression, AttributeContainer attributeContainer) {
+      super(expression, attributeContainer, expression.getAttributePath());
+    }
+
+    @Override
+    protected boolean test(Schema.Attribute attribute, Object actualValue) {
+      if (attribute.isMultiValued()) {
+        log.debug("Invalid expression, target is collection");
+        return false;
+      }
+      return actualValue != null;
+    }
+  }
+}
diff --git a/scim-spec/scim-spec-protocol/src/test/java/org/apache/directory/scim/spec/protocol/LuckyNumberExtension.java b/scim-spec/scim-spec-protocol/src/test/java/org/apache/directory/scim/spec/protocol/LuckyNumberExtension.java
new file mode 100644
index 0000000..482cad8
--- /dev/null
+++ b/scim-spec/scim-spec-protocol/src/test/java/org/apache/directory/scim/spec/protocol/LuckyNumberExtension.java
@@ -0,0 +1,61 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements.  See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership.  The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License.  You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied.  See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.spec.protocol;
+
+import jakarta.xml.bind.annotation.XmlAccessType;
+import jakarta.xml.bind.annotation.XmlAccessorType;
+import jakarta.xml.bind.annotation.XmlElement;
+import jakarta.xml.bind.annotation.XmlRootElement;
+import lombok.Data;
+import org.apache.directory.scim.spec.annotation.ScimAttribute;
+import org.apache.directory.scim.spec.annotation.ScimExtensionType;
+import org.apache.directory.scim.spec.resources.ScimExtension;
+import org.apache.directory.scim.spec.schema.Schema;
+
+/**
+ * Allows a User's lucky number to be passed as part of the User's entry via
+ * the SCIM protocol.
+ * 
+ * @author Chris Harm &lt;crh5255@psu.edu&gt;
+ */
+@XmlRootElement( name = "LuckyNumberExtension", namespace = "http://www.psu.edu/schemas/psu-scim" )
+@XmlAccessorType(XmlAccessType.NONE)
+@Data
+@ScimExtensionType(id = LuckyNumberExtension.SCHEMA_URN, description="Lucky Numbers", name="LuckyNumbers", required=true)
+public class LuckyNumberExtension implements ScimExtension {
+  
+  public static final String  SCHEMA_URN = "urn:mem:params:scim:schemas:extension:LuckyNumberExtension";
+
+  @ScimAttribute(returned=Schema.Attribute.Returned.DEFAULT, required=true)
+  @XmlElement
+  private long luckyNumber;
+  
+  /**
+   * Provides the URN associated with this extension which, as defined by the
+   * SCIM specification is the extension's unique identifier.
+   * 
+   * @return The extension's URN.
+   */
+  @Override
+  public String getUrn() {
+    return SCHEMA_URN;
+  }
+
+}
diff --git a/scim-spec/scim-spec-protocol/src/test/java/org/apache/directory/scim/spec/protocol/filter/InMemoryScimFilterMatcherTest.java b/scim-spec/scim-spec-protocol/src/test/java/org/apache/directory/scim/spec/protocol/filter/InMemoryScimFilterMatcherTest.java
new file mode 100644
index 0000000..87b7681
--- /dev/null
+++ b/scim-spec/scim-spec-protocol/src/test/java/org/apache/directory/scim/spec/protocol/filter/InMemoryScimFilterMatcherTest.java
@@ -0,0 +1,236 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+
+ * http://www.apache.org/licenses/LICENSE-2.0
+
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.directory.scim.spec.protocol.filter;
+
+import org.apache.directory.scim.spec.protocol.LuckyNumberExtension;
+import org.apache.directory.scim.spec.protocol.search.Filter;
+import org.apache.directory.scim.spec.resources.*;
+import org.apache.directory.scim.spec.schema.Meta;
+import org.apache.directory.scim.spec.schema.Schemas;
+import org.assertj.core.api.AbstractAssert;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+public class InMemoryScimFilterMatcherTest {
+
+  private final static ScimUser USER1 = user("user1", "User", "One")
+        .setNickName("one")
+        .setAddresses(List.of(
+          new Address()
+            .setType("home")
+            .setPrimary(true)
+            .setStreetAddress("742 Evergreen Terrace")
+            .setRegion("Springfield")
+            .setLocality("Unknown")
+            .setPostalCode("012345")
+            .setCountry("USA"),
+          new Address()
+            .setType("work")
+            .setPrimary(true)
+            .setStreetAddress("101 Reactor Way")
+            .setRegion("Springfield")
+            .setLocality("Unknown")
+            .setPostalCode("012345")
+            .setCountry("USA")
+        )); static {
+            USER1.addExtension(new LuckyNumberExtension().setLuckyNumber(111));
+        }
+
+  private final static ScimUser USER2 = user("user2", "User", "Two")
+        .setAddresses(List.of(
+          new Address()
+            .setType("home")
+            .setPrimary(true)
+            .setStreetAddress("11234 Slumvillage Pass")
+            .setRegion("Burfork")
+            .setLocality("CA")
+            .setPostalCode("221134")
+            .setCountry("USA")
+        )); static {
+            USER2.addExtension(new LuckyNumberExtension().setLuckyNumber(8));
+        }
+
+  @Test
+  public void userNameMatch() {
+    FilterAssert.assertThat(FilterBuilder.create().equalTo("userName", "user1"))
+      .matches(USER1)
+      .notMatches(USER2);
+  }
+
+  @Test
+  public void familyNameMatches() {
+    FilterAssert.assertThat(FilterBuilder.create().equalTo("name.familyName", "Two"))
+      .matches(USER2)
+      .notMatches(USER1);
+  }
+
+  @Test
+  public void startsWithMatches() {
+    FilterAssert.assertThat(FilterBuilder.create().startsWith("name.familyName", "Tw"))
+      .matches(USER2)
+      .notMatches(USER1);
+  }
+
+  @Test
+  public void endsWithMatches() {
+    FilterAssert.assertThat(FilterBuilder.create().endsWith("name.familyName", "wo"))
+      .matches(USER2)
+      .notMatches(USER1);
+  }
+
+  @Test
+  public void containsMatches() {
+    FilterAssert.assertThat(FilterBuilder.create().contains("name.familyName", "w"))
+      .matches(USER2)
+      .notMatches(USER1);
+  }
+
+  @Test
+  public void givenAndFamilyNameMatches() {
+
+    FilterBuilder filter = FilterBuilder.create()
+      .equalTo("name.givenName", "User")
+      .and(builder -> builder.equalTo("name.familyName", "Two"));
+
+    FilterAssert.assertThat(filter)
+      .matches(USER2)
+      .notMatches(USER1);
+  }
+
+  @Test
+  public void familyNameOrMatches() {
+    FilterBuilder filter = FilterBuilder.create()
+      .equalTo("name.familyName", "One")
+      .or(builder -> builder.equalTo("name.familyName", "Two"));
+
+    FilterAssert.assertThat(filter)
+      .matches(USER2)
+      .matches(USER1);
+  }
+
+  @Test
+  public void noMatchPassword() {
+    FilterAssert.assertThat(FilterBuilder.create().equalTo("password", "super-secret"))
+      .notMatches(USER1);
+  }
+
+  @Test
+  public void invertUserNameMatches() {
+    FilterAssert.assertThat(FilterBuilder.create().not(filter -> filter.equalTo("userName", "user1")))
+      .notMatches(USER1)
+      .matches(USER2);
+  }
+
+  @Test
+  public void presentAttributeMatches() {
+    FilterAssert.assertThat(FilterBuilder.create().present("nickName"))
+      .matches(USER1)
+      .notMatches(USER2);
+  }
+
+  @Test
+  public void valuePathExpressionMatches() {
+    FilterAssert.assertThat(FilterBuilder.create().attributeHas("addresses", filter -> filter.equalTo("type", "work")))
+      .matches(USER1)
+      .notMatches(USER2);
+  }
+
+//  @Test
+//  public void extensionValueMatches() {
+//    assertThat(FilterBuilder.create().equalTo("luckyNumber", 111))
+//      .matches(USER1)
+//      .notMatches(USER2);
+//  }
+
+  @Test
+  public void metaMatches() {
+    FilterAssert.assertThat(FilterBuilder.create().lessThan("meta.lastModified", LocalDateTime.now()))
+      .matches(USER1);
+
+    FilterAssert.assertThat(FilterBuilder.create().greaterThan("meta.lastModified", LocalDateTime.now().minusYears(1)))
+      .matches(USER1);
+
+    FilterAssert.assertThat(FilterBuilder.create().greaterThanOrEquals("meta.lastModified", USER1.getMeta().getLastModified()))
+      .matches(USER1);
+
+    FilterAssert.assertThat(FilterBuilder.create().lessThanOrEquals("meta.lastModified", USER1.getMeta().getLastModified()))
+      .matches(USER1);
+
+    FilterAssert.assertThat(FilterBuilder.create().equalTo("meta.lastModified", USER1.getMeta().getLastModified()))
+      .matches(USER1);
+  }
+
+  static class FilterAssert extends AbstractAssert<FilterAssert, Filter> {
+
+    protected FilterAssert(Filter actual) {
+      super(actual, FilterAssert.class);
+    }
+
+    public FilterAssert matches(ScimUser user) {
+      isNotNull();
+      if (!FilterExpressions.inMemory(actual, Schemas.schemaFor(ScimUser.class)).test(user)) {
+        failWithMessage("Expected filter '%s' to match user '%s'", actual.toString(), user);
+      }
+      return this;
+    }
+
+    public FilterAssert notMatches(ScimUser user) {
+      isNotNull();
+      if (FilterExpressions.inMemory(actual, Schemas.schemaFor(ScimUser.class)).test(user)) {
+        failWithMessage("Expected filter '%s' to NOT match user '%s'", actual.toString(), user);
+      }
+      return this;
+    }
+
+    public static FilterAssert assertThat(Filter actual) {
+      return new FilterAssert(actual);
+    }
+
+    public static FilterAssert assertThat(FilterBuilder actual) {
+      return new FilterAssert(actual.build());
+    }
+  }
+
+  private static ScimUser user(String username, String givenName, String familyName) {
+    ScimUser user = new ScimUser()
+      .setUserName(username)
+      .setPassword("super-secret")
+      .setActive(true)
+      .setName(new Name()
+        .setGivenName(givenName)
+        .setFamilyName(familyName)
+      )
+      .setEmails(List.of(
+        new Email()
+          .setType("work")
+          .setPrimary(true)
+          .setValue(username + "@example.com"),
+        new Email()
+          .setType("personal")
+          .setValue(givenName + "." + familyName + "@example.com")
+      ));
+
+    user.setMeta(new Meta().setLastModified(LocalDateTime.now()));
+
+    return user;
+  }
+}