Making properties filterable

A filterable property is a property that can be used to narrow down the results returned from a request to retrieve a resource collection. Many properties are filterable in the base configuration, but there might come a time when you need to add a custom filter, either to extend the base configuration or when adding a custom entity. There are two ways to add a custom filter, depending on the type of property.

  • Schema files. For properties directly on a data model entity, you can make the property filterable by updating the appropriate schema file.

  • Resource files. Properties with values that are either generated at run-time or derived from multiple data model entities can be made filterable by updating the resource file of the collection entity on which you’re filtering. (Note that this applies only when you are filtering a query-backed collection.)

Keep in mind that if a base configuration property is filterable, you cannot make it unfilterable.

Performance considerations

Before getting into the details of how to add filters, it’s a good idea to think about the size of the collection you’re filtering on. Filtering on very large collections can come with performance costs. Here are some suggestions for minimizing or avoiding this issue.

Add database indexes. Filtering on a property that’s indexed in the database is much more efficient than filtering on a property that is not indexed. If you’re adding a filter to a collection that is or could potentially be very large, the best option for avoiding system problems is to index the property. See Configuration for more information on adding indexes.

However, in some cases indexing alone doesn’t solve the problem. You must take additional steps to be certain that your filter works in such as way that the index will be leveraged by the database.

Limit the operators that can be used with the filter. When you update the schema to specify a property as filterable, the filter is automatically assigned all possible filter operators, such as starts with (sw) and greater than (gt). However, these operators will likely not be able to leverage the index at the database level. Filtering on large collections aught to be limited to allow only the equals (eq) operator. To enforce this limitation, you must create the filter using resource files rather than the schema file. This will enable you to specify which operators are allowed on the property when you filter.

Make the filter case sensitive. By default, filtering is not case sensitive. However, case-insensitive searches will likely not be able to leverage the index at the database level. This means that even if you’ve indexed a property and limited filter operators to only eq, your filter can still cause serious performance issues. You must take the additional step of making the filter case-sensitive to ensure the index is used, thus avoiding most performance issues. As with limiting operators, making filters case-sensitive requires you to use resource files rather than the schema to define your filter.

Query-backed collections vs stream-backed collections

The way in which you make a property filterable depends not only on the type of property, but also on the type of the collection. Collections can be query-backed or stream-backed.

  • Query-backed collections extend RestQueryCollectionResource.

  • Stream-backed collections extend RestStreamCollectionResource.

  • Resources that have both stream-backed and query-backed collections extend RestDualCollectionResource.

For all collection types, use the schema file to make a property filterable when the property exists directly on the data model entity.

For query-backed collections, use resource files to filter on a property that is derived at run time, derived from a join of multiple data model entities, or when you want to limit the filter operators.

For stream-backed collections, always use the schema files. If you want to filter based on a property that’s accessed through a join of data model entities, create an enhancement property with the join, then add that property to the schema file (as well as the mapping file).

Note: See Gosu Reference for more information on enhancements. See Syntax for schema configuration files for information on adding properties to mapping files.

For dual collections, perform the required actions for both query-backed and stream-backed. For example, if you create a filter based on a property that requires a join between multiple data model entities, you need to update the resource file (for query-backed) and create an enhancement and update the schema file (for stream-backed).

Adding filters through the schema file

Properties that are directly on a data model entity (or created through an enhancement for stream-backed collections) can easily be made filterable by updating the schema file associated with the API to which the entity belongs. For example, to make a property on the User data model entity filterable, you update the admin_ext-1.0.schema.json file.

Suppose you’ve created a CustomEntity_Ext entity with an ExpirationDate field, and you want to filter on that field. Within the schema file, you need to set the entity’s filterable property within the x-gw-extensions object to true:

