Skip to content

fix(schema): resolve m2m BasePKs through the base table's FieldMap#1375

Open
dragonator wants to merge 1 commit into
uptrace:masterfrom
dragonator:fix/m2m-embedded-base-pk-index
Open

fix(schema): resolve m2m BasePKs through the base table's FieldMap#1375
dragonator wants to merge 1 commit into
uptrace:masterfrom
dragonator:fix/m2m-embedded-base-pk-index

Conversation

@dragonator
Copy link
Copy Markdown

Summary

m2mRelation copies rel.BasePKs from leftRel.JoinPKs, whose Field.Index is relative to the m2m left field's belongs-to target. When the base table embeds that target — a common pattern for wrapping a model with extra fields — walking those indices against the outer struct at query time hits the wrong field, and panics when the Go field types disagree.

This PR re-resolves the base PKs by name through t.FieldMap so embedded outer tables use their re-indexed fields. When t is the target itself the lookup returns the same *Field, so existing behavior — including the composite-key fix from #996 — is preserved.

Reproduction

type Tag struct {
    ID int64 `bun:",pk,autoincrement"`
}

type Order struct {
    bun.BaseModel `bun:"table:orders"`

    ID   int64 `bun:",pk,autoincrement"`
    Tags []Tag `bun:"m2m:order_tags,join:Order=Tag"`
}

type OrderTag struct {
    bun.BaseModel `bun:"table:order_tags"`

    OrderID int64  `bun:",pk"`
    Order   *Order `bun:"rel:belongs-to,join:order_id=id"`
    TagID   int64  `bun:",pk"`
    Tag     *Tag   `bun:"rel:belongs-to,join:tag_id=id"`
}

// Wrapper that embeds *Order with an extra column —
// e.g. attaching computed/eligibility data to an existing model.
type OrderWithExtra struct {
    bun.BaseModel `bun:"table:orders,alias:orders"`
    *Order

    Extra bool `bun:"extra,scanonly"`
}

var rows []OrderWithExtra
err := db.NewSelect().Model(&rows).Relation("Tags").Scan(ctx)
// before: panic / wrong values in the m2m IN-list
// after:  rows[i].Tags populated correctly

Root cause

schema/table.go m2mRelation:

leftRel := m2mTable.belongsToRelation(leftField)
rel.BasePKs    = leftRel.JoinPKs   // ← problem
rel.M2MBasePKs = leftRel.BasePKs

leftRel.JoinPKs are looked up in the belongs-to target's FieldMapOrder.FieldMap["id"] with Index=[1] in the example above.

At query time, relation_join.go m2mQuery walks those PKs against the outer base model:

join = appendChildValues(fmter, join, j.BaseModel.rootValue(), index, j.Relation.BasePKs)

For OrderWithExtra, j.BaseModel.rootValue() is the wrapper struct — its field index [1] is the embedded *Order pointer, not Order.ID. The correct path through OrderWithExtra → *Order → ID is [1, 1], which is exactly what t.FieldMap["id"] already holds for the wrapper (processFields re-indexes embedded fields via makeIndex(...)).

Regression history

  • v1.2.3 — works.
  • v1.2.5 — broken by Fix M2M relations for models with composite keys #996 ("Fix M2M relations for models with composite keys"), commit d99b767, which changed m2mQuery from baseTable.PKs to j.Relation.BasePKs. Correct in spirit (composite keys need the relation's ordered PK set, not all base PKs), but it implicitly assumed the relation's BasePKs were already indexed against the base table — which they aren't when the base table embeds the target.
  • v1.2.4 was never tagged on GitHub, so v1.2.5 is the first affected release.
  • The bug is still present on master / v1.2.18.

The fix

leftRel := m2mTable.belongsToRelation(leftField)
rel.BasePKs    = baseTablePKs(t, leftRel.JoinPKs)
rel.M2MBasePKs = leftRel.BasePKs

// ...

func baseTablePKs(t *Table, pks []*Field) []*Field {
    out := make([]*Field, len(pks))
    for i, f := range pks {
        if local, ok := t.FieldMap[f.Name]; ok {
            out[i] = local
            continue
        }
        out[i] = f
    }
    return out
}

What this means in practice:

  • Embedded base (OrderWithExtra*Order): t.FieldMap["id"] returns the cloned field with Index=[1, 1]. m2mQuery walks the wrapper correctly.
  • Plain base (Order queried directly): t.FieldMap["id"] is the same *Field instance as leftRel.JoinPKs[0]. No behavior change.
  • Composite keys (Fix M2M relations for models with composite keys #996): leftRel.JoinPKs still drives the order and count of base PKs — the lookup just substitutes per-element references. testCompositeM2M continues to pass.
  • Fallback: if a name isn't in t.FieldMap (e.g. shadowed by bun:"-" on the wrapper), the original field is kept so behavior is no worse than today.

Tests

Unit testschema/table_test.go::TestTable/m2m_on_embedded_base. Builds the wrapper shape, registers the m2m table, and asserts:

  • outer.FieldMap["id"].Index == [1, 1] — sanity that processFields re-indexed the embedded field.
  • outer.Relations["Items"].BasePKs[0] is the same *Field instance as outer.FieldMap["id"].

Without the fix the relation references the embedded type's *Field (Index=[1]), and the test fails with a clear []int{1, 1} vs []int{1} diff.

Integration testinternal/dbtest/orm_test.go::testM2MRelationOnEmbeddedBaseModel. Runs an end-to-end SELECT with .Relation("Items") against the embedded wrapper and asserts the right children load. Compiles against all dialect builds; full end-to-end verification needs the docker-compose setup from internal/dbtest/test.sh.

Risk

Minimal. The change is local to m2mRelation and only swaps the source of rel.BasePKs for same-named entries in the base table's FieldMap. All existing schema and ORM unit tests pass; the composite-key m2m test is unaffected.

@dragonator dragonator marked this pull request as draft April 28, 2026 14:41
@dragonator dragonator force-pushed the fix/m2m-embedded-base-pk-index branch from fef87d4 to a6ab5d8 Compare April 28, 2026 14:57
@dragonator dragonator marked this pull request as ready for review April 28, 2026 15:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant