Skip to main content

Dissociation Operations

When saving associated objects using the association mode AssociatedSaveMode.REPLACE, there is an important concept: dissociation operation.

Concept

Existing Database StructureUser's Expected Data Structure
+-Food
|
|
|
+-----Meat(ignore child nodes)
|
\-----Bread(ignore child nodes)
+-Food
|
+-----Drinks(ignore child nodes)
|
|
|
\-----Bread(ignore child nodes)
  • For Bread, it exists in both old and new data structures, corresponding to an UPDATE operation

  • For Drinks, it doesn't exist in the old data structure but exists in the new data, corresponding to an INSERT operation

  • For Meat, it exists in the old data structure but not in the new data, corresponding to an operation called dissociation operation.

Dissociation applies to two types of associations:

  • Middle table associations

    Obviously, middle table associations refer to associations like Book.authors and Author.books in this tutorial.

    Their dissociation operation is very simple, just deleting the association in the middle table, without affecting the associated objects themselves.

  • Child table associations

    Child table associations refer to inverse associations based on foreign keys (whether real or fake), such as BookStore.books in this tutorial.

    Their dissociation operation is relatively complex. Developers can control specific dissociation behavior through configuration.

Next, let's discuss these two types of dissociation operations.

Dissociating Middle Table Associations

We've already demonstrated this simplest form of dissociation when introducing the REPLACE save mode for associated objects.

Therefore, we won't repeat it here.

Dissociating Child Table Associations

Child table associations refer to inverse associations based on foreign keys (whether real or fake), such as BookStore.books.

Their dissociation operation is relatively complex. Developers can configure the dissociation mode for properties corresponding to foreign keys (such as Book.store here) to achieve different dissociation behaviors.

Dissociation Modes

There are 5 modes for child object dissociation operations

ModeDescription

NONE (Default)

Depends on global configuration jimmer.default-dissociate-action-checking

LAX

Dissociation operation performs no action.

  • If the foreign key is real (see Real and Fake Foreign Keys), when parent object is deleted:

    • If cascade delete behavior is configured for the database foreign key (on cascade set null or on delete delete), database automatically clears the foreign key of dissociated child objects or automatically deletes the dissociated child objects

      Although database-level cascade modification performs better than ORM-level cascade modification, the ORM is unaware of this. Use with caution in projects requiring cache consistency

    • Otherwise, database reports an error and the save command is terminated

  • If the foreign key is fake (see Real and Fake Foreign Keys), when parent object is deleted, no additional behavior occurs, allowing dangling issues with child object foreign key values

    Even if fake foreign key values are invalid dangling values, jimmer queries won't error - the query system will return null for parent objects rather than error due to non-existent parent

CHECKDoes not support dissociation operations. Throws exception to prevent operation if current parent object in database has child objects that need to be dissociated.
SET_NULLSets the foreign key of dissociated child objects to null. This mode requires that the child object's foreign key property is nullable; otherwise attempting this configuration will cause an exception.
DELETEDeletes the dissociated child objects.

There are two ways to configure dissociation mode:

  • Static configuration using annotations on entities, which is global.

  • Dynamic configuration in code, which can override static configuration but only affects the current save command.

Taking SET_NULL as an example:

  • Static entity-based configuration (chosen in most cases, used by the majority of business logic)

    Book.java
    @Entity
    public interface Book {

    @OnDissociate(DissociateAction.SET_NULL)
    @Nullable
    @ManyToOne
    BookStore store();

    ...omitted other code...
    }
  • Dynamic save command-based configuration (only for individual save commands, used by very few businesses with special requirements)

    sqlClient
    .getEntities()
    .saveCommand(book)
    .setDissociateAction(
    BookProps.STORE,
    DissociateAction.SET_NULL
    )
    .execute();

Save Code

Next, let's explain various dissociation modes through saving data structures containing one-to-many association BookStore.books.

Here's the save code:

List<BookStore> stores = Arrays.asList(
Immutables.createBookStore(draft -> {
draft.setName("O'REILLY");
draft.addIntoBooks(book -> {
book.setName("Learning GraphQL");
book.setEdition(3);
book.setPrice(new BigDecimal("51.9"));
});
draft.addIntoBooks(book -> {
book.setName("Learning GraphQL");
book.setEdition(4);
book.setPrice(new BigDecimal("43.9"));
});
draft.addIntoBooks(book -> {
book.setName("Effective TypeScript");
book.setEdition(3);
book.setPrice(new BigDecimal("88.9"));
});
draft.addIntoBooks(book -> {
book.setName("Effective TypeScript");
book.setEdition(4);
book.setPrice(new BigDecimal("85.9"));
});
draft.addIntoBooks(book -> {
book.setName("Programming TypeScript");
book.setEdition(3);
book.setPrice(new BigDecimal("48.9"));
});
draft.addIntoBooks(book -> {
book.setName("Programming TypeScript");
book.setEdition(4);
book.setPrice(new BigDecimal("47.9"));
});
}),
Immutables.createBookStore(draft -> {
draft.setName("MANNING");
draft.addIntoBooks(book -> {
book.setName("GraphQL in Action");
book.setEdition(3);
book.setPrice(new BigDecimal("80.9"));
});
draft.addIntoBooks(book -> {
book.setName("GraphQL in Action");
book.setEdition(4);
book.setPrice(new BigDecimal("81.9"));
});
})
);
sqlClient.saveEntities(
stores,
// This parameter can be omitted, for the `save` method,
// AssociatedSaveMode defaults to REPLACE
AssociatedSaveMode.REPLACE
);

Comparison between old and new objects:

Existing Database DataData User Wants to Save
--+-O'REILLY
|
+---Learn GraphQL(edition = 1)
|
+---Learn GraphQL(edition = 2)
|
+---Learn GraphQL(edition = 3)
|
|
|
+---Effective TypeScript(edition = 1)
|
+---Effective TypeScript(edition = 2)
|
+---Effective TypeScript(edition = 3)
|
|
|
+---Programming TypeScript(edition = 1)
|
+---Programming TypeScript(edition = 2)
|
\---Programming TypeScript(edition = 3)


--+-O'REILLY
|
|
|
|
|
+---Learn GraphQL(edition = 3)
|
+---Learn GraphQL(edition = 4)
|
|
|
|
|
+---Effective TypeScript(edition = 3)
|
+---Effective TypeScript(edition = 4)
|
|
|
|
|
+---Programming TypeScript(edition = 3)
|
\---Programming TypeScript(edition = 4)
--+-MANNING
|
+---GraphQL in Action(edition = 1)
|
+---GraphQL in Action(edition = 2)
|
\---GraphQL in Action(edition = 3)


--+-MANNING
|
|
|
|
|
+---GraphQL in Action(edition = 3)
|
\---GraphQL in Action(edition = 4)
info

Among them, the 8 associated objects that are no longer needed, i.e., the 8 objects that need to be dissociated, are highlighted.

So, how will Jimmer dissociate these associated objects?

In fact, different dissociation configurations will lead to different dissociation behaviors.

1. NONE (Default)

NONE's behavior is not fixed, but depends on the global configuration jimmer.default-dissociate-action-check:

  • If jimmer.default-dissociate-action-check is true (default) or if the current association is based on a real foreign key (foreign key constraint exists in database, see Real vs Fake Foreign Keys), treated as CHECK.

  • If jimmer.default-dissociate-action-check is false and the current association is based on a fake foreign key (no corresponding foreign key constraint in database, see Real vs Fake Foreign Keys), treated as LAX.

2. LAX

  • Dissociate mode LAX means the dissociation operation does nothing.

  • Associated save mode AssociatedSaveMode.REPLACE means unnecessary associations need to be discarded.

Clearly, these two rules conflict. In this case, AssociatedSaveMode.REPLACE takes precedence, LAX is ignored, and it's ultimately treated as CHECK.

Therefore, we cannot demonstrate LAX through this article. To understand how LAX works, please see Delete Command.

3. CHECK

CHECK mode does not allow dissociating associated objects. Jimmer will check if there are objects that need to be dissociated, and if they exist, throws an exception.

The dissociate mode can be set either through the entity definition or through the save command.

Here, we set the dissociate mode for Book.store through entity configuration:

Book.java
@Entity
@KeyUniqueConstraint(noMoreUniqueConstraints = true)
public interface Book {

@OnDissociate(DissociateAction.CHECK)
@Nullable
@ManyToOne
BookStore store();

...omitted other code...
}

