Contents

JPA Soft Deletes Implementation - Spring Boot

Overview

Deleting data permanently from a table is a common requirement when interacting with database. But, sometimes there are business requirements to not permanently delete data from the database. The solution is we just hide that data so that can’t be accessed from the front-end.

In this documentation, I will share how I implementing custom JPA repository with soft deletes using JpaRepositoryFactoryBean. So, that data can be tracked or audited when is created, updated, or deleted. For example, let’s design a table with a book sale case study like this. There are created_at, created_by, updated_at and deleted_at fields. Some case updated_at can be replace with modified_at and modified_by. But, the point is deleted_at field.

/images/custom-soft-deletes-1.png

Project Setup and Dependency

I’m depending Spring Initializr for this as it is much easier.

We need spring-boot-starter-data-jpa, spring-boot-starter-web, lombok and h2database. There is my pom.xml.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>

Change configuration application.properties file like following below.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
server.port=8080
spring.application.name=custom-soft-deletes
server.servlet.context-path=/api

spring.datasource.url=jdbc:h2:mem:db;
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.h2.console.enabled=true
spring.jpa.show-sql=true

Implementation

Soft Deletes Repository Interface

Create an interface SoftDeletesRepository<T, ID> which will be used to replace the repository that inherit from JpaRepository<T, ID>.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@SuppressWarnings("java:S119")
@Transactional
@NoRepositoryBean
public interface SoftDeletesRepository<T, ID extends Serializable> extends PagingAndSortingRepository<T, ID> {

    @Override
    Iterable<T> findAll();

    @Override
    Iterable<T> findAll(Sort sort);

    @Override
    Page<T> findAll(Pageable page);

    Optional<T> findOne(ID id);

    @Modifying
    void delete(ID id);

    @Override
    @Modifying
    void delete(T entity);

    void hardDelete(T entity);

}

Create an implementation from SoftDeletesRepository<T, ID> interface class.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@SuppressWarnings("java:S119")
@Slf4j
public class SoftDeletesRepositoryImpl<T, ID extends Serializable> extends SimpleJpaRepository<T, ID>
        implements SoftDeletesRepository<T, ID> {
    
    private final JpaEntityInformation<T, ?> entityInformation;
    private final EntityManager em;
    private final Class<T> domainClass;
    private static final String DELETED_FIELD = "deletedAt";

    public SoftDeletesRepositoryImpl(Class<T> domainClass, EntityManager em) {
        super(domainClass, em);
        this.em = em;
        this.domainClass = domainClass;
        this.entityInformation = JpaEntityInformationSupport.getEntityInformation(domainClass, em);
    }


    @Override
    public Optional<T> findOne(ID id) {
        return Optional.empty();
    }

    @Override
    public void delete(ID id) {

    }

    @Override
    public void hardDelete(T entity) {

    }
}

Add method in SoftDeletesRepositoryImpl to check if field deletedAt is exist on super class, because some entity have deletedAt some case the don’t have. So, I create method returning boolean to handle that.

1
2
3
4
5
6
7
8
private boolean isFieldDeletedAtExists() {
    try {
        domainClass.getSuperclass().getDeclaredField(DELETED_FIELD);
        return true;
    } catch (NoSuchFieldException e) {
        return false;
    }
}

Create predicate specification class to filter entity if deletedAt is null. So, if translated in a native query is SELECT * FROM table WHERE deleted_at is null.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
private static final class DeletedIsNUll<T> implements Specification<T> {

    private static final long serialVersionUID = -940322276301888908L;

    @Override
    public Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder) {
        return criteriaBuilder.isNull(root.<LocalDateTime>get(DELETED_FIELD));
    }

}

private static <T> Specification<T> notDeleted() {
    return Specification.where(new DeletedIsNUll<>());
}

Create predicate specification class to filter entity by ID. And can be reuse with notDeleted() or without notDeleted(). If I translated in sql is SELECT * FROM table WHERE id = ? or SELECT * FROM table WHERE id = ? AND deletedAt is null.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
private static final class ByIdSpecification<T, ID> implements Specification<T> {

    private static final long serialVersionUID = 6523470832851906115L;
    private final transient JpaEntityInformation<T, ?> entityInformation;
    private final transient ID id;

    ByIdSpecification(JpaEntityInformation<T, ?> entityInformation, ID id) {
        this.entityInformation = entityInformation;
        this.id = id;
    }

    @Override
    public Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb) {
        return cb.equal(root.<ID>get(Objects.requireNonNull(entityInformation.getIdAttribute()).getName()), id);
    }
}

Then, create method to do updating deletedAt with LocalDateTime.now() when process delete data.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
private void softDelete(ID id, LocalDateTime localDateTime) {
    Assert.notNull(id, "The given id must not be null!");

    Optional<T> entity = findOne(id);

    if (entity.isEmpty())
        throw new EmptyResultDataAccessException(
                String.format("No %s entity with id %s exists!", entityInformation.getJavaType(), id), 1);

    softDelete(entity.get(), localDateTime);
}

private void softDelete(T entity, LocalDateTime localDateTime) {
    Assert.notNull(entity, "The entity must not be null!");

    CriteriaBuilder cb = em.getCriteriaBuilder();

    CriteriaUpdate<T> update = cb.createCriteriaUpdate(domainClass);

    Root<T> root = update.from(domainClass);

    update.set(DELETED_FIELD, localDateTime);

    update.where(
            cb.equal(
                    root.<ID>get(Objects.requireNonNull(entityInformation.getIdAttribute()).getName()),
                    entityInformation.getId(entity)
            )
    );

    em.createQuery(update).executeUpdate();
}

Enhance override method findAll(), findOne, delete() and etc.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@Override
public List<T> findAll(){
    if (isFieldDeletedAtExists()) return super.findAll(notDeleted());
    return super.findAll();
}

@Override
public List<T> findAll(Sort sort){
    if (isFieldDeletedAtExists()) return super.findAll(notDeleted(), sort);
    return super.findAll(sort);
}

@Override
public Page<T> findAll(Pageable page) {
    if (isFieldDeletedAtExists()) return super.findAll(notDeleted(), page);
    return super.findAll(page);
}

@Override
public Optional<T> findOne(ID id) {
    if (isFieldDeletedAtExists())
        return super.findOne(Specification.where(new ByIdSpecification<>(entityInformation, id)).and(notDeleted()));
    return super.findOne(Specification.where(new ByIdSpecification<>(entityInformation, id)));
}

@Override
@Transactional
public void delete(ID id) {
    softDelete(id, LocalDateTime.now());
}

@Override
@Transactional
public void delete(T entity) {
    softDelete(entity, LocalDateTime.now());
}

@Override
public void hardDelete(T entity) {
    super.delete(entity);
}

Jpa Repository Factory Bean

I create a custom repository factory to replace the default RepositoryFactoryBean that will in turn produce a custom RepositoryFactory. The new repository factory will then provide your SoftDeletesRepositoryImpl as the implementation of any interfaces that extend the Repository interface, replacing the SimpleJpaRepository implementation I just extended.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@SuppressWarnings("all")
public class CustomJpaRepositoryFactoryBean<T extends JpaRepository<S, ID>, S, ID extends Serializable>
        extends JpaRepositoryFactoryBean<T, S, ID> {

    public CustomJpaRepositoryFactoryBean(Class<? extends T> repositoryInterface) {
        super(repositoryInterface);
    }

    @Override
    protected RepositoryFactorySupport createRepositoryFactory(EntityManager entityManager) {
        return new CustomJpaRepositoryFactory<T, ID>(entityManager);
    }

    private static class CustomJpaRepositoryFactory<T, ID extends Serializable> extends JpaRepositoryFactory {

        private final EntityManager entityManager;

        CustomJpaRepositoryFactory(EntityManager entityManager) {
            super(entityManager);
            this.entityManager = entityManager;
        }

        @Override
        protected JpaRepositoryImplementation<?, ?> getTargetRepository(RepositoryInformation information, EntityManager entityManager) {
            return new SoftDeletesRepositoryImpl<T, ID>((Class<T>) information.getDomainType(), this.entityManager);
        }

        @Override
        protected Class<?> getRepositoryBaseClass(RepositoryMetadata metadata) {
            return SoftDeletesRepositoryImpl.class;
        }
    }

}

