Skip to content

How to use the FilterNode

Pascal Knüppel edited this page Mar 21, 2024 · 4 revisions

SCIM defines a filter-language that is used to filter for resources. This chapter will explain how the FilterNode-implementation that is passed to the ResourceHandler can be utilized.

The FilterNode is a built in a tree-like structure that determines how the expression should be resolved and it has exactly 5 relevant usecase implementations:

  1. AndExpressionNode
  2. OrExpressionNode
  3. NotExpressionNode
  4. AttributePathRoot
  5. AttirubteExpressionLeaf

First of all we will discuss the AttributeExpressionLeaf and the AttributePathRoot.

AttributeExpressionLeaf

This node represents a simple expression like the following:

userName eq "chuck"
externalId sw "1"
name.givenName ew "lo"
meta.created lt "2022-10-17T01:07:00Z"
name pr
emails.display pr
etc.

AttributePathRoot

An AttributePathRoot is used on complex types in combination with a nested filter expression:

name[givenName eq "larry" and familyName sw "ha"]
emails[value eq "max@mustermann.de"]

The AttributePathRoot will then contain a child-element:

FilterNode currentNode = filterNode;
if (filterNode instanceof AttributePathRoot)
{
  AttributePathRoot attributePathRoot = (AttributePathRoot)filterNode;
  currentNode = attributePathRoot.getChild();
}

and this child will then be one of the other 4 implementations listed above [AndExpressionNode, OrExpressionNode, NotExpressionNode, AttirubteExpressionLeaf].

AndExpressionNode

Represents a simple and expression:

userName sw "u" and userName ew "a"
name[givenName sw "u" and familyName ew "d"] // (will be an AttributeExpressionPath but with an AndExpressionNode as child)

OrExpressionNode

Represents a simple or expression:

userName sw "u" or userName ew "a"
name[givenName sw "u" or familyName ew "d"] // (will be an AttributeExpressionPath but with an OrExpressionNode as child)

NotExpressionNode

Any expression but negated. The NotExpressionNode contains only a right-node that might be any other node-type:

not(userName sw "a")
not(name[givenName sw "u" or familyName ew "a"])

here is tiny part of a real world implementation that is running in production:

/**
 * parses the SCIM filter node into a JPQL where-clause representation. The filternode is built in a logical
 * tree-like structure that makes it very easy to translate it into a valid JPQL where-expression
 *
 * @param filterNode the SCIM filter expression in a tree-structure
 * @return the JPQL where filter-expression
 */
protected String getFilterExpression(FilterNode filterNode)
{
  if (filterNode == null)
  {
    return "";
  }

  FilterNode currentNode = filterNode;
  if (filterNode instanceof AttributePathRoot)
  {
    AttributePathRoot attributePathRoot = (AttributePathRoot)filterNode;
    currentNode = attributePathRoot.getChild();
  }

  if (currentNode instanceof AndExpressionNode)
  {
    AndExpressionNode andExpressionNode = (AndExpressionNode)currentNode;
    return "(" + getFilterExpression(andExpressionNode.getLeftNode()) + " AND "
           + getFilterExpression(andExpressionNode.getRightNode()) + ")";
  }
  else if (currentNode instanceof OrExpressionNode)
  {
    OrExpressionNode orExpressionNode = (OrExpressionNode)currentNode;
    return "(" + getFilterExpression(orExpressionNode.getLeftNode()) + " OR "
           + getFilterExpression(orExpressionNode.getRightNode()) + ")";
  }
  else if (currentNode instanceof NotExpressionNode)
  {
    NotExpressionNode notExpressionNode = (NotExpressionNode)currentNode;
    return "NOT (" + getFilterExpression(notExpressionNode.getRightNode()) + ")";
  }
  else
  {
    AttributeExpressionLeaf attributeExpressionLeaf = (AttributeExpressionLeaf)currentNode;
    boolean isCaseExact = attributeExpressionLeaf.getSchemaAttribute().isCaseExact();
    final String fullResourceName = attributeExpressionLeaf.getSchemaAttribute().getFullResourceName();
    
    String expression = ...;
    ...
    final String comparisonExpression = resolveComparator(jpqlAttribute, attributeExpressionLeaf);
    ...
    return expression;
  }
}

...

/**
 * translates the current attribute comparison into its JPQL representation and adds parameters instead of
 * direct values into the JPQL query. The parameters will be added into the {@link #parameterResolverList}
 * which will then later be added as Query-parameter with JPA in order to prevent SQL-injections.
 *
 * @param jpqlAttribute
 * @param attributeExpressionLeaf the SCIM attribute-filter-expression that should resolve to something like
 *
 *          <pre>
 *            u.userName = :aac0c224621adc44a29f6ddd619b5b12a6
 *          </pre>
 *          <p>
 *          where the string "ac0c224621adc44a29f6ddd619b5b12a6" represents the parameter name
 * @return the JPQL attribute comparison string
 */
private String resolveComparator(String jpqlAttribute, AttributeExpressionLeaf attributeExpressionLeaf)
{
  SchemaAttribute schemaAttribute = attributeExpressionLeaf.getSchemaAttribute();
  final Comparator comparator = attributeExpressionLeaf.getComparator();

  final String parameterName = "a" + UUID.randomUUID().toString().replaceAll("-", "");

  boolean isCaseExact = schemaAttribute.isCaseExact();
  final String jpqlParameter = toCaseCheckedValue(attributeExpressionLeaf.getType(),
                                                  isCaseExact,
                                                  ":" + parameterName);

  switch (comparator)
  {
    case EQ: // equals
      setParameterValue(attributeExpressionLeaf, parameterName);
      return jpqlAttribute + " = " + jpqlParameter;
    case NE: // not equals
      setParameterValue(attributeExpressionLeaf, parameterName);
      return String.format("%1$s != %2$s or %1$s is null", jpqlAttribute, jpqlParameter);
    case CO: // contains
      setParameterValue(attributeExpressionLeaf, parameterName);
      return jpqlAttribute + " like concat('%', " + jpqlParameter + ", '%')";
    case SW: // start with
      setParameterValue(attributeExpressionLeaf, parameterName);
      return jpqlAttribute + " like concat(" + jpqlParameter + ", '%')";
    case EW: // ends with
      setParameterValue(attributeExpressionLeaf, parameterName);
      return jpqlAttribute + " like concat('%', " + jpqlParameter + ")";
    case GE: // greater equals
      setParameterValue(attributeExpressionLeaf, parameterName);
      return jpqlAttribute + " >= " + jpqlParameter;
    case LE: // lower equals
      setParameterValue(attributeExpressionLeaf, parameterName);
      return jpqlAttribute + " <= " + jpqlParameter;
    case GT: // greater than
      setParameterValue(attributeExpressionLeaf, parameterName);
      return jpqlAttribute + " > " + jpqlParameter;
    case LT: // lower than
      setParameterValue(attributeExpressionLeaf, parameterName);
      return jpqlAttribute + " < " + jpqlParameter;
    default: // is "PR" = present
      return jpqlAttribute + " is not null";
  }
}

/**
 * if a SCIM attribute is defined as not case exact the database attributes will be converted into lower case
 * to enable a case-insensitive search
 *
 * @param type lower case makes only sense for string-type values
 * @param isCaseExact if the attribute should be compared case-insensitive or not
 * @param jpqlParameterName the name of the jpql-parameter e.g. "u.userName"
 * @return the unchanged parameter if a case-sensitive check is required and the parameter surrounded by
 *         "lower(...)" if the check should be case-insensitive
 */
private String toCaseCheckedValue(Type type, boolean isCaseExact, String jpqlParameterName)
{
  final boolean isNotStringType = !Type.STRING.equals(type) && !Type.REFERENCE.equals(type);

  if (isCaseExact || isNotStringType)
  {
    return jpqlParameterName;
  }
  else
  {
    return String.format("lower(%s)", jpqlParameterName);
  }
}