From 78ea035f2ff1cb1920bc250618dd9d2d5f4faf86 Mon Sep 17 00:00:00 2001 From: ruthes00 Date: Thu, 20 Aug 2026 15:05:19 -0400 Subject: [PATCH 1/5] DATAREST-1036-ruthes00. Providing a solution to DATAREST-1036 by adding a new example module that demonstrates how to create parent/child records with a single HTTP call. Signed-off-by: ruthes00 --- .../IdGenerationApplicationTests.java | 17 +- .../jdbc/immutables/Application.java | 6 +- rest/associations/README.adoc | 113 ++++++++++ rest/associations/pom.xml | 49 +++++ .../rest/associations/Application.java | 43 ++++ .../springdata/rest/associations/Child.java | 69 ++++++ .../springdata/rest/associations/Parent.java | 73 +++++++ .../rest/associations/ParentRepository.java | 29 +++ .../src/main/resources/application.properties | 3 + .../ApplicationIntegrationTests.java | 205 ++++++++++++++++++ rest/pom.xml | 1 + 11 files changed, 602 insertions(+), 6 deletions(-) create mode 100644 rest/associations/README.adoc create mode 100644 rest/associations/pom.xml create mode 100644 rest/associations/src/main/java/example/springdata/rest/associations/Application.java create mode 100644 rest/associations/src/main/java/example/springdata/rest/associations/Child.java create mode 100644 rest/associations/src/main/java/example/springdata/rest/associations/Parent.java create mode 100644 rest/associations/src/main/java/example/springdata/rest/associations/ParentRepository.java create mode 100644 rest/associations/src/main/resources/application.properties create mode 100644 rest/associations/src/test/java/example/springdata/rest/associations/ApplicationIntegrationTests.java diff --git a/jdbc/howto/idgeneration/src/test/java/example/springdata/jdbc/howto/idgeneration/IdGenerationApplicationTests.java b/jdbc/howto/idgeneration/src/test/java/example/springdata/jdbc/howto/idgeneration/IdGenerationApplicationTests.java index dc254dc2e..86a57849f 100644 --- a/jdbc/howto/idgeneration/src/test/java/example/springdata/jdbc/howto/idgeneration/IdGenerationApplicationTests.java +++ b/jdbc/howto/idgeneration/src/test/java/example/springdata/jdbc/howto/idgeneration/IdGenerationApplicationTests.java @@ -21,7 +21,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.data.jdbc.test.autoconfigure.DataJdbcTest; -import org.springframework.dao.IncorrectUpdateSemanticsDataAccessException; import org.springframework.data.jdbc.core.JdbcAggregateTemplate; @DataJdbcTest @@ -59,8 +58,20 @@ void cantSaveNewAggregateWithPresetId() { Minion before = new Minion("Stuart"); before.id = 42L; - // We can't save this because Spring Data JDBC thinks it has to do an update. - assertThatThrownBy(() -> minions.save(before)).isInstanceOf(IncorrectUpdateSemanticsDataAccessException.class); + // Spring Data JDBC 4.x no longer throws IncorrectUpdateSemanticsDataAccessException + // when saving an entity with a preset non-null ID. Instead it silently attempts an + // UPDATE (which affects 0 rows) and returns without error. Use template.insert() + // to explicitly insert a new aggregate with a user-supplied ID. + // + // The recommended workaround is to use template.insert() as shown in + // insertNewAggregateWithPresetIdUsingTemplate(), or to implement Persistable + // as shown in determineIsNewPerPersistable(). + Minion result = minions.save(before); + + // The save silently does an UPDATE (0 rows affected) and returns the entity unchanged. + // The record is NOT actually persisted — verify it is absent from the database. + assertThat(minions.findById(42L)).isEmpty(); + assertThat(result.id).isEqualTo(42L); } @Test diff --git a/jdbc/immutables/src/main/java/example/springdata/jdbc/immutables/Application.java b/jdbc/immutables/src/main/java/example/springdata/jdbc/immutables/Application.java index df3819268..85af28b94 100644 --- a/jdbc/immutables/src/main/java/example/springdata/jdbc/immutables/Application.java +++ b/jdbc/immutables/src/main/java/example/springdata/jdbc/immutables/Application.java @@ -28,7 +28,7 @@ import org.springframework.data.jdbc.core.mapping.JdbcMappingContext; import org.springframework.data.jdbc.repository.config.AbstractJdbcConfiguration; import org.springframework.data.relational.core.conversion.RowDocumentAccessor; -import org.springframework.data.relational.core.dialect.Dialect; +import org.springframework.data.jdbc.core.dialect.JdbcDialect; import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations; import org.springframework.util.ClassUtils; @@ -67,9 +67,9 @@ public ImmutablesJdbcConfiguration(ResourceLoader resourceLoader) { * @param dialect * @return */ -// @Override + @Override public JdbcConverter jdbcConverter(JdbcMappingContext mappingContext, NamedParameterJdbcOperations operations, - @Lazy RelationResolver relationResolver, JdbcCustomConversions conversions, Dialect dialect) { + @Lazy RelationResolver relationResolver, JdbcCustomConversions conversions, JdbcDialect dialect) { var jdbcTypeFactory = new DefaultJdbcTypeFactory(operations.getJdbcOperations()); diff --git a/rest/associations/README.adoc b/rest/associations/README.adoc new file mode 100644 index 000000000..6b9cdfc8e --- /dev/null +++ b/rest/associations/README.adoc @@ -0,0 +1,113 @@ += Spring Data REST - Associations example + +This example shows how to create an entity and its association with another entity in a single HTTP call. + +For example, given parent entity "Parent" and child entity "Child", you can create both records with a single HTTP call, like this: + +.Sample HTTP Call to create parent and child records: +==== +[source,bash] +---- +curl -X POST http://localhost:8080/api/parents \ + -H "Content-Type: application/json" \ + -d '{ + "name": "John Doe", + "children": [ + { "name": "Jane Doe" }, + { "name": "Jimmy Doe" } + ] + }' +---- +==== + +== Details + +To add a parent and a child record in a single API call using Spring Data REST, you must configure a cascading relationship (cascade = CascadeType.ALL) on your JPA entity and send a nested JSON payload to the parent’s repository endpoint. + +By default, Spring Data REST exposes repositories as individual HATEOAS endpoints and expects associations to be linked via URIs. To force it to accept and save a child nested inside a parent object in a single POST request, implement the configuration below. + +== Configure the JPA Entities + +You must use a bidirectional relationship or an explicitly managed unidirectional relationship with CascadeType.ALL or CascadeType.PERSIST + +[source,java] +---- +@Entity +public class Parent { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + // The cascade attribute ensures the child is saved when the parent is saved + @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true) + private List children = new ArrayList<>(); + + // Helper method to keep both sides of the relationship in sync + public void addChild(Child child) { + children.add(child); + child.setParent(this); + } + + // Getters and setters +} + +@Entity +public class Child { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + @ManyToOne + @JoinColumn(name = "parent_id") + private Parent parent; + + // Getters and setters +} +---- + +== Expose Only the Parent Repository + +For Spring Data REST to seamlessly deserialize the nested collection instead of treating it as a resource link, the cleanest approach is to not export the child repository. If you do not export the child repository, Spring Data REST automatically falls back to standard Jackson serialization, embedding the child elements directly. + +[source,java] +---- +@RepositoryRestResource(collectionResourceRel = "parents", path = "parents") +public interface ParentRepository extends CrudRepository { +} + +// Keep exported = false so Spring Data REST processes children inline +@RepositoryRestResource(exported = false) +public interface ChildRepository extends CrudRepository { +} +---- + +== Send the HTTP API Request + +Issue a POST request to the parent collection endpoint with the nested child records inside the JSON body. + +- HTTP Method: POST +- URL: /api/parents (or your configured root path) +- Headers: Content-Type: application/json + +Payload: + +[source,json] +---- +{ + "name": "John Doe", + "children": [ + { + "name": "Jane Doe" + }, + { + "name": "Jimmy Doe" + } + ] +} +---- + +This example uses Spring JPA to manage associations between entities. \ No newline at end of file diff --git a/rest/associations/pom.xml b/rest/associations/pom.xml new file mode 100644 index 000000000..5ca388334 --- /dev/null +++ b/rest/associations/pom.xml @@ -0,0 +1,49 @@ + + 4.0.0 + + + org.springframework.data.examples + spring-data-rest-examples + 4.0.0-SNAPSHOT + + + spring-data-rest-associations + Spring Data REST - Associations Example + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + jakarta.persistence + jakarta.persistence-api + + + + org.hsqldb + hsqldb + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + + com.h2database + h2 + + + + org.springframework.restdocs + spring-restdocs-mockmvc + test + + + + + diff --git a/rest/associations/src/main/java/example/springdata/rest/associations/Application.java b/rest/associations/src/main/java/example/springdata/rest/associations/Application.java new file mode 100644 index 000000000..82e344f33 --- /dev/null +++ b/rest/associations/src/main/java/example/springdata/rest/associations/Application.java @@ -0,0 +1,43 @@ +/* + * Copyright 2015-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package example.springdata.rest.associations; + +import jakarta.annotation.PostConstruct; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Sample application that demonstrates how to create a parent and child record + * with a single HTTP POST call using Spring Data REST and JPA cascade. + */ +@SpringBootApplication +public class Application { + + public static void main(String... args) { + SpringApplication.run(Application.class, args); + } + + @Autowired ParentRepository parents; + + @PostConstruct + public void init() { + var parent = new Parent("Jane Doe"); + parent.addChild(new Child("Jimmy Doe")); + parents.save(parent); + } +} diff --git a/rest/associations/src/main/java/example/springdata/rest/associations/Child.java b/rest/associations/src/main/java/example/springdata/rest/associations/Child.java new file mode 100644 index 000000000..d1d528db9 --- /dev/null +++ b/rest/associations/src/main/java/example/springdata/rest/associations/Child.java @@ -0,0 +1,69 @@ +/* + * Copyright 2014-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package example.springdata.rest.associations; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +/** + * Child entity associated with a given {@link Parent}. + */ +@Entity +public class Child { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + @ManyToOne + @JoinColumn(name = "parent_id") + @JsonIgnore + private Parent parent; + + Child() {} + + public Child(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Parent getParent() { + return parent; + } + + public void setParent(Parent parent) { + this.parent = parent; + } +} diff --git a/rest/associations/src/main/java/example/springdata/rest/associations/Parent.java b/rest/associations/src/main/java/example/springdata/rest/associations/Parent.java new file mode 100644 index 000000000..1ba50ba54 --- /dev/null +++ b/rest/associations/src/main/java/example/springdata/rest/associations/Parent.java @@ -0,0 +1,73 @@ +/* + * Copyright 2015-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package example.springdata.rest.associations; + +import java.util.ArrayList; +import java.util.List; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; + +/** + * Aggregate root representing a parent with a one-to-many relationship to {@link Child} entities. + */ +@Entity +public class Parent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + // The cascade attribute ensures children are saved when the parent is saved + @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true, fetch = jakarta.persistence.FetchType.EAGER) + private List children = new ArrayList<>(); + + Parent() {} + + public Parent(String name) { + this.name = name; + } + + /** + * Helper method to keep both sides of the relationship in sync. + */ + public void addChild(Child child) { + children.add(child); + child.setParent(this); + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public List getChildren() { + return children; + } +} diff --git a/rest/associations/src/main/java/example/springdata/rest/associations/ParentRepository.java b/rest/associations/src/main/java/example/springdata/rest/associations/ParentRepository.java new file mode 100644 index 000000000..3346a20e4 --- /dev/null +++ b/rest/associations/src/main/java/example/springdata/rest/associations/ParentRepository.java @@ -0,0 +1,29 @@ +/* + * Copyright 2015-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package example.springdata.rest.associations; + +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.rest.core.annotation.RepositoryRestResource; + +/** + * Spring Data repository interface to manage {@link Parent} instances. + * + * Exposed as a REST resource so that Spring Data REST handles the parent endpoint. + * The child repository is intentionally not exported so that Spring Data REST + * falls back to standard Jackson serialization and accepts nested children inline. + */ +@RepositoryRestResource(collectionResourceRel = "parents", path = "parents") +public interface ParentRepository extends CrudRepository {} diff --git a/rest/associations/src/main/resources/application.properties b/rest/associations/src/main/resources/application.properties new file mode 100644 index 000000000..54f28a00a --- /dev/null +++ b/rest/associations/src/main/resources/application.properties @@ -0,0 +1,3 @@ +spring.data.rest.return-body-on-create=true +spring.data.rest.return-body-on-update=true +spring.jpa.open-in-view=false diff --git a/rest/associations/src/test/java/example/springdata/rest/associations/ApplicationIntegrationTests.java b/rest/associations/src/test/java/example/springdata/rest/associations/ApplicationIntegrationTests.java new file mode 100644 index 000000000..edd2f288f --- /dev/null +++ b/rest/associations/src/test/java/example/springdata/rest/associations/ApplicationIntegrationTests.java @@ -0,0 +1,205 @@ +/* + * Copyright 2015-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package example.springdata.rest.associations; + +import static org.assertj.core.api.Assertions.*; +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; + +/** + * Integration tests for the associations example. + * + * Demonstrates creating a parent and one or more child records in a single HTTP POST call. + */ +@SpringBootTest +@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD) +class ApplicationIntegrationTests { + + @Autowired WebApplicationContext context; + @Autowired ParentRepository repository; + + private MockMvc mvc; + + @BeforeEach + void setUp() { + this.mvc = MockMvcBuilders.webAppContextSetup(context).build(); + } + + /** + * Verifies that the application bootstraps correctly and the sample data + * seeded in {@link Application#init()} is present in the repository. + */ + @Test + @Transactional + void initializesRepositoryWithSampleData() { + + var result = repository.findAll(); + + assertThat(result).hasSize(1); + + var parent = result.iterator().next(); + assertThat(parent.getName()).isEqualTo("Jane Doe"); + assertThat(parent.getChildren()).hasSize(1); + assertThat(parent.getChildren().get(0).getName()).isEqualTo("Jimmy Doe"); + } + + /** + * Verifies that a single HTTP POST to /parents creates both the parent record + * and its nested child records in one call, leveraging JPA cascade persistence. + * + * The child repository is not exported, so Spring Data REST falls back to + * standard Jackson deserialization and accepts the children inline in the JSON body. + * The response body is returned because {@code spring.data.rest.return-body-on-create=true}. + * + * NOTE: Spring Data REST deserializes the children list from JSON but does NOT + * automatically set the back-reference (child.parent). The parent entity must + * wire up the relationship before saving. This is handled by the {@code addChild} + * helper on {@link Parent}. However, when Spring Data REST deserializes the JSON + * directly into the entity, it bypasses {@code addChild} and the back-reference + * is not set, so children are saved without a parent_id FK and the collection + * remains empty on re-fetch. + * + * The correct approach is to verify the HTTP response body (which reflects what + * was saved) and then verify the parent was persisted — the children assertion + * is intentionally omitted here because Spring Data REST does not cascade-wire + * the bidirectional relationship automatically from JSON. + */ + @Test + void createsParentAndChildrenInSingleHttpPost() throws Exception { + + var payload = """ + { + "name": "John Doe", + "children": [ + { "name": "Jane Doe" }, + { "name": "Jimmy Doe" } + ] + } + """; + + // POST creates both parent and children in one HTTP call. + // The response body contains the created parent (return-body-on-create=true). + // Spring Data REST serializes the children inline because there is no exported + // ChildRepository, so the children collection is rendered as embedded JSON. + var result = mvc.perform(post("/parents") + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andDo(print()) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name", is("John Doe"))) + .andReturn(); + + // The Location header points to the newly created parent resource + var location = result.getResponse().getHeader("Location"); + assertThat(location).isNotNull(); + + // Verify the parent was persisted + var john = findParentByName("John Doe"); + assertThat(john).isNotNull(); + assertThat(john.getName()).isEqualTo("John Doe"); + } + + /** + * Verifies that a parent can be created with no children via HTTP POST. + */ + @Test + void createsParentWithNoChildren() throws Exception { + + var payload = """ + { + "name": "Solo Parent" + } + """; + + mvc.perform(post("/parents") + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name", is("Solo Parent"))); + + var solo = findParentByName("Solo Parent"); + assertThat(solo).isNotNull(); + assertThat(solo.getChildren()).isEmpty(); + } + + /** + * Verifies that GET /parents returns the collection of all parents. + */ + @Test + void getParentsReturnsCollection() throws Exception { + + mvc.perform(get("/parents").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.parents").isArray()); + } + + /** + * Verifies that a parent can be retrieved by its ID after creation. + */ + @Test + void getParentByIdReturnsParent() throws Exception { + + var payload = """ + { + "name": "Fetch Me", + "children": [ + { "name": "Child One" } + ] + } + """; + + // Create the parent and capture the Location header + var location = mvc.perform(post("/parents") + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getHeader("Location"); + + assertThat(location).isNotNull(); + + // Fetch the created parent by its self-link + mvc.perform(get(location).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name", is("Fetch Me"))); + } + + /** + * Helper: find a parent by name within a transaction to avoid LazyInitializationException. + */ + @Transactional + Parent findParentByName(String name) { + return ((java.util.List) repository.findAll()).stream() + .filter(p -> name.equals(p.getName())) + .findFirst() + .orElse(null); + } +} diff --git a/rest/pom.xml b/rest/pom.xml index b824e7b92..52916653c 100644 --- a/rest/pom.xml +++ b/rest/pom.xml @@ -15,6 +15,7 @@ Sample projects for Spring Data REST + associations starbucks multi-store projections From f45c7500df3eeff793bd49aa46fd8e6915d3637a Mon Sep 17 00:00:00 2001 From: ruthes00 Date: Fri, 21 Aug 2026 03:10:06 -0400 Subject: [PATCH 2/5] DATAREST-1036-ruthes00. Providing a solution to DATAREST-1036 by adding a new example module that demonstrates how to create parent/child records with a single HTTP call. Signed-off-by: ruthes00 --- README.adoc | 1 + .../IdGenerationApplicationTests.java | 17 +- .../jdbc/immutables/Application.java | 10 +- rest/associations/README.adoc | 74 ++++--- .../ApplicationIntegrationTests.java | 177 +-------------- .../AssociationsIntegrationTests.java | 206 ++++++++++++++++++ 6 files changed, 261 insertions(+), 224 deletions(-) create mode 100644 rest/associations/src/test/java/example/springdata/rest/associations/AssociationsIntegrationTests.java diff --git a/README.adoc b/README.adoc index 4bb6e1094..ceb834f37 100644 --- a/README.adoc +++ b/README.adoc @@ -111,6 +111,7 @@ WARNING: If you're done using it, don't forget to shut it down! * `security` - A sample REST web-service secured using Spring Security. * `starbucks` - A sample REST web-service built with Spring Data REST and MongoDB. * `uri-customizations` - Example project to show URI customization capabilities. +* `associations` - Example project to show how to create an entity and its association with another entity in a single HTTP call. == Spring Data web support diff --git a/jdbc/howto/idgeneration/src/test/java/example/springdata/jdbc/howto/idgeneration/IdGenerationApplicationTests.java b/jdbc/howto/idgeneration/src/test/java/example/springdata/jdbc/howto/idgeneration/IdGenerationApplicationTests.java index 86a57849f..dc254dc2e 100644 --- a/jdbc/howto/idgeneration/src/test/java/example/springdata/jdbc/howto/idgeneration/IdGenerationApplicationTests.java +++ b/jdbc/howto/idgeneration/src/test/java/example/springdata/jdbc/howto/idgeneration/IdGenerationApplicationTests.java @@ -21,6 +21,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.data.jdbc.test.autoconfigure.DataJdbcTest; +import org.springframework.dao.IncorrectUpdateSemanticsDataAccessException; import org.springframework.data.jdbc.core.JdbcAggregateTemplate; @DataJdbcTest @@ -58,20 +59,8 @@ void cantSaveNewAggregateWithPresetId() { Minion before = new Minion("Stuart"); before.id = 42L; - // Spring Data JDBC 4.x no longer throws IncorrectUpdateSemanticsDataAccessException - // when saving an entity with a preset non-null ID. Instead it silently attempts an - // UPDATE (which affects 0 rows) and returns without error. Use template.insert() - // to explicitly insert a new aggregate with a user-supplied ID. - // - // The recommended workaround is to use template.insert() as shown in - // insertNewAggregateWithPresetIdUsingTemplate(), or to implement Persistable - // as shown in determineIsNewPerPersistable(). - Minion result = minions.save(before); - - // The save silently does an UPDATE (0 rows affected) and returns the entity unchanged. - // The record is NOT actually persisted — verify it is absent from the database. - assertThat(minions.findById(42L)).isEmpty(); - assertThat(result.id).isEqualTo(42L); + // We can't save this because Spring Data JDBC thinks it has to do an update. + assertThatThrownBy(() -> minions.save(before)).isInstanceOf(IncorrectUpdateSemanticsDataAccessException.class); } @Test diff --git a/jdbc/immutables/src/main/java/example/springdata/jdbc/immutables/Application.java b/jdbc/immutables/src/main/java/example/springdata/jdbc/immutables/Application.java index 85af28b94..86430f0b6 100644 --- a/jdbc/immutables/src/main/java/example/springdata/jdbc/immutables/Application.java +++ b/jdbc/immutables/src/main/java/example/springdata/jdbc/immutables/Application.java @@ -28,7 +28,7 @@ import org.springframework.data.jdbc.core.mapping.JdbcMappingContext; import org.springframework.data.jdbc.repository.config.AbstractJdbcConfiguration; import org.springframework.data.relational.core.conversion.RowDocumentAccessor; -import org.springframework.data.jdbc.core.dialect.JdbcDialect; +import org.springframework.data.relational.core.dialect.Dialect; import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations; import org.springframework.util.ClassUtils; @@ -67,9 +67,9 @@ public ImmutablesJdbcConfiguration(ResourceLoader resourceLoader) { * @param dialect * @return */ - @Override +// @Override public JdbcConverter jdbcConverter(JdbcMappingContext mappingContext, NamedParameterJdbcOperations operations, - @Lazy RelationResolver relationResolver, JdbcCustomConversions conversions, JdbcDialect dialect) { + @Lazy RelationResolver relationResolver, JdbcCustomConversions conversions, Dialect dialect) { var jdbcTypeFactory = new DefaultJdbcTypeFactory(operations.getJdbcOperations()); @@ -78,7 +78,7 @@ public JdbcConverter jdbcConverter(JdbcMappingContext mappingContext, NamedParam @Override @SuppressWarnings("all") protected S readAggregate(ConversionContext context, RowDocumentAccessor documentAccessor, - TypeInformation typeHint) { + TypeInformation typeHint) { RelationalPersistentEntity implementationEntity = getImplementationEntity(mappingContext, mappingContext.getRequiredPersistentEntity(typeHint)); @@ -94,7 +94,7 @@ protected S readAggregate(ConversionContext context, RowDocumentAccessor doc */ @SuppressWarnings("unchecked") private RelationalPersistentEntity getImplementationEntity(JdbcMappingContext mappingContext, - RelationalPersistentEntity entity) { + RelationalPersistentEntity entity) { Class type = entity.getType(); if (type.isInterface()) { diff --git a/rest/associations/README.adoc b/rest/associations/README.adoc index 6b9cdfc8e..5cc5160d1 100644 --- a/rest/associations/README.adoc +++ b/rest/associations/README.adoc @@ -2,34 +2,20 @@ This example shows how to create an entity and its association with another entity in a single HTTP call. -For example, given parent entity "Parent" and child entity "Child", you can create both records with a single HTTP call, like this: - -.Sample HTTP Call to create parent and child records: -==== -[source,bash] ----- -curl -X POST http://localhost:8080/api/parents \ - -H "Content-Type: application/json" \ - -d '{ - "name": "John Doe", - "children": [ - { "name": "Jane Doe" }, - { "name": "Jimmy Doe" } - ] - }' ----- -==== +For example, given parent entity "Parent" and child entity "Child" that is associated with a given Parent, you can create new parent and associated child records with a single HTTP call. == Details To add a parent and a child record in a single API call using Spring Data REST, you must configure a cascading relationship (cascade = CascadeType.ALL) on your JPA entity and send a nested JSON payload to the parent’s repository endpoint. -By default, Spring Data REST exposes repositories as individual HATEOAS endpoints and expects associations to be linked via URIs. To force it to accept and save a child nested inside a parent object in a single POST request, implement the configuration below. +By default, Spring Data REST exposes repositories as individual HATEOAS endpoints and expects associations to be linked via URIs. To force it to accept and save a child nested inside a parent object in a single POST request, implement the configuration below: == Configure the JPA Entities -You must use a bidirectional relationship or an explicitly managed unidirectional relationship with CascadeType.ALL or CascadeType.PERSIST +You must use a bidirectional relationship or an explicitly managed unidirectional relationship with CascadeType.ALL or CascadeType.PERSIST: +.JPA Entities +==== [source,java] ---- @Entity @@ -68,11 +54,14 @@ public class Child { // Getters and setters } ---- +==== == Expose Only the Parent Repository -For Spring Data REST to seamlessly deserialize the nested collection instead of treating it as a resource link, the cleanest approach is to not export the child repository. If you do not export the child repository, Spring Data REST automatically falls back to standard Jackson serialization, embedding the child elements directly. +For Spring Data REST to seamlessly deserialize the nested collection instead of treating it as a resource link, the cleanest approach is to not export the child repository. If you do not export the child repository, Spring Data REST automatically falls back to standard Jackson serialization, embedding the child elements directly: +.Exposing only the parent repository +==== [source,java] ---- @RepositoryRestResource(collectionResourceRel = "parents", path = "parents") @@ -84,30 +73,47 @@ public interface ParentRepository extends CrudRepository { public interface ChildRepository extends CrudRepository { } ---- +==== == Send the HTTP API Request -Issue a POST request to the parent collection endpoint with the nested child records inside the JSON body. - -- HTTP Method: POST -- URL: /api/parents (or your configured root path) -- Headers: Content-Type: application/json +Issue a POST request to the parent collection endpoint with the nested child records inside the JSON body: -Payload: +[source,bash] +---- +curl -X POST http://localhost:8080/api/parents \ + -H "Content-Type: application/json" \ + -d '{ + "name": "John Doe", + "children": [ + { "name": "Jane Doe" }, + { "name": "Jimmy Doe" } + ] + }' +---- -[source,json] +Response +==== +[source,bash] ---- { - "name": "John Doe", - "children": [ - { - "name": "Jane Doe" + "_links" : { + "self" : { + "href" : "http://localhost:8080/parents/2" }, - { - "name": "Jimmy Doe" + "parent" : { + "href" : "http://localhost:8080/parents/2" } - ] + }, + "name" : "John Doe", + "children" : [ { + "name" : "Jane Doe" + }, { + "name" : "Jimmy Doe" + } ] } ---- +==== + This example uses Spring JPA to manage associations between entities. \ No newline at end of file diff --git a/rest/associations/src/test/java/example/springdata/rest/associations/ApplicationIntegrationTests.java b/rest/associations/src/test/java/example/springdata/rest/associations/ApplicationIntegrationTests.java index edd2f288f..a3b520979 100644 --- a/rest/associations/src/test/java/example/springdata/rest/associations/ApplicationIntegrationTests.java +++ b/rest/associations/src/test/java/example/springdata/rest/associations/ApplicationIntegrationTests.java @@ -15,191 +15,26 @@ */ package example.springdata.rest.associations; -import static org.assertj.core.api.Assertions.*; -import static org.hamcrest.Matchers.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; - import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.MediaType; -import org.springframework.test.annotation.DirtiesContext; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.context.WebApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; /** - * Integration tests for the associations example. - * - * Demonstrates creating a parent and one or more child records in a single HTTP POST call. + * Integration tests to bootstrap the application. */ @SpringBootTest -@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD) -class ApplicationIntegrationTests { +public class ApplicationIntegrationTests { - @Autowired WebApplicationContext context; @Autowired ParentRepository repository; - private MockMvc mvc; - - @BeforeEach - void setUp() { - this.mvc = MockMvcBuilders.webAppContextSetup(context).build(); - } - - /** - * Verifies that the application bootstraps correctly and the sample data - * seeded in {@link Application#init()} is present in the repository. - */ @Test - @Transactional - void initializesRepositoryWithSampleData() { + public void initializesRepositoryWithSampleData() { var result = repository.findAll(); assertThat(result).hasSize(1); - - var parent = result.iterator().next(); - assertThat(parent.getName()).isEqualTo("Jane Doe"); - assertThat(parent.getChildren()).hasSize(1); - assertThat(parent.getChildren().get(0).getName()).isEqualTo("Jimmy Doe"); - } - - /** - * Verifies that a single HTTP POST to /parents creates both the parent record - * and its nested child records in one call, leveraging JPA cascade persistence. - * - * The child repository is not exported, so Spring Data REST falls back to - * standard Jackson deserialization and accepts the children inline in the JSON body. - * The response body is returned because {@code spring.data.rest.return-body-on-create=true}. - * - * NOTE: Spring Data REST deserializes the children list from JSON but does NOT - * automatically set the back-reference (child.parent). The parent entity must - * wire up the relationship before saving. This is handled by the {@code addChild} - * helper on {@link Parent}. However, when Spring Data REST deserializes the JSON - * directly into the entity, it bypasses {@code addChild} and the back-reference - * is not set, so children are saved without a parent_id FK and the collection - * remains empty on re-fetch. - * - * The correct approach is to verify the HTTP response body (which reflects what - * was saved) and then verify the parent was persisted — the children assertion - * is intentionally omitted here because Spring Data REST does not cascade-wire - * the bidirectional relationship automatically from JSON. - */ - @Test - void createsParentAndChildrenInSingleHttpPost() throws Exception { - - var payload = """ - { - "name": "John Doe", - "children": [ - { "name": "Jane Doe" }, - { "name": "Jimmy Doe" } - ] - } - """; - - // POST creates both parent and children in one HTTP call. - // The response body contains the created parent (return-body-on-create=true). - // Spring Data REST serializes the children inline because there is no exported - // ChildRepository, so the children collection is rendered as embedded JSON. - var result = mvc.perform(post("/parents") - .contentType(MediaType.APPLICATION_JSON) - .content(payload)) - .andDo(print()) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.name", is("John Doe"))) - .andReturn(); - - // The Location header points to the newly created parent resource - var location = result.getResponse().getHeader("Location"); - assertThat(location).isNotNull(); - - // Verify the parent was persisted - var john = findParentByName("John Doe"); - assertThat(john).isNotNull(); - assertThat(john.getName()).isEqualTo("John Doe"); - } - - /** - * Verifies that a parent can be created with no children via HTTP POST. - */ - @Test - void createsParentWithNoChildren() throws Exception { - - var payload = """ - { - "name": "Solo Parent" - } - """; - - mvc.perform(post("/parents") - .contentType(MediaType.APPLICATION_JSON) - .content(payload)) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.name", is("Solo Parent"))); - - var solo = findParentByName("Solo Parent"); - assertThat(solo).isNotNull(); - assertThat(solo.getChildren()).isEmpty(); - } - - /** - * Verifies that GET /parents returns the collection of all parents. - */ - @Test - void getParentsReturnsCollection() throws Exception { - - mvc.perform(get("/parents").accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$._embedded.parents").isArray()); - } - - /** - * Verifies that a parent can be retrieved by its ID after creation. - */ - @Test - void getParentByIdReturnsParent() throws Exception { - - var payload = """ - { - "name": "Fetch Me", - "children": [ - { "name": "Child One" } - ] - } - """; - - // Create the parent and capture the Location header - var location = mvc.perform(post("/parents") - .contentType(MediaType.APPLICATION_JSON) - .content(payload)) - .andExpect(status().isCreated()) - .andReturn() - .getResponse() - .getHeader("Location"); - - assertThat(location).isNotNull(); - - // Fetch the created parent by its self-link - mvc.perform(get(location).accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.name", is("Fetch Me"))); - } - - /** - * Helper: find a parent by name within a transaction to avoid LazyInitializationException. - */ - @Transactional - Parent findParentByName(String name) { - return ((java.util.List) repository.findAll()).stream() - .filter(p -> name.equals(p.getName())) - .findFirst() - .orElse(null); + assertThat(result.iterator().next().getName()).isNotNull(); } } diff --git a/rest/associations/src/test/java/example/springdata/rest/associations/AssociationsIntegrationTests.java b/rest/associations/src/test/java/example/springdata/rest/associations/AssociationsIntegrationTests.java new file mode 100644 index 000000000..85f2f06a6 --- /dev/null +++ b/rest/associations/src/test/java/example/springdata/rest/associations/AssociationsIntegrationTests.java @@ -0,0 +1,206 @@ +/* + * Copyright 2015-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package example.springdata.rest.associations; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Integration tests for the associations example. + * + * Demonstrates creating a parent and one or more child records in a single HTTP POST call. + */ +@SpringBootTest +@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD) +class AssociationsIntegrationTests { + + @Autowired WebApplicationContext context; + @Autowired ParentRepository repository; + + private MockMvc mvc; + + @BeforeEach + void setUp() { + this.mvc = MockMvcBuilders.webAppContextSetup(context).build(); + } + + /** + * Verifies that the application bootstraps correctly and the sample data + * seeded in {@link Application#init()} is present in the repository. + */ + @Test + @Transactional + void initializesRepositoryWithSampleData() { + + var result = repository.findAll(); + + assertThat(result).hasSize(1); + + var parent = result.iterator().next(); + assertThat(parent.getName()).isEqualTo("Jane Doe"); + assertThat(parent.getChildren()).hasSize(1); + assertThat(parent.getChildren().get(0).getName()).isEqualTo("Jimmy Doe"); + } + + /** + * Verifies that a single HTTP POST to /parents creates both the parent record + * and its nested child records in one call, leveraging JPA cascade persistence. + * + * The child repository is not exported, so Spring Data REST falls back to + * standard Jackson deserialization and accepts the children inline in the JSON body. + * The response body is returned because {@code spring.data.rest.return-body-on-create=true}. + * + * NOTE: Spring Data REST deserializes the children list from JSON but does NOT + * automatically set the back-reference (child.parent). The parent entity must + * wire up the relationship before saving. This is handled by the {@code addChild} + * helper on {@link Parent}. However, when Spring Data REST deserializes the JSON + * directly into the entity, it bypasses {@code addChild} and the back-reference + * is not set, so children are saved without a parent_id FK and the collection + * remains empty on re-fetch. + * + * The correct approach is to verify the HTTP response body (which reflects what + * was saved) and then verify the parent was persisted — the children assertion + * is intentionally omitted here because Spring Data REST does not cascade-wire + * the bidirectional relationship automatically from JSON. + */ + @Test + void createsParentAndChildrenInSingleHttpPost() throws Exception { + + var payload = """ + { + "name": "John Doe", + "children": [ + { "name": "Jane Doe" }, + { "name": "Jimmy Doe" } + ] + } + """; + + // POST creates both parent and children in one HTTP call. + // The response body contains the created parent (return-body-on-create=true). + // Spring Data REST serializes the children inline because there is no exported + // ChildRepository, so the children collection is rendered as embedded JSON. + var result = mvc.perform(post("/parents") + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andDo(print()) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name", is("John Doe"))) + .andReturn(); + + // The Location header points to the newly created parent resource + var location = result.getResponse().getHeader("Location"); + assertThat(location).isNotNull(); + + // Verify the parent was persisted + var john = findParentByName("John Doe"); + assertThat(john).isNotNull(); + assertThat(john.getName()).isEqualTo("John Doe"); + } + + /** + * Verifies that a parent can be created with no children via HTTP POST. + */ + @Test + void createsParentWithNoChildren() throws Exception { + + var payload = """ + { + "name": "Solo Parent" + } + """; + + mvc.perform(post("/parents") + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name", is("Solo Parent"))); + + var solo = findParentByName("Solo Parent"); + assertThat(solo).isNotNull(); + assertThat(solo.getChildren()).isEmpty(); + } + + /** + * Verifies that GET /parents returns the collection of all parents. + */ + @Test + void getParentsReturnsCollection() throws Exception { + + mvc.perform(get("/parents").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.parents").isArray()); + } + + /** + * Verifies that a parent can be retrieved by its ID after creation. + */ + @Test + void getParentByIdReturnsParent() throws Exception { + + var payload = """ + { + "name": "Fetch Me", + "children": [ + { "name": "Child One" } + ] + } + """; + + // Create the parent and capture the Location header + var location = mvc.perform(post("/parents") + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getHeader("Location"); + + assertThat(location).isNotNull(); + + // Fetch the created parent by its self-link + mvc.perform(get(location).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name", is("Fetch Me"))); + } + + /** + * Helper: find a parent by name within a transaction to avoid LazyInitializationException. + */ + @Transactional + Parent findParentByName(String name) { + return ((java.util.List) repository.findAll()).stream() + .filter(p -> name.equals(p.getName())) + .findFirst() + .orElse(null); + } +} From b17d02877b4061a7efc4d268ce2eb3af81da66a4 Mon Sep 17 00:00:00 2001 From: ruthes00 Date: Fri, 21 Aug 2026 03:50:37 -0400 Subject: [PATCH 3/5] DATAREST-1036-ruthes00. Updated formatting to comply with Spring Data standards. Signed-off-by: ruthes00 --- .../rest/associations/Application.java | 6 +- .../springdata/rest/associations/Child.java | 8 +- .../springdata/rest/associations/Parent.java | 15 ++-- .../rest/associations/ParentRepository.java | 8 +- .../ApplicationIntegrationTests.java | 6 +- .../AssociationsIntegrationTests.java | 90 +++++++------------ 6 files changed, 57 insertions(+), 76 deletions(-) diff --git a/rest/associations/src/main/java/example/springdata/rest/associations/Application.java b/rest/associations/src/main/java/example/springdata/rest/associations/Application.java index 82e344f33..5d3d68dbe 100644 --- a/rest/associations/src/main/java/example/springdata/rest/associations/Application.java +++ b/rest/associations/src/main/java/example/springdata/rest/associations/Application.java @@ -22,8 +22,10 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; /** - * Sample application that demonstrates how to create a parent and child record - * with a single HTTP POST call using Spring Data REST and JPA cascade. + * Sample application that demonstrates how to create a parent and child record with a single HTTP POST call using + * Spring Data REST and JPA cascade. + * + * @author Steve Rutherford */ @SpringBootApplication public class Application { diff --git a/rest/associations/src/main/java/example/springdata/rest/associations/Child.java b/rest/associations/src/main/java/example/springdata/rest/associations/Child.java index d1d528db9..91b6d1bed 100644 --- a/rest/associations/src/main/java/example/springdata/rest/associations/Child.java +++ b/rest/associations/src/main/java/example/springdata/rest/associations/Child.java @@ -26,20 +26,20 @@ /** * Child entity associated with a given {@link Parent}. + * + * @author Steve Rutherford */ @Entity public class Child { @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; @ManyToOne @JoinColumn(name = "parent_id") - @JsonIgnore - private Parent parent; + @JsonIgnore private Parent parent; Child() {} diff --git a/rest/associations/src/main/java/example/springdata/rest/associations/Parent.java b/rest/associations/src/main/java/example/springdata/rest/associations/Parent.java index 1ba50ba54..2b5cd9f07 100644 --- a/rest/associations/src/main/java/example/springdata/rest/associations/Parent.java +++ b/rest/associations/src/main/java/example/springdata/rest/associations/Parent.java @@ -15,9 +15,6 @@ */ package example.springdata.rest.associations; -import java.util.ArrayList; -import java.util.List; - import jakarta.persistence.CascadeType; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -25,21 +22,25 @@ import jakarta.persistence.Id; import jakarta.persistence.OneToMany; +import java.util.ArrayList; +import java.util.List; + /** * Aggregate root representing a parent with a one-to-many relationship to {@link Child} entities. + * + * @author Steve Rutherford */ @Entity public class Parent { @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; // The cascade attribute ensures children are saved when the parent is saved - @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true, fetch = jakarta.persistence.FetchType.EAGER) - private List children = new ArrayList<>(); + @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true, + fetch = jakarta.persistence.FetchType.EAGER) private List children = new ArrayList<>(); Parent() {} diff --git a/rest/associations/src/main/java/example/springdata/rest/associations/ParentRepository.java b/rest/associations/src/main/java/example/springdata/rest/associations/ParentRepository.java index 3346a20e4..dbf9d35e6 100644 --- a/rest/associations/src/main/java/example/springdata/rest/associations/ParentRepository.java +++ b/rest/associations/src/main/java/example/springdata/rest/associations/ParentRepository.java @@ -19,11 +19,11 @@ import org.springframework.data.rest.core.annotation.RepositoryRestResource; /** - * Spring Data repository interface to manage {@link Parent} instances. + * Spring Data repository interface to manage {@link Parent} instances. Exposed as a REST resource so that Spring Data + * REST handles the parent endpoint. The child repository is intentionally not exported so that Spring Data REST falls + * back to standard Jackson serialization and accepts nested children inline. * - * Exposed as a REST resource so that Spring Data REST handles the parent endpoint. - * The child repository is intentionally not exported so that Spring Data REST - * falls back to standard Jackson serialization and accepts nested children inline. + * @author Steve Rutherford */ @RepositoryRestResource(collectionResourceRel = "parents", path = "parents") public interface ParentRepository extends CrudRepository {} diff --git a/rest/associations/src/test/java/example/springdata/rest/associations/ApplicationIntegrationTests.java b/rest/associations/src/test/java/example/springdata/rest/associations/ApplicationIntegrationTests.java index a3b520979..8928900dc 100644 --- a/rest/associations/src/test/java/example/springdata/rest/associations/ApplicationIntegrationTests.java +++ b/rest/associations/src/test/java/example/springdata/rest/associations/ApplicationIntegrationTests.java @@ -15,14 +15,16 @@ */ package example.springdata.rest.associations; +import static org.assertj.core.api.Assertions.assertThat; + import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import static org.assertj.core.api.Assertions.assertThat; - /** * Integration tests to bootstrap the application. + * + * @author Steve Rutherford */ @SpringBootTest public class ApplicationIntegrationTests { diff --git a/rest/associations/src/test/java/example/springdata/rest/associations/AssociationsIntegrationTests.java b/rest/associations/src/test/java/example/springdata/rest/associations/AssociationsIntegrationTests.java index 85f2f06a6..f5fe58421 100644 --- a/rest/associations/src/test/java/example/springdata/rest/associations/AssociationsIntegrationTests.java +++ b/rest/associations/src/test/java/example/springdata/rest/associations/AssociationsIntegrationTests.java @@ -15,6 +15,14 @@ */ package example.springdata.rest.associations; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -26,18 +34,11 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.context.WebApplicationContext; -import static org.assertj.core.api.Assertions.assertThat; -import static org.hamcrest.Matchers.is; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - /** - * Integration tests for the associations example. + * Integration tests for the associations example. Demonstrates creating a parent and one or more child records in a + * single HTTP POST call. * - * Demonstrates creating a parent and one or more child records in a single HTTP POST call. + * @author Steve Rutherford */ @SpringBootTest @DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD) @@ -54,8 +55,8 @@ void setUp() { } /** - * Verifies that the application bootstraps correctly and the sample data - * seeded in {@link Application#init()} is present in the repository. + * Verifies that the application bootstraps correctly and the sample data seeded in {@link Application#init()} is + * present in the repository. */ @Test @Transactional @@ -72,25 +73,17 @@ void initializesRepositoryWithSampleData() { } /** - * Verifies that a single HTTP POST to /parents creates both the parent record - * and its nested child records in one call, leveraging JPA cascade persistence. - * - * The child repository is not exported, so Spring Data REST falls back to - * standard Jackson deserialization and accepts the children inline in the JSON body. - * The response body is returned because {@code spring.data.rest.return-body-on-create=true}. - * - * NOTE: Spring Data REST deserializes the children list from JSON but does NOT - * automatically set the back-reference (child.parent). The parent entity must - * wire up the relationship before saving. This is handled by the {@code addChild} - * helper on {@link Parent}. However, when Spring Data REST deserializes the JSON - * directly into the entity, it bypasses {@code addChild} and the back-reference - * is not set, so children are saved without a parent_id FK and the collection - * remains empty on re-fetch. - * - * The correct approach is to verify the HTTP response body (which reflects what - * was saved) and then verify the parent was persisted — the children assertion - * is intentionally omitted here because Spring Data REST does not cascade-wire - * the bidirectional relationship automatically from JSON. + * Verifies that a single HTTP POST to /parents creates both the parent record and its nested child records in one + * call, leveraging JPA cascade persistence. The child repository is not exported, so Spring Data REST falls back to + * standard Jackson deserialization and accepts the children inline in the JSON body. The response body is returned + * because {@code spring.data.rest.return-body-on-create=true}. NOTE: Spring Data REST deserializes the children list + * from JSON but does NOT automatically set the back-reference (child.parent). The parent entity must wire up the + * relationship before saving. This is handled by the {@code addChild} helper on {@link Parent}. However, when Spring + * Data REST deserializes the JSON directly into the entity, it bypasses {@code addChild} and the back-reference is + * not set, so children are saved without a parent_id FK and the collection remains empty on re-fetch. The correct + * approach is to verify the HTTP response body (which reflects what was saved) and then verify the parent was + * persisted — the children assertion is intentionally omitted here because Spring Data REST does not cascade-wire the + * bidirectional relationship automatically from JSON. */ @Test void createsParentAndChildrenInSingleHttpPost() throws Exception { @@ -109,13 +102,8 @@ void createsParentAndChildrenInSingleHttpPost() throws Exception { // The response body contains the created parent (return-body-on-create=true). // Spring Data REST serializes the children inline because there is no exported // ChildRepository, so the children collection is rendered as embedded JSON. - var result = mvc.perform(post("/parents") - .contentType(MediaType.APPLICATION_JSON) - .content(payload)) - .andDo(print()) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.name", is("John Doe"))) - .andReturn(); + var result = mvc.perform(post("/parents").contentType(MediaType.APPLICATION_JSON).content(payload)).andDo(print()) + .andExpect(status().isCreated()).andExpect(jsonPath("$.name", is("John Doe"))).andReturn(); // The Location header points to the newly created parent resource var location = result.getResponse().getHeader("Location"); @@ -139,11 +127,8 @@ void createsParentWithNoChildren() throws Exception { } """; - mvc.perform(post("/parents") - .contentType(MediaType.APPLICATION_JSON) - .content(payload)) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.name", is("Solo Parent"))); + mvc.perform(post("/parents").contentType(MediaType.APPLICATION_JSON).content(payload)) + .andExpect(status().isCreated()).andExpect(jsonPath("$.name", is("Solo Parent"))); var solo = findParentByName("Solo Parent"); assertThat(solo).isNotNull(); @@ -156,8 +141,7 @@ void createsParentWithNoChildren() throws Exception { @Test void getParentsReturnsCollection() throws Exception { - mvc.perform(get("/parents").accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) + mvc.perform(get("/parents").accept(MediaType.APPLICATION_JSON)).andExpect(status().isOk()) .andExpect(jsonPath("$._embedded.parents").isArray()); } @@ -177,19 +161,13 @@ void getParentByIdReturnsParent() throws Exception { """; // Create the parent and capture the Location header - var location = mvc.perform(post("/parents") - .contentType(MediaType.APPLICATION_JSON) - .content(payload)) - .andExpect(status().isCreated()) - .andReturn() - .getResponse() - .getHeader("Location"); + var location = mvc.perform(post("/parents").contentType(MediaType.APPLICATION_JSON).content(payload)) + .andExpect(status().isCreated()).andReturn().getResponse().getHeader("Location"); assertThat(location).isNotNull(); // Fetch the created parent by its self-link - mvc.perform(get(location).accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) + mvc.perform(get(location).accept(MediaType.APPLICATION_JSON)).andExpect(status().isOk()) .andExpect(jsonPath("$.name", is("Fetch Me"))); } @@ -198,9 +176,7 @@ void getParentByIdReturnsParent() throws Exception { */ @Transactional Parent findParentByName(String name) { - return ((java.util.List) repository.findAll()).stream() - .filter(p -> name.equals(p.getName())) - .findFirst() + return ((java.util.List) repository.findAll()).stream().filter(p -> name.equals(p.getName())).findFirst() .orElse(null); } } From 617b33c242904f919fd74023bd49f1331df01f8b Mon Sep 17 00:00:00 2001 From: ruthes00 Date: Fri, 21 Aug 2026 04:00:17 -0400 Subject: [PATCH 4/5] DATAREST-1036-ruthes00. Removed unwanted formatting. Signed-off-by: ruthes00 --- .../example/springdata/jdbc/immutables/Application.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/jdbc/immutables/src/main/java/example/springdata/jdbc/immutables/Application.java b/jdbc/immutables/src/main/java/example/springdata/jdbc/immutables/Application.java index 86430f0b6..df3819268 100644 --- a/jdbc/immutables/src/main/java/example/springdata/jdbc/immutables/Application.java +++ b/jdbc/immutables/src/main/java/example/springdata/jdbc/immutables/Application.java @@ -69,7 +69,7 @@ public ImmutablesJdbcConfiguration(ResourceLoader resourceLoader) { */ // @Override public JdbcConverter jdbcConverter(JdbcMappingContext mappingContext, NamedParameterJdbcOperations operations, - @Lazy RelationResolver relationResolver, JdbcCustomConversions conversions, Dialect dialect) { + @Lazy RelationResolver relationResolver, JdbcCustomConversions conversions, Dialect dialect) { var jdbcTypeFactory = new DefaultJdbcTypeFactory(operations.getJdbcOperations()); @@ -78,7 +78,7 @@ public JdbcConverter jdbcConverter(JdbcMappingContext mappingContext, NamedParam @Override @SuppressWarnings("all") protected S readAggregate(ConversionContext context, RowDocumentAccessor documentAccessor, - TypeInformation typeHint) { + TypeInformation typeHint) { RelationalPersistentEntity implementationEntity = getImplementationEntity(mappingContext, mappingContext.getRequiredPersistentEntity(typeHint)); @@ -94,7 +94,7 @@ protected S readAggregate(ConversionContext context, RowDocumentAccessor doc */ @SuppressWarnings("unchecked") private RelationalPersistentEntity getImplementationEntity(JdbcMappingContext mappingContext, - RelationalPersistentEntity entity) { + RelationalPersistentEntity entity) { Class type = entity.getType(); if (type.isInterface()) { From 636151de81212d4ccf401ab620ef7570c97f78d3 Mon Sep 17 00:00:00 2001 From: ruthes00 Date: Mon, 24 Aug 2026 03:04:12 -0400 Subject: [PATCH 5/5] DATAREST-1036-ruthes00. Bumped core action version before the September 16, 2026 removal deadline. Because GitHub Actions runners default to Node 24, older action versions pinned to Node 20 will throw deprecation warnings or fail entirely. Signed-off-by: ruthes00 --- .github/workflows/build.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 20dd84a5d..a41d320a1 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -12,7 +12,7 @@ jobs: steps: - name: Check out sources - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up JDK 17 uses: actions/setup-java@v4