diff --git a/schema/issue1243_test.go b/schema/issue1243_test.go new file mode 100644 index 000000000..a3a70f2f4 --- /dev/null +++ b/schema/issue1243_test.go @@ -0,0 +1,77 @@ +package schema + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/require" +) + +// Models for TestNestedRelationWithSharedComposition (issue #1243). +// These must be at package level to allow circular type references +// between Business and Lead via shared embedded relation structs. + +type idField struct { + ID int64 `bun:",pk,autoincrement"` +} + +type business struct { + idField + leadRelation +} + +type businessRelation struct { + BusinessID int64 `bun:",nullzero"` + Business *business `bun:"rel:belongs-to,join:business_id=id"` +} + +type lead struct { + idField + businessRelation +} + +type leadRelation struct { + LeadID int64 `bun:",nullzero"` + Lead *lead `bun:"rel:belongs-to,join:lead_id=id"` +} + +type agent struct { + idField + businessRelation + + Associations []*link `bun:"rel:has-many,join:id=agent_id"` +} + +type agentRelation struct { + AgentID int64 `bun:",nullzero"` + Agent *agent `bun:"rel:belongs-to,join:agent_id=id"` +} + +type link struct { + idField + agentRelation + leadRelation +} + +func TestNestedRelationWithSharedComposition(t *testing.T) { + dialect := newNopDialect() + tables := NewTables(dialect) + + // Initialize Link table, which triggers circular initialization: + // Link -> AgentRelation -> Agent -> BusinessRelation -> Business + // -> LeadRelation -> Lead -> BusinessRelation -> Business (cycle) + linkTable := tables.Get(reflect.TypeFor[*link]()) + + require.Contains(t, linkTable.StructMap, "lead", + "Link should have 'lead' in StructMap from embedded LeadRelation") + + leadTable := linkTable.StructMap["lead"].Table + require.Contains(t, leadTable.StructMap, "business", + "Lead should have 'business' in StructMap from embedded BusinessRelation") + + // This is the lookup that fails in issue #1243: + // when scanning column "lead__business__id" from a nested Relation() query. + field := linkTable.LookupField("lead__business__id") + require.NotNil(t, field, + "LookupField('lead__business__id') must resolve through Lead -> Business") +} diff --git a/schema/table.go b/schema/table.go index 0d3637177..551c62f0f 100644 --- a/schema/table.go +++ b/schema/table.go @@ -69,7 +69,8 @@ type Table struct { SoftDeleteField *Field UpdateSoftDeleteField func(fv reflect.Value, tm time.Time) error - flags internal.Flag + flags internal.Flag + initStarted bool } type structField struct { @@ -78,6 +79,10 @@ type structField struct { } func (table *Table) init(dialect Dialect, typ reflect.Type) { + if table.initStarted { + return + } + table.initStarted = true table.dialect = dialect table.Type = typ table.ZeroValue = reflect.New(table.Type).Elem() @@ -233,10 +238,16 @@ func (t *Table) processFields(typ reflect.Type) { if t.StructMap == nil { t.StructMap = make(map[string]*structField) } + // Register the StructMap entry with a placeholder table BEFORE + // triggering recursive initialization. This ensures that types in + // a circular dependency can find this entry during their own + // processFields, even if this table's init hasn't completed yet. + placeholder := t.dialect.Tables().Placeholder(field.IndirectType) t.StructMap[field.Name] = &structField{ Index: field.Index, - Table: t.dialect.Tables().InProgress(field.IndirectType), + Table: placeholder, } + t.dialect.Tables().InProgress(field.IndirectType) } } diff --git a/schema/tables.go b/schema/tables.go index 272fd851b..a8219c27d 100644 --- a/schema/tables.go +++ b/schema/tables.go @@ -65,15 +65,25 @@ func (t *Tables) Get(typ reflect.Type) *Table { return table } +// InProgress returns the in-progress table for typ, initializing it +// if it has not been initialized yet. The Placeholder + init() split +// lets callers register StructMap entries with the table pointer +// before triggering recursive initialization, preventing missing +// entries in circular dependency chains. func (t *Tables) InProgress(typ reflect.Type) *Table { - if table, ok := t.inProgress[typ]; ok { - return table - } - - table := new(Table) - t.inProgress[typ] = table + table := t.Placeholder(typ) table.init(t.dialect, typ) + return table +} +// Placeholder returns an existing in-progress table or creates a new +// uninitialized placeholder entry. +func (t *Tables) Placeholder(typ reflect.Type) *Table { + table, ok := t.inProgress[typ] + if !ok { + table = new(Table) + t.inProgress[typ] = table + } return table }