From 540738d4979f53c9d6a8f33a49200aba7ca21c8b Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Fri, 24 Jul 2026 09:07:41 +0100 Subject: [PATCH] fix(belongsToMany): sync re-inserts rows on a column-mapped pivot selectPivotRows goes through the query adapter, which maps result rows back to attribute names via reverseColumnMap. So a pivot column like `permission_id` comes back under `permissionId` whenever the pivot table has a registered column map. sync read the related value with the raw column name (`row[this.relatedPivotKey]`), which was then undefined, so existingKeys stayed empty, every desired row looked new, and sync re-inserted rows that already existed, tripping the pivot's unique constraint. Read pivot columns under whichever key the row carries: raw column first (lightweight table loader), then the attribute alias (query adapter). Applied at the three sites that indexed a selected pivot row by the raw key: sync, attachPivotToResults, and getQuery, so eager loads and attribute attachment are fixed alongside sync. The existing pivot tests never used a column-mapped pivot table (role_users uses camelCase columns == attributes), which is why this escaped. A regression test needs a pivot whose db columns differ from its attribute names. --- src/relationship/BelongsToManyRelation.ts | 43 +++++++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/src/relationship/BelongsToManyRelation.ts b/src/relationship/BelongsToManyRelation.ts index a40354c..02e04c7 100644 --- a/src/relationship/BelongsToManyRelation.ts +++ b/src/relationship/BelongsToManyRelation.ts @@ -32,6 +32,7 @@ export class BelongsToManyRelation extends Relation private pivotModel: PivotModelStatic | undefined private shouldAttachPivot = false private readonly pivotValues = new Map() + private pivotColumnAliasCache: Map | undefined public constructor( private readonly parent: TParent & { getAttribute: (key: string) => unknown }, @@ -406,6 +407,42 @@ export class BelongsToManyRelation extends Relation return this.parent.getAttribute(this.parentKey) } + /** + * `column -> attribute` for the pivot table, memoized. + * + * The query adapter maps result rows back to attribute names via + * `reverseColumnMap`, so a pivot column such as `permission_id` is returned + * under `permissionId` whenever the pivot table has a registered column map. + */ + private pivotColumnAliases(): Map { + if (!this.pivotColumnAliasCache) { + this.pivotColumnAliasCache = new Map() + for (const [attribute, column] of Object.entries(this.buildPivotTarget().columns ?? {})) { + this.pivotColumnAliasCache.set(column, attribute) + } + } + + return this.pivotColumnAliasCache + } + + /** + * Read a pivot column from a selected row under whichever key it is stored. + * + * Rows read through the query adapter are keyed by attribute name, while + * rows from the lightweight table loader keep their raw column names. + * Indexing only by the raw column made `sync` (and pivot attachment during + * eager loads) treat every existing row as absent, so `sync` re-inserted + * rows that already existed and tripped the pivot's unique constraint. Try + * the raw column first, then its attribute alias. + */ + private readPivotColumn(row: Record, column: string): unknown { + if (row[column] != null) return row[column] + + const alias = this.pivotColumnAliases().get(column) + + return alias != null ? row[alias] : undefined + } + private resolveRelatedPivotValue(related: TRelated | unknown): unknown { if ( related && @@ -719,7 +756,7 @@ export class BelongsToManyRelation extends Relation const existingKeys = new Set() for (const row of existingRows) { - const relatedValue = row[this.relatedPivotKey] + const relatedValue = this.readPivotColumn(row, this.relatedPivotKey) if (relatedValue == null) continue const relatedKey = String(relatedValue) @@ -807,7 +844,7 @@ export class BelongsToManyRelation extends Relation const pivotByRelatedKey = new Map>() pivotRows.forEach((row) => { - const relatedValue = row[this.relatedPivotKey] + const relatedValue = this.readPivotColumn(row, this.relatedPivotKey) if (relatedValue == null) return pivotByRelatedKey.set(String(relatedValue), row) @@ -922,7 +959,7 @@ export class BelongsToManyRelation extends Relation */ public async getQuery(): Promise> { const pivotRows = await this.loadPivotRowsForParent() - const ids = pivotRows.map((row) => row[this.relatedPivotKey]) + const ids = pivotRows.map((row) => this.readPivotColumn(row, this.relatedPivotKey)) return this.decorateQueryBuilder( this.applyConstraint(this.related.query().where({ [this.relatedKey]: { in: ids } })),