"definitions": {
  "CustomEntityExt": {
    ...
      "properties": {
	    ...
	    "expirationDate": {
	      "x-gw-extensions": {
	        "filterable": true
	      }
	    }

Now when retrieving a collection of CustomEntity_Ext instances, the caller application can opt to filter the collection based on the expiration date using a call such as this:

GET /common/v1/customentity-exts?filter=expirationDate:gt:2020-05-11T07::00::00.000Z

Adding filters through resource files

Some properties cannot be made filterable by simply updating the schema file, either because their values are calculated at run-time and not stored in the database, or their values are the result of a join on multiple datasets. In these cases, updating the schema file won’t make the property filterable. Instead, you need to create a new filter class and instantiate it from the resource file associated with the collection on which you want to filter. (This applies only to query-backed collections.)

As an example, suppose you want to filter users based on their employee number. You would need to use the /admin/v1/users endpoint to retrieve the collection of users, then apply a filter to that collection. However, although the Users collection returns an employeeNumber (in the default configuration), that number does not exist on the User data model entity itself. Instead, EmployeeNumber is being pulled from the UserContact data model entity, which User references through a foreign key to the UserContact#ID. So in order to create a filter, you need to do the same thing: query for EmployeeNumber in UserContact by matching the User#Contact field to the UserContact#ID field.

Here’s an example of how to do that.

Create a new class

package gw.rest.ext.cc.admin.v1.user

uses gw.api.database.IQueryBuilder
uses gw.api.database.InOperation
uses gw.api.database.Queries
uses gw.api.database.Relop
uses gw.api.filters.IQueryFilter
uses gw.api.filters.StandardQueryFilter
uses gw.api.modules.rest.framework.v1.query.filters.QueryFilterOp
uses gw.api.modules.rest.framework.v1.query.filters.RestQueryFilter

class EmployeeNumberFilter_Ext extends RestQueryFilter {

   override function createQueryFilter(propertyName : String, queryFilterOp : QueryFilterOp, parseValue : Object) : IQueryFilter {

 return new StandardQueryFilter("employeeNumber_Ext", \q -> {
   var empQuery = Queries.createQuery(entity.UserContact).compare(UserContact#EmployeeNumber, Relop.Equals, parseValue)
   q.subselect(User#Contact, InOperation.CompareIn, empQuery, UserContact#ID)
 })
}

   override property get AllowedOperators() : Set<QueryFilterOp> {

     return {
         QueryFilterOp.eq

     }
   }

   override function parseValue(op : QueryFilterOp, employeeNumber : String) : Object {
     if (employeeNumber == "null" && op.getAllowNulls()) {
       return null;
     }

     return employeeNumber
   }

}

Walking through the example a step at a time, here’s how it works.

The first thing you need to do is create a new class that extends RestQueryFilter.

class EmployeeNumberFilter_Ext extends RestQueryFilter {

When you create the class, you need to decide in which package to store it. In this example, it’s stored in the same place as the resource file that’s being extended:

package gw.rest.ext.cc.admin.v1.user

Next you need to add references to the objects you’re going to use:

uses gw.api.database.IQueryBuilder
uses gw.api.database.InOperation
uses gw.api.database.Queries
uses gw.api.database.Relop
uses gw.api.filters.IQueryFilter
uses gw.api.filters.StandardQueryFilter
uses gw.api.modules.rest.framework.v1.query.filters.QueryFilterOp
uses gw.api.modules.rest.framework.v1.query.filters.RestQueryFilter

Within this new class, you override the createQueryFilter function, which is where you’ll define the query that retrieves the employee number.

override function createQueryFilter(propertyName : String, queryFilterOp : QueryFilterOp, parseValue : Object) : IQueryFilter {

In this example the query is created as a StandardQueryFilter. In creating this filter you first give it a name (in this case exmployeeNumber_Ext) and then a query. This query retrieves the UserContact information for each User, matching the Contact property foreign key to the ID, then compares the UserContact EmployeeNumber to the filter value (parseValue).

return new StandardQueryFilter("employeeNumber_Ext", \q -> {
   var empQuery = Queries.createQuery(entity.UserContact).compare(UserContact#EmployeeNumber, Relop.Equals, parseValue)
   q.subselect(User#Contact, InOperation.CompareIn, empQuery, UserContact#ID)
 })

Notice in the query that UserContact#EmployeeNumber is being compared to the filter value with Relop.Equals. This means that any filter applied will look only for employee numbers that exactly match the filter value. To enforce this restriction, you must also include getAllowedOperators and specify the equality operator:

getAllowedOperators and specify the equality operator:

   override property get AllowedOperators() : Set<QueryFilterOp> {

     return {
         QueryFilterOp.eq
     }
   }

By specifying only the eq operator as an allowed operator, any other operator used with this filter will produce an error.

See “Add filter operators” below for an example of how to allow multiple operators.

Next, you need to include the parseValue function. This function ensures that the filter value entered into the query string is converted to the proper datatype. (For example, a value of 2023-06-13T20:07:44.243Z would need to be parsed from a string to a Date object in order to filter on a Date field.) In this example, there is also a check for null to ensure the string "null" is parsed to an actual null (or empty) value rather than interpreted as the literal string "null".

override function parseValue(op : QueryFilterOp, employeeNumber : String) : Object {
  if (employeeNumber == "null" && op.getAllowNulls()) {
     return null;
  }

  return employeeNumber
}

Update the collection class

The final step is to update the resource file associated with the collection. For this example you’ll update the UsersExtResource.gs file.

In the resource file, override getCustomFilters to enable the new filter:

override function getCustomFilters(config: ApiConfiguration) : Map<String, RestQueryFilter> {
    var customFilters = new HashMap<String, RestQueryFilter>(super.getCustomFilters(config))
    customFilters.put("employeeNumber_Ext", new EmployeeNumberFilter_Ext())
    return customFilters;
  }

Be sure to call super first, to ensure you don’t overwrite any existing filters that could already be in place. Then add your new filter, giving it a name (employeeNumber_Ext) and an instance of the new class you just made.

Grant access to the filter

API role files determine which endpoints and fields a caller can access. By default, a caller has no access to any endpoints nor to any fields on resources returned by any endpoint. You must specifically grant access to each endpoint and then to one or more fields in the resources returned by an endpoint.

In an API role file, the accessibleFields section identifies which fields are accessible. There are different ways that you can grant access to fields:

  • Listing each accessible field explicitly
  • Using the "*" wildcard, which grants access to every field in the resource

When you add a custom filter through resource files, a caller will be able to access it if they have a role that grants view access to all fields using the "*" wildcard, including customer filters. If they do not have view access to all fields, then the filter must be added explicitly. If neither of these things are done, the filter will be inaccessible to the caller.

For example, to grant access to the employeeNumber filter, the relevant API role file needs to have either...
  Users:
    view:
      - "*"
...or...
  Users:
    view:
      - "employeeNumber"
      ...

For more information on how to grant access to fields in API role files, see API role accessible fields.

Using the filter

You can now filter on a specific employee number within the Users collection, like this:

GET /admin/v1/users?filter=employeeNumber_Ext:eq:1001

Add filter operators

The preceding example created a filter that allows you to filter on an exact match of an employee number. But in some cases, you might not want an exact match, you might want to find all values that are greater than or less than a certain value, or that contain or start with a string of characters. The following example demonstrates how to allow multiple operators with your filter.

Note: If the collection you’re applying the filter to is or could potentially become very large, adding certain operators could cause performance issues. For large collections, consider limiting your available operators.

This example modifies the previous example by adding the “starts with” (sw) operator to the allowed operators when filtering on an employee number.

class EmployeeNumberFilter_Ext extends RestQueryFilter {

   override function createQueryFilter(propertyName : String, queryFilterOp : QueryFilterOp, parseValue : Object) : IQueryFilter {

     var eFilter : StandardQueryFilter

     switch (queryFilterOp) {
       case QueryFilterOp.eq:
         eFilter = new StandardQueryFilter("employeeNumber_Ext", \q -> {
           var empQuery = Queries.createQuery(entity.UserContact).compare(UserContact#EmployeeNumber, Relop.Equals, parseValue)
           q.subselect(User#Contact, InOperation.CompareIn, empQuery, UserContact#ID)
         })
         break
       case QueryFilterOp.sw:
         eFilter = new StandardQueryFilter("employeeNumber_Ext", \q -> {
           var empQuery = Queries.createQuery(entity.UserContact).startsWith(UserContact#EmployeeNumber, String.valueOf(parseValue), true)
           q.subselect(User#Contact, InOperation.CompareIn, empQuery, UserContact#ID)
         })
         break

     }
     return eFilter
   }

   override property get AllowedOperators() : Set<QueryFilterOp> {
     return {
         QueryFilterOp.sw,
         QueryFilterOp.eq
     }
   }

   override function parseValue(op : QueryFilterOp, employeeNumber : String) : Object {
     if (employeeNumber == "null" && op.getAllowNulls()) {
       return null;
     }

     return employeeNumber
   }

}

When you allow multiple operators on a filter, you need to create a separate query for each filter. This example uses a switch statement to return the query that matches the operator that is being used.

switch (queryFilterOp) {
Note: For a list of valid operators, see Cloud API Consumer Guide

The first case applies to the eq operator, and is identical to the employee number query shown previously.

case QueryFilterOp.eq:
  eFilter = new StandardQueryFilter("employeeNumber_Ext", \q -> {
    var empQuery = Queries.createQuery(entity.UserContact).compare(UserContact#EmployeeNumber, Relop.Equals, parseValue)
    q.subselect(User#Contact, InOperation.CompareIn, empQuery, UserContact#ID)
  })
  break

The second case applies to the sw operator. This query is similar to the equality case, but instead of performing a query using Relop.Equals, you need to use the startsWith method on the query.

case QueryFilterOp.sw:
  eFilter = new StandardQueryFilter("policyNumber_Ext", \q -> {
    var empQuery = Queries.createQuery(entity.UserContact).startsWith(UserContact#EmployeeNumber, String.valueOf(parseValue), true)
    q.subselect(User#Contact, InOperation.CompareIn, empQuery, UserContact#ID)
  })
  break

Note that the last parameter in the call to startsWith is set to true. This parameter specifies whether the filter is case insensitive. Set this value to false to make the filter case sensitive.

In addition to adding a new query in the createQueryFilter function, you also need to add the additional operator to the list of AllowedOperators:

override property get AllowedOperators() : Set<QueryFilterOp> {
  return {
     QueryFilterOp.eq,
     QueryFilterOp.sw
  }
}

The rest of the code is created in the same way as in the earlier example.

Now you can use endpoints like either of the following to retrieve users based on their employee number:

GET /admin/v1/users?fields=lastName,employeeNumber&filter=employeeNumber_Ext:eq:US-10011

GET /admin/v1/users?fields=lastName,employeeNumber&filter=employeeNumber_Ext:sw:US