5

I make a REST webservice with SpringBoot web and the Morphia DAO (to use MongoDB).

As I did when I used Hibernate on MySQL, I would like to use Generic entities, repositories and endpoints so that I just have to set my entities, inherit Repositories and Services and let use the generated CRUD with REST calls.

It almost done, but I encounter a problem with the generic update of my entities with Morphia. Everything I've seen so far talks about manually setting a request with the fields that have to change ; but in the Hibernate way, we just setted the Id field, called persist() and it automatically knowed what changed and applied changes in database.

Here is some code.

BaseEntity.java

package org.beep.server.entity;

import org.mongodb.morphia.annotations.Entity;

@Entity
abstract public class BaseEntity {
    public static class JsonViewContext {
        public interface Summary {}
        public interface Detailed extends Summary{}
    }

    protected String id;

    public void setId(String id) {
        this.id = id;
    }
}

User.java (one of my final entities)

package org.beep.server.entity;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonView;
import lombok.*;
import org.hibernate.validator.constraints.Email;
import org.hibernate.validator.constraints.NotEmpty;
import org.mongodb.morphia.annotations.*;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
import javax.ws.rs.FormParam;
import java.security.NoSuchAlgorithmException;
import java.security.spec.InvalidKeySpecException;

@Entity
@Indexes(
        @Index(value="identifier", fields=@Field("email"))
)

@Builder
@NoArgsConstructor
@AllArgsConstructor
final public class User extends BaseEntity {

    /**
     * User's id
     */
    @Id
    @JsonView(JsonViewContext.Summary.class)
    private String id;

    /**
     * User's email address
     */
    @Getter @Setter
    @JsonView(JsonViewContext.Summary.class)
    @FormParam("email")
    @Indexed

    @Email
    private String email;

    /**
     * User's hashed password
     */
    @Getter
    @JsonView(JsonViewContext.Detailed.class)
    @FormParam("password")

    @NotEmpty
    private String password;

    /**
     * Sets the password after having hashed it
     * @param clearPassword The clear password
     */
    public void setPassword(String clearPassword) throws NoSuchAlgorithmException, InvalidKeySpecException {
        PasswordEncoder encoder = new BCryptPasswordEncoder();
        String hashedPassword = encoder.encode(clearPassword);
        setHashedPassword(hashedPassword);
    }

    /**
     * Directly sets the hashed password, whithout hashing it
     * @param hashedPassword The hashed password
     */
    protected void setHashedPassword(String hashedPassword) {
        this.password = hashedPassword;
    }

    /**
     * Converts the user to a UserDetail spring instance
     */
    public UserDetails toUserDetails() {
        return new org.springframework.security.core.userdetails.User(
                getEmail(),
                getPassword(),
                true,
                true,
                true,
                true,
                AuthorityUtils.createAuthorityList("USER")
        );
    }
}

EntityRepository.java (my base repository, inheriting from the Morphia one)

package org.beep.server.repository;

import org.beep.server.entity.BaseEntity;
import org.bson.types.ObjectId;
import org.mongodb.morphia.Datastore;
import org.mongodb.morphia.dao.BasicDAO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Repository;

public class EntityRepository<Entity extends BaseEntity> extends BasicDAO<Entity, ObjectId> {

    @Autowired
    protected EntityRepository(Datastore ds) {
        super(ds);
    }
}

UserRepository.java (my user repository)

package org.beep.server.repository;

import org.beep.server.entity.User;
import org.bson.types.ObjectId;
import org.mongodb.morphia.Datastore;
import org.mongodb.morphia.dao.BasicDAO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;

@Repository
public class UserRepository extends EntityRepository<User> {

    @Autowired
    protected UserRepository(Datastore ds) {
        super(ds);
    }

}

EntityService.java (the generic service, used from the Rest endpoints)

package org.beep.server.service;

import org.beep.server.entity.BaseEntity;
import org.beep.server.exception.EntityNotFoundException;
import org.beep.server.exception.UserEmailAlreadyExistsException;
import org.beep.server.repository.EntityRepository;
import org.mongodb.morphia.Datastore;
import org.mongodb.morphia.query.Query;
import org.mongodb.morphia.query.UpdateOperations;

import java.util.List;

public abstract class EntityService<Entity extends BaseEntity, Repository extends EntityRepository> implements ServiceInterface<Entity> {

    protected Repository repository;

    public EntityService(Repository repository) {
        this.repository = repository;
    }

    /**
     * {@inheritDoc}
     */
    public Entity create(Entity entity) throws UserEmailAlreadyExistsException {
        repository.save(entity);
        return entity;
    }

    /**
     * {@inheritDoc}
     */
    public void delete(String id) throws EntityNotFoundException {
        //repository.deleteById(id).;
    }

    /**
     * {@inheritDoc}
     */
    public List<Entity> findAll() {
        return repository.find().asList();
    }

    /**
     * {@inheritDoc}
     */
    public Entity findOneById(String id) throws EntityNotFoundException {
        return (Entity) repository.get(id);
    }

    /**
     * {@inheritDoc}
     */
    public Entity update(String id, Entity entity) {

        // Try to get the old entity, and to set the Id on the inputed one
        // But how can I merge the two ? If I persist like that, I will just have the modified fields, others
        // will be set to null...
        Entity oldEntity = (Entity) repository.get(id);
        entity.setId(id);
        repository.save(entity);

        // Create update operations works, but I have to set the changing fields manually...
        // not so compatible with generics !

        /*final Query<Entity> updateSelection = repository.createQuery().filter("_id",id);
        repository.createUpdateOperations().

        repository.update(updateSelection,entity);*/
        return entity;
    }
}

