Cascade Upsert

This article will guide you on how to use the cascading insert or update feature of Kronos.

Cascading insert or update

Use KPojo.upsert().on(...).cascade(...).execute() to upsert a root record and let the declared @Cascade relationships participate in the operation.

Cascade upsert uses the match-field upsert flow described in 📚Upsert Records:

1. Check whether the root record exists by the fields passed to `on { ... }`.
2. If the record exists, run the update branch and apply cascade update behavior.
3. If the record is absent, run the insert branch and apply cascade insert behavior.

Cascade upsert runs through the insert and update branches, so a relation participates when its @Cascade.usage allows KOperationType.INSERT or KOperationType.UPDATE. The default @Cascade usage already includes both operation types. For relation mapping rules, see 📚Cascade Definition.

Enable cascade upsert

Cascade is enabled by default. You can also call .cascade(true) to make the setting explicit.

kotlin

@Table("account")
data class Account(
    @PrimaryKey
    var id: Long? = null,
    var email: String? = null,
    var profile: AccountProfile? = null,
    var auditLogs: List<AccountAudit> = listOf()
) : KPojo

@Table("account_profile")
data class AccountProfile(
    @PrimaryKey
    var id: Long? = null,
    var accountEmail: String? = null,
    var displayName: String? = null,
    @Cascade(["accountEmail"], ["email"])
    var account: Account? = null
) : KPojo

@Table("account_audit")
data class AccountAudit(
    @PrimaryKey
    var id: Long? = null,
    var accountEmail: String? = null,
    var action: String? = null,
    @Cascade(["accountEmail"], ["email"])
    var account: Account? = null
) : KPojo

Account(
    id = 1,
    email = "[email protected]",
    profile = AccountProfile(displayName = "Ada")
)
    .upsert { it.email }
    .on { it.id }
    .cascade(true)
    .execute()

mysql

SELECT COUNT(1) FROM `account` WHERE `id` = :id LIMIT 1 FOR UPDATE;

# If the root record is absent
INSERT INTO `account` (`id`, `email`) VALUES (:id, :email);
INSERT INTO `account_profile` (`account_email`, `display_name`) VALUES (:accountEmail, :displayName);

# If the root record exists
SELECT `id`, `email` FROM `account` WHERE `id` = :id;
SELECT `id`, `account_email` AS `accountEmail`, `display_name` AS `displayName`
FROM `account_profile`
WHERE `account_email` = :accountEmail;
UPDATE `account_profile`
SET `account_email` = :accountEmailNew
WHERE `id` = :id AND `account_email` = :accountEmail;
UPDATE `account` SET `email` = :emailNew WHERE `id` = :id;

Result shape:

result

Absent root: account is inserted first, then account_profile is inserted with account_email = account.email.
Existing root: account_profile.account_email is updated before account.email when email changes.

One-to-one cascade upsert

For a one-to-one relationship, put the reference object on the root object and declare the reference fields with @Cascade.

kotlin

@Table("user_profile")
data class UserProfile(
    @PrimaryKey
    var id: Long? = null,
    var userCode: String? = null,
    var nickname: String? = null,
    @Cascade(["userCode"], ["code"])
    var user: User? = null
) : KPojo

@Table("user")
data class User(
    @PrimaryKey
    var id: Long? = null,
    var code: String? = null,
    var profile: UserProfile? = null
) : KPojo

User(
    id = 10,
    code = "U-001",
    profile = UserProfile(nickname = "Ada")
)
    .upsert { it.code }
    .on { it.id }
    .execute()

execution order

1. SELECT COUNT(1) on `user` by id.
2. Insert branch: insert `user`, then insert `user_profile`.
3. Update branch: query the existing `user`, query the matching `user_profile`, update the profile reference field, then update `user`.

mysql

INSERT INTO `user` (`id`, `code`) VALUES (:id, :code);
INSERT INTO `user_profile` (`user_code`, `nickname`) VALUES (:userCode, :nickname);

