Skip to content

BaseServiceOperations: OneToMany Update โ€” Detailed Flows

This document is a companion to BaseServiceOperations: OneToMany Update. It provides visual transaction flow diagrams and step-by-step payload examples for each update scenario.


flowchart TD
    A([START<br/>update<br/>id, data, currentUser]) --> B

    subgraph TXN ["๐Ÿ”’ manager.transaction()"]
        B["txRepo = transactionalManager<br/>.getRepository(Entity)"] --> C
        C["dataRecord = { ...data }<br/>(spread DTO to plain object)"] --> D

        D["๐Ÿ” Inspect metadata<br/>manyToManyRelations = relations<br/>&nbsp;&nbsp;.filter(isManyToMany and isOwning)<br/>oneToManyRelations = relations<br/>&nbsp;&nbsp;.filter(isOneToMany)"] --> E

        subgraph EXTRACT ["Step 2โ€“3: Extract Relations from DTO"]
            E["๐Ÿ“ฆ Extract M2M<br/>for each manyToManyRelation:<br/>&nbsp;&nbsp;if propertyName in DTO โ†’<br/>&nbsp;&nbsp;&nbsp;&nbsp;manyToManyPayload[name] = value<br/>&nbsp;&nbsp;&nbsp;&nbsp;delete dataRecord[name]"] --> F
            F["๐Ÿ“ฆ Extract OneToMany<br/>for each oneToManyRelation:<br/>&nbsp;&nbsp;if propertyName in DTO โ†’<br/>&nbsp;&nbsp;&nbsp;&nbsp;oneToManyPayload[name] = value<br/>&nbsp;&nbsp;&nbsp;&nbsp;delete dataRecord[name]<br/>&nbsp;&nbsp;&nbsp;&nbsp;mark as explicitlySet"]
        end

        F --> G

        subgraph PRELOAD ["Step 4โ€“5: Preload & Save Parent"]
            G["โš™๏ธ preload scalar fields only<br/>preloadData = { id, ...dataRecord }<br/>if currentUser โ†’ set updated_by"] --> H
            H{entity<br/>found?}
            H -- No --> ERR1(["โŒ NotFoundException"])
            H -- Yes --> I
            I["๐Ÿ’พ txRepo.save(entityToUpdate)<br/>Save parent scalars only<br/>No cascade on relations"]
        end

        I --> J

        subgraph O2M ["Step 6: Handle OneToMany โ€” Bypass Cascade"]
            J{Has<br/>oneToManyPayload?}
            J -- No --> M
            J -- Yes --> K
            K["๐Ÿ”Ž Resolve FK from metadata<br/>fkDbName = inverseRelation<br/>&nbsp;&nbsp;.joinColumns[0].databaseName<br/>fkColumn = inverseEntityMetadata<br/>&nbsp;&nbsp;.columns.find(databaseName)"] --> L
            L["๐Ÿ’พ childRepo.save(children)<br/>for each child:<br/>&nbsp;&nbsp;child[fkColumn.propertyName] = id  โ† FK!<br/>&nbsp;&nbsp;delete child[inverseSidePropertyPath]<br/>โ†’ no id = INSERT โœ…<br/>โ†’ has id = UPDATE โœ…"]
        end

        L --> M

        subgraph M2M ["Step 7: Sync ManyToMany โ€” addAndRemove"]
            M{Has<br/>manyToManyPayload?}
            M -- No --> P
            M -- Yes --> N
            N["๐Ÿ“ก _syncManyToManyRelations()<br/>for each M2M relation:<br/>&nbsp;&nbsp;loadMany() current junction items<br/>&nbsp;&nbsp;filter soft-deleted is_deleted<br/>&nbsp;&nbsp;diff: toAdd / toRemove<br/>&nbsp;&nbsp;addAndRemove(toAdd, toRemove)"]
        end

        N --> P

        subgraph RELOAD ["Step 8: Reload for Complete Response"]
            P["updatedRelationNames =<br/>&nbsp;&nbsp;[...keys oneToManyPayload,<br/>&nbsp;&nbsp;&nbsp;...keys manyToManyPayload]"]
            P --> Q{Has updated<br/>relation names?}
            Q -- No (scalar only) --> S
            Q -- Yes --> R
            R["๐Ÿ”„ txRepo.findOne<br/>&nbsp;&nbsp;where: { id, ...softDeleteFilter }<br/>&nbsp;&nbsp;relations: updatedRelationNames<br/>โ† 1 extra query for full relation response"]
        end
    end

    R --> T{reloaded<br/>!= null?}
    T -- Yes --> U(["โœ… return reloaded"])
    T -- No --> S(["โœ… return saved"])

    style TXN fill:#1e3a5f,stroke:#4a9eff,color:#fff
    style EXTRACT fill:#1a3a2a,stroke:#4aff7a,color:#fff
    style PRELOAD fill:#2a1a3a,stroke:#9a4aff,color:#fff
    style O2M fill:#3a1a1a,stroke:#ff4a4a,color:#fff
    style M2M fill:#3a2a1a,stroke:#ffaa4a,color:#fff
    style RELOAD fill:#1a2a3a,stroke:#4aaaff,color:#fff
    style ERR1 fill:#8b0000,stroke:#ff0000,color:#fff

flowchart TD
    A(["syncOne<br/>relationName, newItems"]) --> B
    B["relationMeta = metadata.relations<br/>&nbsp;&nbsp;.find(propertyName)"] --> C
    C{relationMeta<br/>found?}
    C -- No --> Z(["return โ€” skip"])
    C -- Yes --> D

    D["inverseEntity = relationMeta<br/>&nbsp;&nbsp;.inverseEntityMetadata<br/>hasSoftDelete = columns.some<br/>&nbsp;&nbsp;(propertyName === is_deleted)"] --> E

    E["๐Ÿ“ก loadMany<br/>rawItems = createQueryBuilder<br/>&nbsp;&nbsp;.relation(Entity, relationName)<br/>&nbsp;&nbsp;.of(entityId)<br/>&nbsp;&nbsp;.loadMany()"] --> F

    F{hasSoftDelete?}
    F -- Yes --> G["currentIds = rawItems<br/>&nbsp;&nbsp;.filter(item.is_deleted !== true)<br/>&nbsp;&nbsp;.map(i => i.id)"]
    F -- No --> H["currentIds = rawItems<br/>&nbsp;&nbsp;.map(i => i.id)"]

    G --> I
    H --> I

    I["newIds = newItems<br/>&nbsp;&nbsp;.map(i => i.id).filter(Boolean)"] --> J

    J["๐Ÿ”€ Diff<br/>toAdd = newIds.filter(nid not in currentIds)<br/>toRemove = currentIds.filter(cid not in newIds)"] --> K

    K{toAdd.length > 0<br/>OR<br/>toRemove.length > 0?}
    K -- No changes --> L(["โœ… no-op โ€” skip"])
    K -- Has changes --> M

    M["โšก addAndRemove(toAdd, toRemove)<br/>ATOMIC:<br/>&nbsp;&nbsp;INSERT junction rows for toAdd<br/>&nbsp;&nbsp;DELETE junction rows for toRemove"] --> N(["โœ… done"])

    style A fill:#1a3a2a,stroke:#4aff7a,color:#fff
    style M fill:#1e3a5f,stroke:#4a9eff,color:#fff
    style L fill:#2a3a1a,stroke:#aaff4a,color:#fff
    style N fill:#2a3a1a,stroke:#aaff4a,color:#fff

flowchart LR
    subgraph OK ["โœ… Happy Path"]
        direction TB
        A1["Save parent scalar"] --> A2["Save OneToMany children"]
        A2 --> A3["Sync ManyToMany junction"]
        A3 --> A4["Reload with relations"]
        A4 --> A5["COMMIT"]
    end

    subgraph FAIL ["โŒ Any Step Fails"]
        direction TB
        B1["Save parent scalar"] --> B2["Save OneToMany children"]
        B2 -->|ERROR| B3["โŒ throw"]
        B3 --> B4["AUTO ROLLBACK<br/>All operations cancelled"]
    end

    TXN(["manager.transaction()"]) --> OK
    TXN --> FAIL

    style OK fill:#1a3a1a,stroke:#4aff4a,color:#fff
    style FAIL fill:#3a1a1a,stroke:#ff4a4a,color:#fff
    style TXN fill:#1e3a5f,stroke:#4a9eff,color:#fff

The examples below use this parent/child relationship:

// Order (parent) โ€” owns the relations
@Entity()
export class Order {
@PrimaryGeneratedColumn('uuid') id: string;
// OneToMany (cascade save enabled)
@OneToMany(() => OrderItem, (item) => item.order, { cascade: true })
items: OrderItem[];
// ManyToMany (owning side โ€” has @JoinTable)
@ManyToMany(() => Tag)
@JoinTable({ name: 'orders_tags', ... })
tags: Tag[];
}
// OrderItem (child) โ€” has explicit FK column + ManyToOne back-reference
@Entity()
export class OrderItem {
@Column({ type: 'uuid' }) // explicit FK column โ€” NOT nullable
order_id: string;
@ManyToOne(() => Order, (o) => o.items)
@JoinColumn({ name: 'order_id' })
order: Order; // relation object
@Column({ type: 'boolean' })
is_primary: boolean;
}

The explicit order_id FK column is the key to avoiding the 23502 error. It gives childRepo.save() a concrete column to populate โ€” bypassing the ambiguity of the cascade relation object.


