spring-data-rest
spring-data-rest copied to clipboard
Fields named "id" in nested MongoDB objects are not updated by PUT requests
PUT requests for nested MongoDB objects with an exposed "id" field update other fields in the nested objects, but not the "id" field. This is true for lists of nested objects, and as of Spring Data REST 4.0.3 (due I think to #2174) for directly nested objects.
This feels like it's in a similar vein to https://github.com/spring-projects/spring-data-mongodb/issues/3351, in that nested MongoDB objects aren't really themselves entities, but Spring Data MongoDB & Spring Data REST treat them as such.
I ran across this issue when upgrading a project from Spring Boot 2.7.4 to 3.1.1, since Spring Data REST changed non-list nested object behavior, changing the existing behavior of the application.
Example
Build
build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '3.1.1'
id 'io.spring.dependency-management' version '1.1.0'
}
java { sourceCompatibility = '17' }
repositories { mavenCentral() }
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
implementation 'org.springframework.boot:spring-boot-starter-data-rest'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:mongodb'
}
tasks.named('test') { useJUnitPlatform() }
Application
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ExampleApplication {
public static void main(String[] args) {
SpringApplication.run(ExampleApplication.class, args);
}
}
import java.util.Objects;
public class Nested {
private String id;
private Integer value;
public Nested() {
}
public Nested(String id, Integer value) {
this.id = id;
this.value = value;
}
public String getId() {return id;}
public void setId(String id) {this.id = id;}
public Integer getValue() {return value;}
public void setValue(Integer value) {this.value = value;}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Nested that)) return false;
return Objects.equals(id, that.id) && Objects.equals(value, that.value);
}
@Override public int hashCode() {return Objects.hash(id, value);}
@Override public String toString() {return "[%s,%s]".formatted(id, value);}
}
public class Example {
private String id;
private Nested nested;
public Example() {
}
public Example(String id, Nested nested) {
this.id = id;
this.nested = nested;
}
public String getId() {return id;}
public void setId(String id) {this.id = id;}
public Nested getNested() {return nested;}
public void setNested(Nested nested) {this.nested = nested;}
}
import java.util.List;
public class ListExample {
private String id;
private List<Nested> list;
public ListExample() {
}
public ListExample(String id, List<Nested> list) {
this.id = id;
this.list = list;
}
public String getId() {return id;}
public void setId(String id) {this.id = id;}
public List<Nested> getList() {return list;}
public void setList(List<Nested> list) {this.list = list;}
}
import org.springframework.data.repository.CrudRepository;
public interface ExampleRepository extends CrudRepository<Example, String> {}
import org.springframework.data.repository.CrudRepository;
public interface ListExampleRepository extends CrudRepository<ListExample, String> { }
import org.springframework.data.rest.core.config.RepositoryRestConfiguration;
import org.springframework.data.rest.webmvc.config.RepositoryRestConfigurer;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
@Component
public class ExampleRepositoryConfigurer implements RepositoryRestConfigurer {
@Override
public void configureRepositoryRestConfiguration(RepositoryRestConfiguration configuration, CorsRegistry corsRegistry) {
configuration.exposeIdsFor(Nested.class);
}
}
Tests
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.json.JacksonTester;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureJsonTesters
@Testcontainers
public class IssueTest {
@Autowired
private ExampleRepository repository;
@Autowired
protected MockMvc mockMvc;
@Autowired
protected JacksonTester<Example> jsonTester;
@Test
public void issue() throws Exception {
repository.save(new Example("a", new Nested("A", 1)));
performUpdateRequest(new Example("a", new Nested("B", 2)));
Example saved = repository.findById("a").orElseThrow();
assertEquals(2, saved.getNested().getValue()); // Succeeds
assertEquals("B", saved.getNested().getId()); // Fails
}
private ResultActions performUpdateRequest(Example input) throws Exception {
return mockMvc.perform(put("/examples/{id}", input.getId())
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(jsonTester.write(input).getJson()))
.andExpect(status().isOk());
}
}
=>
expected: <B> but was: <A>
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.json.JacksonTester;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureJsonTesters
@Testcontainers
public class ListIssueTest {
@Autowired
private ListExampleRepository repository;
@Autowired
protected MockMvc mockMvc;
@Autowired
protected JacksonTester<ListExample> jsonTester;
@Test
public void issue() throws Exception {
List<Nested> initialList =
List.of(new Nested("A", 1), new Nested("B", 2), new Nested("C", 3));
List<Nested> updatedList =
List.of(new Nested("B", 2), new Nested("D", 4), new Nested("A", 1), new Nested("E", 5));
repository.save(new ListExample("a", initialList));
performUpdateRequest(new ListExample("a", updatedList));
ListExample saved = repository.findById("a").orElseThrow();
assertEquals(updatedList, saved.getList());
}
private ResultActions performUpdateRequest(ListExample input) throws Exception {
return mockMvc.perform(put("/listExamples/{id}", input.getId())
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(jsonTester.write(input).getJson()))
.andExpect(status().isOk());
}
}
=>
expected: <[[B,2], [D,4], [A,1], [E,5]]> but was: <[[A,2], [B,4], [C,1], [E,5]]>
Workaround
One workaround I've discovered is to rename the id field but keep the getId() method, marking it as @Transient.
public static class Nested {
@Field("_id")
@JsonIgnore
private String nestedId;
@Transient
public String getId() {return nestedId;}
public void setId(String id) {this.nestedId = id;}