GraphQL with RSQL/FIQL: Dynamic Query Filtering for Modern APIs
Learn how to integrate RSQL (RESTful Service Query Language) with GraphQL to enable powerful, dynamic filtering capabilities. Build flexible APIs that combine GraphQL's type safety with RSQL's expressive query syntax.
Table of Contents
Introduction
Building APIs that support flexible querying is a common challenge in modern software development. While GraphQL provides excellent type safety and allows clients to request exactly the data they need, it traditionally requires predefined query parameters. RSQL (RESTful Service Query Language), also known as FIQL (Feed Item Query Language), offers a powerful alternative - a query language that enables dynamic, SQL-like filtering through URL parameters.
What if you could combine the best of both worlds? The graphql-rsql project demonstrates exactly this - integrating RSQL's expressive filtering capabilities directly into GraphQL queries, giving clients unprecedented flexibility in data retrieval.
Key Insight: By combining GraphQL with RSQL, you enable clients to construct complex queries dynamically without requiring backend changes for each new filter combination.
Understanding RSQL/FIQL
What is RSQL?
RSQL is a query language for parametrized filtering of entries in RESTful APIs. It provides a simple, URL-friendly syntax for expressing complex search queries that would otherwise require multiple endpoints or custom query parameters.
RSQL Syntax Overview
| Operator | Symbol | Example | Description |
|---|---|---|---|
| Equal | == | name==John | Exact match |
| Not Equal | != | status!=deleted | Exclusion |
| Greater Than | =gt= or > | age=gt=18 | Numeric comparison |
| Greater Equal | =ge= or >= | score=ge=100 | Inclusive comparison |
| Less Than | =lt= or < | price=lt=50 | Numeric comparison |
| Less Equal | =le= or <= | count=le=10 | Inclusive comparison |
| In | =in= | status=in=(active,pending) | Set membership |
| Out | =out= | role=out=(guest,banned) | Set exclusion |
| Like | =like= | name=like=*john* | Pattern matching |
| AND | ; | age=gt=18;status==active | Logical conjunction |
| OR | , | role==admin,role==mod | Logical disjunction |
RSQL Query Examples
# Simple equality
name==Buddy
# Numeric comparison
age=gt=3
# Combined conditions (AND)
species==dog;age=gt=2
# Multiple options (OR)
status==available,status==pending
# Complex query
species==cat;(age=lt=2,vaccinated==true)
Architecture Overview
The graphql-rsql project implements a clean architecture that bridges GraphQL and RSQL:
+------------------------------------------------------------------+
| GRAPHQL-RSQL ARCHITECTURE |
+------------------------------------------------------------------+
| |
| +---------------------------+ |
| | GraphQL Query | |
| | query { pets(filter: | |
| | "species==dog;age>2") | |
| | { id name age } } | |
| +------------+--------------+ |
| | |
| +--------v--------+ |
| | GraphQL Resolver | |
| | (PetResolver) | |
| +--------+---------+ |
| | |
| +--------v---------+ |
| | RSQL Parser | |
| | (cz.jirutka.rsql)| |
| +--------+---------+ |
| | |
| +--------v---------+ |
| | RSQL Visitor | |
| | (AST Traversal) | |
| +--------+---------+ |
| | |
| +--------v-----------+ |
| | JPA Specification | |
| | (RsqlSpecification)| |
| +--------+-----------+ |
| | |
| +--------v--------+ |
| | Spring Data | |
| | Repository | |
| +--------+--------+ |
| | |
| +--------v--------+ |
| | Database | |
| | (Derby/SQL) | |
| +-----------------+ |
| |
+------------------------------------------------------------------+
Project Setup
Maven Dependencies
The project uses Spring Boot with GraphQL and RSQL parser libraries:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.gonnect.graphql</groupId>
<artifactId>graphql-rsql</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.4.RELEASE</version>
</parent>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<!-- GraphQL -->
<dependency>
<groupId>com.graphql-java</groupId>
<artifactId>graphql-java-tools</artifactId>
<version>5.2.4</version>
</dependency>
<dependency>
<groupId>com.graphql-java</groupId>
<artifactId>graphql-spring-boot-starter</artifactId>
<version>5.0.2</version>
</dependency>
<!-- RSQL Parser -->
<dependency>
<groupId>cz.jirutka.rsql</groupId>
<artifactId>rsql-parser</artifactId>
<version>2.1.0</version>
</dependency>
<!-- Spring Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
<exclusions>
<exclusion>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-entitymanager</artifactId>
</exclusion>
<exclusion>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- EclipseLink JPA Provider -->
<dependency>
<groupId>org.eclipse.persistence</groupId>
<artifactId>org.eclipse.persistence.jpa</artifactId>
<version>2.7.4</version>
</dependency>
<!-- Database -->
<dependency>
<groupId>org.apache.derby</groupId>
<artifactId>derby</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.16.20</version>
</dependency>
</dependencies>
</project>
Implementation Deep Dive
Domain Entity
First, define the domain entity that will be queried:
package com.gonnect.graphql.entities;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import javax.persistence.*;
@Entity
@Table(name = "pets")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Pet {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String species;
private Integer age;
private String breed;
@Enumerated(EnumType.STRING)
private PetStatus status;
private Boolean vaccinated;
private String ownerName;
}
RSQL Search Operations
Define the supported RSQL operators and their mappings:
package com.gonnect.graphql.rsql;
import cz.jirutka.rsql.parser.ast.ComparisonOperator;
import cz.jirutka.rsql.parser.ast.RSQLOperators;
public enum RsqlSearchOperation {
EQUAL(RSQLOperators.EQUAL),
NOT_EQUAL(RSQLOperators.NOT_EQUAL),
GREATER_THAN(RSQLOperators.GREATER_THAN),
GREATER_THAN_OR_EQUAL(RSQLOperators.GREATER_THAN_OR_EQUAL),
LESS_THAN(RSQLOperators.LESS_THAN),
LESS_THAN_OR_EQUAL(RSQLOperators.LESS_THAN_OR_EQUAL),
IN(RSQLOperators.IN),
NOT_IN(RSQLOperators.NOT_IN);
private final ComparisonOperator operator;
RsqlSearchOperation(ComparisonOperator operator) {
this.operator = operator;
}
public static RsqlSearchOperation getSimpleOperator(ComparisonOperator operator) {
for (RsqlSearchOperation operation : values()) {
if (operation.operator.equals(operator)) {
return operation;
}
}
return null;
}
}
JPA Specification Builder
The core of the RSQL integration - converting RSQL expressions to JPA Specifications:
package com.gonnect.graphql.rsql;
import org.springframework.data.jpa.domain.Specification;
import javax.persistence.criteria.*;
import java.util.List;
import java.util.stream.Collectors;
public class RsqlSpecification<T> implements Specification<T> {
private final String property;
private final RsqlSearchOperation operation;
private final List<String> arguments;
public RsqlSpecification(String property, RsqlSearchOperation operation,
List<String> arguments) {
this.property = property;
this.operation = operation;
this.arguments = arguments;
}
@Override
public Predicate toPredicate(Root<T> root, CriteriaQuery<?> query,
CriteriaBuilder builder) {
List<Object> args = castArguments(root);
Object argument = args.get(0);
switch (operation) {
case EQUAL:
if (argument instanceof String) {
return builder.like(root.get(property),
argument.toString().replace('*', '%'));
} else if (argument == null) {
return builder.isNull(root.get(property));
}
return builder.equal(root.get(property), argument);
case NOT_EQUAL:
if (argument instanceof String) {
return builder.notLike(root.get(property),
argument.toString().replace('*', '%'));
} else if (argument == null) {
return builder.isNotNull(root.get(property));
}
return builder.notEqual(root.get(property), argument);
case GREATER_THAN:
return builder.greaterThan(root.get(property),
argument.toString());
case GREATER_THAN_OR_EQUAL:
return builder.greaterThanOrEqualTo(root.get(property),
argument.toString());
case LESS_THAN:
return builder.lessThan(root.get(property),
argument.toString());
case LESS_THAN_OR_EQUAL:
return builder.lessThanOrEqualTo(root.get(property),
argument.toString());
case IN:
return root.get(property).in(args);
case NOT_IN:
return builder.not(root.get(property).in(args));
default:
return null;
}
}
private List<Object> castArguments(Root<T> root) {
Class<?> type = root.get(property).getJavaType();
return arguments.stream().map(arg -> {
if (type.equals(Integer.class) || type.equals(int.class)) {
return Integer.parseInt(arg);
} else if (type.equals(Long.class) || type.equals(long.class)) {
return Long.parseLong(arg);
} else if (type.equals(Boolean.class) || type.equals(boolean.class)) {
return Boolean.parseBoolean(arg);
} else if (type.equals(Double.class) || type.equals(double.class)) {
return Double.parseDouble(arg);
} else if (type.isEnum()) {
return Enum.valueOf((Class<Enum>) type, arg);
}
return arg;
}).collect(Collectors.toList());
}
}
RSQL Visitor Implementation
The visitor pattern traverses the RSQL AST and builds JPA Specifications:
package com.gonnect.graphql.rsql;
import cz.jirutka.rsql.parser.ast.*;
import org.springframework.data.jpa.domain.Specification;
public class PetRsqlVisitor<T> implements RSQLVisitor<Specification<T>, Void> {
@Override
public Specification<T> visit(AndNode node, Void param) {
return node.getChildren().stream()
.map(n -> n.accept(this))
.reduce(Specification::and)
.orElse(null);
}
@Override
public Specification<T> visit(OrNode node, Void param) {
return node.getChildren().stream()
.map(n -> n.accept(this))
.reduce(Specification::or)
.orElse(null);
}
@Override
public Specification<T> visit(ComparisonNode node, Void param) {
return new RsqlSpecification<>(
node.getSelector(),
RsqlSearchOperation.getSimpleOperator(node.getOperator()),
node.getArguments()
);
}
}
Pet Specification Builder
A builder class that orchestrates the RSQL parsing and specification creation:
package com.gonnect.graphql.rsql;
import cz.jirutka.rsql.parser.RSQLParser;
import cz.jirutka.rsql.parser.ast.Node;
import org.springframework.data.jpa.domain.Specification;
public class PetBuilder {
public static Specification<Pet> buildSpecification(String rsqlQuery) {
if (rsqlQuery == null || rsqlQuery.isEmpty()) {
return Specification.where(null);
}
RSQLParser parser = new RSQLParser();
Node rootNode = parser.parse(rsqlQuery);
PetRsqlVisitor<Pet> visitor = new PetRsqlVisitor<>();
return rootNode.accept(visitor);
}
}
GraphQL Schema
Define the GraphQL schema with RSQL filter support:
type Query {
pets(filter: String): [Pet!]!
pet(id: ID!): Pet
}
type Mutation {
createPet(input: PetInput!): Pet!
updatePet(id: ID!, input: PetInput!): Pet
deletePet(id: ID!): Boolean!
}
type Pet {
id: ID!
name: String!
species: String!
age: Int
breed: String
status: PetStatus!
vaccinated: Boolean
ownerName: String
}
enum PetStatus {
AVAILABLE
PENDING
ADOPTED
}
input PetInput {
name: String!
species: String!
age: Int
breed: String
status: PetStatus
vaccinated: Boolean
ownerName: String
}
GraphQL Resolver
The resolver integrates RSQL filtering with GraphQL queries:
package com.gonnect.graphql.resolvers;
import com.coxautodev.graphql.tools.GraphQLQueryResolver;
import com.gonnect.graphql.entities.Pet;
import com.gonnect.graphql.repository.PetRepository;
import com.gonnect.graphql.rsql.PetBuilder;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class PetQueryResolver implements GraphQLQueryResolver {
private final PetRepository petRepository;
public PetQueryResolver(PetRepository petRepository) {
this.petRepository = petRepository;
}
public List<Pet> pets(String filter) {
if (filter == null || filter.isEmpty()) {
return petRepository.findAll();
}
Specification<Pet> specification = PetBuilder.buildSpecification(filter);
return petRepository.findAll(specification);
}
public Pet pet(Long id) {
return petRepository.findById(id).orElse(null);
}
}
Spring Data Repository
The repository extends JpaSpecificationExecutor for specification support:
package com.gonnect.graphql.repository;
import com.gonnect.graphql.entities.Pet;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
@Repository
public interface PetRepository extends JpaRepository<Pet, Long>,
JpaSpecificationExecutor<Pet> {
}
GraphQL Query Examples
Basic Filtering
# Find all dogs
query {
pets(filter: "species==dog") {
id
name
breed
age
}
}
# Find pets older than 3 years
query {
pets(filter: "age=gt=3") {
id
name
species
age
}
}
Complex Queries
# Find vaccinated dogs over 2 years old
query {
pets(filter: "species==dog;age=gt=2;vaccinated==true") {
id
name
breed
age
vaccinated
}
}
# Find available or pending pets
query {
pets(filter: "status==AVAILABLE,status==PENDING") {
id
name
status
ownerName
}
}
# Find cats or dogs that are available
query {
pets(filter: "(species==cat,species==dog);status==AVAILABLE") {
id
name
species
status
}
}
Pattern Matching
# Find pets with names starting with 'B'
query {
pets(filter: "name==B*") {
id
name
species
}
}
# Find pets with 'buddy' anywhere in the name
query {
pets(filter: "name==*buddy*") {
id
name
}
}
Using IN Operator
# Find pets of specific breeds
query {
pets(filter: "breed=in=(Labrador,Golden Retriever,Beagle)") {
id
name
breed
species
}
}
Advanced Configuration
Custom Operators
Extend RSQL with custom operators for domain-specific queries:
package com.gonnect.graphql.rsql;
import cz.jirutka.rsql.parser.ast.ComparisonOperator;
import java.util.Set;
public class CustomOperators {
// Custom 'between' operator
public static final ComparisonOperator BETWEEN =
new ComparisonOperator("=between=", true);
// Custom 'like' operator with case insensitivity
public static final ComparisonOperator ILIKE =
new ComparisonOperator("=ilike=", false);
public static Set<ComparisonOperator> getOperators() {
Set<ComparisonOperator> operators = RSQLOperators.defaultOperators();
operators.add(BETWEEN);
operators.add(ILIKE);
return operators;
}
}
// Usage in parser
RSQLParser parser = new RSQLParser(CustomOperators.getOperators());
Error Handling
Implement robust error handling for invalid RSQL queries:
package com.gonnect.graphql.rsql;
import cz.jirutka.rsql.parser.RSQLParserException;
import graphql.GraphQLException;
public class RsqlQueryHandler {
public static <T> Specification<T> parseQuery(String rsqlQuery,
RSQLVisitor<Specification<T>, Void> visitor) {
if (rsqlQuery == null || rsqlQuery.trim().isEmpty()) {
return Specification.where(null);
}
try {
RSQLParser parser = new RSQLParser();
Node rootNode = parser.parse(rsqlQuery);
return rootNode.accept(visitor);
} catch (RSQLParserException e) {
throw new GraphQLException(
"Invalid filter syntax: " + e.getMessage()
);
} catch (IllegalArgumentException e) {
throw new GraphQLException(
"Invalid filter field or value: " + e.getMessage()
);
}
}
}
Pagination Support
Combine RSQL filtering with pagination:
package com.gonnect.graphql.resolvers;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
@Component
public class PetQueryResolver implements GraphQLQueryResolver {
public PetConnection pets(String filter, Integer first,
String after, String sortBy) {
Specification<Pet> spec = PetBuilder.buildSpecification(filter);
int page = decodeAfterCursor(after);
int size = first != null ? first : 10;
Sort sort = sortBy != null
? Sort.by(Sort.Direction.ASC, sortBy)
: Sort.unsorted();
PageRequest pageRequest = PageRequest.of(page, size, sort);
Page<Pet> petPage = petRepository.findAll(spec, pageRequest);
return new PetConnection(petPage);
}
}
Benefits of GraphQL + RSQL Integration
| Benefit | Description |
|---|---|
| Dynamic Filtering | Clients can construct complex queries without backend changes |
| Type Safety | GraphQL schema ensures type-safe responses |
| Single Endpoint | All filtering through one GraphQL endpoint |
| Reduced API Surface | No need for multiple filter parameters |
| SQL-like Syntax | Familiar query language for developers |
| Composable Queries | AND/OR logic for complex conditions |
Best Practices
- Validate Input: Always validate RSQL queries before execution to prevent injection attacks
- Limit Complexity: Consider limiting query depth or complexity to prevent resource exhaustion
- Index Fields: Ensure frequently filtered fields are properly indexed
- Document Operators: Provide clear documentation of supported RSQL operators
- Handle Errors Gracefully: Return meaningful error messages for invalid queries
Conclusion
Integrating RSQL with GraphQL provides a powerful combination that gives clients maximum flexibility while maintaining the benefits of GraphQL's type system and efficient data fetching. This approach is particularly valuable for:
- Admin dashboards requiring ad-hoc filtering
- Search functionality with complex criteria
- Reporting systems with dynamic queries
- APIs serving diverse client needs
The graphql-rsql project demonstrates how these technologies work together seamlessly, using Spring Boot's ecosystem to build production-ready APIs with sophisticated query capabilities.