Skip to content

Commit

Permalink
Per-entity CRUD filters for update/delete operations #502
Browse files Browse the repository at this point in the history
.. read filter for updates
  • Loading branch information
andrus committed Dec 5, 2021
1 parent 298be52 commit 9834094
Show file tree
Hide file tree
Showing 10 changed files with 335 additions and 108 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ public CayenneUpdateProcessorFactoryFactoryProvider(
@Inject CayenneCommitStage commitStage,
@Inject CayenneOkResponseStage okResponseStage,
@Inject CayenneCreatedResponseStage createdResponseStage,
@Inject CayenneCreatedOrOkResponseStage createdOrOkResponseStage
@Inject CayenneCreatedOrOkResponseStage createdOrOkResponseStage,

@Inject FilterResultStage filterResultStage
) {

this.createStages = new EnumMap<>(UpdateStage.class);
Expand All @@ -53,6 +55,7 @@ public CayenneUpdateProcessorFactoryFactoryProvider(
this.createStages.put(UpdateStage.MERGE_CHANGES, mergeStage);
this.createStages.put(UpdateStage.COMMIT, commitStage);
this.createStages.put(UpdateStage.FILL_RESPONSE, createdResponseStage);
this.createStages.put(UpdateStage.FILTER_RESULT, filterResultStage);

this.updateStages = new EnumMap<>(UpdateStage.class);
this.updateStages.put(UpdateStage.START, startStage);
Expand All @@ -64,6 +67,7 @@ public CayenneUpdateProcessorFactoryFactoryProvider(
this.updateStages.put(UpdateStage.MERGE_CHANGES, mergeStage);
this.updateStages.put(UpdateStage.COMMIT, commitStage);
this.updateStages.put(UpdateStage.FILL_RESPONSE, okResponseStage);
this.updateStages.put(UpdateStage.FILTER_RESULT, filterResultStage);

this.createOrUpdateStages = new EnumMap<>(UpdateStage.class);
this.createOrUpdateStages.put(UpdateStage.START, startStage);
Expand All @@ -75,6 +79,7 @@ public CayenneUpdateProcessorFactoryFactoryProvider(
this.createOrUpdateStages.put(UpdateStage.MERGE_CHANGES, mergeStage);
this.createOrUpdateStages.put(UpdateStage.COMMIT, commitStage);
this.createOrUpdateStages.put(UpdateStage.FILL_RESPONSE, createdOrOkResponseStage);
this.createOrUpdateStages.put(UpdateStage.FILTER_RESULT, filterResultStage);

this.idempotentCreateOrUpdateStages = new EnumMap<>(UpdateStage.class);
this.idempotentCreateOrUpdateStages.put(UpdateStage.START, startStage);
Expand All @@ -86,6 +91,7 @@ public CayenneUpdateProcessorFactoryFactoryProvider(
this.idempotentCreateOrUpdateStages.put(UpdateStage.MERGE_CHANGES, mergeStage);
this.idempotentCreateOrUpdateStages.put(UpdateStage.COMMIT, commitStage);
this.idempotentCreateOrUpdateStages.put(UpdateStage.FILL_RESPONSE, createdOrOkResponseStage);
this.idempotentCreateOrUpdateStages.put(UpdateStage.FILTER_RESULT, filterResultStage);

this.idempotentFullSyncStages = new EnumMap<>(UpdateStage.class);
this.idempotentFullSyncStages.put(UpdateStage.START, startStage);
Expand All @@ -97,6 +103,7 @@ public CayenneUpdateProcessorFactoryFactoryProvider(
this.idempotentFullSyncStages.put(UpdateStage.MERGE_CHANGES, mergeStage);
this.idempotentFullSyncStages.put(UpdateStage.COMMIT, commitStage);
this.idempotentFullSyncStages.put(UpdateStage.FILL_RESPONSE, createdOrOkResponseStage);
this.idempotentFullSyncStages.put(UpdateStage.FILTER_RESULT, filterResultStage);
}

@Override
Expand Down
138 changes: 138 additions & 0 deletions agrest-cayenne/src/test/java/io/agrest/cayenne/PUT_ReadFilterIT.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package io.agrest.cayenne;

import io.agrest.Ag;
import io.agrest.DataResponse;
import io.agrest.EntityUpdate;
import io.agrest.access.ReadFilter;
import io.agrest.cayenne.cayenne.main.E2;
import io.agrest.cayenne.cayenne.main.E3;
import io.agrest.cayenne.cayenne.main.E4;
import io.agrest.cayenne.unit.AgCayenneTester;
import io.agrest.cayenne.unit.DbTest;
import io.agrest.meta.AgEntity;
import io.bootique.junit5.BQTestTool;
import org.apache.cayenne.Cayenne;
import org.apache.cayenne.DataObject;
import org.junit.jupiter.api.Test;

import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.core.Configuration;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.UriInfo;
import java.util.List;

public class PUT_ReadFilterIT extends DbTest {

@BQTestTool
static final AgCayenneTester tester = tester(Resource.class)
.entities(E2.class, E3.class, E4.class)
.agCustomizer(ab -> ab
.entityOverlay(AgEntity.overlay(E2.class).readFilter(evenFilter()))
.entityOverlay(AgEntity.overlay(E3.class).readFilter(oddFilter()))
.entityOverlay(AgEntity.overlay(E4.class).readFilter(evenFilter()))
)
.build();

static <T extends DataObject> ReadFilter<T> evenFilter() {
return o -> Cayenne.intPKForObject(o) % 2 == 0;
}

static <T extends DataObject> ReadFilter<T> oddFilter() {
return o -> Cayenne.intPKForObject(o) % 2 != 0;
}

@Test
public void testFilter_InStack() {

tester.e2().insertColumns("id_", "name")
.values(1, "a")
.values(2, "b")
.values(4, "c")
.exec();

// all 3 updates should have been processed, but only the ones matching the read filter should be returned

tester.target("/e2_stack_filter")
.queryParam("include", "id", "name")
.put("[{\"id\":2,\"name\":\"Bb\"},{\"id\":1,\"name\":\"Aa\"},{\"id\":4,\"name\":\"Cc\"}]")
.wasOk().bodyEquals(2, "{\"id\":2,\"name\":\"Bb\"}", "{\"id\":4,\"name\":\"Cc\"}");

tester.e2().matcher().assertMatches(3);
tester.e2().matcher().eq("id_", 1).eq("name", "Aa").assertOneMatch();
tester.e2().matcher().eq("id_", 2).eq("name", "Bb").assertOneMatch();
tester.e2().matcher().eq("id_", 4).eq("name", "Cc").assertOneMatch();
}

@Test
public void testFilter_InStack_Nested() {

tester.e2().insertColumns("id_", "name")
.values(1, "a")
.values(2, "b")
.exec();

tester.e3().insertColumns("id_", "e2_id")
.values(11, 1)
.values(21, 2)
.values(22, 2)
.exec();

// related entity rules must be applied just the same as for the root entity
tester.target("/e2_stack_filter")
.queryParam("include", "id", "e3s.id")
.queryParam("sort", "id")
.put("[{\"id\":2,\"name\":\"Bb\"},{\"id\":1,\"name\":\"Aa\"}]")
.wasOk().bodyEquals(1, "{\"id\":2,\"e3s\":[{\"id\":21}]}");
}

@Test
public void testFilter_InStackAndRequest() {


tester.e2().insertColumns("id_", "name")
.values(1, "a")
.values(2, "b")
.values(4, "c")
.exec();

// all 3 updates should have been processed, but only the one matching the read filter should be returned

tester.target("/e2_request_and_stack_filter/Bb")
.queryParam("include", "id", "name")
.put("[{\"id\":2,\"name\":\"Bb\"},{\"id\":1,\"name\":\"Aa\"},{\"id\":4,\"name\":\"Cc\"}]")
.wasOk().bodyEquals(1, "{\"id\":2,\"name\":\"Bb\"}");

tester.e2().matcher().assertMatches(3);
tester.e2().matcher().eq("id_", 1).eq("name", "Aa").assertOneMatch();
tester.e2().matcher().eq("id_", 2).eq("name", "Bb").assertOneMatch();
tester.e2().matcher().eq("id_", 4).eq("name", "Cc").assertOneMatch();
}

@Path("")
public static class Resource {

@Context
private Configuration config;

@PUT
@Path("e2_stack_filter")
public DataResponse<E2> putE2StackFilter(@Context UriInfo uriInfo, List<EntityUpdate<E2>> updates) {
return Ag.service(config).createOrUpdate(E2.class).uri(uriInfo).syncAndSelect(updates);
}

@PUT
@Path("e2_request_and_stack_filter/{name}")
public DataResponse<E2> putE2RequestAndStackFilter(
@Context UriInfo uriInfo,
@PathParam("name") String name,
List<EntityUpdate<E2>> updates) {

return Ag.service(config).createOrUpdate(E2.class)
.uri(uriInfo)
.readableFilter(E2.class, e2 -> name.equals(e2.getName()))
.syncAndSelect(updates);
}
}
}
15 changes: 14 additions & 1 deletion agrest-engine/src/main/java/io/agrest/UpdateBuilder.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package io.agrest;

import io.agrest.constraints.Constraint;
import io.agrest.access.PropertyFilter;
import io.agrest.access.ReadFilter;
import io.agrest.constraints.Constraint;
import io.agrest.meta.AgEntity;
import io.agrest.meta.AgEntityOverlay;
import io.agrest.processor.Processor;
Expand Down Expand Up @@ -106,6 +107,18 @@ default <A> UpdateBuilder<T> writeablePropFilter(Class<A> entityType, PropertyFi
return entityOverlay(AgEntity.overlay(entityType).writablePropFilter(rules));
}

/**
* Installs an in-memory filter for the specified entity type (not necessarily the root entity of the request).
* The filter is applied to the response objects of the given type and will result in exclusion of objects that do
* not match the filter. The filter is combined with any existing runtime-level filters for the same entity.
*
* @return this builder instance
* @since 4.8
*/
default <A> UpdateBuilder<T> readableFilter(Class<A> entityType, ReadFilter<A> filter) {
return entityOverlay(AgEntity.overlay(entityType).readFilter(filter));
}

/**
* Installs request-scoped {@link AgEntityOverlay} that allows to customize, add or redefine request entity structure,
* e.g. change property read/write access. This method can be called multiple times to add more than one overlay.
Expand Down
9 changes: 8 additions & 1 deletion agrest-engine/src/main/java/io/agrest/UpdateStage.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,12 @@ public enum UpdateStage {

COMMIT,

FILL_RESPONSE
FILL_RESPONSE,

/**
* A stage when the read filters are applied to the result object tree
*
* @since 4.8
*/
FILTER_RESULT
}
8 changes: 6 additions & 2 deletions agrest-engine/src/main/java/io/agrest/runtime/AgBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,12 @@
import io.agrest.runtime.entity.IExpMerger;
import io.agrest.runtime.entity.IIncludeMerger;
import io.agrest.runtime.entity.IMapByMerger;
import io.agrest.runtime.entity.IResultFilter;
import io.agrest.runtime.entity.ISizeMerger;
import io.agrest.runtime.entity.ISortMerger;
import io.agrest.runtime.entity.IncludeMerger;
import io.agrest.runtime.entity.MapByMerger;
import io.agrest.runtime.entity.ResultFilter;
import io.agrest.runtime.entity.SizeMerger;
import io.agrest.runtime.entity.SortMerger;
import io.agrest.runtime.executor.UnboundedExecutorServiceProvider;
Expand All @@ -65,6 +67,7 @@
import io.agrest.runtime.processor.select.SelectProcessorFactoryProvider;
import io.agrest.runtime.processor.select.StartStage;
import io.agrest.runtime.processor.update.AuthorizeChangesStage;
import io.agrest.runtime.processor.update.FilterResultStage;
import io.agrest.runtime.protocol.EntityUpdateParser;
import io.agrest.runtime.protocol.ExcludeParser;
import io.agrest.runtime.protocol.ExpParser;
Expand Down Expand Up @@ -428,8 +431,8 @@ private Module createCoreModule() {
.to(io.agrest.runtime.processor.update.ParseRequestStage.class);
binder.bind(io.agrest.runtime.processor.update.CreateResourceEntityStage.class)
.to(io.agrest.runtime.processor.update.CreateResourceEntityStage.class);
binder.bind(AuthorizeChangesStage.class)
.to(AuthorizeChangesStage.class);
binder.bind(AuthorizeChangesStage.class).to(AuthorizeChangesStage.class);
binder.bind(FilterResultStage.class).to(FilterResultStage.class);

// metadata stages
binder.bind(MetadataProcessorFactory.class).toProvider(MetadataProcessorFactoryProvider.class);
Expand Down Expand Up @@ -469,6 +472,7 @@ private Module createCoreModule() {
binder.bind(ISizeMerger.class).to(SizeMerger.class);
binder.bind(IIncludeMerger.class).to(IncludeMerger.class);
binder.bind(IExcludeMerger.class).to(ExcludeMerger.class);
binder.bind(IResultFilter.class).to(ResultFilter.class);

binder.bind(IResourceParser.class).to(ResourceParser.class);
binder.bind(IEntityUpdateParser.class).to(EntityUpdateParser.class);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package io.agrest.runtime.entity;

import io.agrest.RootResourceEntity;

/**
* @since 4.8
*/
public interface IResultFilter {

<T> void filterTree(RootResourceEntity<T> entity);
}
104 changes: 104 additions & 0 deletions agrest-engine/src/main/java/io/agrest/runtime/entity/ResultFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package io.agrest.runtime.entity;

import io.agrest.AgObjectId;
import io.agrest.NestedResourceEntity;
import io.agrest.ResourceEntity;
import io.agrest.RootResourceEntity;
import io.agrest.ToManyResourceEntity;
import io.agrest.ToOneResourceEntity;
import io.agrest.access.ReadFilter;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
* @since 4.8
*/
public class ResultFilter implements IResultFilter {

@Override
public <T> void filterTree(RootResourceEntity<T> entity) {
ReadFilter<T> filter = entity.getAgEntity().getReadFilter();
if (!filter.allowsAll() && !entity.getResult().isEmpty()) {

// replacing the list to avoid messing up possible data source caches, and also
// it is likely faster to create a new list than to remove entries from an existing ArrayList
entity.setResult(filterList(entity.getResult(), filter));
}

filterChildren(entity);
}

protected void filterChildren(ResourceEntity<?> entity) {
for (NestedResourceEntity<?> child : entity.getChildren().values()) {
if (child instanceof ToOneResourceEntity) {
filterToOne((ToOneResourceEntity<?>) child);
} else {
filterToMany((ToManyResourceEntity<?>) child);
}
}
}

protected <T> void filterToOne(ToOneResourceEntity<T> entity) {

ReadFilter<T> filter = entity.getAgEntity().getReadFilter();
if (!filter.allowsAll() && !entity.getResultsByParent().isEmpty()) {

// filter the map in place - key removal should be fast
entity.getResultsByParent().entrySet().removeIf(e -> !filter.isAllowed(e.getValue()));
}

filterChildren(entity);
}

protected <T> void filterToMany(ToManyResourceEntity<T> entity) {

ReadFilter<T> filter = entity.getAgEntity().getReadFilter();
if (!filter.allowsAll() && !entity.getResultsByParent().isEmpty()) {

// Filter the map in place;
// Replace relationship lists to avoid messing up possible data source caches, and also
// it is likely faster to create a new list than to remove entries from an existing ArrayList
for (Map.Entry<AgObjectId, List<T>> e : entity.getResultsByParent().entrySet()) {
e.setValue(filterList(e.getValue(), filter));
}
}

filterChildren(entity);
}

static <T> List<T> filterList(List<T> unfiltered, ReadFilter<T> filter) {

int len = unfiltered.size();
for (int i = 0; i < len; i++) {
T t = unfiltered.get(i);
if (!filter.isAllowed(t)) {

// avoid list copy until we can't
return filterListByCopy(unfiltered, filter, i);
}
}

return unfiltered;
}

static <T> List<T> filterListByCopy(List<T> unfiltered, ReadFilter<T> filter, int firstExcluded) {

int len = unfiltered.size();
List<T> filtered = new ArrayList<>(len - 1);

for (int i = 0; i < firstExcluded; i++) {
filtered.add(unfiltered.get(i));
}

for (int i = firstExcluded + 1; i < len; i++) {
T t = unfiltered.get(i);
if (filter.isAllowed(t)) {
filtered.add(t);
}
}

return filtered;
}
}
Loading

0 comments on commit 9834094

Please sign in to comment.