Skip to content

Commit d4ef3a8

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 diagrams show, flat or nested. 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 slices that would be drawn under the same name, instead of drawing them as one node. 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 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 8a2c09b commit d4ef3a8

42 files changed

Lines changed: 691 additions & 395 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎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
65+
/// either, so the id is made from the name with those characters replaced by "_", and
66+
/// suffixed until it clashes with no other name in the diagram. The name itself stays the
67+
/// label, and slices whose names are valid ids keep them.
6768
///
6869
/// A valid name can still be taken by a package: a nested slice named after its own
6970
/// namespace, such as "App.Orders", would share its id with the package "App.Orders" it

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

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,12 +138,14 @@ public PlantUmlFileBuilder WithDependenciesFrom(
138138
/// <c>package</c> of the deepest namespace that contains all of its types. With
139139
/// <see cref="GenerationOptions.LimitDependencies"/>, only the arrows between slices whose
140140
/// types share an equally deep namespace are drawn.</param>
141+
/// <exception cref="ArgumentException">Two slices would be drawn under the same name.</exception>
141142
public PlantUmlFileBuilder WithDependenciesFrom(
142143
IEnumerable<Slice> slices,
143144
GenerationOptions generationOptions = null
144145
)
145146
{
146147
_sliceList = slices.Distinct().ToList();
148+
AssertDistinctNames();
147149
RemovePatternInappropriateSlices();
148150
AssignCommonNamespaces();
149151

@@ -315,14 +317,15 @@ var parent in parentNamespaces.Where(parentName =>
315317
/// <param name="generationOptions">Only <see cref="GenerationOptions.NestSlicesInPackages"/>
316318
/// is read; the defaults if <c>null</c>.</param>
317319
/// <exception cref="ArgumentException">The package is empty, a single dot or a namespace
318-
/// that no slice lies in.</exception>
320+
/// that no slice lies in, or two slices would be drawn under the same name.</exception>
319321
public PlantUmlFileBuilder WithDependenciesFromFocusOn(
320322
IEnumerable<Slice> slices,
321323
string package,
322324
GenerationOptions generationOptions = null
323325
)
324326
{
325327
_sliceList = slices.Distinct().ToList();
328+
AssertDistinctNames();
326329

327330
switch (package)
328331
{
@@ -509,6 +512,40 @@ private void ReplaceCirclesWithAppropriateDependencyType()
509512
}
510513
}
511514

515+
/// <summary>
516+
/// Rejects two slices with the same name, which the diagram would draw as one node. Only
517+
/// slices with different parts can share one, when a part contains " - ", as
518+
/// <c>["A - B"]</c> and <c>["A", "B"]</c> are both named "Slice A - B".
519+
/// </summary>
520+
private void AssertDistinctNames()
521+
{
522+
var slicesByName = new Dictionary<string, Slice>();
523+
foreach (var slice in _sliceList)
524+
{
525+
if (slicesByName.TryGetValue(slice.Description, out var other))
526+
{
527+
throw new ArgumentException(
528+
"The slices with parts "
529+
+ FormatParts(other)
530+
+ " and "
531+
+ FormatParts(slice)
532+
+ " would both be drawn as \""
533+
+ slice.Description
534+
+ "\"."
535+
);
536+
}
537+
538+
slicesByName.Add(slice.Description, slice);
539+
}
540+
}
541+
542+
private static string FormatParts(Slice slice)
543+
{
544+
return "["
545+
+ string.Join(", ", slice.Identifier.Parts.Select(part => "\"" + part + "\""))
546+
+ "]";
547+
}
548+
512549
/// <summary>
513550
/// The slices that a dependency starts or ends at, in the order of the slice list.
514551
/// </summary>
Lines changed: 87 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,103 @@
1-
namespace ArchUnitNET.Domain
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
5+
namespace ArchUnitNET.Domain
26
{
3-
public class SliceIdentifier : StringIdentifier, IHasDescription
7+
/// <summary>
8+
/// Identifies a slice by its parts: for a slice pattern, the values its capture groups
9+
/// captured, in group order. Two identifiers are the same slice exactly when their parts are
10+
/// equal and both are ignored or both are not.
11+
/// </summary>
12+
public class SliceIdentifier : IHasDescription
413
{
514
public static readonly SliceIdentifierComparer Comparer = new SliceIdentifierComparer();
615
public readonly bool Ignored;
716

817
private SliceIdentifier(
9-
string identifier,
18+
IReadOnlyList<string> parts,
1019
bool ignored,
1120
int? countOfAsteriskInPattern = null
1221
)
13-
: base(identifier)
1422
{
23+
Parts = parts;
1524
Ignored = ignored;
1625
CountOfAsteriskInPattern = countOfAsteriskInPattern;
1726
}
1827

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

2142
public readonly int? CountOfAsteriskInPattern;
2243

23-
public static SliceIdentifier Of(string identifier, int? countOfAsteriskInPattern = null)
44+
/// <summary>
45+
/// Creates an identifier for a slice with a single part, the same slice as
46+
/// <c>Of(new[] { part })</c>.
47+
/// </summary>
48+
/// <param name="part">
49+
/// The slice's only part: a custom <c>SliceAssignment</c> that returns identifiers with the
50+
/// same part for two types puts them into the same slice.
51+
/// </param>
52+
/// <param name="countOfAsteriskInPattern">
53+
/// The number of "(*)" groups in the pattern, if the slice comes from one.
54+
/// </param>
55+
public static SliceIdentifier Of(string part, int? countOfAsteriskInPattern = null)
2456
{
25-
return new SliceIdentifier(identifier, false, countOfAsteriskInPattern);
57+
return new SliceIdentifier(CheckParts(new[] { part }), false, countOfAsteriskInPattern);
58+
}
59+
60+
/// <summary>
61+
/// Creates an identifier for the slice with the given parts. Identifiers with equal parts,
62+
/// in the same order, belong to the same slice.
63+
/// </summary>
64+
/// <param name="parts">The parts, at least one; none of them may be <c>null</c>.</param>
65+
/// <exception cref="ArgumentException">There are no parts, or one of them is null.</exception>
66+
public static SliceIdentifier Of(IEnumerable<string> parts)
67+
{
68+
return new SliceIdentifier(CheckParts(parts), false);
2669
}
2770

2871
public static SliceIdentifier Ignore()
2972
{
30-
return new SliceIdentifier("Ignored", true);
73+
return new SliceIdentifier(new[] { "Ignored" }, true);
74+
}
75+
76+
private static IReadOnlyList<string> CheckParts(IEnumerable<string> parts)
77+
{
78+
if (parts == null)
79+
{
80+
throw new ArgumentNullException(nameof(parts));
81+
}
82+
83+
var list = parts.ToList();
84+
if (list.Count == 0)
85+
{
86+
throw new ArgumentException("A slice needs at least one part.", nameof(parts));
87+
}
88+
89+
if (list.Any(part => part == null))
90+
{
91+
throw new ArgumentException("A slice part can't be null.", nameof(parts));
92+
}
93+
94+
return list.AsReadOnly();
3195
}
3296

3397
/// <summary>
34-
/// Is true when the two SliceIdentifiers belong to the same slice
98+
/// Whether the two identifiers belong to the same slice: both ignored, or both not
99+
/// ignored with the same parts.
35100
/// </summary>
36-
/// <param name="other"></param>
37-
/// <returns></returns>
38101
public bool CompareTo(SliceIdentifier other)
39102
{
40103
if (other == null)
@@ -43,12 +106,12 @@ public bool CompareTo(SliceIdentifier other)
43106
}
44107

45108
return Ignored && other.Ignored
46-
|| !Ignored && !other.Ignored && Identifier == other.Identifier;
109+
|| !Ignored && !other.Ignored && Parts.SequenceEqual(other.Parts);
47110
}
48111

49112
private bool Equals(SliceIdentifier other)
50113
{
51-
return Identifier == other.Identifier && Ignored == other.Ignored;
114+
return Ignored == other.Ignored && Parts.SequenceEqual(other.Parts);
52115
}
53116

54117
public override bool Equals(object obj)
@@ -70,10 +133,19 @@ public override int GetHashCode()
70133
{
71134
unchecked
72135
{
73-
var hashCode = 397 ^ Identifier.GetHashCode();
74-
hashCode = (hashCode * 397) ^ Ignored.GetHashCode();
136+
var hashCode = Ignored.GetHashCode();
137+
foreach (var part in Parts)
138+
{
139+
hashCode = (hashCode * 397) ^ part.GetHashCode();
140+
}
141+
75142
return hashCode;
76143
}
77144
}
145+
146+
public override string ToString()
147+
{
148+
return Description;
149+
}
78150
}
79151
}

‎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>

0 commit comments

Comments
 (0)