Enable Custom JPA Repository Bean

Add @EnableJpaRepositories in the main Application class.

1
2
@SpringBootApplication
@EnableJpaRepositories(repositoryFactoryBeanClass = CustomJpaRepositoryFactoryBean.class)

Base Entity

Create a base entity so I can reuse it for all entity by extending the base entity. I create BaseEntity and BaseEntityWithDeletedAt which is extending from BaseEntity. It means the BaseEntityWithDeletedAt has the attributes contained in the BaseEntity.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Data
@SuperBuilder
@MappedSuperclass
@NoArgsConstructor
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public abstract class BaseEntity implements Serializable {

    private static final long serialVersionUID = 346886977546599767L;

    @Column(name = "created_at", nullable = false)
    private LocalDateTime createdAt;

    @Column(name = "created_by", nullable = false)
    private String createdBy;

    @Column(name = "updated_at")
    private LocalDateTime updatedAt;

    @PrePersist
    void onCreate() {
        this.createdAt = LocalDateTime.now();
        if (createdBy == null) createdBy = AppConstant.DEFAULT_SYSTEM;
    }

    @PreUpdate
    void onUpdate() {
        this.updatedAt = LocalDateTime.now();
    }

}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@EqualsAndHashCode(callSuper = true)
@Data
@SuperBuilder
@MappedSuperclass
@NoArgsConstructor
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public abstract class BaseEntityWithDeletedAt extends BaseEntity {

    private static final long serialVersionUID = 8570014337552990877L;

    @JsonIgnore
    @Column(name = "deleted_at")
    private LocalDateTime deletedAt;

}

Create Entity According Study Case

Author

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@EqualsAndHashCode(callSuper = true)
@Data
@Entity
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@Table(name = "M_AUTHOR")
public class Author extends BaseEntityWithDeletedAt {

    private static final long serialVersionUID = 5703123232205376654L;

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

    @Column(name = "full_name", nullable = false)
    private String fullName;

}

Create Request DTO

AuthorRequest

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public class AuthorRequest implements Serializable {

    private static final long serialVersionUID = 2120677063776280918L;

    private String fullName;

}

Create Repository, Service and Controller

AuthorRepository

1
2
3
@Repository
public interface AuthorRepository extends SoftDeletesRepository<Author, Long> {
}

AuthorService

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Slf4j
@Service
public class AuthorService {

    private final AuthorRepository authorRepository;

    @Autowired
    public AuthorService(AuthorRepository authorRepository) {
        this.authorRepository = authorRepository;
    }

    public ResponseEntity<Object> save(AuthorRequest request) {
        log.info("Save new author: {}", request);
        Author author = Author.builder()
                .fullName(request.getFullName())
                .build();
        return ResponseEntity.ok().body(authorRepository.save(author));
    }

    public ResponseEntity<Object> getAll() {
        log.info("Get all author");
        return ResponseEntity.ok().body(authorRepository.findAll());
    }

}

AuthorController

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@RestController
@RequestMapping(value = "/author", produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE)
public class AuthorController {

    private final AuthorService authorService;

    public AuthorController(AuthorService authorService) {
        this.authorService = authorService;
    }

    @PostMapping(value = "")
    public ResponseEntity<Object> createAuthor(@RequestBody AuthorRequest request) {
        return authorService.save(request);
    }

    @GetMapping(value = "")
    public ResponseEntity<Object> getAllAuthor() {
        return authorService.getAll();
    }

}

