Managing entity associations, relationships and updates in applications

How do different languages handle updating entities?

  1. especially, many-to-many or one-to-many relationships?
  2. How is it done in springboot?
  3. How is the reconciliation done for rows to be added, previous rows to be deleted in the second table with the foreign key?

See JPA - entity associations and association mappings

Managing Many-to-Many Relationships with Spring Data JPA: Creating Association Tables Manually

Source: https://medium.com/@simulbista/managing-many-to-many-relationships-with-spring-data-jpa-creating-association-tables-manually-a86306b4cbbe

This focuses on handling many-to-many relationships between entities by manually creating association tables.

Manually managing many-to-many relationships in Spring Data JPA allows for greater customization and control, especially when additional fields are required in the association table. By following this approach, we can efficiently manage these relationships in your Spring Boot application.

Example Scenario

E-commerce Application

Consider an e-commerce application that allows users to purchase workout equipment. Our application has two key entities: Product and Category. These entities have a many-to-many relationship: a product can belong to multiple categories, and a category can contain multiple products.

For instance, the category “Starter Pack” might include products like dip bars and gym rings, while the product “dip bars” could belong to both “Starter Pack” and “Equipment” categories.

Approach: Creating an Association Table

The logical way to manage many-to-many relationships is by creating an association table containing the primary keys of both Product and Category tables as foreign keys. While Spring Data JPA can automate the creation of this table (See https://www.baeldung.com/jpa-many-to-many), we will manually manage it to include additional custom fields such as userId and timestamp.

Entities

Product entity

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Entity
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long productId;
    private String name;
    private String specification;
    private String usage_url;

    @Column(columnDefinition = "boolean default false")
    private boolean deleteFlag;

    //@JsonIgnore is used to prevent infinite recursion (Jackson will not serialize this side of the relationship)
    //orphanRemoval = true removes the record in product_categories table when that association object is removed from the set in product
    @JsonIgnore
    @OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "product_id", referencedColumnName = "productId")
    private Set<ProductCategory> productCategories = new HashSet<>();

}

productCategories - is a field of type ProductCategory that maps the relationship with the association table ProductCategory.

We’ve used CascadeType.ALL for the cascade type, meaning that any changes (insert, update, delete) made to a Product will cascade to its associated ProductCategory entities.

Category entity

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Entity
public class Category {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long categoryId;
    private String name;

    @Column(columnDefinition = "boolean default false")
    private boolean deleteFlag;;

    //orphanRemoval = true removes the record in product_categories table when that association object is removed from the set in category
    @OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "category_id", referencedColumnName = "categoryId")
    private Set<ProductCategory> productCategories = new HashSet<>();
}

Association entity

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Entity
public class ProductCategory {

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

    @Column(name = "product_id")
    private long productId;

    @Column(name = "category_id", nullable = false)
    private long categoryId;

    @Column(name = "user_id", nullable = false)
    private UUID userId;

    @Column(nullable = false)
    private LocalDateTime timestamp;

    //This is a helper method to set the timestamp before saving the record
    @PrePersist
    @PreUpdate
    protected void onUpdate() {
        this.timestamp = LocalDateTime.now();
    }

}

The productId field is not set to nullable=false, meaning it can have null values. This detail is significant.

Handling Associations Manually

By managing the association table manually, we can add custom fields. For example, if we want to update a product, we need to manage the associations accordingly i.e. we must delete existing associations and create new ones.

Updating Associations

Here’s how you can update a product. Lets use a updateProduct service method to do that. In this example, we use DTOs (Data Transfer Objects) following the DTO pattern with Mapstruct. This approach abstracts the entity details and simplifies the data transfer process.

@Service
public class ProductService {

    private final ProductRepository productRepository;
    private final CategoryRepository categoryRepository;
    private final ProductCategoryRepository productCategoryRepository;

    //product DTO mapper (mapstruct)
    private final ProductMapper productMapper;

    //injecting dependencies via constructor
    public ProductService(ProductRepository productRepository, CategoryRepository categoryRepository, ProductCategoryRepository productCategoryRepository, ProductMapper productMapper) {
        this.productRepository = productRepository;
        this.categoryRepository = categoryRepository;
        this.productCategoryRepository = productCategoryRepository;
        this.productMapper = productMapper;
    }

    //updateProduct method
    @Transactional
    public ResponseEntity<String> updateProduct(ProductDTO productDTO, long productId) {
        //check if the product exists
        Product existingProduct = productRepository.findByProductIdAndDeleteFlagIsFalse(productId).orElse(null);
        if (existingProduct == null) {
            return ResponseEntity.badRequest().body("The product does not exist!");
        }

        //get the updated information from the DTO and store it in the respective fields in existingProduct
        //id and associations are not updated till this point
        existingProduct.setName(productDTO.getName());
        existingProduct.setSpecification(productDTO.getSpecification());
        existingProduct.setUsage_url(productDTO.getUsage_url());

        // Remove existing associations
        existingProduct.getProductCategories().clear();

        //create new associations with the updated category ids in the productDTO
        Set<ProductCategory> newProductCategories = new HashSet<>();

        for (Long categoryId : productDTO.getCategories()) {
            //check if the category id exists
            categoryRepository.findById(categoryId)
                    .orElseThrow(() -> new RuntimeException("Category with ID " + categoryId + " not found"));

            //create the productcategory association
            ProductCategory productCategory = new ProductCategory();
            productCategory.setProductId(productId);
            productCategory.setCategoryId(categoryId);
            productCategory.setUserId(UUID.randomUUID());
            productCategory.setTimestamp(LocalDateTime.now());

            //add the association to the set
            newProductCategories.add(productCategory);
        }

        //Add new associations to the product
        existingProduct.getProductCategories().addAll(newProductCategories);


        //update the product
        productRepository.save(existingProduct);

        return ResponseEntity.ok("Product updated successfully!");
    }

}

Key Consideration — Handling Null IDs

When deleting existing associations, JPA temporarily sets the foreign keys to null before deleting the records. To avoid issues, ensure that foreign keys (productId in this case) in the ProductCategory entity are NOT set to nullable=false.

Reading material

  1. https://www.baeldung.com/jpa-many-to-many
  2. https://www.reddit.com/r/SpringBoot/comments/1gpksd1/jpa_many_to_many_relationship_crud_management/
  3. https://stackoverflow.com/questions/62345177/jpql-query-jpa-spring-boot-best-practice-of-updating-many-to-many-table
  4. https://stackoverflow.com/questions/75301480/how-to-update-tables-in-many-to-many-relationship-in-entity-framework-core
  5. https://www.thereformedprogrammer.net/updating-a-many-to-many-relationship-in-entity-framework/

Tags

  1. JPA - entity associations and association mappings
  2. Spring Data JPA - Entities

Links to this note