BaseServiceOperations: OneToMany Update
Overview
Section titled “Overview”BaseServiceOperations.update() is the central method all services use to update TypeORM entities that have relations. It solves two critical TypeORM errors that appear as soon as you try to update a parent entity with OneToMany or ManyToMany children:
| PostgreSQL Error | Symptom | Relation Type |
|---|---|---|
23502 | null value in column "X_id" violates not-null constraint | OneToMany |
23505 | duplicate key value violates unique constraint | ManyToMany |
The method runs entirely inside a single database transaction — any failure rolls back all operations atomically.
The Root Problem
Section titled “The Root Problem”Both errors stem from how TypeORM’s preload() + save() handles relations:
Why 23505 Happens (ManyToMany)
Section titled “Why 23505 Happens (ManyToMany)”preload() builds entity instances that include the existing junction records. When save() runs, TypeORM attempts to INSERT every junction row — including ones that already exist — triggering a unique constraint violation.
Fix: Strip M2M keys from the DTO before preload() and use addAndRemove() diff-sync instead.
Why 23502 Happens (OneToMany — INSERT)
Section titled “Why 23502 Happens (OneToMany — INSERT)”preload() creates new child instances but never sets the inverse relation object (e.g., order). TypeORM cascades the INSERT with order_id = NULL.
Fix: Extract OneToMany from the DTO before preload(), then save via the child repository with the FK set explicitly.
Why 23502 Happens (OneToMany — UPDATE)
Section titled “Why 23502 Happens (OneToMany — UPDATE)”preload() does not load existing children from the database. If the entity has cascade: true and you pass a partial child array, TypeORM detects missing children as “detached” and runs SET order_id = NULL on them.
Fix: Never assign children to the preloaded entity — save via childRepo directly, bypassing cascade entirely.
IUpdateOptions — Orphan Handling
Section titled “IUpdateOptions — Orphan Handling”What Are Orphans?
Section titled “What Are Orphans?”Orphans are OneToMany children that exist in the database for a given parent FK but are absent from the incoming DTO array.
DB state before update: items: [item-A, item-B]
Incoming DTO: items: [item-A] ← only item-A
Orphan: item-B (in DB, not in payload)IUpdateOptions
Section titled “IUpdateOptions”export class IUpdateOptions { /** * Permanently DELETE orphaned child rows. * Use for value objects (gallery images, temp tags) that have no * meaning without their parent and carry no audit significance. * @default false */ hardDeleteOneToMany?: boolean = false;
/** * Soft-delete orphaned child rows: * sets is_deleted=true, deleted_at=now(), deleted_reason='delete with cascade'. * * Recommended for children with business value (order items, assignment logs), * foreign keys from other tables, or audit/compliance requirements. * * @default true */ softDeleteOneToMany?: boolean = true;
constructor(partial?: Partial<IUpdateOptions>) { if (partial) Object.assign(this, partial); }}Priority rule: when both
hardDeleteOneToManyandsoftDeleteOneToManyaretrue, hard delete wins.
REST / PATCH Semantics
Section titled “REST / PATCH Semantics”The framework distinguishes two dimensions of intent:
Dimension 1 — Should orphans be processed at all?
Section titled “Dimension 1 — Should orphans be processed at all?”| Client sends | Framework interpretation | Orphan handling |
|---|---|---|
Key omitted — { name: 'Updated' } | Partial update — don’t touch children | No orphan logic runs |
Empty array — { items: [] } | Explicit intent to clear all children | Apply orphan strategy |
Array with items — { items: [A] } | Desired final set; B is an orphan | Apply orphan strategy |
This mirrors the standard HTTP PATCH contract: omitted = untouched, explicit empty = clear.
Dimension 2 — Hard Delete or Soft Delete?
Section titled “Dimension 2 — Hard Delete or Soft Delete?”| Use Hard Delete when… | Use Soft Delete when… |
|---|---|
| Child is a value object with no independent meaning | Child has business value (order items, logs) |
| No audit or history requirements | Needed for audit trails or reporting |
| No FK references from other tables | Other tables reference the child |
| Examples: gallery images, temp tags | Examples: order line items, approval records |
softDeleteOneToMany = true is the safe default — it preserves history while flagging data as removed.
Behavior Matrix
Section titled “Behavior Matrix”| Scenario | Options | Result |
|---|---|---|
| Children key omitted | any | No orphan logic — children untouched |
items: [] | default | Soft-delete all existing children |
items: [] | { hardDeleteOneToMany: true } | Hard-delete all existing children |
items: [A], B in DB | default | B soft-deleted |
items: [A], B in DB | { hardDeleteOneToMany: true } | B permanently deleted |
items: [A], B in DB | { softDeleteOneToMany: false } | B kept untouched |
Usage Examples
Section titled “Usage Examples”1. Partial Update — Children Key Omitted
Section titled “1. Partial Update — Children Key Omitted”// Only scalar fields — all children completely untouchedawait this.service.update(id, { name: 'Updated Name' }, currentUser);// All existing items remain in DB unchanged2. Clear All Children
Section titled “2. Clear All Children”// Empty array = explicit intention to remove allawait this.service.update(id, { items: [] }, currentUser);// All existing items: is_deleted=true, deleted_reason='delete with cascade'3. Update Set — Soft-Delete Orphans (Default)
Section titled “3. Update Set — Soft-Delete Orphans (Default)”// item-A updated, item-B becomes orphan → soft-deleted by defaultawait this.service.update(id, { items: [{ id: 'item-A', is_primary: true }]}, currentUser);4. Hard-Delete Orphans
Section titled “4. Hard-Delete Orphans”// Use when children are value objects with no business historyawait this.service.update(id, { items: [{ id: 'item-A', is_primary: true }]}, currentUser, { hardDeleteOneToMany: true });5. Keep Orphans Explicitly
Section titled “5. Keep Orphans Explicitly”// Disable all orphan deletionawait this.service.update(id, { items: [{ id: 'item-A', is_primary: true }]}, currentUser, { softDeleteOneToMany: false, hardDeleteOneToMany: false,});Step-by-Step Implementation
Section titled “Step-by-Step Implementation”The update() method proceeds in 8 ordered steps inside a single transaction.
Step 1 — Identify Relation Types from Metadata
Section titled “Step 1 — Identify Relation Types from Metadata”const manyToManyRelations = metadata.relations.filter((r) => r.isManyToMany && r.isOwning);const oneToManyRelations = metadata.relations.filter((r) => r.isOneToMany);Step 2 — Extract ManyToMany from DTO (Before Preload)
Section titled “Step 2 — Extract ManyToMany from DTO (Before Preload)”const manyToManyPayload: Record<string, Array<{ id: string }>> = {};for (const rel of manyToManyRelations) { if (rel.propertyName in dataRecord) { manyToManyPayload[rel.propertyName] = dataRecord[rel.propertyName]; delete dataRecord[rel.propertyName]; // prevent preload from touching junction table }}Step 3 — Extract OneToMany from DTO (Before Preload) + Track Explicit Keys
Section titled “Step 3 — Extract OneToMany from DTO (Before Preload) + Track Explicit Keys”const explicitlySetOneToMany = new Set<string>(); // tracks keys sent by client, even if []const oneToManyPayload: Record<string, unknown[]> = {};
for (const rel of oneToManyRelations) { if (rel.propertyName in dataRecord) { explicitlySetOneToMany.add(rel.propertyName); // key insight — omitted ≠ [] oneToManyPayload[rel.propertyName] = dataRecord[rel.propertyName]; delete dataRecord[rel.propertyName]; // prevent cascade }}Step 4 — Preload Parent (Scalar Fields Only)
Section titled “Step 4 — Preload Parent (Scalar Fields Only)”const preloadData = { id, ...dataRecord };if (currentUser) preloadData.updated_by = userId;
const entityToUpdate = await txRepo.preload(preloadData);if (!entityToUpdate) throw new NotFoundException(`Entity with id '${id}' not found.`);// entityToUpdate.items === undefined → cascade will NOT triggerStep 5 — Save Parent
Section titled “Step 5 — Save Parent”const saved = await txRepo.save(entityToUpdate);// Parent committed first so child FK references a real, committed rowStep 6 — Save OneToMany Children via Child Repo (Bypass Cascade)
Section titled “Step 6 — Save OneToMany Children via Child Repo (Bypass Cascade)”for (const rel of oneToManyRelations) { const incomingChildren = oneToManyPayload[rel.propertyName]; if (!Array.isArray(incomingChildren)) continue;
// Resolve FK column from TypeORM metadata const fkDbName = rel.inverseRelation.joinColumns[0].databaseName; const fkColumn = rel.inverseEntityMetadata.columns .find((c) => c.databaseName === fkDbName);
const childRepo = transactionalManager.getRepository(rel.inverseEntityMetadata.target);
const children = incomingChildren.map((item) => { const child = item as Record<string, unknown>; child[fkColumn.propertyName] = id; // ← set FK explicitly delete child[rel.inverseSidePropertyPath]; // ← remove relation object return child; // TypeORM: no id → INSERT ✅ // TypeORM: has id → UPDATE ✅ // Both have correct FK ✅ });
await childRepo.save(children);Step 6a — Orphan Handling
Section titled “Step 6a — Orphan Handling” // Only runs when the children key was EXPLICITLY sent (even if []) if (explicitlySetOneToMany.has(rel.propertyName)) { const resolvedOptions = new IUpdateOptions(options);
if (resolvedOptions.hardDeleteOneToMany || resolvedOptions.softDeleteOneToMany) { const existingChildren = await childRepo.find({ where: { [fkColumn.propertyName]: id, is_deleted: false }, });
const incomingIds = new Set( incomingChildren .map((c) => (c as Record<string, unknown>).id as string | undefined) .filter((cId): cId is string => cId !== undefined), );
const orphanIds = existingChildren .map((c) => String((c as Record<string, unknown>).id)) .filter((cId) => !incomingIds.has(cId));
if (orphanIds.length > 0) { if (resolvedOptions.hardDeleteOneToMany) { await childRepo.delete(orphanIds); } else { await childRepo.update(orphanIds, { is_deleted: true, deleted_at: new Date(), deleted_reason: 'delete with cascade', }); } } } } // Children key omitted → no-op (true PATCH semantics)}Step 7 — Sync ManyToMany via Diff-Based addAndRemove
Section titled “Step 7 — Sync ManyToMany via Diff-Based addAndRemove”await this._syncManyToManyRelations(txRepo, id, manyToManyPayload);// Loads current junction rows, diffs against payload, calls addAndRemove atomicallyStep 8 — Reload with Updated Relations for Complete Response
Section titled “Step 8 — Reload with Updated Relations for Complete Response”const updatedRelationNames = [ ...Object.keys(oneToManyPayload), ...Object.keys(manyToManyPayload),];
if (updatedRelationNames.length > 0) { const reloaded = await txRepo.findOne({ where: { id, ...softDeleteFilter }, relations: updatedRelationNames, }); return reloaded ?? saved;}return saved; // scalar-only update — no extra query needed| DTO contains | Extra reload query | Response |
|---|---|---|
| Scalar fields only | No | Parent fields only |
| OneToMany | Yes (1×) | Parent + all O2M children |
| ManyToMany | Yes (1×) | Parent + all M2M objects |
| Both | Yes (1×) | Parent + all updated relations |
Problem / Solution Summary
Section titled “Problem / Solution Summary”| Problem | Root Cause | Solution |
|---|---|---|
| 23505 duplicate key (M2M) | preload + save re-INSERTs existing junction rows | addAndRemove() diff-based sync |
| 23502 FK null (INSERT) | preload does not set inverse relation on new children | childRepo.save() with explicit FK |
| 23502 FK null (UPDATE) | preload doesn’t know original children → TypeORM NULLs orphans | Bypass cascade entirely |
| Response missing relations | preload entity has no relations loaded | findOne({ relations }) after save |
Further Reading
Section titled “Further Reading”- Detailed Flow Diagrams — Mermaid transaction diagrams and payload-by-payload walkthroughs
- API Design: Relations, Cascade & Deletion — Junction table patterns and cascade design decisions