Spring Boot JPA Relational

Many-to-One and One-to-One

Let’s see our ERD in the top page, there are Many-to-One, One-to-Many, Many-to-Many and One-to-One relationship. I will implement the relation of M_AUTHOR, M_BOOK and M_BOOK_DETAIL first.

Entity Class

Book

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@EqualsAndHashCode(callSuper = true)
@Data
@Entity
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@Table(name = "M_BOOK")
public class Book extends BaseEntityWithDeletedAt {

    private static final long serialVersionUID = 3000665212891573963L;

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

    @ManyToOne
    @JoinColumn(name = "author_id", nullable = false)
    private Author author;

    @Column(name = "title", nullable = false)
    private String title;

    @Column(name = "price", nullable = false)
    private Integer price;

    @JsonIgnore
    @OneToOne(cascade = CascadeType.ALL)
    private BookDetail detail;

}

Author

Add this method to mapping an author have books.

1
2
3
@JsonIgnore
@OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, mappedBy = "author")
private List<Book> books;

BookDetail

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@EqualsAndHashCode(callSuper = true)
@Data
@Entity
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@Table(name = "M_BOOK_DETAIL")
public class BookDetail extends BaseEntityWithDeletedAt {

    private static final long serialVersionUID = -4930414280222129820L;

    /**
     * @Id column should exists for one to one relationship
     */
    @Id
    @JsonIgnore
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long bookId;

    @OneToOne(mappedBy = "detail")
    @JoinColumn(name = "book_id", nullable = false)
    private Book book;

    @Column(name = "page", nullable = false)
    private Integer page;

    @Column(name = "weight", nullable = false)
    private Integer weight;

}

In this case, BookDetail should not have field bookId but the JPA entity should have an id. So, I added bookId as id field but it is ignored from json.

BookRequest

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public class BookRequest implements Serializable {

    private static final long serialVersionUID = 7993247371386533518L;

    private Long authorId;

    private String title;

    private Integer price;

    private Integer page;

    private Integer weight;

}

Repository, Service and Controller

BookRepository

1
2
3
@Repository
public interface BookRepository extends SoftDeletesRepository<Book, Long> {
}

BookDetailRepository

1
2
3
@Repository
public interface BookDetailRepository extends SoftDeletesRepository<BookDetail, Long> {
}

BookService

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
@Slf4j
@Service
public class BookService {

    private final AuthorRepository authorRepository;
    private final BookRepository bookRepository;
    private final BookDetailRepository bookDetailRepository;

    @Autowired
    public BookService(AuthorRepository authorRepository, BookRepository bookRepository,
                       BookDetailRepository bookDetailRepository) {
        this.authorRepository = authorRepository;
        this.bookRepository = bookRepository;
        this.bookDetailRepository = bookDetailRepository;
    }

    public ResponseEntity<Object> addBook(BookRequest request) {
        log.info("Save new book: {}", request);

        log.info("Find author by author id");
        Optional<Author> author = authorRepository.findOne(request.getAuthorId());
        if (author.isEmpty()) return ResponseEntity.notFound().build();

        Book book = Book.builder()
                .author(author.get())
                .detail(BookDetail.builder()
                        .page(request.getPage())
                        .weight(request.getWeight())
                        .build())
                .title(request.getTitle())
                .price(request.getPrice())
                .build();
        return ResponseEntity.ok().body(bookRepository.save(book));
    }

    public ResponseEntity<Object> getAllBook() {
        return ResponseEntity.ok().body(bookRepository.findAll());
    }

    public ResponseEntity<Object> getBookDetail(Long bookId) {
        log.info("Find book detail by book id: {}", bookId);
        Optional<BookDetail> bookDetail = bookDetailRepository.findOne(bookId);
        if (bookDetail.isEmpty()) return ResponseEntity.badRequest().body(Map.ofEntries(Map.entry("message", "Data not found")));

        return ResponseEntity.ok().body(bookDetail.get());
    }

