Skip to content

Commit 676c0b6

Browse files
feat: identify and name slices by their captures, like ArchUnit
A slice was identified by its name, a single string, and named after what the pattern captured with nothing to mark it as a slice. A custom SliceAssignment had no way to say what its slice consists of, and a slice name read like a namespace. Identify a slice by its parts, as ArchUnit's SliceIdentifier does: for a slice pattern, the values its capture groups captured, in group order. SliceIdentifier.Parts holds them, and equality, hash code, CompareTo and therefore the grouping in SliceAssignment.Apply compare the parts (and Ignored) alone. The matcher still captures a single value per type, so every pattern slice has one part and groups exactly as before. SliceIdentifier.Of(IEnumerable<string>) creates a slice with several parts; Of(string) is the same slice as Of(new[] { part }). Name every slice after its parts like ArchUnit's default: "Slice " followed by the parts joined with " - ": App.(*) App.Orders Slice Orders App.(**) App.Orders.Web Slice Orders.Web Of(new[] { "Orders", "Http" }) Slice Orders - Http The prefix keeps a name from being mistaken for a namespace. It applies to every slice, including SlicedBy and custom SliceAssignments, and it is what failure messages and flat diagrams show. Nested diagrams draw slices under their qualified names, so they don't change. SliceIdentifier no longer derives from StringIdentifier: the parts are all that identifies a slice. Violation stores need a string key, which SlicesShould takes from the slice's name when it reports a violation, so a store holds what failure messages show, e.g. "Slice Orders", as ArchUnit's stores, which keep violation messages, do. Hand-built parts that contain " - " can share a name, and thus a key, with another slice: Of(new[] { "A - B" }) and Of(new[] { "A", "B" }) are different slices, both named "Slice A - B". Parts captured by a pattern never contain " - ", and ArchUnit has the same limitation. The exporter rejects two such slices in one diagram, as they would be drawn under the same name. The guide's "Cycle Rule" section becomes "Slice Rules", with how slices are named and frozen, and the NotDependOnEachOther rule. Breaking changes: - Slices are named "Slice ..." from their parts, which changes every slice failure message and flat slice diagram label. - Frozen slice violations are stored by the slice's name, so stores written before no longer match and have to be regenerated. - New are SliceIdentifier.Parts and SliceIdentifier.Of(IEnumerable). - SliceIdentifier no longer derives from StringIdentifier, so its Identifier field is gone, and ToString() returns the slice's name. Signed-off-by: Alexander Linne <alexander.linne@tngtech.com>
1 parent e8f8233 commit 676c0b6

30 files changed

Lines changed: 539 additions & 300 deletions

File tree

‎ArchUnitNET/Domain/PlantUml/Export/PlantUmlDiagram.cs‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,14 @@ public string GetPlantUmlString(RenderOptions renderOptions)
5757
/// </summary>
5858
/// <remarks>
5959
/// A slice is identified in the diagram by its name, but not every name is a valid id.
60-
/// PlantUML rejects "[Inner] as App.Outer+Inner" and "Container(App.Orders.*.Http, ...)"
61-
/// as syntax errors, and slice names can contain such characters: SlicedBy names slices
62-
/// after type names such as "Outer+Inner" or "List`1", and a hand-built SliceIdentifier
63-
/// can be called anything. No character that a namespace cannot contain works in their
64-
/// place either, so the id is made from the name with those characters replaced by "_",
65-
/// and suffixed until it clashes with no other name in the diagram. The name itself stays
66-
/// the label, and slices whose names are valid ids keep them.
60+
/// PlantUML rejects "[Inner] as App.Outer+Inner" and "Container(Slice Orders, ...)" as
61+
/// syntax errors, and slice names can contain such characters: every slice description
62+
/// starts with "Slice " and joins several parts with " - ", SlicedBy names slices after
63+
/// type names such as "Outer+Inner" or "List`1", and a hand-built SliceIdentifier can be
64+
/// called anything. No character that a namespace cannot contain works in their place either, so
65+
/// the id is made from the name with those characters replaced by "_", and suffixed until
66+
/// it clashes with no other name in the diagram. The name itself stays the label, and
67+
/// slices whose names are valid ids keep them.
6768
/// </remarks>
6869
private static void AssignAliases(IReadOnlyCollection<IPlantUmlElement> elements)
6970
{

‎ArchUnitNET/Domain/PlantUml/Export/PlantUmlFileBuilder.cs‎

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -728,12 +728,14 @@ private void ReplaceCirclesWithAppropriateDependencyType()
728728
/// nested slice is therefore drawn under its qualified name, the namespace its types
729729
/// matched before the first capture group followed by its
730730
/// <see cref="SliceIdentifier.DiagramPath"/>, e.g. "App.Orders.Http", with that namespace
731-
/// as its root package. Every other slice is drawn under its description, without a
732-
/// package.
731+
/// as its root package. Every other slice is drawn under its description, e.g.
732+
/// "Slice Orders - Http", without a package.
733733
/// </para>
734734
/// <para>
735-
/// Qualified names of one pattern are distinct, but a description can coincide with a
736-
/// qualified name. Drawing two slices as one node would be wrong, so that is rejected.
735+
/// A description always contains a space and a qualified name never does, so the two
736+
/// can't be mistaken for each other, and qualified names of one pattern are distinct.
737+
/// Descriptions can still coincide, for a custom slice whose part contains " - ".
738+
/// Drawing two slices as one node would be wrong, so that is rejected.
737739
/// </para>
738740
/// </remarks>
739741
/// <param name="nestSlicesInPackages">
@@ -757,11 +759,11 @@ private void AssignDiagramNames(bool nestSlicesInPackages)
757759
if (slicesByName.TryGetValue(diagramName.Name, out var other))
758760
{
759761
throw new ArgumentException(
760-
"The slices \""
761-
+ other.Identifier.Identifier
762-
+ "\" and \""
763-
+ slice.Identifier.Identifier
764-
+ "\" would both be drawn as \""
762+
"The slices with parts "
763+
+ FormatParts(other)
764+
+ " and "
765+
+ FormatParts(slice)
766+
+ " would both be drawn as \""
765767
+ diagramName.Name
766768
+ "\"."
767769
);
@@ -772,6 +774,13 @@ private void AssignDiagramNames(bool nestSlicesInPackages)
772774
}
773775
}
774776

777+
private static string FormatParts(Slice slice)
778+
{
779+
return "["
780+
+ string.Join(", ", slice.Identifier.Parts.Select(part => "\"" + part + "\""))
781+
+ "]";
782+
}
783+
775784
/// <summary>The name the diagram draws a slice under; see <see cref="AssignDiagramNames"/>.</summary>
776785
private string NameOf(Slice slice)
777786
{

‎ArchUnitNET/Domain/SliceIdentifier.cs‎

Lines changed: 88 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,48 @@
1-
using JetBrains.Annotations;
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using JetBrains.Annotations;
25

36
namespace ArchUnitNET.Domain
47
{
5-
public class SliceIdentifier : StringIdentifier, IHasDescription
8+
/// <summary>
9+
/// Identifies a slice by its parts: for a slice pattern, the values its capture groups
10+
/// captured, in group order. Two identifiers are the same slice exactly when their parts are
11+
/// equal and both are ignored or both are not.
12+
/// </summary>
13+
public class SliceIdentifier : IHasDescription
614
{
715
public static readonly SliceIdentifierComparer Comparer = new SliceIdentifierComparer();
816
public readonly bool Ignored;
917

1018
private SliceIdentifier(
11-
string identifier,
19+
IReadOnlyList<string> parts,
1220
bool ignored,
1321
int? countOfAsteriskInPattern = null,
1422
string matchedPrefix = null,
1523
string diagramPath = null
1624
)
17-
: base(identifier)
1825
{
26+
Parts = parts;
1927
Ignored = ignored;
2028
CountOfAsteriskInPattern = countOfAsteriskInPattern;
2129
MatchedPrefix = matchedPrefix;
2230
DiagramPath = diagramPath;
2331
}
2432

25-
public string Description => Identifier;
33+
/// <summary>
34+
/// What identifies the slice: for a slice pattern, the values its capture groups
35+
/// captured, in group order. "App.(**)" over "App.Orders.Web" gives
36+
/// <c>[Orders.Web]</c>.
37+
/// </summary>
38+
public IReadOnlyList<string> Parts { get; }
39+
40+
/// <summary>
41+
/// The slice's name, built from its parts: "Slice " followed by the parts joined with
42+
/// " - ", e.g. "Slice Orders - Http". It depends on the parts only, so equal slices always
43+
/// have the same name.
44+
/// </summary>
45+
public string Description => "Slice " + string.Join(" - ", Parts);
2646

2747
/// <summary>
2848
/// The part of the type's namespace the pattern matched before the first capture group,
@@ -44,24 +64,46 @@ private SliceIdentifier(
4464

4565
public readonly int? CountOfAsteriskInPattern;
4666

47-
public static SliceIdentifier Of(string identifier, int? countOfAsteriskInPattern = null)
67+
/// <summary>
68+
/// Creates an identifier for a slice with a single part, the same slice as
69+
/// <c>Of(new[] { part })</c>.
70+
/// </summary>
71+
/// <param name="part">
72+
/// The slice's only part: a custom <c>SliceAssignment</c> that returns identifiers with the
73+
/// same part for two types puts them into the same slice.
74+
/// </param>
75+
/// <param name="countOfAsteriskInPattern">
76+
/// The number of "(*)" groups in the pattern, if the slice comes from one.
77+
/// </param>
78+
public static SliceIdentifier Of(string part, int? countOfAsteriskInPattern = null)
79+
{
80+
return new SliceIdentifier(CheckParts(new[] { part }), false, countOfAsteriskInPattern);
81+
}
82+
83+
/// <summary>
84+
/// Creates an identifier for the slice with the given parts. Identifiers with equal parts,
85+
/// in the same order, belong to the same slice.
86+
/// </summary>
87+
/// <param name="parts">The parts, at least one; none of them may be <c>null</c>.</param>
88+
/// <exception cref="ArgumentException">There are no parts, or one of them is null.</exception>
89+
public static SliceIdentifier Of(IEnumerable<string> parts)
4890
{
49-
return new SliceIdentifier(identifier, false, countOfAsteriskInPattern);
91+
return new SliceIdentifier(CheckParts(parts), false);
5092
}
5193

5294
/// <summary>
5395
/// Creates the identifier a slice pattern assigns to a type, together with where the
5496
/// pattern matched, which diagrams use to nest the slice.
5597
/// </summary>
5698
internal static SliceIdentifier OfMatch(
57-
string identifier,
99+
IEnumerable<string> parts,
58100
int? countOfAsteriskInPattern,
59101
string matchedPrefix,
60102
string diagramPath
61103
)
62104
{
63105
return new SliceIdentifier(
64-
identifier,
106+
CheckParts(parts),
65107
false,
66108
countOfAsteriskInPattern,
67109
matchedPrefix,
@@ -71,14 +113,34 @@ string diagramPath
71113

72114
public static SliceIdentifier Ignore()
73115
{
74-
return new SliceIdentifier("Ignored", true);
116+
return new SliceIdentifier(new[] { "Ignored" }, true);
117+
}
118+
119+
private static IReadOnlyList<string> CheckParts(IEnumerable<string> parts)
120+
{
121+
if (parts == null)
122+
{
123+
throw new ArgumentNullException(nameof(parts));
124+
}
125+
126+
var list = parts.ToList();
127+
if (list.Count == 0)
128+
{
129+
throw new ArgumentException("A slice needs at least one part.", nameof(parts));
130+
}
131+
132+
if (list.Any(part => part == null))
133+
{
134+
throw new ArgumentException("A slice part can't be null.", nameof(parts));
135+
}
136+
137+
return list.AsReadOnly();
75138
}
76139

77140
/// <summary>
78-
/// Is true when the two SliceIdentifiers belong to the same slice
141+
/// Whether the two identifiers belong to the same slice: both ignored, or both not
142+
/// ignored with the same parts.
79143
/// </summary>
80-
/// <param name="other"></param>
81-
/// <returns></returns>
82144
public bool CompareTo(SliceIdentifier other)
83145
{
84146
if (other == null)
@@ -87,12 +149,12 @@ public bool CompareTo(SliceIdentifier other)
87149
}
88150

89151
return Ignored && other.Ignored
90-
|| !Ignored && !other.Ignored && Identifier == other.Identifier;
152+
|| !Ignored && !other.Ignored && Parts.SequenceEqual(other.Parts);
91153
}
92154

93155
private bool Equals(SliceIdentifier other)
94156
{
95-
return Identifier == other.Identifier && Ignored == other.Ignored;
157+
return Ignored == other.Ignored && Parts.SequenceEqual(other.Parts);
96158
}
97159

98160
public override bool Equals(object obj)
@@ -114,10 +176,19 @@ public override int GetHashCode()
114176
{
115177
unchecked
116178
{
117-
var hashCode = 397 ^ Identifier.GetHashCode();
118-
hashCode = (hashCode * 397) ^ Ignored.GetHashCode();
179+
var hashCode = Ignored.GetHashCode();
180+
foreach (var part in Parts)
181+
{
182+
hashCode = (hashCode * 397) ^ part.GetHashCode();
183+
}
184+
119185
return hashCode;
120186
}
121187
}
188+
189+
public override string ToString()
190+
{
191+
return Description;
192+
}
122193
}
123194
}

‎ArchUnitNET/Fluent/Slices/SliceRuleInitializer.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ private static SliceIdentifier AssignFunc(
170170
// What the pattern captured is both what identifies the slice and the path a
171171
// diagram nests it along.
172172
return SliceIdentifier.OfMatch(
173-
sliceString,
173+
new[] { sliceString },
174174
countOfSingleAsterisk,
175175
matchedPrefix,
176176
sliceString

‎ArchUnitNET/Fluent/Slices/SlicesShould.cs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ Architecture architecture
9191
}
9292

9393
var cycleIdentifier = new EnumerableIdentifier(
94-
cycle.Contents.Select(slice => slice.Identifier)
94+
cycle.Contents.Select(slice => new StringIdentifier(slice.Description))
9595
);
9696
yield return new EvaluationResult(
9797
cycle,
@@ -106,7 +106,7 @@ Architecture architecture
106106
else
107107
{
108108
var slicesIdentifier = new EnumerableIdentifier(
109-
slicesList.Select(slice => slice.Identifier)
109+
slicesList.Select(slice => new StringIdentifier(slice.Description))
110110
);
111111
yield return new EvaluationResult(
112112
slicesList,
@@ -166,7 +166,7 @@ Architecture architecture
166166

167167
yield return new EvaluationResult(
168168
slice,
169-
slice.Identifier,
169+
new StringIdentifier(slice.Description),
170170
passed,
171171
description,
172172
archRule,

‎ArchUnitNETTests/ArchUnitNET/Storage/CustomPathFrozenRules.json‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@
1515
{
1616
"ArchRuleDescription": "Slices matching \"SlicesTestAssembly.MultipleSubnamespaces.(**)\" should not depend on each other",
1717
"Violations": [
18-
"Slice3",
19-
"Slice3.Group1",
20-
"Slice2",
21-
"Slice2.Service",
22-
"Slice1",
23-
"Slice1.Service"
18+
"Slice Slice3",
19+
"Slice Slice3.Group1",
20+
"Slice Slice2",
21+
"Slice Slice2.Service",
22+
"Slice Slice1",
23+
"Slice Slice1.Service"
2424
]
2525
}
2626
]

‎ArchUnitNETTests/ArchUnitNET/Storage/FrozenRules.json‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,12 @@
2121
{
2222
"ArchRuleDescription": "Slices matching \"SlicesTestAssembly.MultipleSubnamespaces.(**)\" should not depend on each other",
2323
"Violations": [
24-
"Slice3",
25-
"Slice3.Group1",
26-
"Slice2",
27-
"Slice2.Service",
28-
"Slice1",
29-
"Slice1.Service"
24+
"Slice Slice3",
25+
"Slice Slice3.Group1",
26+
"Slice Slice2",
27+
"Slice Slice2.Service",
28+
"Slice Slice1",
29+
"Slice Slice1.Service"
3030
]
3131
},
3232
{

‎ArchUnitNETTests/ArchUnitNET/Storage/FrozenRules.xml‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@
1212
<Violation>ArchUnitNETTests.Fluent.FreezeTests+Violation</Violation>
1313
</FrozenRule>
1414
<FrozenRule ArchRule="Slices matching &quot;SlicesTestAssembly.MultipleSubnamespaces.(**)&quot; should not depend on each other">
15-
<Violation>Slice3</Violation>
16-
<Violation>Slice3.Group1</Violation>
17-
<Violation>Slice2</Violation>
18-
<Violation>Slice2.Service</Violation>
19-
<Violation>Slice1</Violation>
20-
<Violation>Slice1.Service</Violation>
15+
<Violation>Slice Slice3</Violation>
16+
<Violation>Slice Slice3.Group1</Violation>
17+
<Violation>Slice Slice2</Violation>
18+
<Violation>Slice Slice2.Service</Violation>
19+
<Violation>Slice Slice1</Violation>
20+
<Violation>Slice Slice1.Service</Violation>
2121
</FrozenRule>
2222
</FrozenRules>

‎ArchUnitNETTests/Domain/PlantUml/PlantUmlSliceDiagramTests.cs‎

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -154,9 +154,8 @@ public Task BuildUmlBySlicesFocusOn_Matching_ColorsFocusedSlices()
154154
{
155155
// Slices that are not nested have no namespace, so the focused ones take
156156
// PlantUmlSlice.BuildString's namespace-less branch with a colour set -- the only
157-
// path on which the exporter renders "[Slice1] #99ffd1" rather than a package.
158-
// They are drawn under their description, which carries no Root prefix, hence the
159-
// bare focus string.
157+
// path on which the exporter renders "[Slice Slice1] #99ffd1" rather than a package.
158+
// They are drawn under their description, which "Slice1" is contained in.
160159
var slices = SortedSlices(SliceRuleDefinition.Slices().Matching(Root + "(**)"));
161160
var uml = new PlantUmlFileBuilder()
162161
.WithDependenciesFromFocusOn(slices, "Slice1")
@@ -371,30 +370,28 @@ public void BuildUmlBySlices_SameSlicesDrawnFlatAndNested()
371370
var nested = new PlantUmlFileBuilder().WithDependenciesFrom(slices, Nested).AsString();
372371

373372
Assert.DoesNotContain("package", flat);
374-
Assert.Contains("[Slice1.Service]", flat);
373+
Assert.Contains("[Slice Slice1] as Slice_Slice1", flat);
375374
Assert.Contains("package \"SlicesTestAssembly.MultipleSubnamespaces\"", nested);
376-
Assert.DoesNotContain("[Slice1.Service]", nested);
375+
Assert.DoesNotContain("Slice Slice1", nested);
377376
}
378377

379378
// --- Two slices may not share a node ----------------------------------------------
380379

381380
[Fact]
382381
public void BuildUmlBySlices_SameDiagramName_Throws()
383382
{
384-
// A slice that is not nested is drawn under its description, which here is the
385-
// qualified name a nested slice is drawn under.
386-
var slices = SortedSlices(SliceRuleDefinition.Slices().Matching(Root + "(**)"))
387-
.Concat(
388-
new[] { new Slice(SliceIdentifier.Of(Root + "Slice1"), Array.Empty<IType>()) }
389-
)
390-
.ToList();
383+
// Different slices, both described "Slice A - B".
384+
var slices = new[]
385+
{
386+
new Slice(SliceIdentifier.Of(new[] { "A - B" }), Array.Empty<IType>()),
387+
new Slice(SliceIdentifier.Of(new[] { "A", "B" }), Array.Empty<IType>()),
388+
};
391389

392390
var ex = Assert.Throws<ArgumentException>(() =>
393-
new PlantUmlFileBuilder().WithDependenciesFrom(slices, Nested)
391+
new PlantUmlFileBuilder().WithDependenciesFrom(slices)
394392
);
395393
Assert.Equal(
396-
"The slices \"Slice1\" and \"SlicesTestAssembly.MultipleSubnamespaces.Slice1\" "
397-
+ "would both be drawn as \"SlicesTestAssembly.MultipleSubnamespaces.Slice1\".",
394+
"The slices with parts [\"A - B\"] and [\"A\", \"B\"] would both be drawn as \"Slice A - B\".",
398395
ex.Message
399396
);
400397
}

0 commit comments

Comments
 (0)