From 492fe97d4429a39401cd0c2c8612b5f058a01a86 Mon Sep 17 00:00:00 2001 From: Devereux Henley Date: Fri, 15 May 2026 11:54:34 -0500 Subject: [PATCH 1/3] Fix: ClassTable dedup ignores duplicate inherited interface indexes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BuildClassTableIndexes' inherited-interface dedup guard compares type.Indexes against the interfaceIndex reference itself: if (type.Indexes.Any(i => i.DeclaringIndex == interfaceIndex)) continue; When a type sits in DirectInterfaces via more than one path (e.g. a hierarchy root implements IBusinessEntity both directly via a base class and transitively via a sub-interface like INamedBusinessEntity), each path contributes a distinct IndexInfo for the same logical interface index. The identity check misses the second visit, BuildInheritedIndex runs again and produces an index with the same name, and NodeCollection.Add throws "Item with name '..FK_' already exists in '.Indexes'" — the hierarchy fails to build. Sibling BuildConcreteTableIndexes already handles this with a name-based check after BuildInheritedIndex (and disposes the surplus IndexInfo). Mirror that pattern here. Also dispose the index in the IndexBuiltOverInheritedFields branch, which previously leaked the unused IndexInfo. BuildSingleTableIndexes is unaffected — it dedups against interfaceIndex.DeclaringIndex (chain-normalized identity). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Building/Builders/IndexBuilder.ClassTable.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/Orm/Xtensive.Orm/Orm/Building/Builders/IndexBuilder.ClassTable.cs b/Orm/Xtensive.Orm/Orm/Building/Builders/IndexBuilder.ClassTable.cs index e9dccce41b..4c36f8c882 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); } } From 116d26c5b3427a746df2c7f42a5c93c510cbcfba Mon Sep 17 00:00:00 2001 From: Devereux Henley Date: Fri, 15 May 2026 11:59:20 -0500 Subject: [PATCH 2/3] Test: ClassTable hierarchy build with multi-path interface implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression test for the previous commit. Repros the case where a type implements the same persistent interface through two paths in DirectInterfaces (here: Root implements IOwned directly via OwnedBase AND transitively via NamedOwnedBase → INamedAndOwned : IOwned). Before the fix, Domain.Build threw "Item with name 'Root.IOwned.FK_Owner' already exists in 'Root.Indexes'" out of IndexBuilder.BuildClassTableIndexes. With the fix it builds cleanly. Mirrors the conventions in IndexesAndInheritanceSchemaTest.cs (nested *TestModel namespace, DomainConfigurationFactory.Create, namespace-scoped Types.Register). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ClassTableMultiPathInterfaceTest.cs | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 Orm/Xtensive.Orm.Tests/Storage/ClassTableMultiPathInterfaceTest.cs diff --git a/Orm/Xtensive.Orm.Tests/Storage/ClassTableMultiPathInterfaceTest.cs b/Orm/Xtensive.Orm.Tests/Storage/ClassTableMultiPathInterfaceTest.cs new file mode 100644 index 0000000000..e9e4031c7a --- /dev/null +++ b/Orm/Xtensive.Orm.Tests/Storage/ClassTableMultiPathInterfaceTest.cs @@ -0,0 +1,98 @@ +// 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; } + } + } + + /// + /// 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)); + } + } + } +} From fe22e61a35844a637154fd81e1b801d8de2ed6a5 Mon Sep 17 00:00:00 2001 From: Devereux Henley Date: Mon, 18 May 2026 12:28:44 -0500 Subject: [PATCH 3/3] Fix test falling under concrete table optimization. --- .../Storage/ClassTableMultiPathInterfaceTest.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Orm/Xtensive.Orm.Tests/Storage/ClassTableMultiPathInterfaceTest.cs b/Orm/Xtensive.Orm.Tests/Storage/ClassTableMultiPathInterfaceTest.cs index e9e4031c7a..45dbb7981b 100644 --- a/Orm/Xtensive.Orm.Tests/Storage/ClassTableMultiPathInterfaceTest.cs +++ b/Orm/Xtensive.Orm.Tests/Storage/ClassTableMultiPathInterfaceTest.cs @@ -57,6 +57,14 @@ 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; } + } } ///