spring-data-mongodb optional query parameter - java

I am using spring-data-mongodb.
I want to query database by passing some optional parameter in my query.
I have a domain class.
public class Doc {
#Id
private String id;
private String type;
private String name;
private int index;
private String data;
private String description;
private String key;
private String username;
// getter & setter
}
My controller:
#RequestMapping(value = "/getByCategory", method = RequestMethod.GET, consumes = MediaType.APPLICATION_JSON, produces = MediaType.APPLICATION_JSON)
public Iterable<Doc> getByCategory(
#RequestParam(value = "key", required = false) String key,
#RequestParam(value = "username", required = false) String username,
#RequestParam(value = "page", required = false, defaultValue = "0") int page,
#RequestParam(value = "size", required = false, defaultValue = "0") int size,
#RequestParam(value = "categories") List<String> categories)
throws EntityNotFoundException {
Iterable<Doc> nodes = docService.getByCategory(key, username , categories, page, size);
return nodes;
}
Here Key and username are optional query parameters.
If I pass any one of them it should return the matching document with given key or username.
My service method is:
public Iterable<Doc> getByCategory(String key, String username, List<String> categories, int page, int size) {
return repository.findByCategories(key, username, categories, new PageRequest(page, size));
}
Repository:
#Query("{ $or : [ {'key':?0},{'username':?1},{categories:{$in: ?2}}] }")
List<Doc> findByCategories(String key, String username,List<String> categories, Pageable pageable);
But by using above query it does not returns a document with either given key or username.
What is wrong in my query?
This is how I am making request
http://localhost:8080/document/getByCategory?key=key_one&username=ppotdar&categories=category1&categories=category2

Personally, I'd ditch the interface-driven repository pattern at that point, create a DAO that #Autowires a MongoTemplate object, and then query the DB using a Criteria instead. that way, you have clear code that isn't stretching the capabilities of the #Query annotation.
So, something like this (untested, pseudo-code):
#Repository
public class DocDAOImpl implements DocDAO {
#Autowired private MongoTemplate mongoTemplate;
public Page<Doc> findByCategories(UserRequest request, Pageable pageable){
//Go through user request and make a criteria here
Criteria c = Criteria.where("foo").is(bar).and("x").is(y);
Query q = new Query(c);
Long count = mongoTemplate.count(q);
// Following can be refactored into another method, given the Query and the Pageable.
q.with(sort); //Build the sort from the pageable.
q.limit(limit); //Build this from the pageable too
List<Doc> results = mongoTemplate.find(q, Doc.class);
return makePage(results, pageable, count);
}
...
}
I know this flies in the face of the trend towards runtime generation of DB code, but to my mind, it's still the best approach for more challenging DB operations, because it's loads easier to see what's actually going on.

Filtering out parts of the query depending on the input value is not directly supported. Nevertheless it can be done using #Query the $and operator and a bit of SpEL.
interface Repo extends CrudRepository<Doc,...> {
#Query("""
{ $and : [
?#{T(com.example.Repo.QueryUtil).ifPresent([0], 'key')},
?#{T(com.example.Repo.QueryUtil).ifPresent([1], 'username')},
...
]}
""")
List<Doc> findByKeyAndUsername(#Nullable String key, #Nullable String username, ...)
class QueryUtil {
public static Document ifPresent(Object value, String property) {
if(value == null) {
return new Document("$expr", true); // always true
}
return new Document(property, value); // eq match
}
}
// ...
}
Instead of addressing the target function via the T(...) Type expression writing an EvaluationContextExtension (see: json spel for details) allows to get rid of repeating the type name over and over again.

Related

spring boot find by field search specification

My problem is I can search from database. But I did searching by using findAll of JpaSpecificationExecutor. But, I want to do searching by using findById and pass my specification, pageable and id to it return page. But it is not working.
Here is my controller:
#GetMapping(value = "/search")
public ResponseEntity<ResponseDTO> allAccountRightService(
#RequestParam(value = "search", required = false) String search,
#RequestParam(value = "page", required = false) Integer page,
#RequestParam(value = "size", required = false) Integer size,
#RequestParam(value = "order", required = false) String order,
#RequestParam(value = "orderBy", required = false) String orderBy) {
ResponseDTO responseDTO = new ResponseDTO("accountRightService List", accountRightService.search(search, page, size, order, orderBy));
return new ResponseEntity<>(responseDTO, HttpStatus.OK);
}
and here is my `service impl` method:
public Map<PageInformation, List<AccountRightDTO>> search(String search, Integer page, Integer size, String order,
String orderBy) {
Map<PageInformation, List<AccountRightDTO>> accountRightList = new HashMap<>();
PageInformation pageInfo = new PageInformation();
if (order == null || order.isEmpty())
order = "DESC";
if (orderBy == null || orderBy.isEmpty())
orderBy = "createdAt";
Pageable pageable = CommonUtil.createPageRequest(page, size, order, orderBy);
Specification<AccountRight> spec = CommonUtil.buildSearchSpecification(search);
//Page<AccountRight> accountRightPage = accountRightRepository.findAllByRightByAppointment(CommonUtil.getAppointment().getAppointmentID(), spec, pageable);
Page<AccountRight> accountRightPage = accountRightRepository.findAll(spec, pageable);
List<AccountRight> accountRights = accountRightPage.getContent();
List<AccountRightDTO> accountRightDTOs = new ArrayList<>();
accountRightDTOs = accountRights.stream().map(accountRight -> {
AccountRightDTO accountRightDTO = new AccountRightDTO();
AppointmentDTO rightToAppointmentDTO = new AppointmentDTO();
AppointmentDTO rightByAppointmentDTO = new AppointmentDTO();
BeanUtils.copyProperties(accountRight, accountRightDTO, "accountRightID");
accountRightDTO.setAccountRightID(Long.toString(accountRight.getAccountRightID()));
BeanUtils.copyProperties(accountRight.getRightToAppointment(), rightToAppointmentDTO, "appointmentID");
rightToAppointmentDTO.setAppointmentID(Long.toString(accountRight.getRightToAppointment().getAppointmentID()));
BeanUtils.copyProperties(accountRight.getRightByAppointment(), rightByAppointmentDTO, "appointmentID");
rightByAppointmentDTO.setAppointmentID(Long.toString(accountRight.getRightToAppointment().getAppointmentID()));
accountRightDTO.setRightByAppointment(rightByAppointmentDTO);
accountRightDTO.setRightToAppointment(rightToAppointmentDTO);
return accountRightDTO;
}).collect(Collectors.toList());
pageInfo.setSize(accountRightPage.getSize());
pageInfo.setTotalElements(accountRightPage.getTotalElements());
pageInfo.setTotalPages(accountRightPage.getTotalPages());
accountRightList.put(pageInfo, accountRightDTOs);
return accountRightList;
}
and this is my buildsearchspecification method
public static <T> Specification<T> buildSearchSpecification(String search) {
SearchSpecificationsBuilder<T> builder = new SearchSpecificationsBuilder<T>();
if (search != null && !search.isEmpty()) {
String[] str = search.split(",");
if (str != null) {
for (String strTemp : str) {
Pattern pattern = Pattern.compile("(\\p{Punct}?)(.*)(:|!|<|>|~)(.*)(\\p{Punct}?),");
Matcher matcher = pattern.matcher(strTemp + ",");
while (matcher.find()) {
builder.with(matcher.group(1), matcher.group(2),
SearchOperation.getSimpleOperation(matcher.group(3).toCharArray()[0]),
matcher.group(4));
}
}
}
}
Specification<T> spec = builder.build();
return spec;
}
and here is my findAllByRightByAppointment repository method
#Query("select account from AccountRight account where account.rightByAppointment.appointmentID=?1")
Page<AccountRight> findAllByRightByAppointment(Long appointmentID, #Nullable Specification<AccountRight> spec, Pageable pageable);
If I use findAll method than searching will work otherwise by using my custom method pagination works without searching
I found an answer by using Specification.Where(your_specification).and(your_search_specification).
here is my updated code now:
Specification<AccountRight> searchSpec = CommonUtil.buildSearchSpecification(search); //this specification needs my search string.
SearchSpecification<AccountRight> rightByAppointmentID =
new SearchSpecification<AccountRight>(new SearchCriteria("rightByAppointment.appointmentID", SearchOperation.EQUALITY, CommonUtil.getAppointment().getAppointmentID())); //this specification accepts search criteria with key, operation and value.
Page<AccountRight> accountRightPage = accountRightRepository.findAll(Specification.where(rightByAppointmentID).and(searchSpec), pageable);
//here you will just tell findAll method to findAll entities where rightByAppointmentID is equal to
//CommonUtil.getAppointment().getAppointmentID() and search query is searchSpec

Spring boot: Optional parameter query in Query method

I am new to Spring boot and hibernate. Here I am trying run a search based optional parameter query Where i can search by name, country etc. If I kept this field null then query should all list. But the problem is my method is returning all data ignoring my search parameter. my model class look like
#Entity(name="MLFM_ORDER_OWNER")
public class ModelOrderOwner {
#Id #GenericGenerator(name = "custom_sequence", strategy =
"com.biziitech.mlfm.IdGenerator")
#GeneratedValue(generator = "custom_sequence")
#Column(name="ORDER_OWNER_ID")
private Long orderOwnerId;
#Column(name="OWNER_NAME")
private String ownerName;
#OneToOne
#JoinColumn(name="BUSINESS_TYPE_ID")
private ModelBusinessType businessTypeId;
#Column(name="SHORT_CODE")
private String shortCode;
#ManyToOne
#JoinColumn(name="OWNER_COUNTRY")
private ModelCountry ownerCountry;
// getter setter..
My Repository interface looks like
public interface OrderOwnerRepository extends
JpaRepository<ModelOrderOwner,Long>{
#Query("select a from MLFM_ORDER_OWNER a where a.businessTypeId.typeId=coalsec(:typeId,a.businessTypeId.typeId) and a.ownerCountry.countryId=coalsec(:countryId,a.ownerCountry.countryId) and a.ownerName LIKE %:name and a.shortCode LIKE %:code")
public List <ModelOrderOwner> findOwnerDetails(#Param("typeId")Long typeId,#Param("countryId")Long countryId,#Param("name")String name,#Param("code")String code);
}
And here is my method in controller
#RequestMapping(path="/owners/search")
public String getAllOwner(Model model,#RequestParam("owner_name") String name,#RequestParam("shortCode") String code,
#RequestParam("phoneNumber") String phoneNumber,#RequestParam("countryName") Long countryId,
#RequestParam("businessType") Long typeId
) {
model.addAttribute("ownerList",ownerRepository.findOwnerDetails(typeId, countryId, name, code));
return "data_list";
}
Can Any one help me in this regard? please?
It is too late too answer, but for anyone who looks for a solution yet there is a more simple way as below:
In my case my controller was like:
#RestController
#RequestMapping("/order")
public class OrderController {
private final IOrderService service;
public OrderController(IOrderService service) {
this.service = service;
}
#RequestMapping(value = "/{username}/", method = RequestMethod.GET)
public ResponseEntity<ListResponse<UserOrdersResponse>> getUserOrders(
#RequestHeader Map<String, String> requestHeaders,
#RequestParam(required=false) Long id,
#RequestParam(required=false) Long flags,
#RequestParam(required=true) Long offset,
#RequestParam(required=true) Long length) {
// Return successful response
return new ResponseEntity<>(service.getUserOrders(requestDTO), HttpStatus.OK);
}
}
As you can see, I have Username as #PathVariable and length and offset which are my required parameters, but I accept id and flags for filtering search result, so they are my optional parameters and are not necessary for calling the REST service.
Now in my repository layer I have just created my #Query as below:
#Query("select new com.ada.bourse.wealth.services.models.response.UserOrdersResponse(FIELDS ARE DELETED TO BECOME MORE READABLE)" +
" from User u join Orders o on u.id = o.user.id where u.userName = :username" +
" and (:orderId is null or o.id = :orderId) and (:flag is null or o.flags = :flag)")
Page<UserOrdersResponse> findUsersOrders(String username, Long orderId, Long flag, Pageable page);
And that's it, you can see that I checked my optional arguments with (:orderId is null or o.id = :orderId) and (:flag is null or o.flags = :flag) and I think it needs to be emphasized that I checked my argument with is null condition not my columns data, so if client send Id and flags parameters for me I will filter the Result with them otherwise I just query with username which was my #PathVariable.
Don't know how but below code is working for me:
#Query("select a from MLFM_ORDER_OWNER a
where a.businessTypeId.typeId=COALESCE(:typeId,a.businessTypeId.typeId)
and a.ownerCountry.countryId=COALESCE(:countryId,a.ownerCountry.countryId)
and a.ownerName LIKE %:name and a.shortCode LIKE %:code")
public List <ModelOrderOwner> findOwnerDetails(
#Param("typeId")Long typeId,
#Param("countryId")Long countryId,
#Param("name")String name,
#Param("code")String code);
and in my controller class:
#RequestMapping(path="/owners/search")
public String getAllOwner(Model model,
#RequestParam("owner_name") String name,
#RequestParam("shortCode") String code,
#RequestParam("phoneNumber") String phoneNumber,
#RequestParam("countryName") Long countryId,
#RequestParam(value = "active", required = false) String active, #RequestParam("businessType") Long typeId) {
if(typeId==0)
typeId=null;
if(countryId==0)
countryId=null; model.addAttribute("ownerList",ownerRepository.findOwnerDetails(typeId, countryId, name, code, status));
return "data_list";
}
JPQL doesn't support optional parameters.
There is no easy way of doing this in JPQL. You will have to write multiple WHERE clauses with OR operator.
Refer these answers to similar questions: Answer 1 & Answer 2
PS: You might want to look into Query by Example for your use case. It supports handling of null parameters.
Use JpaSpecificationExecutor //import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
Step 1: Implement JpaSpecificationExecutor in your JPA Repository
Ex:
public interface TicketRepo extends JpaRepository<Ticket, Long>, JpaSpecificationExecutor<Ticket> {
Step 2 Now to fetch tickets based on optional parameters you can build Specification query using CriteriaBuilder
Ex:
public Specification<Ticket> getTicketQuery(Integer domainId, Calendar startDate, Calendar endDate, Integer gameId, Integer drawId) {
return (root, query, criteriaBuilder) -> {
List<Predicate> predicates = new ArrayList<>();
predicates.add(criteriaBuilder.equal(root.get("domainId"), domainId));
predicates.add(criteriaBuilder.greaterThanOrEqualTo(root.get("createdAt"), startDate));
predicates.add(criteriaBuilder.lessThanOrEqualTo(root.get("createdAt"), endDate));
if (gameId != null) {
predicates.add(criteriaBuilder.equal(root.get("gameId"), gameId));
}
return criteriaBuilder.and(predicates.toArray(new Predicate[0]));
};
}
Step 3: Pass the Specification instance to jpaRepo.findAll(specification), it will return you the list of your entity object (Tickets here in the running example)
ticketRepo.findAll(specification); // Pass output of function in step 2 to findAll

A custom domain object isn't being shown while using #ModelAttribute

I am using Springfox and Swagger to generate swagger files. Right now I'm using #ModelAttribute to pull the variables from an object (NetworkCmd) to show as query params in the swagger doc.
I currently have the following controller:
#RequestMapping(value = "/{product_id}", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE)
#ResponseHeader()
public ResponseEntity<?> networkResponse(
#RequestHeader HttpHeaders headers,
#PathVariable("product_id")String productId,
#Valid #ModelAttribute NetworkCmd cmd,
BindingResult result)
throws Exception {
...
}
And here is a sample of NetworkCmd:
#ItemId
#NotNull(message = "product cannot be null")
#ApiModelProperty(
value = "testing")
private String product_id;
#ApiModelProperty(
value = "key",
private String key;
#ApiModelProperty(
value = "parent")
private Boolean is_parent_id;
#Min(0)
#ApiModelProperty(
value = "radius")
private double radius = 10d;
One of the variables in this class is a custom domain object Nearby.
private Nearby nearby = null;
public Nearby getNearby() {
return nearby;
}
public void setNearby(String nearby) throws ParseException {
this.nearby = Nearby.parse(nearby);
}
This is kind of a special variable because it takes in a String, and then parses that string and turns it into the Nearby object.
My problem is that this Nearby variable isn't showing up on the generated swagger document through #ModelAttribute. I'm happy to provide any more information.
One way to get around this problem is to create an alternate type rule in your docket. This way anytime we encounter the nearby type we treat it as a string.
new Docket(...)
.directModelSubstitute(Nearby.class, String.class)

How to page rerturned data in a GET request

I have the following mapping:
#RequestMapping(value = "/client/list", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<List<Client>> listAll(
#RequestHeader(value = "username", required = true) String username,
#RequestHeader(value = "api_key", required = false) String apiKey) {
if (!authenticationService.validate(username, apiKey)){
throw new UnauthorizedUserException();
}
List<Client> clients = clientService.findAll();
return new ResponseEntity<List<Client>>(clients, HttpStatus.OK);
}
My issue is that many thousands of records may be returned. How would I best desing this mapping to support paging on the client?
You can make a class as your input object or request which takes all parameters and then if you have to apply the pagination by using the parameters. Please check below :
public class ClientSearchCriteria {
private String username;
private String apikey;
private Long pageNo;
private Long recordsPerPage;
/*getters and setters*/
}
And in you database call(if you are using hibernate and criteria): use the following method -
public Criteria applyPaginationCriteria(ClientSearchCriteria searchCriteria){
Long recordsPerPage = searchCriteria.getRecordsPerPage();
if (recordsPerPage >= 0L) {
criteria.setFirstResult((int) (searchCriteria.getPageNo() * recordsPerPage - recordsPerPage));
criteria.setMaxResults(recordsPerPage.intValue());
}
return criteria;
}
or if other than hibernate call, you can use the sql query to retrieve the results (as above HQL query) and then pass it your service call.
You can always use a JTable. See an example here

How to distinguish between null and not provided values for partial updates in Spring Rest Controller

I'm trying to distinguish between null values and not provided values when partially updating an entity with PUT request method in Spring Rest Controller.
Consider the following entity, as an example:
#Entity
private class Person {
#Id
#GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/* let's assume the following attributes may be null */
private String firstName;
private String lastName;
/* getters and setters ... */
}
My Person repository (Spring Data):
#Repository
public interface PersonRepository extends CrudRepository<Person, Long> {
}
The DTO I use:
private class PersonDTO {
private String firstName;
private String lastName;
/* getters and setters ... */
}
My Spring RestController:
#RestController
#RequestMapping("/api/people")
public class PersonController {
#Autowired
private PersonRepository people;
#Transactional
#RequestMapping(path = "/{personId}", method = RequestMethod.PUT)
public ResponseEntity<?> update(
#PathVariable String personId,
#RequestBody PersonDTO dto) {
// get the entity by ID
Person p = people.findOne(personId); // we assume it exists
// update ONLY entity attributes that have been defined
if(/* dto.getFirstName is defined */)
p.setFirstName = dto.getFirstName;
if(/* dto.getLastName is defined */)
p.setLastName = dto.getLastName;
return ResponseEntity.ok(p);
}
}
Request with missing property
{"firstName": "John"}
Expected behaviour: update firstName= "John" (leave lastName unchanged).
Request with null property
{"firstName": "John", "lastName": null}
Expected behaviour: update firstName="John" and set lastName=null.
I cannot distinguish between these two cases, sincelastName in the DTO is always set to null by Jackson.
Note:
I know that REST best practices (RFC 6902) recommend using PATCH instead of PUT for partial updates, but in my particular scenario I need to use PUT.
Another option is to use java.util.Optional.
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.Optional;
#JsonInclude(JsonInclude.Include.NON_NULL)
private class PersonDTO {
private Optional<String> firstName;
private Optional<String> lastName;
/* getters and setters ... */
}
If firstName is not set, the value is null, and would be ignored by the #JsonInclude annotation. Otherwise, if implicitly set in the request object, firstName would not be null, but firstName.get() would be. I found this browsing the solution #laffuste linked to a little lower down in a different comment (garretwilson's initial comment saying it didn't work turns out to work).
You can also map the DTO to the Entity with Jackson's ObjectMapper, and it will ignore properties that were not passed in the request object:
import com.fasterxml.jackson.databind.ObjectMapper;
class PersonController {
// ...
#Autowired
ObjectMapper objectMapper
#Transactional
#RequestMapping(path = "/{personId}", method = RequestMethod.PUT)
public ResponseEntity<?> update(
#PathVariable String personId,
#RequestBody PersonDTO dto
) {
Person p = people.findOne(personId);
objectMapper.updateValue(p, dto);
personRepository.save(p);
// return ...
}
}
Validating a DTO using java.util.Optional is a little different as well. It's documented here, but took me a while to find:
// ...
import javax.validation.constraints.NotNull;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
// ...
private class PersonDTO {
private Optional<#NotNull String> firstName;
private Optional<#NotBlank #Pattern(regexp = "...") String> lastName;
/* getters and setters ... */
}
In this case, firstName may not be set at all, but if set, may not be set to null if PersonDTO is validated.
//...
import javax.validation.Valid;
//...
public ResponseEntity<?> update(
#PathVariable String personId,
#RequestBody #Valid PersonDTO dto
) {
// ...
}
Also might be worth mentioning the use of Optional seems to be highly debated, and as of writing Lombok's maintainer(s) won't support it (see this question for example). This means using lombok.Data/lombok.Setter on a class with Optional fields with constraints doesn't work (it attempts to create setters with the constraints intact), so using #Setter/#Data causes an exception to be thrown as both the setter and the member variable have constraints set. It also seems better form to write the Setter without an Optional parameter, for example:
//...
import lombok.Getter;
//...
#Getter
private class PersonDTO {
private Optional<#NotNull String> firstName;
private Optional<#NotBlank #Pattern(regexp = "...") String> lastName;
public void setFirstName(String firstName) {
this.firstName = Optional.ofNullable(firstName);
}
// etc...
}
There is a better option, that does not involve changing your DTO's or to customize your setters.
It involves letting Jackson merge data with an existing data object, as follows:
MyData existingData = ...
ObjectReader readerForUpdating = objectMapper.readerForUpdating(existingData);
MyData mergedData = readerForUpdating.readValue(newData);
Any fields not present in newData will not overwrite data in existingData, but if a field is present it will be overwritten, even if it contains null.
Demo code:
ObjectMapper objectMapper = new ObjectMapper();
MyDTO dto = new MyDTO();
dto.setText("text");
dto.setAddress("address");
dto.setCity("city");
String json = "{\"text\": \"patched text\", \"city\": null}";
ObjectReader readerForUpdating = objectMapper.readerForUpdating(dto);
MyDTO merged = readerForUpdating.readValue(json);
Results in {"text": "patched text", "address": "address", "city": null}
Note that text and city were patched (city is now null) and that address was left alone.
In a Spring Rest Controller you will need to get the original JSON data instead of having Spring deserialize it in order to do this. So change your endpoint like this:
#Autowired ObjectMapper objectMapper;
#RequestMapping(path = "/{personId}", method = RequestMethod.PATCH)
public ResponseEntity<?> update(
#PathVariable String personId,
#RequestBody JsonNode jsonNode) {
RequestDTO existingData = getExistingDataFromSomewhere();
ObjectReader readerForUpdating = objectMapper.readerForUpdating(existingData);
RequestDTO mergedData = readerForUpdating.readValue(jsonNode);
...
}
Use boolean flags as jackson's author recommends.
class PersonDTO {
private String firstName;
private boolean isFirstNameDirty;
public void setFirstName(String firstName){
this.firstName = firstName;
this.isFirstNameDirty = true;
}
public String getFirstName() {
return firstName;
}
public boolean hasFirstName() {
return isFirstNameDirty;
}
}
Actually,if ignore the validation,you can solve your problem like this.
public class BusDto {
private Map<String, Object> changedAttrs = new HashMap<>();
/* getter and setter */
}
First, write a super class for your dto,like BusDto.
Second, change your dto to extend the super class, and change the
dto's set method,to put the attribute name and value to the
changedAttrs(beacause the spring would invoke the set when the
attribute has value no matter null or not null).
Third,traversal the map.
I have tried to solve the same problem. I found it quite easy to use JsonNode as the DTOs. This way you only get what is submitted.
You will need to write a MergeService yourself that does the actual work, similar to the BeanWrapper. I haven't found an existing framework that can do exactly what is needed. (If you use only Json requests you might be able to use Jacksons readForUpdate method.)
We actually use another node type as we need the same functionality from "standard form submits" and other service calls. Additionally the modifications should be applied within a transaction inside something called EntityService.
This MergeService will unfortunately become quite complex, as you will need to handle properties, lists, sets and maps yourself :)
The most problematic piece for me was to distinguish between changes within an element of a list/set and modifications or replacements of lists/sets.
And also validation will not be easy as you need to validate some properties against another model (the JPA entities in my case)
EDIT - Some mapping code (pseudo-code):
class SomeController {
#RequestMapping(value = { "/{id}" }, method = RequestMethod.POST, consumes = MediaType.APPLICATION_JSON_VALUE)
#ResponseBody
public void save(
#PathVariable("id") final Integer id,
#RequestBody final JsonNode modifications) {
modifierService.applyModifications(someEntityLoadedById, modifications);
}
}
class ModifierService {
public void applyModifications(Object updateObj, JsonNode node)
throws Exception {
BeanWrapperImpl bw = new BeanWrapperImpl(updateObj);
Iterator<String> fieldNames = node.fieldNames();
while (fieldNames.hasNext()) {
String fieldName = fieldNames.next();
Object valueToBeUpdated = node.get(fieldName);
Class<?> propertyType = bw.getPropertyType(fieldName);
if (propertyType == null) {
if (!ignoreUnkown) {
throw new IllegalArgumentException("Unkown field " + fieldName + " on type " + bw.getWrappedClass());
}
} else if (Map.class.isAssignableFrom(propertyType)) {
handleMap(bw, fieldName, valueToBeUpdated, ModificationType.MODIFY, createdObjects);
} else if (Collection.class.isAssignableFrom(propertyType)) {
handleCollection(bw, fieldName, valueToBeUpdated, ModificationType.MODIFY, createdObjects);
} else {
handleObject(bw, fieldName, valueToBeUpdated, propertyType, createdObjects);
}
}
}
}
Maybe too late for an answer, but you could:
By default, don't unset 'null' values. Provide an explicit list via query params what fields you want to unset. In such a way you can still send JSON that corresponds to your entity and have flexibility to unset fields when you need.
Depending on your use case, some endpoints may explicitly treat all null values as unset operations. A little bit dangerous for patching, but in some circumstances might be an option.
Another solution would be to imperatively deserialize the request body. By doing it, you will be able to collect user provided fields and selectively validate them.
So your DTO might look like this:
public class CatDto {
#NotBlank
private String name;
#Min(0)
#Max(100)
private int laziness;
#Max(3)
private int purringVolume;
}
And your controller can be something like this:
#RestController
#RequestMapping("/api/cats")
#io.swagger.v3.oas.annotations.parameters.RequestBody(
content = #Content(schema = #Schema(implementation = CatDto.class)))
// ^^ this passes your CatDto model to swagger (you must use springdoc to get it to work!)
public class CatController {
#Autowired
SmartValidator validator; // we'll use this to validate our request
#PatchMapping(path = "/{id}", consumes = "application/json")
public ResponseEntity<String> updateCat(
#PathVariable String id,
#RequestBody Map<String, Object> body
// ^^ no Valid annotation, no declarative DTO binding here!
) throws MethodArgumentNotValidException {
CatDto catDto = new CatDto();
WebDataBinder binder = new WebDataBinder(catDto);
BindingResult bindingResult = binder.getBindingResult();
List<String> patchFields = new ArrayList<>();
binder.bind(new MutablePropertyValues(body));
// ^^ imperatively bind to DTO
body.forEach((k, v) -> {
patchFields.add(k);
// ^^ collect user provided fields if you need
validator.validateValue(CatDto.class, k, v, bindingResult);
// ^^ imperatively validate user input
});
if (bindingResult.hasErrors()) {
throw new MethodArgumentNotValidException(null, bindingResult);
// ^^ this can be handled by your regular exception handler
}
// Here you can do normal stuff with your catDto.
// Map it to cat model, send to cat service, whatever.
return ResponseEntity.ok("cat updated");
}
}
No need for Optional's, no extra dependencies, your normal validation just works, your swagger looks good. The only problem is, you don't get proper merge patch on nested objects, but in many use cases that's not even required.
Probably to late but following code works for me to distinguish between null and not provided values
if(dto.getIban() == null){
log.info("Iban value is not provided");
}else if(dto.getIban().orElse(null) == null){
log.info("Iban is provided and has null value");
}else{
log.info("Iban value is : " + dto.getIban().get());
}

Categories