crnk-project / crnk-framework

JSON API library for Java
Apache License 2.0
289 stars 154 forks source link

Feature request: Allow saving relational fields in Post Controller / Resource Upsert #677

Open kubaseai opened 4 years ago

kubaseai commented 4 years ago

I'm using Crnk with Spring Boot and MongoDB. I've got:

@Repository
public interface MongoDbMainEntityRepository extends MongoRepository<MainEntity, UUID> {
  public Iterable<FabricModel> findAllByOwner(String owner);
  public Optional<FabricModel> findByIdAndOwner(UUID id, String owner);
  public Optional<FabricModel> findByChildren_Id(UUID id);
}

Inside MainEntity I've got JsonApiRelation for children, however I want to be compliant with MongoDB recommendation for not dividing one logical document into smaller pieces, because MongoDB is not relational database. I created 'virtual' Repository for children where access is delegated to repository handling MainEntity:

  @Override
  public ResourceList<Child> findAll(Collection<UUID> ids,
      QuerySpec qs)
  {
    LinkedList<Child> list = new LinkedList<>();
    SecurityContext sc = new SecurityContext();
    for (UUID id : ids) {
      mainEntityRepo.findByChildId(id).ifPresent( m -> {
        list.addAll(m.getChildren());
      });
    }
    return qs.apply(list);
  }

I would like to request a feature to store related entity together with main entity as one MongoDB document. In crnk-core-3.1.20191020144522 this can be accomplished by modification of io.crnk.core.engine.internal.dispatcher.controller.ResourceUpsert.setAttribute():

      ResourceField field = resourceInformation.findAttributeFieldByName(attributeName);
      if (field==null) {
        /* we treat relational member of entity as attribute to allow saving it together */
        ResourceField f = resourceInformation.findFieldByName(attributeName);
        if (f!=null && f.getResourceFieldType() ==  io.crnk.core.engine.information.resource.ResourceFieldType.RELATIONSHIP) {
          field = f;
        }
      }

Above it's a naive implementation for MongoDB. To be usable for everyone there should be a switch in configuration. Can you accept this feature request? I checked master and stable branches and code is different that currently used in my project. I tried using ResourceUpsert from stable, and it looks like setAttribute receives filtered attribute, so ResourceField is always attribute and my code doesn't work. Can you help with understanding where should I change code now, so I can prepare pull request? For proper pull request I would also need to know how to create and use configuration switch.

remmeier commented 4 years ago

There are different angles to this ticket:

which case are you looking more into?

kubaseai commented 4 years ago

I'm going to have main uber entity with 10-20 member fields which are lists of related entities. I tried HTTP post with include section, but didn't see related entities in repository create method. JsonAnySetter doesn't work for me because I want to use well defined Java objects/classes. Creation is very important for me. I need one HTTP call to save main entity and related ones. Doing 21 HTTP calls is not an option. I would like to have URL mainEntity/{id}/relatedEntity to fetch only some field. So I need standard relations plus bonus feature to create all entities in one call and store them in one document.

remmeier commented 4 years ago

the truely resource-oriented approach might be to create a second "ueber" resource for the update case. but some short cut would still maybe be desirable to also update relationships. do you make use of the pure JSON:API (default) or the simplified crnk-plain-json-format?

kubaseai commented 4 years ago

I use default JSON:API.

remmeier commented 4 years ago

ok, because for the other one, relationships are already inlined, so it comes quite close to mongo.

I think I would consider that additonal ueber resource. Seems simplest & most resource-oriented approach.

kubaseai commented 4 years ago

I would like to resolve it this way:

/**
 * This class allows setting relational fields as plain JSON:API attributes
 * during POST request. This is very useful with MongoDB if you want to store embedded
 * entities in one document, but still be able to read and write them in JSON:API way.
 */
public class UberEntitySupportModule implements Module {
  private static final String UBER_ENTITY_PROCESSOR_ATTR = "UBER_ENTITY_PROCESSOR";
  private UberEntityDocFilter filter;

  public UberEntitySupportModule(CrnkBoot crnkBoot) {
    this.filter = new UberEntityDocFilter(crnkBoot);
  }

  @Override
  public String getModuleName() {
    return UberEntitySupportModule.class.getName();
  }

  @Override
  public void setupModule(ModuleContext ctx) {
    ctx.addFilter(filter);
  }

  public static final void install(CrnkBoot crnkBoot) {
    crnkBoot.addModule(new UberEntitySupportModule(crnkBoot));
  }

  public static final void processResource(Object resource) {
    RequestAttributes reqAttrs = RequestContextHolder.getRequestAttributes();
    @SuppressWarnings("unchecked")
    Consumer<Object> uberEntityProcessor = (Consumer<Object>) reqAttrs
      .getAttribute(UBER_ENTITY_PROCESSOR_ATTR, RequestAttributes.SCOPE_REQUEST); 
    if (uberEntityProcessor!=null) {
      uberEntityProcessor.accept(resource);
    }
  }

  private static final class UberEntityDocFilter implements DocumentFilter {

    private CrnkBoot crnkBoot;

    public UberEntityDocFilter(CrnkBoot crnkBoot) {
      this.crnkBoot = crnkBoot;
    }

    @Override
    public Response filter(DocumentFilterContext ctx, DocumentFilterChain chain) {
      RequestAttributes reqAttrs = RequestContextHolder.getRequestAttributes();
      reqAttrs.setAttribute(UBER_ENTITY_PROCESSOR_ATTR, 
        new UberEntityResourcePostController(ctx, crnkBoot), RequestAttributes.SCOPE_REQUEST);
      return chain.doFilter(ctx);
    }
  }

  private static final class UberEntityResourcePostController extends ResourcePostController implements Consumer<Object> {
    private DocumentFilterContext ctx;

    public UberEntityResourcePostController(final DocumentFilterContext ctx, final CrnkBoot crnkBoot) {
      this.ctx = ctx;
      this.context = new ControllerContext(crnkBoot.getModuleRegistry(), new Supplier<DocumentMapper>() {
        @Override
        public DocumentMapper get() {
          return crnkBoot.getDocumentMapper();
        }
      });
    }

    @Override
    public HttpMethod getHttpMethod() {
      return HttpMethod.valueOf(ctx.getMethod());
    }

    @Override
    public void accept(Object instance) {
      if (ctx.getRequestBody().getData().isPresent()) {
        Resource res = (Resource) ctx.getRequestBody().getData().get();
        ResourceInformation ri = new UberEntityResourceInformation(
          ctx.getQueryAdapter().getResourceInformation());
        setAttributes(res, instance, ri, ctx.getQueryAdapter().getQueryContext());
      }
    }
  }

  private static final class UberEntityResourceInformation extends ResourceInformation {

    public UberEntityResourceInformation(ResourceInformation ri) {
      super(new TypeParser(), ri.getImplementationType(), ri.getResourceType(), ri.getSuperResourceType(),
          ri.getFields(), ri.getPagingSpecType());
    }

    @Override
    public ResourceField findAttributeFieldByName(String name) {
      ResourceField field = super.findAttributeFieldByName(name);
      if (field==null) {
        ResourceField anyField = super.findFieldByName(name);
        if (anyField!=null && anyField.getResourceFieldType() == ResourceFieldType.RELATIONSHIP) {
          field = anyField;
        }
      }
      return field;
    }
  }
}

Is there a high probability that used API is stable?