UPDATE `user_profile`
SET `user_code` = :userCodeNew
WHERE `id` = :id AND `user_code` = :userCode;
UPDATE `user` SET `code` = :codeNew WHERE `id` = :id;

Result shape:

result

The child reference column is filled from the parent field declared in `@Cascade`.
On the update branch, Kronos updates the child reference column that depends on the changed parent field.

One-to-many cascade upsert

For a one-to-many relationship, put the child list on the parent and declare @Cascade on the child reference back to the parent.

kotlin

@Table("catalog")
data class Catalog(
    @PrimaryKey
    var id: Long? = null,
    var code: String? = null,
    var items: List<CatalogItem> = listOf()
) : KPojo

@Table("catalog_item")
data class CatalogItem(
    @PrimaryKey
    var id: Long? = null,
    var catalogCode: String? = null,
    var sku: String? = null,
    @Cascade(["catalogCode"], ["code"])
    var catalog: Catalog? = null
) : KPojo

Catalog(
    id = 20,
    code = "SPRING",
    items = listOf(
        CatalogItem(sku = "SKU-1"),
        CatalogItem(sku = "SKU-2")
    )
)
    .upsert { it.code }
    .on { it.id }
    .execute()

execution order

1. SELECT COUNT(1) on `catalog` by id.
2. Insert branch: insert `catalog`, then insert each `catalog_item`.
3. Update branch: query the existing `catalog`, query child rows by the old catalog code, update child reference fields, then update `catalog`.

mysql

INSERT INTO `catalog` (`id`, `code`) VALUES (:id, :code);
INSERT INTO `catalog_item` (`catalog_code`, `sku`) VALUES (:catalogCode, :sku);

SELECT `id`, `catalog_code` AS `catalogCode`, `sku`
FROM `catalog_item`
WHERE `catalog_code` = :catalogCode;
UPDATE `catalog_item`
SET `catalog_code` = :catalogCodeNew
WHERE `id` = :id AND `catalog_code` = :catalogCode;
UPDATE `catalog` SET `code` = :codeNew WHERE `id` = :id;

Result shape:

result

Inserted children receive catalog_code from catalog.code.
When catalog.code changes on the update branch, matching child rows receive the new catalog_code before the parent update runs.

Specify cascade fields

Use .cascade { [...] } to run cascade upsert for selected reference fields. The callback must select at least one reference property.

kotlin

Account(
    id = 1,
    email = "[email protected]",
    profile = AccountProfile(displayName = "Ada"),
    auditLogs = listOf(AccountAudit(action = "created"))
)
    .upsert { it.email }
    .on { it.id }
    .cascade { [Account::profile] }
    .execute()

execution order

1. Root existence check still uses `on { it.id }`.
2. The root insert or update task runs as usual.
3. Only `Account::profile` participates in the cascade tree.

mysql

SELECT COUNT(1) FROM `account` WHERE `id` = :id LIMIT 1 FOR UPDATE;
INSERT INTO `account` (`id`, `email`) VALUES (:id, :email);
INSERT INTO `account_profile` (`account_email`, `display_name`) VALUES (:accountEmail, :displayName);

Result shape:

result

The profile row is cascaded.
Other cascade-capable properties on Account keep their current database state for this call.

Disable cascade upsert

Use .cascade(false) when the call should affect only the root table.

kotlin

Account(
    id = 1,
    email = "[email protected]",
    profile = AccountProfile(displayName = "Ada")
)
    .upsert { it.email }
    .on { it.id }
    .cascade(false)
    .execute()

mysql

SELECT COUNT(1) FROM `account` WHERE `id` = :id LIMIT 1 FOR UPDATE;

# If the root record is absent
INSERT INTO `account` (`id`, `email`) VALUES (:id, :email);

# If the root record exists
UPDATE `account` SET `email` = :emailNew WHERE `id` = :id;

Result shape:

result

Only the account row is inserted or updated.
The profile object in memory is ignored by this database call.

