GraphQL Filters
A way to define automated filters on GraphQL fields.
This gem provides a module to include (or prepend, see below) in your resolvers that will automatically generate a tree of input types that your clients can use to filter the result of their queries. The filters are completely typed in the GraphQL schema, and are transpiled into an Active Record relation. The relation uses subqueries instead of joins to apply filters on associations, which might be less efficient. This, however, makes it possible for the client to make a query like "I need all the Kanto routes where I can catch Oddish, but for each route I also need all the other Pokémon".
Installation
Add this line to your application's Gemfile:
gem 'graphql-filters', '~> 1.0'And then execute:
$ bundleUsage
The easiest way to use GraphQL Filters is on top of SearchObject and its GraphQL plugin. Just include GraphQL::Filters::Filterable in your resolvers, and it will add an option to automatically filter the underlying collection. For example:
class RoutesResolver < BaseResolver
include SearchObject.module(:graphql)
scope { Route.all }
type [RouteType], null: false
include GraphQL::Filters::Filterable
endOtherwise, you have to explicitly define a resolve method and have it return an ActiveRecord::Relation; in this case you need to prepend GraphQL::Filters::Filterable in the resolver, otherwise it won't have access to the starting scope:
class RoutesResolver < BaseResolver
type [RouteType], null: false
def resolve
Route.all
end
prepend GraphQL::Filters::Filterable
endUsing either of these resolvers for a routes field will generate all the necessary input types to make a query like this:
query {
routes(
filter: {
fields: {
region: {
fields: {
name: { equals: "Kanto" }
}
},
catchablePokemon: {
any: {
fields: {
pokemon: {
types: {
any: {
fields: {
name: { equals: "Water"}
}
}
}
}
}
}
}
}
}
){
name
trainers{
name
isDouble
}
catchablePokemon{
levelRange
rate
pokemon{
name
types{
name
}
}
}
}
}Notice that eager loading is outside the scope of this gem, so without anything else the above query will fall victim to the N+1 problem.
Each input type is generated based on the respective type: scalar and enum types allow for basic comparisons like equality and inclusion, while object types let you build complex queries that mix and match comparisons on their fields. List types let you make any, all, and none queries based on a nested filter. Support for null-checked filters is planned for future development.
Note
Active Storage classes don't play well with GraphQL Filters. You should disable filters for their respective fields using filter: false. A cleaner handling of these cases is planned for future development.
Underlying models
GraphQL Filters relies on the assumption that every object type exists on top of an Active Record model. A field of type PokemonType will always be resolved to an instance of Pokemon, and its fields will match (at least loosely) the attributes of the model. This assumption allows the gem to generate appropriate subqueries for nested filters.
By default, the gem assumes that if an object type is <Name>Type, then its underlying model is <Name> and it is in the same module. This isn't always the case, especially if you follow the convention suggested by the GraphQL Ruby documentation, so all your object types are in a Types module. For a single exception you can explicitly declare the model inside your object type:
module Types
class PokemonType < BaseObject
model_class Pokemon
...
end
endTo change the default behavior of the gem, override the default_model_class class method in your base object class:
module Types
class BaseObject < GraphQL::Schema::Object
def self.default_model_class
model_name = name
.delete_prefix('Types::')
.delete_suffix('Type')
const_get model_name
end
end
endComparators
The best way to know what comparators you can use with each field is to open the GraphQL schema in your favorite client. What follows is a description of what each comparator does; unless explicitly indicated, not<Comparison> will match if and only if <comparison> will not.
Constant
constant is available for all types, and either always or never matches, depending on the passed boolean value. It can be useful to build complex filters.
Equality
equals is available for all scalar and enum types, and their respective list types, and matches if the field has the provided value.
Inclusion
in is available for all scalar and enum types, and their respective list types, and matches if the field has one of the provided values.
Numerical comparisons
greaterThan, greaterThanOrEqualsTo, lessThan and lessThanOrEqualsTo are available for numerical types (including ISO8601Date and ISO8601DateTime) and behave as expected.
Pattern matching
matches is available for the String type. Its value is a string in the format <version>/<pattern>/<options>.
- At the moment,
<version>can only bev1, but extensions are planned for future development. - For version
v1,<pattern>will match a.with any one character and a*with zero or more characters. To match any literal character you can prefix it with\. - For version
v1,<options>can be empty, or bei, which will make the match case insensitive.
Note
This is not a regex engine, just a simple wildcard matcher.
List comparisons
any, all, and none are available for any list, take a comparator for the type of the elements of the list, and match, respectively, if at least one, all, or none, of the elements of the list match the nested comparator.
Object comparisons
For each object type <Type>, two comparison input types are generated. <Type>ComplexFilterInput has and, or, and not fields to build complex comparators, plus a fields field of type <Type>ComparisonInput. For each field of <Type>, <Type>ComparisonInput has a field with the same name and the respective comparison input type. For example , if Pokemon has a name field of type String, then PokemonComparisonInput has a name field of type StringComparisonInput.
Input Types
The autogenerated input types are ready to go as is, but you might need to access them, for example to provide some documentation (the autogeneration of extensive documentation is planned for future development). For each type, you can access its corresponding input type through the comparison_input_type method. For object types, this method returns the class corresponding to the <Type>ComplexFilterInput input type; in turn, it has a fields_comparison_input_type that returns the class corresponding to the <Type>ComparisonInput input type.
Configuration
To configure the behavior of GraphQL Filters, create an initializer in config/initializers, and call the configure method:
GraphQL::Filters.configure do |config|
...
endconfig.base_input_object_class
By default, GraphQL Filters uses GraphQL::Schema::InputObject as base class for comparison input types. To use another class, assign it to config.base_input_object_class.
Options
The field method accepts some options that can tweak the generated input types and the transpilation of the arguments into a query. You can pass these options in three ways, in order of priority:
- as a keyword argument to the
fieldmethod:
field :name, String, null: false, filter: {...}- as a single positional argument to the
filtermethod inside the block you pass to thefieldmethod:
field :name, String, null: false do
filter({...})
end- as keyword arguments to the
filtermethod inside the block you pass to thefieldmethod:
field :name, String, null: false do
filter ...
endValid options
-
enabledCan be
trueorfalse(default:true). Controls whether or not the client can use this field in the filters. Passingtrueorfalsedirectly tofilteris a shortcut:
field :name, String, null: false, filter: false
# is equivalent to
field :name, String, null: false, filter: {enabled: false}-
attribute_nameThe name of the attribute that the field is tied to in the model. Defaults to the name of the resolver method for the field (which by default is the same as the name of the field itself).
-
association_nameThe name of the Active Record association that the field is tied to in the model. Equivalent to
attribute_name, has the same default. -
filtered_typeThe type the filters for this field need to be based on, if it's different from the field's own type (for example, a field thet returns a connection will need to be filtered based on the connection's node type). Can also be set calling the
filtered_typeon a resolver/mutation if the field is associated with one.
API usage examples
To get all pokémon whose main color is blue:
query{
pokemons(
filter: {
fields: {
mainColor: { equals: "blue" }
}
}
){
...
}
}To get all pokémon whose main color is either blue or red:
query{
pokemons(
filter: {
fields: {
mainColor: { in: ["blue", "red"] }
}
}
){
...
}
}To get all pokémon whose name begins with 's':
query{
pokemons(
filter: {
fields: {
name: { match: "v1/s*/i" }
}
}
){
...
}
}To get all pokémon whose base friendship is 70 or whose catch rate is more than 100:
query{
pokemons(
filter: {
or: [
{
fields: {
baseFriendship: { equals: 70 }
}
}
{
fields: {
catchRate: { greaterThanOrEqualsTo: 100 }
}
}
]
}
){
...
}
}To get all pokémon that evolve from Eevee:
query{
pokemons(
filter: {
fields: {
preEvolution: {
fields: {
name: { equals: "Eevee" }
}
}
}
}
){
...
}
}To get all pokémon catchable in the Kanto route 1:
query{
pokemons(
filter: {
fields: {
routes: {
any: {
fields: {
name: { equals: "1" }
region: {
fields: {
name: { equals: "Kanto" }
}
}
}
}
}
}
}
){
...
}
}To get all routes in Kanto where you can catch a water type pokémon (this is the example at the top of the page):
query {
routes(
filter: {
fields: {
region: {
fields: {
name: { equals: "Kanto" }
}
},
catchablePokemon: {
any: {
fields: {
pokemon: {
types: {
any: {
fields: {
name: { equals: "Water"}
}
}
}
}
}
}
}
}
}
){
...
}
}Plans for future development
- A finer grain support for non-nullable fields.
- A pattern format for the string match comparator that uses real regular expressions.
- Autogenerated documentation.
- More options, both for the gem as a whole and for the single fields.
- The ability to (easily) specify custom comparator input types and provide custom logic for specific comparators.
Version numbers
GraphQL Filters loosely follows Semantic Versioning, with a hard guarantee that breaking changes to the public API will always coincide with an increase to the MAJOR number.
Version numbers are in three parts: MAJOR.MINOR.PATCH.
- Breaking changes to the public API increment the
MAJOR. There may also be changes that would otherwise increase theMINORor thePATCH. - Additions, deprecations, and "big" non breaking changes to the public API increment the
MINOR. There may also be changes that would otherwise increase thePATCH. - Bug fixes and "small" non breaking changes to the public API increment the
PATCH.
Notice that any feature deprecated by a minor release can be expected to be removed by the next major release.
Changelog
Full list of changes in CHANGELOG.md
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/moku-io/graphql-filters.
License
The gem is available as open source under the terms of the MIT License.