To demonstrate simpler SQL generation, let's assume sqlClient's targetTransferable feature is enabled, which was previously discussed, see here

Running the save code from before will generate three SQL statements:

  1. Saving the root object is not the focus of this article, collapsed by default
    merge into BOOK_STORE(
    NAME
    ) key(NAME) values(
    ?
    )
    /* batch-0: [MANNING] */
    /* batch-1: [AMAZON] */
    • Assume MANNING exists with an id of 2
    • Assume AMAZON exists, after insertion, the database automatically assigns a new id of 100
  2. Saving associations and associated objects is also not the focus of this article, collapsed by default
    merge into BOOK(
    NAME, EDITION, PRICE, STORE_ID
    ) key(
    NAME, EDITION
    ) values(
    ?, ?, ?, ?
    )
    /* batch-0: [Learning GraphQL, 3, 51.9, 1] */
    /* batch-1: [Learning GraphQL, 4, 43.9, 1] */
    /* batch-2: [Effective TypeScript, 3, 88.9, 1] */
    /* batch-3: [Effective TypeScript, 4, 85.9, 1] */
    /* batch-4: [Programming TypeScript, 3, 48.9, 1] */
    /* batch-5: [Programming TypeScript, 4, 47.9, 1] */
    /* batch-6: [GraphQL in Action, 3, 80.9, 2] */
    /* batch-7: [GraphQL in Action, 4, 81.9, 2] */
  3. Check if there are objects that need to be dissociated (if any exist, throws exception to prevent save command)

    select
    tb_1_.ID
    from BOOK tb_1_
    where
    tb_1_.STORE_ID in (
    ? /* 1 */, ? /* 2 */
    )
    and
    (tb_1_.STORE_ID, tb_1_.ID) not in (
    (? /* 1 */, ? /* 3 */),
    (? /* 1 */, ? /* 100 */),
    (? /* 1 */, ? /* 6 */),
    (? /* 1 */, ? /* 101 */),
    (? /* 1 */, ? /* 9 */),
    (? /* 1 */, ? /* 102 */),
    (? /* 2 */, ? /* 12 */),
    (? /* 2 */, ? /* 103 */)
    )
    limit ? /* 1 */

    After this SQL check, some Book objects that will be dissociated are found through the saved BookStore objects' Book.authors property. However, they do not agree to be dissociated and throw the following exception:

    Save error caused by the path: "<root>.books": 
    Cannot dissociate child objects
    because the
    dissociation action of the many-to-one property
    "com.yourcommany.yourproject.model.Book.store"
    is not configured as "set null" or "cascade".
    There are two ways to resolve this issue:
    Decorate the many-to-one property
    "com.yourcommany.yourproject.model.Bookstore" by
    @org.babyfish.jimmer.sql.OnDissociate whose argument
    is `DissociateAction.SET_NULL` or `DissociateAction.DELETE`,
    or use save command's runtime configuration to override it

4. SET_NULL

SET_NULL mode achieves dissociation by setting the associated object's foreign key property to null.

caution

SET_NULL mode requires that the foreign key-based property (here Book.store) must be nullable, otherwise it will cause a compilation error

The dissociate mode can be set either through the entity definition or through the save command.

Here, we set the dissociate mode for Book.store through entity configuration:

Book.java
@Entity
@KeyUniqueConstraint(noMoreUniqueConstraints = true)
public interface Book {

@OnDissociate(DissociateAction.SET_NULL)
@Nullable
@ManyToOne
BookStore store();

...omitted other code...
}

To demonstrate simpler SQL generation, let's assume sqlClient's targetTransferable feature is enabled, which was previously discussed, see here

