Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 18 additions & 10 deletions DomainModeling.Generator/IdentityGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,22 @@ public override void Initialize(IncrementalGeneratorInitializationContext contex

private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancellationToken = default)
{
// Partial struct with some interface
if (node is StructDeclarationSyntax sds && sds.Modifiers.Any(SyntaxKind.PartialKeyword) && sds.BaseList is not null)
// Partial (record) struct with some interface
if (node is TypeDeclarationSyntax tds && tds is StructDeclarationSyntax or RecordDeclarationSyntax { ClassOrStructKeyword.ValueText: "struct" } && tds.Modifiers.Any(SyntaxKind.PartialKeyword) && tds.BaseList is not null)
{
// With SourceGenerated attribute
if (sds.HasAttributeWithPrefix(Constants.SourceGeneratedAttributeShortName))
if (tds.HasAttributeWithPrefix(Constants.SourceGeneratedAttributeShortName))
{
// Consider any type with SOME 1-param generic "IIdentity" inheritance/implementation
foreach (var baseType in sds.BaseList.Types)
foreach (var baseType in tds.BaseList.Types)
{
if (baseType.Type.HasArityAndName(1, Constants.IdentityInterfaceTypeName))
return true;
}
}
}

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

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

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

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

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

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

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

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

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

string ? propertyNameParseStatement = null;
string? propertyNameParseStatement = null;
if (idType.IsOrImplementsInterface(interf => interf.Name == "ISpanParsable" && interf.ContainingNamespace.HasFullName("System") && interf.Arity == 1 && interf.TypeArguments[0].Equals(idType, SymbolEqualityComparer.Default), out _))
propertyNameParseStatement = $"return reader.GetParsedString<{idTypeName}>(System.Globalization.CultureInfo.InvariantCulture);";
else if (underlyingType.IsType<string>())
Expand Down Expand Up @@ -354,7 +361,7 @@ namespace {containingNamespace}
{(existingComponents.HasFlags(IdTypeComponents.NewtonsoftJsonConverter) ? "*/" : "")}

{(hasSourceGeneratedAttribute ? "" : "[SourceGenerated]")}
{(entityTypeName is null ? "/* Generated */ " : "")}{accessibility.ToCodeString()} readonly{(entityTypeName is null ? " partial" : "")} struct {idTypeName} : {Constants.IdentityInterfaceTypeName}<{underlyingTypeFullyQualifiedName}>, IEquatable<{idTypeName}>, IComparable<{idTypeName}>
{(entityTypeName is null ? "/* Generated */ " : "")}{accessibility.ToCodeString()} readonly{(entityTypeName is null ? " partial" : "")}{(idType.IsRecord ? " record" : "")} struct {idTypeName} : {Constants.IdentityInterfaceTypeName}<{underlyingTypeFullyQualifiedName}>, IEquatable<{idTypeName}>, IComparable<{idTypeName}>
{{
{(existingComponents.HasFlags(IdTypeComponents.Value) ? "/*" : "")}
{nonNullStringSummary}
Expand Down Expand Up @@ -548,6 +555,7 @@ private sealed record Generatable : IGeneratable
public bool IdTypeExists { get; set; }
public string EntityTypeName { get; set; } = null!;
public bool IsIIdentity { get; set; }
public bool IsRecord { get; set; }
public string ContainingNamespace { get; set; } = null!;
public string IdTypeName { get; set; } = null!;
public string UnderlyingTypeFullyQualifiedName { get; set; } = null!;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella
else if (type.IsOrInheritsClass(type => type.Arity == 2 && type.IsType(Constants.DummyBuilderTypeName, Constants.DomainModelingNamespace), out _))
expectedTypeName = tds is ClassDeclarationSyntax ? null : "class"; // Expect a class
else if (type.IsOrImplementsInterface(type => type.Arity == 1 && type.IsType(Constants.IdentityInterfaceTypeName, Constants.DomainModelingNamespace), out _))
expectedTypeName = tds is StructDeclarationSyntax ? null : "struct"; // Expect a struct
expectedTypeName = tds is StructDeclarationSyntax or RecordDeclarationSyntax { ClassOrStructKeyword.ValueText: "struct" } ? null : "struct"; // Expect a struct
else
expectedTypeName = "*"; // No suitable inheritance found for source generation

Expand Down
24 changes: 21 additions & 3 deletions DomainModeling.Generator/TypeSyntaxExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ namespace Architect.DomainModeling.Generator;
/// Provides extensions on <see cref="TypeSyntax"/>.
/// </summary>
internal static class TypeSyntaxExtensions
{
public static bool HasArityAndName(this TypeSyntax typeSyntax, int arity, string unqualifiedName)
{
/// <summary>
/// Returns whether the given <see cref="TypeSyntax"/> has the given arity (type parameter count) and (unqualified) name.
/// </summary>
/// <param name="arity">Pass null to accept any arity.</param>
public static bool HasArityAndName(this TypeSyntax typeSyntax, int? arity, string unqualifiedName)
{
int actualArity;
string actualUnqualifiedName;
Expand All @@ -32,6 +36,20 @@ public static bool HasArityAndName(this TypeSyntax typeSyntax, int arity, string
return false;
}

return actualArity == arity && actualUnqualifiedName == unqualifiedName;
return (arity is null || actualArity == arity) && actualUnqualifiedName == unqualifiedName;
}

/// <summary>
/// Returns the given <see cref="TypeSyntax"/>'s name, or null if no name can be obtained.
/// </summary>
public static string? GetNameOrDefault(this TypeSyntax typeSyntax)
{
return typeSyntax switch
{
SimpleNameSyntax simpleName => simpleName.Identifier.ValueText,
QualifiedNameSyntax qualifiedName => qualifiedName.Right.Identifier.ValueText,
AliasQualifiedNameSyntax aliasQualifiedName => aliasQualifiedName.Name.Identifier.ValueText,
_ => null,
};
}
}
10 changes: 4 additions & 6 deletions DomainModeling.Generator/ValueObjectGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public override void Initialize(IncrementalGeneratorInitializationContext contex

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

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

/* Supporting records has the following disadvantages:
* - Allows the use of automatic properties. This generates a constructor and init-properties, stimulating non-validated ValueObjects, an antipattern.
* - Overrides equality without an easy way to specify (or even think of) how to compare strings.
* - Overrides equality without special-casing collections.
* - Omits IComparable<T> and comparison operators.
/* Supporting records has the following issues:
* - Cannot inherit from a non-record class (and vice versa).
* - Promotes the use of "positional" (automatic) properties. This generates a constructor and init-properties, stimulating non-validated ValueObjects, an antipattern.
* - Provides multiple nearly-identical solutions, reducing standardization.
// Partial record with some interface/base
if (node is RecordDeclarationSyntax rds && rds.Modifiers.Any(SyntaxKind.PartialKeyword) && rds.BaseList is not null)
Expand Down
30 changes: 24 additions & 6 deletions DomainModeling.Tests/IdentityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,28 @@ public void Equals_WithUnintializedObject_ShouldReturnExpectedResult()
Assert.Equal(new StringId(""), left);
}

[Fact]
public void ObjectEquals_WithRegularStruct_ShouldReturnExpectedResult()
{
var one = (object)new IntId(1);
var alsoOne = (object)new IntId(1);
var two = (object)new IntId(2);

Assert.Equal(one, alsoOne);
Assert.NotEqual(one, two);
}

[Fact]
public void ObjectEquals_WithRecordStruct_ShouldReturnExpectedResult()
{
var one = (object)new DecimalId(1);
var alsoOne = (object)new DecimalId(1);
var two = (object)new DecimalId(2);

Assert.Equal(one, alsoOne);
Assert.NotEqual(one, two);
}

[Theory]
[InlineData(0, 0)]
[InlineData(0, 1)]
Expand Down Expand Up @@ -495,14 +517,10 @@ internal partial struct IntId : IIdentity<int>
}

[SourceGenerated]
internal partial struct DecimalId : IIdentity<decimal>
{
}
internal partial record struct DecimalId : IIdentity<decimal>;

[SourceGenerated]
internal partial struct StringId : IIdentity<string>
{
}
internal partial record struct StringId : IIdentity<string>;

[SourceGenerated]
internal partial struct IgnoreCaseStringId : IIdentity<string>
Expand Down
84 changes: 83 additions & 1 deletion DomainModeling.Tests/ValueObjectTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using System.Collections;
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Runtime.InteropServices;
using Architect.DomainModeling.Tests.ValueObjectTestTypes;
Expand Down Expand Up @@ -399,6 +398,84 @@ public void ContainsWhitespaceOrNonPrintableCharacters_Regularly_ShouldReturnExp
}
}

[Fact]
public void ContainsNonPrintableCharactersOrDoubleQuotes_WithFlagNewLinesAndTabs_ShouldReturnExpectedResult()
{
for (var i = 0; i < UInt16.MaxValue; i++)
{
var chr = (char)i;

var category = Char.GetUnicodeCategory(chr);
var isPrintable = category != UnicodeCategory.Control && category != UnicodeCategory.PrivateUse && category != UnicodeCategory.OtherNotAssigned;
var isNotDoubleQuote = chr != '"';

var span = MemoryMarshal.CreateReadOnlySpan(ref chr, length: 1);
var result = ManualValueObject.ContainsNonPrintableCharactersOrDoubleQuotes(span, flagNewLinesAndTabs: true);

if (isPrintable && isNotDoubleQuote)
Assert.False(result, $"{nameof(ManualValueObject.ContainsNonPrintableCharactersOrDoubleQuotes)} (disallowing newlines and tabs) for '{chr}' ({i}) should have been false, but was true.");
else
Assert.True(result, $"{nameof(ManualValueObject.ContainsNonPrintableCharactersOrDoubleQuotes)} (disallowing newlines and tabs) for '{chr}' ({i}) should have been true, but was false.");

var longVersion = new string(chr, count: 33);
var longResult = ManualValueObject.ContainsNonPrintableCharactersOrDoubleQuotes(longVersion, flagNewLinesAndTabs: true);
Assert.Equal(result, longResult);
}
}

[Fact]
public void ContainsNonPrintableCharactersOrDoubleQuotes_WithoutFlagNewLinesAndTabs_ShouldReturnExpectedResult()
{
for (var i = 0; i < UInt16.MaxValue; i++)
{
var chr = (char)i;

var category = Char.GetUnicodeCategory(chr);
var isPrintable = category != UnicodeCategory.Control && category != UnicodeCategory.PrivateUse && category != UnicodeCategory.OtherNotAssigned;
isPrintable = isPrintable || chr == '\r' || chr == '\n' || chr == '\t';
var isNotDoubleQuote = chr != '"';

var span = MemoryMarshal.CreateReadOnlySpan(ref chr, length: 1);
var result = ManualValueObject.ContainsNonPrintableCharactersOrDoubleQuotes(span, flagNewLinesAndTabs: false);

if (isPrintable && isNotDoubleQuote)
Assert.False(result, $"{nameof(ManualValueObject.ContainsNonPrintableCharactersOrDoubleQuotes)} (allowing newlines and tabs) for '{chr}' ({i}) should have been false, but was true.");
else
Assert.True(result, $"{nameof(ManualValueObject.ContainsNonPrintableCharactersOrDoubleQuotes)} (allowing newlines and tabs) for '{chr}' ({i}) should have been true, but was false.");

var longVersion = new string(chr, count: 33);
var longResult = ManualValueObject.ContainsNonPrintableCharactersOrDoubleQuotes(longVersion, flagNewLinesAndTabs: false);
Assert.Equal(result, longResult);
}
}

[Theory]
[InlineData("_12345678901234561234567890123456", false)]
[InlineData("12345678901234561234567890123456_", false)]
[InlineData("12345678901234561234567890123456ë", false)]
[InlineData("12345678901234561234567890123456💩", false)]
[InlineData("1234567890123456123456789012345💩", false)]
[InlineData("123456789012345612345678901234💩", false)]
[InlineData("💩12345678901234561234567890123456", false)]
[InlineData("12345678901234💩561234567890123456", false)]
[InlineData("12345678901234561234567890123456", true)] // Ends with an invisible control character
[InlineData("12345678901234561234567890123456\0", true)]
[InlineData("1234567890123456123456789012345\0", true)]
[InlineData("123456789012345612345678901234\0", true)]
[InlineData("\012345678901234561234567890123456", true)]
[InlineData("12345678901234561234567890123456\"", true)]
[InlineData("1234567890123456123456789012345\"", true)]
[InlineData("123456789012345612345678901234\"", true)]
[InlineData("12345678901234\0561234567890123456", true)]
[InlineData("\"12345678901234561234567890123456", true)]
[InlineData("12345678901234\"561234567890123456", true)]
public void ContainsNonPrintableCharactersOrDoubleQuotes_WithLongInput_ShouldReturnExpectedResult(string text, bool expectedResult)
{
var result = ManualValueObject.ContainsNonPrintableCharactersOrDoubleQuotes(text, flagNewLinesAndTabs: true);

Assert.Equal(expectedResult, result);
}

[Theory]
[InlineData("_12345678901234561234567890123456", false)]
[InlineData("12345678901234561234567890123456_", false)]
Expand Down Expand Up @@ -964,6 +1041,11 @@ public ManualValueObject(int id)
return ValueObject.ContainsNonPrintableCharacters(text, flagNewLinesAndTabs);
}

public static new bool ContainsNonPrintableCharactersOrDoubleQuotes(ReadOnlySpan<char> text, bool flagNewLinesAndTabs)
{
return ValueObject.ContainsNonPrintableCharactersOrDoubleQuotes(text, flagNewLinesAndTabs);
}

public static new bool ContainsWhitespaceOrNonPrintableCharacters(ReadOnlySpan<char> text)
{
return ValueObject.ContainsWhitespaceOrNonPrintableCharacters(text);
Expand Down
7 changes: 6 additions & 1 deletion DomainModeling/DomainModeling.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,19 @@
</PropertyGroup>

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

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

Release notes:

2.1.0:
- Added ValueObject.ContainsNonPrintableCharactersOrDoubleQuotes(), a common validation requirement for proper names.
- Explicitly declared identity types now support "record struct", allowing their curly braces to be omitted.
- Added the IEntity interface, implemented by the Entity class and its derivatives.

2.0.1:
- Fixed a bug where arrays in (Wrapper)ValueObjects would trip the generator.

Expand Down
2 changes: 1 addition & 1 deletion DomainModeling/Entity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,6 @@ public virtual bool Equals(Entity<TId>? other)
/// </para>
/// </summary>
[Serializable]
public abstract class Entity : DomainObject
public abstract class Entity : DomainObject, IEntity
{
}
10 changes: 10 additions & 0 deletions DomainModeling/IEntity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Architect.DomainModeling;

/// <summary>
/// <para>
/// 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.
/// </para>
/// </summary>
public interface IEntity : IDomainObject
{
}
Loading