Skip to main content

Interceptor before save

Concept

Any entity object will be intercepted by interceptors before being saved by save commands (whether inserted or updated).

At this point, users have an opportunity to modify the data to be saved, especially to assign values to some missing properties.

If interceptors are used to assign values to missing properties (which is also the recommended usage), it is somewhat similar to default values at the database level, but with the following differences:
  • Database default values can only provide business-irrelevant default value rules.

  • Interceptors can provide default values based on business context-related information, such as the user's identity information in the permission system.

Users can provide default values that are closely combined with the business based on such business context information, which cannot be achieved by database-level default values.

Define Intercepted Data Format

Draft interceptors work with Save Command to adjust data before objects are saved.

If most entity tables have the four fields created_time, modified_time, created_by and modified_by, a super class can be provided as follows:

@MappedSuperclass
public interface BaseEntity {

LocalDateTime createdTime();

LocalDateTime modifiedTime();

@Nullable
@ManyToOne
@OnDissociate(DissociateAction.SET_NULL)
User creator();

@Nullable
@ManyToOne
@OnDissociate(DissociateAction.SET_NULL)
User editor();
}

All entities that need these fields can derive from this superclass.

note

The @OnDissociate(DissociateAction.SET_NULL) here is to prevent deletion operations on associated User data from being blocked due to these two foreign keys. When associated User is deleted, these two foreign keys are automatically cleared.

tip

Of course, user can directly intercept the entity type (decorated with @Entity), rather than an abstract type (decorated with @MappedSupperClass).

However, if an abstract type is intercepted, the save operations of all derived entity types will be intercepted, which can greatly improve the flexibility of the system, especially when the abstract type supports multiple inheritance.

Therefore, the example in this article chooses to intercept the abstract type instead of the entity type.

Define Interceptor

Assume there is a service class called UserService whose Java method getCurrentUserId() or Kotlin property currentUserId returns the id of the currently logged in user.

The interceptor must implement the org.babyfish.jimmer.sql.DraftInterceptor interface.

If using Spring management (two ways of using DraftHandler will be introduced below), the code is:

@Component
public class BaseEntityDraftInterceptor
implements DraftInterceptor<BaseEntity, BaseEntityDraft> {

private final UserService userService;

public BaseEntityDraftInterceptor(UserService userService) {
this.userService = userService;
}

@Override
public void beforeSave(BaseEntityDraft draft, @Nullable BaseEntity original) {
if (!ImmutableObjects.isLoaded(draft, BaseEntityProps.MODIFIED_TIME)) {
draft.setModifiedTime(LocalDateTime.now());
}
if (!ImmutableObjects.isLoaded(draft, BaseEntityProps.EDITOR)) {
draft.applyModifiedBy(user -> {
user.setId(userService.getCurrentUserId());
});
}
if (original == null) {
if (!ImmutableObjects.isLoaded(draft, BaseEntityProps.CREATED_TIME)) {
draft.setCreatedTime(LocalDateTime.now());
}
if (!ImmutableObjects.isLoaded(draft, BaseEntityProps.CREATOR)) {
draft.applyCreatedBy(user -> {
user.setId(userService.getCurrentUserId());
});
}
}
}
}

The beforeSave method is called before an object is saved, where the user can make final adjustments to the data to be saved.

If the isNew parameter is true, it means the subsequent operation is an insert; otherwise, it is an update.

warning

Please do not modify properties decorated with @Id or @Key in the beforeSave method.

Controlling the format of the original parameter

It was mentioned above that if the current operation is UPDATE, the original parameter of the beforeSave method is non-null, representing the old value in the database.

original is a Jimmer dynamic object. By default, only the id and key properties are loaded and accessible. However, can we control the format of the original object to allow more properties to be accessed?

The DraftInterceptor interface provides another default method dependencies() which returns a collection of properties to indicate that in addition to the id property and key property, which other properties of the original object need to be loaded.

@Component
public class BaseEntityDraftInterceptor
implements DraftInterceptor<BaseEntity, BaseEntityDraft> {

@Override
public void beforeSave(
BaseEntityDraft draft,
// The format of `original` is controlled by `dependencies()`
@Nullable BaseEntity original
) {
...implementation is omitted...
}

@Override
public Collection<TypedProp<BaseEntity, ?>> dependencies() {
return Arrays.asList(
BaseEntityProps.CREATED_BY,
BaseEntityProps.MODIFIED_BY
);
}
}
tip

The returned property collection does not need to contain the id property and key property, because they are always loaded.

Apply interceptor

Using Jimmer Spring Starter

In the above, the class BaseEntityDraftInterceptor is decorated with @Component, obviously a Spring-managed object.

info

If using Spring Boot Starter and ensuring the interceptor is Spring-managed, then Jimmer will register it automatically without additional configuration.

Otherwise, it must be manually registered.

Not Using Jimmer Spring Starter

If jimmer spring starter is not used, attaching the interceptor to the SqlClient object makes it take effect:

@Bean
public JSqlClient sqlClient(
List<DraftInterceptor<?>> interceptors,
...other params omitted...
) {
return JSqlClient
.newBuilder()
.addDraftInterceptors(interceptors)
...other config omitted...
.build();
}
tip

Although only one DraftInterceptor is demoed in this article, there may be many in an actual project.

So here a collection is used for Spring to inject all DraftInterceptor instances.

Final Usage

Assume Book inherits from BaseEntity, then it can be used like:

Book book = Immutables.createBook(draft -> {
draft.setName("SQL in Action");
draft.setEdition(1);
draft.setPrice(new BigDecimal("59"));
draft.applyStore(store -> store.setId(2L));
});
sqlClient.getEntities().save(book);
  • If the above save command finally results in an insert operation, the generated SQL is:

    insert into BOOK(
    /* highlight-start */
    CREATED_TIME,
    MODIFIED_TIME,
    CREATED_BY,
    MODIFIED_BY,
    /* highlight-end */
    NAME,
    EDITION,
    PRICE,
    STORE_ID
    ) values(
    /* highlight-next-line */
    ?, ?, ?, ?,
    ?, ?, ?, ?
    )

    Where the assignment behavior for CREATED_TIME, MODIFIED_TIME, CREATED_BY and MODIFIED_BY is automatically added by the interceptor.

  • If the above save command finally results in an update operation, the generated SQL is:

    update BOOK set
    /* highlight-start */
    MODIFIED_TIME = ?,
    MODIFIED_TIME,
    /* highlight-end */
    PRICE = ?,
    STORE_ID = ?
    where ID = ?

    Where the assignment behavior for MODIFIED_TIME and MODIFIED_BY is automatically added by the interceptor.