Skip to content

Many-to-many relation is duplicated when its owning model is reached via a belongs-to shared by multiple parent rows #1386

Description

@kubakl

Summary

When eager-loading a nested relation chain where a many-to-many relation sits on a model
reached through a belongs-to that is shared by multiple top-level rows, the m2m children
are appended once per top-level row that references the shared model. The result is the
m2m slice multiplied by the number of sharing rows, even though the join table holds exactly
one row per pair.

A has-many at the same shared node is not affected — only the many-to-many is.

Version

  • bun: v1.2.15
  • dialect/driver: pgdialect + pgdriver, PostgreSQL 15
  • Go: 1.23

Schema / models

child   -> parent (belongs-to)              -- several children can share one parent
parent  -> parent_node (has-many)
parent_node -> node (belongs-to)
node    -> tag (many-to-many via node_tag)
type Tag struct {
    bun.BaseModel `bun:"table:tag,alias:tag"`
    Hash  int64 `bun:",pk"`
    Label string

type Node struct {
    bun.BaseModel `bun:"table:node,alias:node"`
    Hash int64  `bun:",pk"`
    Tags []*Tag `bun:"m2m:node_tag,join:Node=Tag"`
}

type NodeTag struct {
    bun.BaseModel `bun:"table:node_tag,alias:nt"`
    ID       int64 `bun:",pk,autoincrement"`
    NodeHash int64 `bun:",unique:node_tag_uq"`
    Node     *Node `bun:"rel:belongs-to,join:node_hash=hash"`
    TagHash  int64 `bun:",unique:node_tag_uq"`
    Tag      *Tag  `bun:"rel:belongs-to,join:tag_hash=hash"`
}

type Parent struct {
    bun.BaseModel `bun:"table:parent,alias:p"`
    ID    int64         `bun:",pk,autoincrement"`
    Nodes []*ParentNode `bun:"rel:has-many,join:id=parent_id"`
}

type ParentNode struct {
    bun.BaseModel `bun:"table:parent_node,alias:pn"`
    ID       int64 `bun:",pk,autoincrement"`
    ParentID int64
    NodeHash int64
    Node     *Node `bun:"rel:belongs-to,join:node_hash=hash"`
}

type Child struct {
    bun.BaseModel `bun:"table:child,alias:c"`
    ID       int64 `bun:",pk,autoincrement"`
    ParentID int64
    Parent   *Parent `bun:"rel:belongs-to,join:parent_id=id"`
}

db.RegisterModel((*NodeTag)(nil)).

Data

  • 1 parent, 1 node (with 2 tags A, B via 2 node_tag rows), 1 parent_node linking them.
  • N child rows all pointing at the same parent.

Query (mirrors the real read)

var children []*Child
err := db.NewSelect().Model(&children).
    Relation("Parent").
    Relation("Parent.Nodes").
    Relation("Parent.Nodes.Node").
    Relation("Parent.Nodes.Node.Tags").
    Scan(ctx)

Observed vs expected

children sharing one parent rows in node_tag Node.Tags per child
1 2 2 ✅
2 2 4
3 2 6

Parent.Nodes always has the correct count (1); only Node.Tags is multiplied. Expected:
Node.Tags is always the 2 rows present in the DB, independent of how many children share the parent.

Impact

Any list query that loads an m2m through a belongs-to shared across rows returns duplicated
m2m children, silently corrupting the response (and growing with each duplicate).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions