Skip to content

Commit

Permalink
Feature: Custom filter rules (#17)
Browse files Browse the repository at this point in the history
* Adding `FilterRule` for custom criteria queries with cursors
  • Loading branch information
p3t authored Jun 29, 2024
1 parent dcfcb0d commit 90299f7
Show file tree
Hide file tree
Showing 15 changed files with 443 additions and 25 deletions.
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,64 @@ public long queryCount( PageRequest<DataRecord> request ) {

The request (if available) should be passed as it might contain filters which reduce returned count.

### Example: Extend filter with custom rules

In some cases you might want to filter the results by defining your own custom rules. One example where this could be useful is, when you need to implement sophisticated access rights (ACLs.

In order to support this, `cursorpaging-jpa` provides an interface: `FilterRule` which can be used to extend the internally executed criteria query with custom `Predicates`.

```java

@RequiredArgsConstructor
@Builder
private static class AclCheckFilterRule implements FilterRule {

private final String subject;
private final AccessEntry.Action action;

@Override
public Predicate getPredicate( final QueryBuilder cqb ) {
final var builder = cqb.cb();
final var root = cqb.root();
root.join( DataRecord_.SECURITY_CLASS, JoinType.LEFT );

final var subquery = cqb.query().subquery( Long.class );
final var ae = subquery.from( AccessEntry.class );
subquery.select( builder.max( ae.get( AccessEntry_.SECURITY_CLASS ).get( SecurityClass_.LEVEL ) ) )
.where( builder.equal( ae.get( AccessEntry_.SUBJECT ), subject ),
builder.equal( ae.get( AccessEntry_.ACTION ), action ) );
return builder.equal( root.get( DataRecord_.SECURITY_CLASS ).get( SecurityClass_.LEVEL ), subquery );
}
}

@Test
public void shouldUseMoreComplicateFilterRulesForAclChecks() {
generateData( 100 );
final PageRequest<DataRecord> request = PageRequest.create( b -> b.pageSize( 100 )
.desc( Attribute.path( DataRecord_.auditInfo, AuditInfo_.createdAt ) )
.asc( DataRecord_.id )
.rule( new AclCheckFilterRule( SUBJECT_READ_STANDARD, READ ) ) );

final var firstPage = dataRecordRepository.loadPage( request );

assertThat( firstPage ).isNotNull();
assertThat( firstPage.getContent() ).allMatch( e -> e.getSecurityClass().getLevel() <= 1 );

final PageRequest<DataRecord> request2 = PageRequest.create( b -> b.pageSize( 100 )
.desc( Attribute.path( DataRecord_.auditInfo, AuditInfo_.createdAt ) )
.asc( DataRecord_.id )
.rule( new AclCheckFilterRule( "does not exist", READ ) ) );

final var shouldBeEmpty = dataRecordRepository.loadPage( request2 );
assertThat( shouldBeEmpty ).isNotNull();
assertThat( shouldBeEmpty.getContent() ).isEmpty();
assertThat( dataRecordRepository.count( request2 ) ).isEqualTo( 0 );
}
```

Important: `FilterRules` will
*not* be serialized to the client, due to their unknown nature, and must be re-added for each subsequent page request!

## Using the page request in a controller

In order to keep the server stateless, the information within a `PageRequest` have to be passed to the client and send
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package io.vigier.cursorpaging.jpa;

import jakarta.persistence.criteria.Predicate;


/**
* A custom rule to filter the query.
*/
public interface FilterRule {

default void applyQuery( final QueryBuilder cqb ) {
cqb.addWhere( getPredicate( cqb ) );
}

default void applyCount( final QueryBuilder cqb ) {
cqb.addWhere( getCountPredicate( cqb ) );
}

/**
* Sometimes the query-predicate must be different than the count-predicate due to the criteria API
*
* @param cqb Query, Builder and Root
* @return the predicate which should be applied to the where-clause
*/
default Predicate getCountPredicate( final QueryBuilder cqb ) {
return getPredicate( cqb );
}

/**
* Get the predicate for the query.
*
* @param cqb Query, Builder and Root
* @return the predicate which should be applied to the where-clause
*/
Predicate getPredicate( final QueryBuilder cqb );
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ public Optional<PageRequest<E>> next() {
}

/**
* Map the content with the given function an get the result
* Map the content with the given function and get the result
*
* @param mapper the function to be applied on each element of the page
* @param <T> the result type of the mapping function
Expand All @@ -99,4 +99,13 @@ public Optional<PageRequest<E>> next() {
public <T> List<T> content( final Function<E, T> mapper ) {
return content.stream().map( mapper ).toList();
}

/**
* Get the number of elements in this page.
*
* @return the number of elements
*/
public int size() {
return content.size();
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.vigier.cursorpaging.jpa;

import jakarta.persistence.Transient;
import jakarta.persistence.metamodel.SingularAttribute;
import java.util.ArrayList;
import java.util.List;
Expand Down Expand Up @@ -46,6 +47,14 @@ public class PageRequest<E> {
@Singular
private final List<Filter> filters;

/**
* The filter rules to apply to the query (removing results). Note that filter rules are <i>not</i> passed to the
* client in serialized form, and must be added every-time the cursor is deserialized.
*/
@Singular
@Transient
private final List<FilterRule> rules;

/**
* The size of the page to fetch
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
package io.vigier.cursorpaging.jpa;

import jakarta.persistence.EntityManager;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import java.util.Collection;

/**
Expand Down Expand Up @@ -47,4 +52,41 @@ public interface QueryBuilder {
* @param value the value to filter on
*/
void isEqual( Attribute attribute, Comparable<?> value );

/**
* Low level access to add custom filter rules
*
* @param predicate
*/
void addWhere( final Predicate predicate );

/**
* Low level access to the query for the root-entity
*
* @param <R> the return type
* @return the query
*/
<R> CriteriaQuery<R> query();

/**
* Low level access to the criteria builder
*
* @return the criteria builder
*/
CriteriaBuilder cb();

/**
* Low level access to the root
*
* @param <E> the entity type
* @return the root
*/
<E> Root<E> root();

/**
* Low level access to the entity manager
*
* @return the entity manager
*/
EntityManager entityManager();
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,20 @@ public class CriteriaQueryBuilder<E, R> implements QueryBuilder {
private final CriteriaBuilder cb;
private final Root<E> root;
private final Class<E> entityType;
private final EntityManager entityManager;

public static <T> CriteriaQueryBuilder<T, T> forEntity( final Class<T> entityType,
final EntityManager entityManager ) {
final var cb = entityManager.getCriteriaBuilder();
final var query = cb.createQuery( entityType );
final var root = query.from( entityType );
query.select( root );
return CriteriaQueryBuilder.<T, T>builder().query( query ).cb( cb ).root( root ).entityType( entityType )
return CriteriaQueryBuilder.<T, T>builder()
.query( query )
.cb( cb )
.root( root )
.entityType( entityType )
.entityManager( entityManager )
.build();
}

Expand All @@ -50,7 +56,12 @@ public static <E> CriteriaQueryBuilder<E, Long> forCount( final Class<E> entityT
final var query = cb.createQuery( Long.class );
final var root = query.from( entityType );
query.select( cb.count( root ) );
return CriteriaQueryBuilder.<E, Long>builder().query( query ).cb( cb ).root( root ).entityType( entityType )
return CriteriaQueryBuilder.<E, Long>builder()
.query( query )
.cb( cb )
.root( root )
.entityType( entityType )
.entityManager( entityManager )
.build();
}

Expand Down Expand Up @@ -92,7 +103,8 @@ public void isEqual( final Attribute attribute, final Comparable<?> value ) {
addWhere( cb().equal( attribute.path( root ), value ) );
}

private void addWhere( final Predicate predicate ) {
@Override
public void addWhere( final Predicate predicate ) {
final var restriction = query().getRestriction();
if ( restriction == null ) {
query().where( predicate );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ public Page<E> loadPage( final PageRequest<E> request ) {

request.positions().forEach( position -> position.apply( cqb ) );
request.filters().forEach( filter -> filter.apply( cqb ) );
request.rules().forEach( rule -> rule.applyQuery( cqb ) );

final var results = entityManager.createQuery( cqb.query() )
.setMaxResults( getMaxResultSize( request ) )
Expand All @@ -63,12 +64,13 @@ public Page<E> loadPage( final PageRequest<E> request ) {

@Override
public long count( final PageRequest<E> request ) {
final CriteriaQueryBuilder<E, Long> c = CriteriaQueryBuilder.forCount( entityInformation.getJavaType(),
final CriteriaQueryBuilder<E, Long> cqb = CriteriaQueryBuilder.forCount( entityInformation.getJavaType(),
entityManager );

request.filters().forEach( filter -> filter.apply( c ) );

return entityManager.createQuery( c.query() ).getSingleResult();
request.filters().forEach( filter -> filter.apply( cqb ) );
request.rules().forEach( rule -> rule.applyCount( cqb ) );

return entityManager.createQuery( cqb.query() ).getSingleResult();
}

private int getMaxResultSize( final PageRequest<E> request ) {
Expand Down
4 changes: 2 additions & 2 deletions cursorpaging-jpa/src/main/resources/META-INF/spring.factories
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Auto Configure
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
io.vigier.cursorpaging.jpa.config.CursorPageAutoConfigure
org.springframework.boot.autoconfigure.EnableAutoConfiguration=io.vigier.cursorpaging.jpa.config.CursorPageAutoConfigure
org.springframework.data.repository.core.support.RepositoryFactorySupport=io.vigier.cursorpaging.jpa.bootstrap.CursorPageJpaRepositoryFactory
Loading

0 comments on commit 90299f7

Please sign in to comment.