Running the save code from before will generate three SQL statements:

  1. Saving the root object is not the focus of this article, collapsed by default
    merge into BOOK_STORE(
    NAME
    ) key(NAME) values(
    ?
    )
    /* batch-0: [MANNING] */
    /* batch-1: [AMAZON] */
    • Assume MANNING exists with an id of 2
    • Assume AMAZON exists, after insertion, the database automatically assigns a new id of 100
  2. Saving associations and associated objects is also not the focus of this article, collapsed by default
    merge into BOOK(
    NAME, EDITION, PRICE, STORE_ID
    ) key(
    NAME, EDITION
    ) values(
    ?, ?, ?, ?
    )
    /* batch-0: [Learning GraphQL, 3, 51.9, 1] */
    /* batch-1: [Learning GraphQL, 4, 43.9, 1] */
    /* batch-2: [Effective TypeScript, 3, 88.9, 1] */
    /* batch-3: [Effective TypeScript, 4, 85.9, 1] */
    /* batch-4: [Programming TypeScript, 3, 48.9, 1] */
    /* batch-5: [Programming TypeScript, 4, 47.9, 1] */
    /* batch-6: [GraphQL in Action, 3, 80.9, 2] */
    /* batch-7: [GraphQL in Action, 4, 81.9, 2] */
  3. Set foreign keys to null for all objects being dissociated

    update BOOK
    set STORE_ID = null
    where
    STORE_ID = ?
    and
    not (
    ID = any(?)
    )
    /* batch-0: [1, [3, 100, 6, 101, 9, 102]] */
    /* batch-1: [2, [12, 103]] */

    We can see significant differences between databases:

    • H2 and Postgres use batched simple SQL
    • MySQL uses a single complex SQL
    info

    This is because H2 and Postgres support the =any(array) syntax, while MySQL does not.

5. DELETE

DELETE mode achieves dissociation by deleting the associated objects.

info

Here when saving BookStore, we perform dissociation on the BookStore.books association, where DELETE mode indicates we want to delete unnecessary Book objects.

However, since the Book entity has a deeper association Book.authors, we need to consider how to dissociate Author objects before deleting Book objects.

In fact, if objects that need to be deleted due to dissociation have deeper associations, it evolves into a recursive deletion problem. This behavior is detailed in Delete Command, which we won't discuss here.

Therefore, to simplify the discussion, we'll temporarily ignore the Author entity and assume the current system only has BookStore and Book entity types.

The dissociate mode can be set either through the entity definition or through the save command.

Here, we set the dissociate mode for Book.store through entity configuration:

Book.java
@Entity
@KeyUniqueConstraint(noMoreUniqueConstraints = true)
public interface Book {

@OnDissociate(DissociateAction.DELETE)
@Nullable
@ManyToOne
BookStore store();

...omitted other code...
}

To demonstrate simpler SQL generation, let's assume sqlClient's targetTransferable feature is enabled, which was previously discussed, see here

Running the save code from before will generate three SQL statements:

  1. Saving the root object is not the focus of this article, collapsed by default
    merge into BOOK_STORE(
    NAME
    ) key(NAME) values(
    ?
    )
    /* batch-0: [MANNING] */
    /* batch-1: [AMAZON] */
    • Assume MANNING exists with an id of 2
    • Assume AMAZON exists, after insertion, the database automatically assigns a new id of 100
  2. Saving associations and associated objects is also not the focus of this article, collapsed by default
    merge into BOOK(
    NAME, EDITION, PRICE, STORE_ID
    ) key(
    NAME, EDITION
    ) values(
    ?, ?, ?, ?
    )
    /* batch-0: [Learning GraphQL, 3, 51.9, 1] */
    /* batch-1: [Learning GraphQL, 4, 43.9, 1] */
    /* batch-2: [Effective TypeScript, 3, 88.9, 1] */
    /* batch-3: [Effective TypeScript, 4, 85.9, 1] */
    /* batch-4: [Programming TypeScript, 3, 48.9, 1] */
    /* batch-5: [Programming TypeScript, 4, 47.9, 1] */
    /* batch-6: [GraphQL in Action, 3, 80.9, 2] */
    /* batch-7: [GraphQL in Action, 4, 81.9, 2] */
  3. Delete all objects that need to be dissociated

    delete from BOOK
    where
    STORE_ID = ?
    and
    not (
    ID = any(?)
    )
    /* batch-0: [1, [3, 100, 6, 101, 9, 102]] */
    /* batch-1: [2, [12, 103]] */

    We can see significant differences between databases:

    • H2 and Postgres use batched simple SQL
    • MySQL uses a single complex SQL
    info

    This is because H2 and Postgres support the =any(array) syntax, while MySQL does not.