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.
Flow Chart: update() Method
Section titled โFlow Chart: update() MethodโMain Transaction Flow
Section titled โMain Transaction Flowโ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/> .filter(isManyToMany and isOwning)<br/>oneToManyRelations = relations<br/> .filter(isOneToMany)"] --> E
subgraph EXTRACT ["Step 2โ3: Extract Relations from DTO"]
E["๐ฆ Extract M2M<br/>for each manyToManyRelation:<br/> if propertyName in DTO โ<br/> manyToManyPayload[name] = value<br/> delete dataRecord[name]"] --> F
F["๐ฆ Extract OneToMany<br/>for each oneToManyRelation:<br/> if propertyName in DTO โ<br/> oneToManyPayload[name] = value<br/> delete dataRecord[name]<br/> 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/> .joinColumns[0].databaseName<br/>fkColumn = inverseEntityMetadata<br/> .columns.find(databaseName)"] --> L
L["๐พ childRepo.save(children)<br/>for each child:<br/> child[fkColumn.propertyName] = id โ FK!<br/> 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/> loadMany() current junction items<br/> filter soft-deleted is_deleted<br/> diff: toAdd / toRemove<br/> addAndRemove(toAdd, toRemove)"]
end
N --> P
subgraph RELOAD ["Step 8: Reload for Complete Response"]
P["updatedRelationNames =<br/> [...keys oneToManyPayload,<br/> ...keys manyToManyPayload]"]
P --> Q{Has updated<br/>relation names?}
Q -- No (scalar only) --> S
Q -- Yes --> R
R["๐ txRepo.findOne<br/> where: { id, ...softDeleteFilter }<br/> 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
_syncManyToManyRelations Detail
Section titled โ_syncManyToManyRelations Detailโflowchart TD
A(["syncOne<br/>relationName, newItems"]) --> B
B["relationMeta = metadata.relations<br/> .find(propertyName)"] --> C
C{relationMeta<br/>found?}
C -- No --> Z(["return โ skip"])
C -- Yes --> D
D["inverseEntity = relationMeta<br/> .inverseEntityMetadata<br/>hasSoftDelete = columns.some<br/> (propertyName === is_deleted)"] --> E
E["๐ก loadMany<br/>rawItems = createQueryBuilder<br/> .relation(Entity, relationName)<br/> .of(entityId)<br/> .loadMany()"] --> F
F{hasSoftDelete?}
F -- Yes --> G["currentIds = rawItems<br/> .filter(item.is_deleted !== true)<br/> .map(i => i.id)"]
F -- No --> H["currentIds = rawItems<br/> .map(i => i.id)"]
G --> I
H --> I
I["newIds = newItems<br/> .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/> INSERT junction rows for toAdd<br/> 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
Transaction Scope & Rollback
Section titled โTransaction Scope & Rollbackโ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
Reference Entity Model
Section titled โReference Entity Modelโ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_idFK column is the key to avoiding the23502error. It giveschildRepo.save()a concrete column to populate โ bypassing the ambiguity of the cascade relation object.
Payload Walkthroughs
Section titled โPayload WalkthroughsโExample 1: Update Scalar Fields Only
Section titled โExample 1: Update Scalar Fields Onlyโ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, ... }Example 2: Update ManyToMany (Tags)
Section titled โExample 2: Update ManyToMany (Tags)โ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", ... }] }Example 3: Add New Child to OneToMany
Section titled โExample 3: Add New Child to OneToManyโ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, ... }] }Example 4: Update Existing + Add New Child Together
Section titled โExample 4: Update Existing + Add New Child Togetherโ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", ... }] }Transaction & Rollback
Section titled โTransaction & Rollbackโ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 formattedHttpExceptionโ no manual rollback needed
Soft Delete Filter in M2M Sync
Section titled โSoft Delete Filter in M2M Syncโ_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" setconst 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.