    public ResponseEntity<Object> deleteBook(Long bookId) {
        log.info("Find book detail by book id for delete: {}", bookId);
        try {
            bookDetailRepository.delete(bookId);
            bookRepository.delete(bookId);
        } catch (EmptyResultDataAccessException e) {
            log.error("Data not found. Error: {}", e.getMessage());
            return ResponseEntity.badRequest().body(Map.ofEntries(Map.entry("message", "Data not found")));
        }
        return ResponseEntity.ok().body(Map.ofEntries(Map.entry("message", "ok")));
    }

    public ResponseEntity<Object> updatePrice(BookRequest request, Long bookId) {
        log.info("Update price: {}", request);
        Optional<Book> book = bookRepository.findOne(bookId);
        if (book.isEmpty()) return ResponseEntity.badRequest().body(Map.ofEntries(Map.entry("message", "Data not found")));

        book.get().setPrice(request.getPrice());
        bookRepository.save(book.get());
        return ResponseEntity.ok().body(book.get());
    }

}

BookController

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@RestController
@RequestMapping(value = "/book", produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE)
public class BookController {

    private final BookService bookService;

    public BookController(BookService bookService) {
        this.bookService = bookService;
    }

    @PostMapping(value = "")
    public ResponseEntity<Object> addBook(@RequestBody BookRequest request) {
        return bookService.addBook(request);
    }

    @GetMapping(value = "")
    public ResponseEntity<Object> getAllBooks() {
        return bookService.getAllBook();
    }

    @GetMapping(value = "/detail/{id}")
    public ResponseEntity<Object> getBookDetail(@PathVariable(value = "id") Long bookId) {
        return bookService.getBookDetail(bookId);
    }

    @DeleteMapping(value = "/{id}")
    public ResponseEntity<Object> deleteBook(@PathVariable(value = "id") Long bookId) {
        return bookService.deleteBook(bookId);
    }

    @PostMapping(value = "/{id}")
    public ResponseEntity<Object> updatePrice(@PathVariable(value = "id") Long bookId,
                                              @RequestBody BookRequest request) {
        return bookService.updatePrice(request, bookId);
    }

}

One-to-Many and Many-to-Many

Let’s see T_TRANSACTION and T_TRANSACTION_DETAIL, they have One-to-Many relationship between T_TRANSACTION and T_TRANSACTION_DETAIL. Also T_TRANSACTION_DETAIL have Many-To-Many relationship between T_TRANSACTION and M_BOOK that means T_TRANSACTION_DETAIL have two primary keys, namely transaction_id and book_id.

Entity and DTO Request Class

Transaction

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@EqualsAndHashCode(callSuper = true)
@Data
@Entity
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@Table(name = "T_TRANSACTION")
public class Transaction extends BaseEntity {

    private static final long serialVersionUID = 6417258128520039672L;

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

    @Column(name = "customer_name", nullable = false)
    private String customerName;

    @Column(name = "transaction_date", nullable = false)
    private LocalDateTime transactionDate;

    @Column(name = "total_price", nullable = false)
    private Integer totalPrice;

    @Column(name = "total_qty", nullable = false)
    private Integer totalQty;

    @JsonIgnore
    @OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, mappedBy = "transaction")
    private List<TransactionDetail> transactionDetails;

}

Book

Add this method to mapping a book have transaction details.

1
2
3
@JsonIgnore
@OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, mappedBy = "book")
private List<TransactionDetail> transactionDetails;

TransactionDetail

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@EqualsAndHashCode(callSuper = true)
@Data
@Entity
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@Table(name = "T_TRANSACTION_DETAIL")
@IdClass(TransactionDetail.TransactionDetailId.class)
public class TransactionDetail extends BaseEntity {

    private static final long serialVersionUID = -2700555234966165635L;

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public static class TransactionDetailId implements Serializable {
        private static final long serialVersionUID = 2209912596164063361L;
        private Long transaction;
        private Long book;
    }

