Skip to content

Commit 26f8df1

Browse files
authored
Feature/2.1.0 (#21)
* 2.1.0: Record struct IDs and IEntity interface. * Minor corrections. * Clarifications. * Minor readme improvements. * Minor readme improvements.
1 parent 42098f9 commit 26f8df1

File tree

11 files changed

+259
-37
lines changed

11 files changed

+259
-37
lines changed

DomainModeling.Generator/IdentityGenerator.cs

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,22 +18,22 @@ public override void Initialize(IncrementalGeneratorInitializationContext contex
1818

1919
private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancellationToken = default)
2020
{
21-
// Partial struct with some interface
22-
if (node is StructDeclarationSyntax sds && sds.Modifiers.Any(SyntaxKind.PartialKeyword) && sds.BaseList is not null)
21+
// Partial (record) struct with some interface
22+
if (node is TypeDeclarationSyntax tds && tds is StructDeclarationSyntax or RecordDeclarationSyntax { ClassOrStructKeyword.ValueText: "struct" } && tds.Modifiers.Any(SyntaxKind.PartialKeyword) && tds.BaseList is not null)
2323
{
2424
// With SourceGenerated attribute
25-
if (sds.HasAttributeWithPrefix(Constants.SourceGeneratedAttributeShortName))
25+
if (tds.HasAttributeWithPrefix(Constants.SourceGeneratedAttributeShortName))
2626
{
2727
// Consider any type with SOME 1-param generic "IIdentity" inheritance/implementation
28-
foreach (var baseType in sds.BaseList.Types)
28+
foreach (var baseType in tds.BaseList.Types)
2929
{
3030
if (baseType.Type.HasArityAndName(1, Constants.IdentityInterfaceTypeName))
3131
return true;
3232
}
3333
}
3434
}
3535

36-
// Concrete, non-generic class
36+
// Concrete, non-generic class with any inherited/implemented types
3737
if (node is ClassDeclarationSyntax cds && !cds.Modifiers.Any(SyntaxKind.AbstractKeyword) && cds.Arity == 0 && cds.BaseList is not null)
3838
{
3939
// Consider any type with SOME 2-param generic "Entity" inheritance/implementation
@@ -107,6 +107,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella
107107

108108
result.IsIIdentity = true;
109109
result.IdTypeExists = true;
110+
result.IsRecord = type.IsRecord;
110111
result.SetAssociatedData(new Tuple<INamedTypeSymbol?, ITypeSymbol, ITypeSymbol>(null, type, underlyingType));
111112
result.ContainingNamespace = type.ContainingNamespace.ToString();
112113
result.IdTypeName = type.Name;
@@ -124,29 +125,35 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella
124125
existingComponents |= IdTypeComponents.Constructor.If(type.Constructors.Any(ctor =>
125126
!ctor.IsStatic && ctor.Parameters.Length == 1 && ctor.Parameters[0].Type.Equals(underlyingType, SymbolEqualityComparer.Default)));
126127

127-
existingComponents |= IdTypeComponents.ToStringOverride.If(members.Any(member =>
128+
// Records override this, but our implementation is superior
129+
existingComponents |= IdTypeComponents.ToStringOverride.If(!result.IsRecord && members.Any(member =>
128130
member.Name == nameof(ToString) && member is IMethodSymbol method && method.Parameters.Length == 0));
129131

130-
existingComponents |= IdTypeComponents.GetHashCodeOverride.If(members.Any(member =>
132+
// Records override this, but our implementation is superior
133+
existingComponents |= IdTypeComponents.GetHashCodeOverride.If(!result.IsRecord && members.Any(member =>
131134
member.Name == nameof(GetHashCode) && member is IMethodSymbol method && method.Parameters.Length == 0));
132135

136+
// Records irrevocably and correctly override this, checking the type and delegating to IEquatable<T>.Equals(T)
133137
existingComponents |= IdTypeComponents.EqualsOverride.If(members.Any(member =>
134138
member.Name == nameof(Equals) && member is IMethodSymbol method && method.Parameters.Length == 1 &&
135139
method.Parameters[0].Type.IsType<object>()));
136140

137-
existingComponents |= IdTypeComponents.EqualsMethod.If(members.Any(member =>
141+
// Records override this, but our implementation is superior
142+
existingComponents |= IdTypeComponents.EqualsMethod.If(!result.IsRecord && members.Any(member =>
138143
member.Name == nameof(Equals) && member is IMethodSymbol method && method.Parameters.Length == 1 &&
139144
method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default)));
140145

141146
existingComponents |= IdTypeComponents.CompareToMethod.If(members.Any(member =>
142147
member.Name == nameof(IComparable.CompareTo) && member is IMethodSymbol method && method.Parameters.Length == 1 &&
143148
method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default)));
144149

150+
// Records irrevocably and correctly override this, delegating to IEquatable<T>.Equals(T)
145151
existingComponents |= IdTypeComponents.EqualsOperator.If(members.Any(member =>
146152
member.Name == "op_Equality" && member is IMethodSymbol method && method.Parameters.Length == 2 &&
147153
method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) &&
148154
method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default)));
149155

156+
// Records irrevocably and correctly override this, delegating to IEquatable<T>.Equals(T)
150157
existingComponents |= IdTypeComponents.NotEqualsOperator.If(members.Any(member =>
151158
member.Name == "op_Inequality" && member is IMethodSymbol method && method.Parameters.Length == 2 &&
152159
method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) &&
@@ -298,7 +305,7 @@ private static void GenerateSource(SourceProductionContext context, Generatable
298305
#endif
299306
";
300307

301-
string ? propertyNameParseStatement = null;
308+
string? propertyNameParseStatement = null;
302309
if (idType.IsOrImplementsInterface(interf => interf.Name == "ISpanParsable" && interf.ContainingNamespace.HasFullName("System") && interf.Arity == 1 && interf.TypeArguments[0].Equals(idType, SymbolEqualityComparer.Default), out _))
303310
propertyNameParseStatement = $"return reader.GetParsedString<{idTypeName}>(System.Globalization.CultureInfo.InvariantCulture);";
304311
else if (underlyingType.IsType<string>())
@@ -354,7 +361,7 @@ namespace {containingNamespace}
354361
{(existingComponents.HasFlags(IdTypeComponents.NewtonsoftJsonConverter) ? "*/" : "")}
355362
356363
{(hasSourceGeneratedAttribute ? "" : "[SourceGenerated]")}
357-
{(entityTypeName is null ? "/* Generated */ " : "")}{accessibility.ToCodeString()} readonly{(entityTypeName is null ? " partial" : "")} struct {idTypeName} : {Constants.IdentityInterfaceTypeName}<{underlyingTypeFullyQualifiedName}>, IEquatable<{idTypeName}>, IComparable<{idTypeName}>
364+
{(entityTypeName is null ? "/* Generated */ " : "")}{accessibility.ToCodeString()} readonly{(entityTypeName is null ? " partial" : "")}{(idType.IsRecord ? " record" : "")} struct {idTypeName} : {Constants.IdentityInterfaceTypeName}<{underlyingTypeFullyQualifiedName}>, IEquatable<{idTypeName}>, IComparable<{idTypeName}>
358365
{{
359366
{(existingComponents.HasFlags(IdTypeComponents.Value) ? "/*" : "")}
360367
{nonNullStringSummary}
@@ -548,6 +555,7 @@ private sealed record Generatable : IGeneratable
548555
public bool IdTypeExists { get; set; }
549556
public string EntityTypeName { get; set; } = null!;
550557
public bool IsIIdentity { get; set; }
558+
public bool IsRecord { get; set; }
551559
public string ContainingNamespace { get; set; } = null!;
552560
public string IdTypeName { get; set; } = null!;
553561
public string UnderlyingTypeFullyQualifiedName { get; set; } = null!;

DomainModeling.Generator/SourceGeneratedAttributeAnalyzer.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella
5555
else if (type.IsOrInheritsClass(type => type.Arity == 2 && type.IsType(Constants.DummyBuilderTypeName, Constants.DomainModelingNamespace), out _))
5656
expectedTypeName = tds is ClassDeclarationSyntax ? null : "class"; // Expect a class
5757
else if (type.IsOrImplementsInterface(type => type.Arity == 1 && type.IsType(Constants.IdentityInterfaceTypeName, Constants.DomainModelingNamespace), out _))
58-
expectedTypeName = tds is StructDeclarationSyntax ? null : "struct"; // Expect a struct
58+
expectedTypeName = tds is StructDeclarationSyntax or RecordDeclarationSyntax { ClassOrStructKeyword.ValueText: "struct" } ? null : "struct"; // Expect a struct
5959
else
6060
expectedTypeName = "*"; // No suitable inheritance found for source generation
6161

DomainModeling.Generator/TypeSyntaxExtensions.cs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,12 @@ namespace Architect.DomainModeling.Generator;
66
/// Provides extensions on <see cref="TypeSyntax"/>.
77
/// </summary>
88
internal static class TypeSyntaxExtensions
9-
{
10-
public static bool HasArityAndName(this TypeSyntax typeSyntax, int arity, string unqualifiedName)
9+
{
10+
/// <summary>
11+
/// Returns whether the given <see cref="TypeSyntax"/> has the given arity (type parameter count) and (unqualified) name.
12+
/// </summary>
13+
/// <param name="arity">Pass null to accept any arity.</param>
14+
public static bool HasArityAndName(this TypeSyntax typeSyntax, int? arity, string unqualifiedName)
1115
{
1216
int actualArity;
1317
string actualUnqualifiedName;
@@ -32,6 +36,20 @@ public static bool HasArityAndName(this TypeSyntax typeSyntax, int arity, string
3236
return false;
3337
}
3438

35-
return actualArity == arity && actualUnqualifiedName == unqualifiedName;
39+
return (arity is null || actualArity == arity) && actualUnqualifiedName == unqualifiedName;
40+
}
41+
42+
/// <summary>
43+
/// Returns the given <see cref="TypeSyntax"/>'s name, or null if no name can be obtained.
44+
/// </summary>
45+
public static string? GetNameOrDefault(this TypeSyntax typeSyntax)
46+
{
47+
return typeSyntax switch
48+
{
49+
SimpleNameSyntax simpleName => simpleName.Identifier.ValueText,
50+
QualifiedNameSyntax qualifiedName => qualifiedName.Right.Identifier.ValueText,
51+
AliasQualifiedNameSyntax aliasQualifiedName => aliasQualifiedName.Name.Identifier.ValueText,
52+
_ => null,
53+
};
3654
}
3755
}

DomainModeling.Generator/ValueObjectGenerator.cs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public override void Initialize(IncrementalGeneratorInitializationContext contex
1818

1919
private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancellationToken = default)
2020
{
21-
// Partial subclass
21+
// Partial subclass with any inherited/implemented types
2222
if (node is not ClassDeclarationSyntax cds || !cds.Modifiers.Any(SyntaxKind.PartialKeyword) || cds.BaseList is null)
2323
return false;
2424

@@ -29,11 +29,9 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella
2929
return true;
3030
}
3131

32-
/* Supporting records has the following disadvantages:
33-
* - Allows the use of automatic properties. This generates a constructor and init-properties, stimulating non-validated ValueObjects, an antipattern.
34-
* - Overrides equality without an easy way to specify (or even think of) how to compare strings.
35-
* - Overrides equality without special-casing collections.
36-
* - Omits IComparable<T> and comparison operators.
32+
/* Supporting records has the following issues:
33+
* - Cannot inherit from a non-record class (and vice versa).
34+
* - Promotes the use of "positional" (automatic) properties. This generates a constructor and init-properties, stimulating non-validated ValueObjects, an antipattern.
3735
* - Provides multiple nearly-identical solutions, reducing standardization.
3836
// Partial record with some interface/base
3937
if (node is RecordDeclarationSyntax rds && rds.Modifiers.Any(SyntaxKind.PartialKeyword) && rds.BaseList is not null)

DomainModeling.Tests/IdentityTests.cs

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,28 @@ public void Equals_WithUnintializedObject_ShouldReturnExpectedResult()
104104
Assert.Equal(new StringId(""), left);
105105
}
106106

107+
[Fact]
108+
public void ObjectEquals_WithRegularStruct_ShouldReturnExpectedResult()
109+
{
110+
var one = (object)new IntId(1);
111+
var alsoOne = (object)new IntId(1);
112+
var two = (object)new IntId(2);
113+
114+
Assert.Equal(one, alsoOne);
115+
Assert.NotEqual(one, two);
116+
}
117+
118+
[Fact]
119+
public void ObjectEquals_WithRecordStruct_ShouldReturnExpectedResult()
120+
{
121+
var one = (object)new DecimalId(1);
122+
var alsoOne = (object)new DecimalId(1);
123+
var two = (object)new DecimalId(2);
124+
125+
Assert.Equal(one, alsoOne);
126+
Assert.NotEqual(one, two);
127+
}
128+
107129
[Theory]
108130
[InlineData(0, 0)]
109131
[InlineData(0, 1)]
@@ -495,14 +517,10 @@ internal partial struct IntId : IIdentity<int>
495517
}
496518

497519
[SourceGenerated]
498-
internal partial struct DecimalId : IIdentity<decimal>
499-
{
500-
}
520+
internal partial record struct DecimalId : IIdentity<decimal>;
501521

502522
[SourceGenerated]
503-
internal partial struct StringId : IIdentity<string>
504-
{
505-
}
523+
internal partial record struct StringId : IIdentity<string>;
506524

507525
[SourceGenerated]
508526
internal partial struct IgnoreCaseStringId : IIdentity<string>

DomainModeling.Tests/ValueObjectTests.cs

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
using System.Collections;
22
using System.Collections.Immutable;
3-
using System.Diagnostics.CodeAnalysis;
43
using System.Globalization;
54
using System.Runtime.InteropServices;
65
using Architect.DomainModeling.Tests.ValueObjectTestTypes;
@@ -399,6 +398,84 @@ public void ContainsWhitespaceOrNonPrintableCharacters_Regularly_ShouldReturnExp
399398
}
400399
}
401400

401+
[Fact]
402+
public void ContainsNonPrintableCharactersOrDoubleQuotes_WithFlagNewLinesAndTabs_ShouldReturnExpectedResult()
403+
{
404+
for (var i = 0; i < UInt16.MaxValue; i++)
405+
{
406+
var chr = (char)i;
407+
408+
var category = Char.GetUnicodeCategory(chr);
409+
var isPrintable = category != UnicodeCategory.Control && category != UnicodeCategory.PrivateUse && category != UnicodeCategory.OtherNotAssigned;
410+
var isNotDoubleQuote = chr != '"';
411+
412+
var span = MemoryMarshal.CreateReadOnlySpan(ref chr, length: 1);
413+
var result = ManualValueObject.ContainsNonPrintableCharactersOrDoubleQuotes(span, flagNewLinesAndTabs: true);
414+
415+
if (isPrintable && isNotDoubleQuote)
416+
Assert.False(result, $"{nameof(ManualValueObject.ContainsNonPrintableCharactersOrDoubleQuotes)} (disallowing newlines and tabs) for '{chr}' ({i}) should have been false, but was true.");
417+
else
418+
Assert.True(result, $"{nameof(ManualValueObject.ContainsNonPrintableCharactersOrDoubleQuotes)} (disallowing newlines and tabs) for '{chr}' ({i}) should have been true, but was false.");
419+
420+
var longVersion = new string(chr, count: 33);
421+
var longResult = ManualValueObject.ContainsNonPrintableCharactersOrDoubleQuotes(longVersion, flagNewLinesAndTabs: true);
422+
Assert.Equal(result, longResult);
423+
}
424+
}
425+
426+
[Fact]
427+
public void ContainsNonPrintableCharactersOrDoubleQuotes_WithoutFlagNewLinesAndTabs_ShouldReturnExpectedResult()
428+
{
429+
for (var i = 0; i < UInt16.MaxValue; i++)
430+
{
431+
var chr = (char)i;
432+
433+
var category = Char.GetUnicodeCategory(chr);
434+
var isPrintable = category != UnicodeCategory.Control && category != UnicodeCategory.PrivateUse && category != UnicodeCategory.OtherNotAssigned;
435+
isPrintable = isPrintable || chr == '\r' || chr == '\n' || chr == '\t';
436+
var isNotDoubleQuote = chr != '"';
437+
438+
var span = MemoryMarshal.CreateReadOnlySpan(ref chr, length: 1);
439+
var result = ManualValueObject.ContainsNonPrintableCharactersOrDoubleQuotes(span, flagNewLinesAndTabs: false);
440+
441+
if (isPrintable && isNotDoubleQuote)
442+
Assert.False(result, $"{nameof(ManualValueObject.ContainsNonPrintableCharactersOrDoubleQuotes)} (allowing newlines and tabs) for '{chr}' ({i}) should have been false, but was true.");
443+
else
444+
Assert.True(result, $"{nameof(ManualValueObject.ContainsNonPrintableCharactersOrDoubleQuotes)} (allowing newlines and tabs) for '{chr}' ({i}) should have been true, but was false.");
445+
446+
var longVersion = new string(chr, count: 33);
447+
var longResult = ManualValueObject.ContainsNonPrintableCharactersOrDoubleQuotes(longVersion, flagNewLinesAndTabs: false);
448+
Assert.Equal(result, longResult);
449+
}
450+
}
451+
452+
[Theory]
453+
[InlineData("_12345678901234561234567890123456", false)]
454+
[InlineData("12345678901234561234567890123456_", false)]
455+
[InlineData("12345678901234561234567890123456ë", false)]
456+
[InlineData("12345678901234561234567890123456💩", false)]
457+
[InlineData("1234567890123456123456789012345💩", false)]
458+
[InlineData("123456789012345612345678901234💩", false)]
459+
[InlineData("💩12345678901234561234567890123456", false)]
460+
[InlineData("12345678901234💩561234567890123456", false)]
461+
[InlineData("12345678901234561234567890123456", true)] // Ends with an invisible control character
462+
[InlineData("12345678901234561234567890123456\0", true)]
463+
[InlineData("1234567890123456123456789012345\0", true)]
464+
[InlineData("123456789012345612345678901234\0", true)]
465+
[InlineData("\012345678901234561234567890123456", true)]
466+
[InlineData("12345678901234561234567890123456\"", true)]
467+
[InlineData("1234567890123456123456789012345\"", true)]
468+
[InlineData("123456789012345612345678901234\"", true)]
469+
[InlineData("12345678901234\0561234567890123456", true)]
470+
[InlineData("\"12345678901234561234567890123456", true)]
471+
[InlineData("12345678901234\"561234567890123456", true)]
472+
public void ContainsNonPrintableCharactersOrDoubleQuotes_WithLongInput_ShouldReturnExpectedResult(string text, bool expectedResult)
473+
{
474+
var result = ManualValueObject.ContainsNonPrintableCharactersOrDoubleQuotes(text, flagNewLinesAndTabs: true);
475+
476+
Assert.Equal(expectedResult, result);
477+
}
478+
402479
[Theory]
403480
[InlineData("_12345678901234561234567890123456", false)]
404481
[InlineData("12345678901234561234567890123456_", false)]
@@ -964,6 +1041,11 @@ public ManualValueObject(int id)
9641041
return ValueObject.ContainsNonPrintableCharacters(text, flagNewLinesAndTabs);
9651042
}
9661043
1044+
public static new bool ContainsNonPrintableCharactersOrDoubleQuotes(ReadOnlySpan<char> text, bool flagNewLinesAndTabs)
1045+
{
1046+
return ValueObject.ContainsNonPrintableCharactersOrDoubleQuotes(text, flagNewLinesAndTabs);
1047+
}
1048+
9671049
public static new bool ContainsWhitespaceOrNonPrintableCharacters(ReadOnlySpan<char> text)
9681050
{
9691051
return ValueObject.ContainsWhitespaceOrNonPrintableCharacters(text);

DomainModeling/DomainModeling.csproj

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,19 @@
1313
</PropertyGroup>
1414

1515
<PropertyGroup>
16-
<VersionPrefix>2.0.1</VersionPrefix>
16+
<VersionPrefix>2.1.0</VersionPrefix>
1717
<Description>
1818
A complete Domain-Driven Design (DDD) toolset for implementing domain models, including base types and source generators.
1919

2020
https://github.com/TheArchitectDev/Architect.DomainModeling
2121

2222
Release notes:
2323

24+
2.1.0:
25+
- Added ValueObject.ContainsNonPrintableCharactersOrDoubleQuotes(), a common validation requirement for proper names.
26+
- Explicitly declared identity types now support "record struct", allowing their curly braces to be omitted.
27+
- Added the IEntity interface, implemented by the Entity class and its derivatives.
28+
2429
2.0.1:
2530
- Fixed a bug where arrays in (Wrapper)ValueObjects would trip the generator.
2631

DomainModeling/Entity.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,6 @@ public virtual bool Equals(Entity<TId>? other)
100100
/// </para>
101101
/// </summary>
102102
[Serializable]
103-
public abstract class Entity : DomainObject
103+
public abstract class Entity : DomainObject, IEntity
104104
{
105105
}

DomainModeling/IEntity.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace Architect.DomainModeling;
2+
3+
/// <summary>
4+
/// <para>
5+
/// An entity is a data model that is defined by its identity and a thread of continuity. It may be mutated during its life cycle.
6+
/// </para>
7+
/// </summary>
8+
public interface IEntity : IDomainObject
9+
{
10+
}

0 commit comments

Comments
 (0)