diff --git a/Orm/Xtensive.Orm.Tests/Storage/ClassTableMultiPathInterfaceTest.cs b/Orm/Xtensive.Orm.Tests/Storage/ClassTableMultiPathInterfaceTest.cs new file mode 100644 index 000000000..45dbb7981 --- /dev/null +++ b/Orm/Xtensive.Orm.Tests/Storage/ClassTableMultiPathInterfaceTest.cs @@ -0,0 +1,106 @@ +// Copyright (C) 2025 Xtensive LLC. +// This code is distributed under MIT license terms. +// See the License.txt file in the project root for more information. + +using NUnit.Framework; +using Xtensive.Orm.Model; +using Xtensive.Orm.Tests.Storage.ClassTableMultiPathInterfaceTestModel; + +namespace Xtensive.Orm.Tests.Storage +{ + namespace ClassTableMultiPathInterfaceTestModel + { + [HierarchyRoot] + public class Owner : Entity + { + [Field, Key] + public int Id { get; private set; } + } + + // Persistent interface that declares an Entity-reference field. Xtensive auto-emits an + // FK_Owner index for it. + public interface IOwned : IEntity + { + [Field] + Owner Owner { get; } + } + + // Sub-interface of IOwned. Its Indexes collection independently carries an inherited + // copy of the FK_Owner IndexInfo (a different IndexInfo reference whose DeclaringIndex + // chain bottoms out at IOwned.FK_Owner). + public interface INamedAndOwned : IOwned + { + [Field(Length = 50)] + string Label { get; set; } + } + + public abstract class OwnedBase : Entity, IOwned + { + [Field, Key] + public int Id { get; private set; } + + [Field] + public Owner Owner { get; set; } + } + + public abstract class NamedOwnedBase : OwnedBase, INamedAndOwned + { + public string Label { get; set; } + } + + // typeof(Root).GetInterfaces() returns both IOwned (directly via OwnedBase) and + // INamedAndOwned (via NamedOwnedBase). Xtensive's ModelInspector.FindInterfaces uses + // .NET reflection's full transitive set, so DirectInterfaces on Root contains both. + [HierarchyRoot(InheritanceSchema.ClassTable)] + public class Root : NamedOwnedBase + { + [Field] + public int Payload { get; set; } + } + + // HierarchyInfo.UpdateState() demotes single-type hierarchies to ConcreteTable, which + // would mask the ClassTable assertion below. A concrete descendant keeps Types.Count > 1. + public class Leaf : Root + { + [Field] + public int Extra { get; set; } + } + } + + /// + /// Regression test for the ClassTable inherited-interface dedup bug. + /// + /// Before the fix, BuildClassTableIndexes used reference identity on the visited + /// interfaceIndex: + /// + /// if (type.Indexes.Any(i => i.DeclaringIndex == interfaceIndex)) continue; + /// + /// When the same logical interface index reached a ClassTable root through two paths + /// in DirectInterfaces, each path yielded a distinct IndexInfo. The guard missed on + /// the second visit; BuildInheritedIndex ran again and produced an index with the + /// same name; NodeCollection.Add threw: + /// + /// "Item with name 'Root.IOwned.FK_Owner' already exists in 'Root.Indexes'" + /// + /// at Domain.Build, before any DB work. + /// + /// Sibling BuildConcreteTableIndexes already handles this with a name-based check + /// after BuildInheritedIndex (disposing the surplus IndexInfo); BuildSingleTableIndexes + /// handles it with a chain-normalized identity check. ClassTable was the odd one out. + /// + [TestFixture] + public class ClassTableMultiPathInterfaceTest + { + [Test] + public void Build_ClassTableHierarchy_WithMultiPathInterfaceImpl_Succeeds() + { + var configuration = DomainConfigurationFactory.Create(); + configuration.Types.Register(typeof(Owner).Assembly, typeof(Owner).Namespace); + using (var domain = Domain.Build(configuration)) { + Assert.That(domain.Model.Types[typeof(Root)], Is.Not.Null); + Assert.That(domain.Model.Types[typeof(Root)].Hierarchy.InheritanceSchema, + Is.EqualTo(InheritanceSchema.ClassTable)); + } + } + } +} diff --git a/Orm/Xtensive.Orm/Orm/Building/Builders/IndexBuilder.ClassTable.cs b/Orm/Xtensive.Orm/Orm/Building/Builders/IndexBuilder.ClassTable.cs index e9dccce41..4c36f8c88 100644 --- a/Orm/Xtensive.Orm/Orm/Building/Builders/IndexBuilder.ClassTable.cs +++ b/Orm/Xtensive.Orm/Orm/Building/Builders/IndexBuilder.ClassTable.cs @@ -68,11 +68,20 @@ private void BuildClassTableIndexes(TypeInfo type) var index = BuildInheritedIndex(type, interfaceIndex, false); if (IndexBuiltOverInheritedFields(index)) { BuildLog.Warning(string.Format(Strings.ExUnableToBuildIndexXBecauseItWasBuiltOverInheritedFields, index.Name)); + index.Dispose(); + continue; } - else { - type.Indexes.Add(index); - context.Model.RealIndexes.Add(index); + // The same logical interface index can arrive through multiple paths in DirectInterfaces + // (e.g. a type implements IBusinessEntity directly via one base AND transitively via a + // sub-interface like INamedBusinessEntity). The identity-equality guard above misses this + // because each path yields a distinct IndexInfo. Mirror BuildConcreteTableIndexes' name- + // based guard after BuildInheritedIndex to drop the redundant emission. + if (type.Indexes.Contains(index.Name)) { + index.Dispose(); + continue; } + type.Indexes.Add(index); + context.Model.RealIndexes.Add(index); } }