UserService.java

package org.beep.server.service;

import org.beep.server.entity.Message;
import org.beep.server.entity.User;
import org.beep.server.exception.EntityNotFoundException;
import org.beep.server.exception.UserEmailAlreadyExistsException;
import org.beep.server.repository.UserRepository;
import org.mongodb.morphia.Datastore;
import org.mongodb.morphia.Key;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.ws.rs.BadRequestException;
import java.util.List;
import java.util.Optional;

@Service
public class UserService extends EntityService<User, UserRepository> {

    @Autowired
    public UserService(UserRepository repository) {
        super(repository);
    }
}

RestResource.java (my base Rest Endpoint)

package org.beep.server.api.rest.v1;

import com.fasterxml.jackson.annotation.JsonView;
import org.beep.server.entity.BaseEntity;
import org.beep.server.entity.User;
import org.beep.server.entity.BaseEntity;
import org.beep.server.exception.EntityNotFoundException;
import org.beep.server.exception.UserEmailAlreadyExistsException;
import org.beep.server.service.EntityService;
import org.beep.server.service.ServiceInterface;
import org.beep.server.service.UserService;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindingResult;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;
import java.util.List;

public class RestResource<Entity extends BaseEntity, Service extends EntityService> {

    protected Service service;

    // Default constructor private to avoid blank constructor
    protected RestResource() {
        this.service = null;
    }

    /**
     * Creates an object
     * @param object Object to create
     * @return The newly created object
     */
    @RequestMapping(method = RequestMethod.POST)
    @ResponseStatus(HttpStatus.CREATED)
    @JsonView(BaseEntity.JsonViewContext.Detailed.class)
    Entity create(@RequestBody @Valid Entity object) throws UserEmailAlreadyExistsException {
        return service.create(object);
    }

    /**
     * Deletes an object from its id
     * @param id Object to delete id
     * @return The deleted object
     * @throws EntityNotFoundException
     */
    @RequestMapping(value = "{id}", method = RequestMethod.DELETE)
    @JsonView(BaseEntity.JsonViewContext.Detailed.class)
    User delete(@PathVariable("id") String id) throws EntityNotFoundException {
        service.delete(id);
        return new User();
    }

    /**
     * Gets all the objects
     * @return All the objects
     */
    @RequestMapping(method = RequestMethod.GET)
    @JsonView(BaseEntity.JsonViewContext.Summary.class)
    List<Entity> findAll() {
        return service.findAll();
    }

    /**
     * Finds one object from its id
     * @param id The object to find id
     * @return The corresponding object
     * @throws EntityNotFoundException
     */
    @RequestMapping(value = "{id}", method = RequestMethod.GET)
    @JsonView(BaseEntity.JsonViewContext.Detailed.class)
    Entity findById(@PathVariable("id") String id) throws EntityNotFoundException {
        return service.findOneById(id);
    }

    /**
     * Updates an object
     * @param object The object to update
     * @return The updated object
     */
    @RequestMapping(value = "{id}", method = RequestMethod.PUT)
    @JsonView(BaseEntity.JsonViewContext.Detailed.class)
    Entity update(@PathVariable String id, @RequestBody @Valid Entity object) {
        return service.update(id, object);
    }

    /**
     * Handles the EntityNotFound exception to return a pretty 404 error
     * @param ex The concerned exception
     */
    @ExceptionHandler
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public void handleEntityNotFound(EntityNotFoundException ex) {
    }

    /**
     * Handles the REST input validation exceptions to return a pretty 400 bad request error
     * with more info
     * @param ex The validation exception
     * @return A pretty list of the errors in the form
     */
    @ExceptionHandler
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public List<ObjectError> handleValidationFailed(MethodArgumentNotValidException ex) {

        // TODO : Check and improve the return of this method according to the front
        // The concept is to automatically bind the error dans the failed parameter
        return ex.getBindingResult().getAllErrors();
    }
}
maxime
  • 1,993
  • 3
  • 28
  • 57
  • Can you provide a little example how you call the update method? Especially regarding null-Value semantic. – Hendrik Jander Jan 26 '16 at 14:59
  • Indeed, I would like to call it like the PHP Doctrine (and many other ORMs) way : repository.persist(myObject); If it is a new object (without id) - the ORM creates a new line ; if it has an id, the ORM updates the edited fields. – maxime Jan 26 '16 at 17:15

1 Answers1

3

You've run into one of the difficult questions with Morphia. Based on the code you posted above you should look into the merge method here.

The important thing to remember is that this is not a deep merge, only top level fields, if you have complicated data objects this probably won't help.

It essentially works like this:

T Entity -> Map and then takes the map and runs a recursive update over the non-null fields like so: update({_id:@Id-field},{$set:mapOfEntityFields})

The standard transformation rules from T Entity apply -> Map, like it does for save.

For a deep merging of any generic entity it would require you to handle this yourself with a custom method.

This is a good example from another question on SO dealing with deep merging using JSON partials in Spring over complex entities using the org.codehaus.jackson.map.ObjectMapper. It should be easily adapted to your problem.

If neither of these help you, please comment in my answer and we can work out a custom recursive method that should work for you. Hope that helps.

Community
  • 1
  • 1
J-Boss
  • 927
  • 4
  • 14