    @Id
    @ManyToOne
    @JoinColumn(name = "transaction_id", nullable = false)
    private Transaction transaction;

    @Id
    @ManyToOne
    @JoinColumn(name = "book_id", nullable = false)
    private Book book;

    @Column(name = "qty", nullable = false)
    private Integer qty;

    @Column(name = "price", nullable = false)
    private Integer price;

}

In this case, because TransactionDetail have composite primary keys. I should define an id class TransactionDetail.TransactionDetailId to map a primary keys and annotate TransactionDetail with @IdClass.

TransactionDetailRequest

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public class TransactionDetailRequest implements Serializable {

    private static final long serialVersionUID = 3141178093304012075L;

    private Long bookId;

    private Integer qty;

}

TransactionRequest

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public class TransactionRequest implements Serializable {

    private static final long serialVersionUID = 122662932230379345L;

    private String customerName;

    private Long transactionId;

    private Long bookId;

    private List<TransactionDetailRequest> details;

}

Repository and Service Class

TransactionRepository

1
2
3
@Repository
public interface TransactionRepository extends SoftDeletesRepository<Transaction, Long> {
}

TransactionDetailRepository

1
2
3
4
5
6
@Repository
public interface TransactionDetailRepository extends SoftDeletesRepository<TransactionDetail, Long> {

    List<TransactionDetail> findAllByTransactionId(Long transactionId);

}

TransactionService

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@Slf4j
@Service
public class TransactionService {

    private final BookRepository bookRepository;
    private final TransactionRepository transactionRepository;
    private final TransactionDetailRepository transactionDetailRepository;

    @Autowired
    public TransactionService(BookRepository bookRepository, TransactionRepository transactionRepository,
                              TransactionDetailRepository transactionDetailRepository) {
        this.bookRepository = bookRepository;
        this.transactionRepository = transactionRepository;
        this.transactionDetailRepository = transactionDetailRepository;
    }

    public ResponseEntity<Object> createTransaction(TransactionRequest request) {
        Transaction transaction = Transaction.builder()
                .transactionDate(LocalDateTime.now())
                .customerName(request.getCustomerName())
                .build();
        List<TransactionDetail> details = new ArrayList<>();
        for (TransactionDetailRequest detailRequest : request.getDetails()) {
            log.info("Find book by bookId");
            Optional<Book> book = bookRepository.findOne(detailRequest.getBookId());
            if (book.isPresent()) {
                Integer price = book.get().getPrice() * detailRequest.getQty();
                details.add(TransactionDetail.builder()
                        .transaction(transaction)
                        .book(book.get())
                        .price(price)
                        .qty(detailRequest.getQty())
                        .build());
            }
        }
        transaction.setTotalPrice(details.stream().mapToInt(TransactionDetail::getPrice).sum());
        transaction.setTotalQty(details.stream().mapToInt(TransactionDetail::getQty).sum());
        transaction.setTransactionDetails(details);
        transactionRepository.save(transaction);
        return ResponseEntity.ok().body(transaction);
    }

    public ResponseEntity<Object> getTransactionDetails(Long transactionId) {
        return ResponseEntity.ok().body(transactionDetailRepository.findAllByTransactionId(transactionId));
    }

}

Controller

TransactionController

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
@RestController
@RequestMapping(value = "/transaction", produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE)
public class TransactionController {

    private final TransactionService transactionService;

    @Autowired
    public TransactionController(TransactionService transactionService) {
        this.transactionService = transactionService;
    }

    @PostMapping(value = "")
    public ResponseEntity<Object> addTransaction(@RequestBody TransactionRequest request) {
        return transactionService.createTransaction(request);
    }

    @GetMapping(value = "/{id}")
    public ResponseEntity<Object> getTransactionDetail(@PathVariable(value = "id") Long transactionId) {
        return transactionService.getTransactionDetails(transactionId);
    }

}

Clone on Github

piinalpin/springboot-data-jpa-soft-delete

Reference