PATCH /orders/uuid-parent
{
"name": "Updated Order Name",
"is_active": true
}
Flow:
preload({ id, name, is_active })
โ†’ save(entity)
โ†’ no relation keys in DTO โ†’ no reload needed
Response:
{ id, name: "Updated Order Name", is_active: true, ... }

PATCH /orders/uuid-parent
{
"tags": [{ "id": "tag-express" }, { "id": "tag-priority" }]
}
Flow:
1. Extract tags from DTO โ†’ manyToManyPayload
2. preload({ id }) โ† tags excluded from preload
3. save(entity) โ† scalar only (no-op here)
4. loadMany(tags of order) โ†’ currentIds: ["tag-express"]
5. diff: toAdd=["tag-priority"], toRemove=[]
6. addAndRemove(["tag-priority"], []) โœ… no duplicate inserts
7. findOne({ relations: ['tags'] }) โ† reload with full tag objects
Response:
{ id, ..., tags: [{ id: "tag-express", ... }, { id: "tag-priority", ... }] }

PATCH /orders/uuid-parent
{
"items": [{ "is_primary": true, "lookup_item_id": "item-abc" }]
}
Flow:
1. Extract items from DTO โ†’ oneToManyPayload
2. preload({ id }) โ† items excluded from preload
3. entityToUpdate.items === undefined โ† cascade won't trigger
4. save(entityToUpdate) โ† parent saved first
5. childRepo.save([{
is_primary: true,
lookup_item_id: "item-abc",
order_id: "uuid-parent" โ† FK set explicitly
}]) โœ… INSERT with correct FK
6. findOne({ relations: ['items'] }) โ† reload
Response:
{ id, ..., items: [{ id: "new-uuid", is_primary: true, ... }] }

PATCH /orders/uuid-parent
{
"items": [
{ "id": "item-A", "is_primary": false, "lookup_item_id": "item-abc" },
{ "is_primary": true, "lookup_item_id": "item-xyz" }
]
}
Flow:
1. Extract items from DTO
2. preload + save parent (scalar fields)
3. childRepo.save([
{ id: "item-A", is_primary: false, ..., order_id: "uuid-parent" }, โ†’ UPDATE item-A
{ is_primary: true, ..., order_id: "uuid-parent" } โ†’ INSERT new
]) โœ… both have correct FK
4. findOne({ relations: ['items'] }) โ† reload all children
Response:
{
id,
items: [
{ id: "item-A", is_primary: false, ... }, โ† updated
{ id: "new-uuid", is_primary: true, ... } โ† inserted
]
}
Note: item-B (if in DB but absent from DTO) will be soft-deleted by default
and will NOT appear in the response, since the reload only fetches
non-deleted children.

Example 5: ManyToMany + OneToMany Together (One Transaction)

Section titled โ€œExample 5: ManyToMany + OneToMany Together (One Transaction)โ€
PATCH /orders/uuid-parent
{
"tags": [{ "id": "tag-priority" }],
"items": [{ "id": "item-A", "is_primary": true }]
}
Flow (single transaction):
1. Extract tags โ†’ manyToManyPayload
2. Extract items โ†’ oneToManyPayload
3. preload({ id }) โ† clean โ€” no relations
4. save(entityToUpdate) โ† parent updated
5. childRepo.save([{ id: "item-A", ..., order_id: "..." }]) โ† OneToMany
6. addAndRemove(["tag-priority"], [...]) โ† ManyToMany
7. findOne({ relations: ['items', 'tags'] }) โ† ONE reload for everything
All in one transaction โ†’ atomic โœ…
Response:
{
id,
items: [{ id: "item-A", is_primary: true, ... }],
tags: [{ id: "tag-priority", ... }]
}

Everything runs inside manager.transaction():

return this.typeOrmRepository.manager.transaction(async (transactionalManager) => {
const txRepo = transactionalManager.getRepository(Entity);
// All operations โ€” parent save, children save, M2M sync โ€” use txRepo
// from the same transactional manager
});

If any step throws an error:

  • TypeORM automatically rolls back the entire transaction
  • The parent update, children saves, and M2M sync are all reverted
  • executeDbOperation() catches and re-throws a formatted HttpException โ€” no manual rollback needed

_syncManyToManyRelations checks whether the related entity has an is_deleted column before computing the diff:

const hasSoftDelete = inverseEntity.columns.some((c) => c.propertyName === 'is_deleted');
const rawItems = await repo
.createQueryBuilder()
.relation(Entity, relationName)
.of(entityId)
.loadMany();
// Exclude soft-deleted records from "current" set
const currentIds = hasSoftDelete
? rawItems.filter((item) => item.is_deleted !== true).map((i) => i.id)
: rawItems.map((i) => i.id);

This prevents soft-deleted records from being counted as โ€œcurrently relatedโ€ and then accidentally re-added to the junction table on the next update.