Logic delete and optimistic lock fields

Cascade upsert follows the same strategy fields as insert and update. On the insert branch, @LogicDelete and @Version fields receive their configured initial values. On the update branch, logically deleted rows can be matched by the on fields, the logic-delete column is restored to the normal value, and the version column is incremented by the update strategy.

kotlin

@Table("account_flag")
data class AccountFlag(
    @PrimaryKey
    var id: Long? = null,
    var name: String? = null,
    @LogicDelete
    @Default("0") // @Default("false") for Postgres
    var deleted: Boolean? = null,
    @Version
    var version: Int? = null
) : KPojo

AccountFlag(id = 1, name = "active")
    .upsert { it.name }
    .on { it.id }
    .execute()

mysql

# Insert branch
INSERT INTO `account_flag` (`id`, `name`, `deleted`, `version`)
VALUES (:id, :name, :deleted, :version);

# Update branch
UPDATE `account_flag`
SET `name` = :nameNew,
    `deleted` = :deletedNew,
    `version` = `version` + :version2PlusNew
WHERE `id` = :id;

Result shape:

result

Inserted row: deleted = false, version = 0.
Existing logically deleted row matched by id: name is updated, deleted is restored to false, version is incremented.

Leave @Version managed by Kronos. If the update branch tries to update the version field manually, the update planner raises an error.

Primary key, unique key, and onConflict boundaries

The on { ... } fields define the root-row existence check. Use the primary key or the same field set as a unique business key.

kotlin

@Table("product")
@TableIndex(name = "uk_product_tenant_code", columns = ["tenant_id", "code"], type = "UNIQUE")
data class Product(
    @PrimaryKey
    var id: Long? = null,
    var tenantId: Long? = null,
    var code: String? = null,
    var name: String? = null
) : KPojo

Product(id = 100, tenantId = 7, code = "A-100", name = "Desk")
    .upsert { it.name }
    .on { [it.tenantId, it.code] }
    .execute()

mysql

SELECT COUNT(1)
FROM `product`
WHERE `tenant_id` = :tenantId AND `code` = :code
LIMIT 1 FOR UPDATE;

UPDATE `product`
SET `name` = :nameNew
WHERE `tenant_id` = :tenantId AND `code` = :code;

Result shape:

result

The row matched by tenantId + code is updated.
If no row matches tenantId + code, Kronos inserts the product row.

For identity primary keys, insert-branch cascade can use the generated root id when child rows reference that id.

kotlin

@Table("identity_parent")
data class IdentityParent(
    @PrimaryKey(identity = true)
    var id: Long? = null,
    var code: String? = null,
    var children: List<IdentityChild> = listOf()
) : KPojo

@Table("identity_child")
data class IdentityChild(
    @PrimaryKey(identity = true)
    var id: Long? = null,
    var parentId: Long? = null,
    var name: String? = null,
    @Cascade(["parentId"], ["id"])
    var parent: IdentityParent? = null
) : KPojo

IdentityParent(
    code = "P-1",
    children = listOf(IdentityChild(name = "child-1"))
)
    .upsert { it.code }
    .on { it.code }
    .execute()

result

The parent insert omits the identity id.
After the parent insert returns the generated id, child.parentId is filled before the child insert runs.

onConflict() handles only the root upsert. Use the match-field upsert flow shown above when this call needs the cascade tree.

kotlin

Product(id = 100, tenantId = 7, code = "A-100", name = "Desk")
    .upsert { it.name }
    .on { [it.tenantId, it.code] }
    .onConflict()
    .execute()

mysql

INSERT INTO `product` (`id`, `tenant_id`, `code`, `name`)
VALUES (:id, :tenantId, :code, :name)
ON DUPLICATE KEY UPDATE `name` = :name;

Result shape:

result

The generated task contains the root product upsert statement.
Database uniqueness on the conflict fields is required for `onConflict()`.
If `on { ... }` is omitted, Kronos infers the conflict fields from primary-key values or declared unique indexes.