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).
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
pgdialect+pgdriver, PostgreSQL 15Schema / models
db.RegisterModel((*NodeTag)(nil)).Data
parent, 1node(with 2 tags A, B via 2node_tagrows), 1parent_nodelinking them.childrows all pointing at the sameparent.Query (mirrors the real read)
Observed vs expected
Node.Tagsper childParent.Nodesalways has the correct count (1); onlyNode.Tagsis multiplied. Expected:Node.Tagsis 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).