From 2d23189144f53b9fc170435ee149f360e1196fa8 Mon Sep 17 00:00:00 2001 From: Alexander Linne Date: Fri, 23 Oct 2026 15:29:33 +0200 Subject: [PATCH] refactor: stop deriving Namespace from Slice Namespace has derived from Slice since the initial import, but uses almost nothing from it: the loader creates it with an empty list and fills Types by casting it back, and ArchBuilder only reads Types. The inheritance also gave it Identifier, NameSpace, CountOfAsteriskInPattern and ContainsNamespace(), which mean nothing for a namespace, and tied its Name and FullName to how slices describe themselves. Namespace is now a class of its own that implements IHasName, IHasDescription and IHasDependencies. Name, FullName, Description, Types, Classes, Interfaces, Dependencies, BackwardsDependencies and equality by full name are unchanged. The loader adds types through an internal AddType instead of the cast. The namespace parts of PlantUmlFileBuilder.WithDependenciesFrom(slices) move into a new overload WithDependenciesFrom(IEnumerable), so WithDependenciesFrom(Architecture.Namespaces) keeps compiling and drawing the same diagram. The slice overload and focus mode lose their "slice is Namespace" branches; the comment there already said that namespaces and slices can't be mixed in one diagram. Both overloads choose dependency targets and drop nodes without dependencies through the same generic helpers. The node removal now collects the nodes before removing them, as removing entries while enumerating a dictionary throws on .NET Framework. A snapshot of a namespace diagram, recorded before the change, pins the output, and a test pins namespace equality. Breaking changes: - Namespace no longer derives from Slice. Code that used a Namespace as a Slice, or used Identifier, NameSpace, CountOfAsteriskInPattern or ContainsNamespace() on it, no longer compiles. - Namespaces can no longer be passed to the slice overload of WithDependenciesFrom or to WithDependenciesFromFocusOn. Signed-off-by: Alexander Linne --- ArchUnitNET/Domain/Namespace.cs | 78 ++++++- .../PlantUml/Export/PlantUmlFileBuilder.cs | 216 +++++++++++------- ArchUnitNET/Loader/TypeProcessor.cs | 2 +- .../PlantUml/PlantUmlFileBuilderTest.cs | 10 + ...ldUmlByNamespacesSnapshotTest.verified.txt | 35 +++ ArchUnitNETTests/Loader/NamespaceTests.cs | 20 +- 6 files changed, 277 insertions(+), 84 deletions(-) create mode 100644 ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlFileBuilderTest.BuildUmlByNamespacesSnapshotTest.verified.txt diff --git a/ArchUnitNET/Domain/Namespace.cs b/ArchUnitNET/Domain/Namespace.cs index db8afdd44..b958c1a07 100644 --- a/ArchUnitNET/Domain/Namespace.cs +++ b/ArchUnitNET/Domain/Namespace.cs @@ -1,13 +1,83 @@ using System.Collections.Generic; +using System.Linq; +using ArchUnitNET.Domain.Dependencies; namespace ArchUnitNET.Domain { - public class Namespace : Slice, IHasName + /// + /// A namespace of the loaded architecture together with the types declared directly in it. + /// Namespaces are equal exactly when their full names are. + /// + public class Namespace : IHasName, IHasDescription, IHasDependencies { + private readonly string _name; + private readonly List _types; + public Namespace(string name, IEnumerable types) - : base(SliceIdentifier.Of(name), types) { } + { + _name = name; + _types = types.ToList(); + } + + /// The full name of this namespace. + public string Name => _name; + + /// The full name of this namespace. + public string FullName => _name; + + /// The full name of this namespace. + public string Description => _name; + + /// The types declared directly in this namespace. + public IEnumerable Types => _types; + + /// The classes declared directly in this namespace. + public IEnumerable Classes => Types.OfType(); + + /// The interfaces declared directly in this namespace. + public IEnumerable Interfaces => Types.OfType(); + + /// Every dependency going out of this namespace's types, including internal ones. + public List Dependencies => + Types.SelectMany(type => type.Dependencies).ToList(); + + /// Every dependency pointing at this namespace's types. + public List BackwardsDependencies => + Types.SelectMany(type => type.BackwardsDependencies).ToList(); + + internal void AddType(IType type) + { + _types.Add(type); + } + + private bool Equals(Namespace other) + { + return FullName == other.FullName; + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + return obj.GetType() == GetType() && Equals((Namespace)obj); + } + + public override int GetHashCode() + { + return FullName != null ? FullName.GetHashCode() : 0; + } - public string Name => Description; - public string FullName => Description; + public override string ToString() + { + return FullName; + } } } diff --git a/ArchUnitNET/Domain/PlantUml/Export/PlantUmlFileBuilder.cs b/ArchUnitNET/Domain/PlantUml/Export/PlantUmlFileBuilder.cs index b02092c59..7432a2354 100644 --- a/ArchUnitNET/Domain/PlantUml/Export/PlantUmlFileBuilder.cs +++ b/ArchUnitNET/Domain/PlantUml/Export/PlantUmlFileBuilder.cs @@ -139,13 +139,15 @@ public PlantUmlFileBuilder WithDependenciesFrom( foreach (var slice in _sliceList) { var sliceIsPackage = IsPackage(slice); - var dependencyTargets = SelectDependencies(slice, generationOptions); + var dependencyTargets = SelectDependencies( + slice, + _sliceList, + target => target.Description, + target => target.Types, + generationOptions + ); - if (slice is Namespace) //This throws errors if namespaces and slices are used in the same diagram, the syntax can't be mixed in PlantUML - { - nodes.Add(slice, new PlantUmlNamespace(slice.Description)); - } - else if (!sliceIsPackage) + if (!sliceIsPackage) { var slc = new PlantUmlSlice(slice.Description, slice.NameSpace); if (generationOptions.C4Style) @@ -222,11 +224,9 @@ public PlantUmlFileBuilder WithDependenciesFrom( if (!generationOptions.IncludeNodesWithoutDependencies) { - nodes = RemoveNodesNotContainedInDependencies(nodes); + RemoveNodesNotContainedInDependencies(nodes, slice => slice.Description); } - var nodeElements = HandleNodes(nodes); - if (!generationOptions.LimitDependencies) { RemoveDuplicateDependenciesWhenShowingPackages(); @@ -234,10 +234,94 @@ public PlantUmlFileBuilder WithDependenciesFrom( } RemoveDuplicatedArrowsIfExist(); - _diagram.AddElements( - nodeElements.OrderBy(element => - element is PlantUmlNamespace @namespace ? @namespace.Name.Length : -1 + _diagram.AddElements(nodes.Values); + _diagram.AddElements(_dependencies); + + return this; + } + + /// + /// Draws the given namespaces as nested PlantUML namespaces, with an arrow from one + /// namespace to another wherever one of its types depends on one of the other's. Parent + /// namespaces that are not in the list are added so that the nesting is complete. + /// + /// The namespaces to draw, e.g. . + /// Filters and layout options; the defaults if null. + /// does not apply to namespaces. + public PlantUmlFileBuilder WithDependenciesFrom( + IEnumerable namespaces, + GenerationOptions generationOptions = null + ) + { + var namespaceList = namespaces.Distinct().ToList(); + if (generationOptions == null) + { + generationOptions = new GenerationOptions(); + } + + var dependencyType = generationOptions.LimitDependencies + ? DependencyType.OneToOneCompact + : DependencyType.OneToOne; + var nodes = new Dictionary(); + + foreach (var ns in namespaceList) + { + nodes.Add(ns, new PlantUmlNamespace(ns.FullName)); + _dependencies.AddRange( + SelectDependencies( + ns, + namespaceList, + target => target.FullName, + target => target.Types, + generationOptions + ) + .Select(target => new PlantUmlDependency( + ns.FullName, + target.FullName, + dependencyType + )) + ); + } + + if (!generationOptions.IncludeNodesWithoutDependencies) + { + RemoveNodesNotContainedInDependencies(nodes, ns => ns.FullName); + } + + var nodeElements = nodes.Values.ToList(); + foreach (var ns in nodes.Keys) + { + var namespaceName = ns.FullName; + var dotIndex = namespaceName.Length; + var parentNamespaces = new List(); + while (dotIndex != -1) + { + namespaceName = namespaceName.Substring(0, dotIndex); + parentNamespaces.Add(namespaceName); + dotIndex = namespaceName.LastIndexOf('.'); + } + + parentNamespaces.Reverse(); + foreach ( + var parent in parentNamespaces.Where(parentName => + nodeElements + .OfType() + .All(element => element.Name != parentName) + ) ) + { + nodeElements.Add(new PlantUmlNamespace(parent)); + } + } + + if (!generationOptions.LimitDependencies) + { + ReplaceCirclesWithAppropriateDependencyType(); + } + RemoveDuplicatedArrowsIfExist(); + + _diagram.AddElements( + nodeElements.OrderBy(element => ((PlantUmlNamespace)element).Name.Length) ); _diagram.AddElements(_dependencies); @@ -282,7 +366,13 @@ string package foreach (var slice in _sliceList) { var sliceIsPackage = IsPackage(slice); - var dependencyTargets = SelectDependencies(slice, generationOptions); + var dependencyTargets = SelectDependencies( + slice, + _sliceList, + target => target.Description, + target => target.Types, + generationOptions + ); if (sliceIsPackage) { @@ -305,11 +395,7 @@ string package ); } - if (slice is Namespace) //This throws errors if namespaces and slices are used in the same diagram, the syntax can't be mixed in PlantUML - { - nodes.Add(slice, new PlantUmlNamespace(slice.Description)); - } - else if (!sliceIsPackage) + if (!sliceIsPackage) { nodes.Add( slice, @@ -326,7 +412,7 @@ string package if (!generationOptions.IncludeNodesWithoutDependencies) { - nodes = RemoveNodesNotContainedInDependencies(nodes); + RemoveNodesNotContainedInDependencies(nodes, slice => slice.Description); } RemoveDuplicateDependenciesWhenShowingPackages(); @@ -356,13 +442,7 @@ string package } } - var nodeElements = HandleNodes(nodes); - - _diagram.AddElements( - nodeElements.OrderBy(element => - element is PlantUmlNamespace @namespace ? @namespace.Name.Length : -1 - ) - ); + _diagram.AddElements(nodes.Values); var tempList = _sliceList .Where(slice => IsPackage(slice) && slice.Description.Contains(package)) @@ -381,72 +461,52 @@ string package return this; } - private Dictionary RemoveNodesNotContainedInDependencies( - Dictionary nodes + /// + /// Removes the nodes whose name no dependency starts or ends at. + /// + private void RemoveNodesNotContainedInDependencies( + Dictionary nodes, + Func nameOf ) { + var namesInDependencies = new HashSet( + _dependencies.SelectMany(dep => new[] { dep.Origin, dep.Target }) + ); + // Collected first: removing entries while enumerating the dictionary throws on + // .NET Framework. foreach ( - var entry in nodes.Where(node => - !_dependencies - .SelectMany(dep => new[] { dep.Origin, dep.Target }) - .Contains(node.Key.Description) - ) + var key in nodes + .Keys.Where(key => !namesInDependencies.Contains(nameOf(key))) + .ToList() ) { - nodes.Remove(entry.Key); + nodes.Remove(key); } - return nodes; } - private static IEnumerable HandleNodes( - Dictionary nodes + /// + /// The candidates, other than itself, that one of the origin's + /// dependencies passing points into. + /// + private static IEnumerable SelectDependencies( + T origin, + IEnumerable candidates, + Func nameOf, + Func> typesOf, + GenerationOptions generationOptions ) + where T : IHasDependencies { - var nodeElements = nodes.Values.ToList(); - - foreach (var node in nodes) - { - if (node.Key is Namespace) - { - var namespaceName = node.Key.Description; - var dotIndex = namespaceName.Length; - var parentNamespaces = new List(); - while (dotIndex != -1) - { - namespaceName = namespaceName.Substring(0, dotIndex); - parentNamespaces.Add(namespaceName); - dotIndex = namespaceName.LastIndexOf('.'); - } - - parentNamespaces.Reverse(); - foreach ( - var namespc in parentNamespaces.Where(namespcName => - nodeElements - .OfType() - .All(element => element.Name != namespcName) - ) - ) - { - nodeElements.Add(new PlantUmlNamespace(namespc)); - } - } - } - - return nodeElements; - } - - private IEnumerable SelectDependencies( - Slice slice, - GenerationOptions generationOptions - ) => - _sliceList.Where(targetSlice => - targetSlice.Description != slice.Description - && slice + var originName = nameOf(origin); + return candidates.Where(target => + nameOf(target) != originName + && origin .Dependencies.Where(dep => generationOptions.DependencyFilter?.Invoke(dep) ?? true ) - .Any(dep => targetSlice.Types.Contains(dep.Target)) + .Any(dep => typesOf(target).Contains(dep.Target)) ); + } private void RemovePatternInappropriateSlices(string thatContainsThisString = null) { diff --git a/ArchUnitNET/Loader/TypeProcessor.cs b/ArchUnitNET/Loader/TypeProcessor.cs index dceed9363..27c49304e 100644 --- a/ArchUnitNET/Loader/TypeProcessor.cs +++ b/ArchUnitNET/Loader/TypeProcessor.cs @@ -1119,7 +1119,7 @@ internal static void AddTypesToNamespaces(IEnumerable types) { foreach (var type in types) { - ((List)type.Namespace.Types).Add(type); + type.Namespace.AddType(type); } } } diff --git a/ArchUnitNETTests/Domain/PlantUml/PlantUmlFileBuilderTest.cs b/ArchUnitNETTests/Domain/PlantUml/PlantUmlFileBuilderTest.cs index 601349c51..64302e59f 100644 --- a/ArchUnitNETTests/Domain/PlantUml/PlantUmlFileBuilderTest.cs +++ b/ArchUnitNETTests/Domain/PlantUml/PlantUmlFileBuilderTest.cs @@ -55,6 +55,16 @@ public void BuildUmlByNamespacesTest() Assert.NotEmpty(uml); } + [Fact] + public Task BuildUmlByNamespacesSnapshotTest() + { + var namespaces = StaticTestArchitectures.SlicesTestArchitecture.Namespaces.Where(ns => + ns.FullName.StartsWith("SlicesTestAssembly.MultipleSubnamespaces") + ); + var uml = new PlantUmlFileBuilder().WithDependenciesFrom(namespaces).AsString(); + return Verifier.Verify(uml).DisableDiff().UseDirectory("Snapshots"); + } + [Fact] public void BuildUmlBySlicesTest() { diff --git a/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlFileBuilderTest.BuildUmlByNamespacesSnapshotTest.verified.txt b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlFileBuilderTest.BuildUmlByNamespacesSnapshotTest.verified.txt new file mode 100644 index 000000000..5b87b1575 --- /dev/null +++ b/ArchUnitNETTests/Domain/PlantUml/Snapshots/PlantUmlFileBuilderTest.BuildUmlByNamespacesSnapshotTest.verified.txt @@ -0,0 +1,35 @@ +@startuml + +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml + +HIDE_STEREOTYPE() + +namespace SlicesTestAssembly { +} +namespace SlicesTestAssembly.MultipleSubnamespaces { +} +namespace SlicesTestAssembly.MultipleSubnamespaces.Slice3 { +} +namespace SlicesTestAssembly.MultipleSubnamespaces.Slice2 { +} +namespace SlicesTestAssembly.MultipleSubnamespaces.Slice1 { +} +namespace SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group2 { +} +namespace SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group1 { +} +namespace SlicesTestAssembly.MultipleSubnamespaces.Slice2.Service { +} +namespace SlicesTestAssembly.MultipleSubnamespaces.Slice1.Service { +} +namespace SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group2.Inner { +} +namespace SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group1.Inner { +} +[SlicesTestAssembly.MultipleSubnamespaces.Slice3] --|> [SlicesTestAssembly.MultipleSubnamespaces.Slice1] +[SlicesTestAssembly.MultipleSubnamespaces.Slice3.Group1] --|> [SlicesTestAssembly.MultipleSubnamespaces.Slice3] +[SlicesTestAssembly.MultipleSubnamespaces.Slice2] --|> [SlicesTestAssembly.MultipleSubnamespaces.Slice3] +[SlicesTestAssembly.MultipleSubnamespaces.Slice2.Service] --|> [SlicesTestAssembly.MultipleSubnamespaces.Slice3] +[SlicesTestAssembly.MultipleSubnamespaces.Slice1] --|> [SlicesTestAssembly.MultipleSubnamespaces.Slice2] +[SlicesTestAssembly.MultipleSubnamespaces.Slice1.Service] --|> [SlicesTestAssembly.MultipleSubnamespaces.Slice2.Service] +@enduml diff --git a/ArchUnitNETTests/Loader/NamespaceTests.cs b/ArchUnitNETTests/Loader/NamespaceTests.cs index a5c56c426..4d29d6bfd 100644 --- a/ArchUnitNETTests/Loader/NamespaceTests.cs +++ b/ArchUnitNETTests/Loader/NamespaceTests.cs @@ -1,4 +1,6 @@ -using System.Linq; +using System.Collections.Generic; +using System.Linq; +using ArchUnitNET.Domain; using Xunit; namespace ArchUnitNETTests.Loader @@ -25,5 +27,21 @@ public void NoEmptyNamespacesTest() ) ); } + + [Fact] + public void NamespacesAreEqualByName() + { + var ns = new Namespace("A.B", new List()); + var sameName = new Namespace("A.B", new List()); + var otherName = new Namespace("A.C", new List()); + + Assert.Equal(ns, sameName); + Assert.Equal(ns.GetHashCode(), sameName.GetHashCode()); + Assert.NotEqual(ns, otherName); + Assert.Equal("A.B", ns.Name); + Assert.Equal("A.B", ns.FullName); + Assert.Equal("A.B", ns.Description); + Assert.Equal("A.B", ns.ToString()); + } } }