Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

How to update aggregate collections? #2324

Open
AresEkb opened this issue Oct 26, 2023 · 1 comment
Open

How to update aggregate collections? #2324

AresEkb opened this issue Oct 26, 2023 · 1 comment
Assignees
Labels
status: waiting-for-triage An issue we've not yet triaged

Comments

@AresEkb
Copy link

AresEkb commented Oct 26, 2023

Here is a sample project

There are two entities:

@Entity
public class Parent {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private List<Child> children = new ArrayList<>();

    // Skipped getters and setters
}

@Entity
@Table(uniqueConstraints = { @UniqueConstraint(columnNames = { "parent_id", "name" }) })
public class Child {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @NotNull
    @ManyToOne
    private Parent parent;

    // Skipped getters and setters
}

One repository for the aggregate root entity:

@RepositoryRestResource(path = "parents", collectionResourceRel = "parents")
public interface ParentRepository extends JpaRepository<Parent, Long> {
}

And the configuration:

@Component
public class SpringDataRestConfiguration implements RepositoryRestConfigurer {

    @Autowired
    private EntityManager entityManager;

    @Override
    public void configureRepositoryRestConfiguration(RepositoryRestConfiguration config, CorsRegistry cors) {
        config.exposeIdsFor(entityManager.getMetamodel().getEntities().stream()
                .map(Type::getJavaType)
                .toArray(Class[]::new));
    }

}

I need to create/update the whole aggregate entity using one HTTP request. There is a number of tests. Some of them works fine, but I have the following questions/problems.

  1. How to create an aggregate entity with child entities in a single request. The test is the following:
    @Test
    void aggregateCollectionIsCreated() {
        String collectionUrl = getCollectionUrlPath(Parent.class);

        HttpHeaders requestHeaders = new HttpHeaders();
        requestHeaders.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);

        String request1 = """
                {
                  "id": 1,
                  "name": "parent1"
                }
                """;

        ResponseEntity<String> response1 = testRestTemplate.postForEntity(
                collectionUrl, new HttpEntity<>(request1, requestHeaders), String.class);
        assertThat(response1.getStatusCode()).isEqualTo(HttpStatus.CREATED);

        URI entityUrl = response1.getHeaders().getLocation();

        String request2 = """
                {
                  "id": 1,
                  "name": "parent1",
                  "children": [
                    {
                      "id": 1,
                      "name": "child1",
                      "parent": "%s"
                    },
                    {
                      "id": 2,
                      "name": "child2",
                      "parent": "%s"
                    }
                  ]
                }
                """.formatted(entityUrl, entityUrl);

        ResponseEntity<String> response2 = testRestTemplate.exchange(
                entityUrl, HttpMethod.PUT, new HttpEntity<>(request2, requestHeaders), String.class);
        System.out.println(response2.getBody());
        assertThat(response2.getStatusCode()).isEqualTo(HttpStatus.OK);

        ResponseEntity<String> response3 = testRestTemplate.getForEntity(entityUrl, String.class);
        System.out.println(response3.getBody());
        assertThat(response3.getBody()).isEqualTo(response2.getBody());
    }

It works, but I have to do two requests. At first create the parent entity, get its URL. And after that I can add child entities, because I have to specify a parent URL for them. Is it possible to use one request without parent URLs?

  1. Child item addition doesn't work:
    @Test
    void itemIsAddedToAggregateCollection() {
        String collectionUrl = getCollectionUrlPath(Parent.class);

        HttpHeaders requestHeaders = new HttpHeaders();
        requestHeaders.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);

        String request1 = """
                {
                  "id": 1,
                  "name": "parent1"
                }
                """;

        ResponseEntity<String> response1 = testRestTemplate.postForEntity(
                collectionUrl, new HttpEntity<>(request1, requestHeaders), String.class);
        assertThat(response1.getStatusCode()).isEqualTo(HttpStatus.CREATED);

        URI entityUrl = response1.getHeaders().getLocation();

        String request2 = """
                {
                  "id": 1,
                  "name": "parent1",
                  "children": [
                    {
                      "id": 1,
                      "name": "child1",
                      "parent": "%s"
                    }
                  ]
                }
                """.formatted(entityUrl);

        ResponseEntity<String> response2 = testRestTemplate.exchange(
                entityUrl, HttpMethod.PUT, new HttpEntity<>(request2, requestHeaders), String.class);
        assertThat(response2.getStatusCode()).isEqualTo(HttpStatus.OK);

        String request3 = """
                {
                  "id": 1,
                  "name": "parent1",
                  "children": [
                    {
                      "id": 1,
                      "name": "child1",
                      "parent": "%s"
                    },
                    {
                      "id": 2,
                      "name": "child2",
                      "parent": "%s"
                    }
                  ]
                }
                """.formatted(entityUrl, entityUrl);

        ResponseEntity<String> response3 = testRestTemplate.exchange(
                entityUrl, HttpMethod.PUT, new HttpEntity<>(request3, requestHeaders), String.class);
        System.out.println(response3.getBody());
        assertThat(response3.getStatusCode()).isEqualTo(HttpStatus.OK);

        ResponseEntity<String> response4 = testRestTemplate.getForEntity(entityUrl, String.class);
        System.out.println(response4.getBody());
        assertThat(response4.getBody()).isEqualTo(response3.getBody());
    }

I get the exception:

java.lang.IllegalStateException: Multiple representations of the same entity [com.example.aggregatecollection.Child#1] are being merged. Detached: [com.example.aggregatecollection.Child@727dc2d6]; Managed: [com.example.aggregatecollection.Child@25f23f02]

  1. And deletion of child items doesn't work:
    @Test
    void itemIsDeletedFromAggregateCollection() {
        String collectionUrl = getCollectionUrlPath(Parent.class);

        HttpHeaders requestHeaders = new HttpHeaders();
        requestHeaders.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);

        String request1 = """
                {
                  "id": 1,
                  "name": "parent1"
                }
                """;

        ResponseEntity<String> response1 = testRestTemplate.postForEntity(
                collectionUrl, new HttpEntity<>(request1, requestHeaders), String.class);
        assertThat(response1.getStatusCode()).isEqualTo(HttpStatus.CREATED);

        URI entityUrl = response1.getHeaders().getLocation();

        String request2 = """
                {
                  "id": 1,
                  "name": "parent1",
                  "children": [
                    {
                      "id": 1,
                      "name": "child1",
                      "parent": "%s"
                    },
                    {
                      "id": 2,
                      "name": "child2",
                      "parent": "%s"
                    }
                  ]
                }
                """.formatted(entityUrl, entityUrl);

        ResponseEntity<String> response2 = testRestTemplate.exchange(
                entityUrl, HttpMethod.PUT, new HttpEntity<>(request2, requestHeaders), String.class);
        assertThat(response2.getStatusCode()).isEqualTo(HttpStatus.OK);

        String request3 = """
                {
                  "id": 1,
                  "name": "parent1",
                  "children": [
                    {
                      "id": 1,
                      "name": "child1",
                      "parent": "%s"
                    }
                  ]
                }
                """.formatted(entityUrl);

        ResponseEntity<String> response3 = testRestTemplate.exchange(
                entityUrl, HttpMethod.PUT, new HttpEntity<>(request3, requestHeaders), String.class);
        System.out.println(response3.getBody());
        assertThat(response3.getStatusCode()).isEqualTo(HttpStatus.OK);

        ResponseEntity<String> response4 = testRestTemplate.getForEntity(entityUrl, String.class);
        System.out.println(response4.getBody());
        assertThat(response4.getBody()).isEqualTo(response3.getBody());
    }

The is no any exceptions. But no DELETE SQL statement is executed at all.

The interesting thing is that it works differently on my full project. Child entities are added successfully. And also the last child entity in the list is deleted. The only problem on my real project is that an entity in the middle of the aggregate collection is not deleted, because instead of a DELETE SQL statement an UPDATE statement is executed. I have no idea how to repeat that behaviour on a small sample project. So i think that at first I should try to fix the sample project. Could you please help? Should it work at all?

@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged label Oct 26, 2023
@AresEkb
Copy link
Author

AresEkb commented Nov 5, 2023

I think I've fixed it.

  1. The first question was how to add both aggregate and nested entities in one request without explicit reference from child to parent in JSON. There are two options: 1) bidirectional association between entities (both ends of the association should be updated) 2) @JsonManagedReference / @JsonBackReference

If the association is bidirectional it's not enough to define getChildren(), addChild(), removeChild() methods. You should define a setChildren() method, that updates the opposite parent reference as well (see an example below).

  1. The second problem is that a list in a DB and a list in JSON can't be merged always. I updated the tests. Most of the tests work, but you can add or delete items only at the end of the list. You can't do it at the middle or at the beginning of the list.

Maybe you can try to implement a custom collection deserializer (here is a an example of its registration). Or you can research @JsonMerge annotation or org.springframework.data.rest.webmvc.json.DomainObjectReader class.

But I was not successful at that and found another solution. You can just replace List by Map collection. Here is an example:

@Entity
public class MapParent {

    @Id
    private UUID id = UUID.randomUUID();

    private String name;

    @OneToMany(mappedBy = "parent", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
    @MapKey
    private Map<UUID, MapChild> children = new LinkedHashMap<>();

    public Collection<MapChild> getChildren() {
        return children.values();
    }

    public void setChildren(Collection<MapChild> children) {
        this.children.clear();
        for (var child : children) {
            addChild(child);
        }
    }

    public void addChild(MapChild child) {
        if (!children.containsValue(child)) {
            children.put(child.getId(), child);
            child.setParent(this);
        }
    }

    public void removeChild(MapChild child) {
        if (children.containsValue(child)) {
            children.remove(child.getId(), child);
            child.setParent(null);
        }
    }

    // Skipped other getters and setters

}

@Entity
@Table(uniqueConstraints = { @UniqueConstraint(columnNames = { "parent_id", "name" }) })
public class MapChild {

    @Id
    private UUID id = UUID.randomUUID();

    private String name;

    @NotNull
    @ManyToOne(fetch = FetchType.LAZY)
    private MapParent parent;

    public MapParent getParent() {
        return parent;
    }

    public void setParent(MapParent parent) {
        if (this.parent != parent) {
            if (this.parent != null) {
                this.parent.removeChild(this);
            }
            this.parent = parent;
            if (this.parent != null) {
                this.parent.addChild(this);
            }
        }
    }

    // Skipped other getters and setters

}

In that case collections in DB and JSON are merged successfuly. All tests are passed.

Some things to note:

  • You don't have to expose Map outside of your class. You can provide Collection/List/... getter and setter
  • Most probably you will need to generate entity identifiers in your application, not in DB. The simplest solution is to use UUID and it totally suites my needs. But maybe you can find another solution
  • Don't forget about cascade = CascadeType.ALL, orphanRemoval = true parameters

I tried to replace Map by Set. In that case addition and deletion of items in the middle of a collection (whatever that means, because Set is unordered) doesn't work. An UPDATE statement is executed instead of INSERT or DELETE.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
status: waiting-for-triage An issue we've not yet triaged
Projects
None yet
Development

No branches or pull requests

3 participants