From c4a747da9f3ffcabedf92263e219a5173cf52e87 Mon Sep 17 00:00:00 2001 From: Timo van Zijll Langhout <655426+Timovzl@users.noreply.github.com> Date: Sat, 12 Aug 2023 18:34:39 +0200 Subject: [PATCH 1/5] 2.1.0: Record struct IDs and IEntity interface. --- DomainModeling.Generator/IdentityGenerator.cs | 28 ++++--- .../SourceGeneratedAttributeAnalyzer.cs | 2 +- .../TypeSyntaxExtensions.cs | 24 +++++- .../ValueObjectGenerator.cs | 10 +-- DomainModeling.Tests/IdentityTests.cs | 30 +++++-- DomainModeling.Tests/ValueObjectTests.cs | 83 +++++++++++++++++++ DomainModeling/DomainModeling.csproj | 7 +- DomainModeling/Entity.cs | 2 +- DomainModeling/IEntity.cs | 10 +++ .../ValueObject.ValidationHelpers.cs | 53 ++++++++++++ README.md | 42 ++++++++-- 11 files changed, 257 insertions(+), 34 deletions(-) create mode 100644 DomainModeling/IEntity.cs diff --git a/DomainModeling.Generator/IdentityGenerator.cs b/DomainModeling.Generator/IdentityGenerator.cs index aaaad74..455d150 100644 --- a/DomainModeling.Generator/IdentityGenerator.cs +++ b/DomainModeling.Generator/IdentityGenerator.cs @@ -18,14 +18,14 @@ 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; @@ -33,7 +33,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella } } - // 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 @@ -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(null, type, underlyingType)); result.ContainingNamespace = type.ContainingNamespace.ToString(); result.IdTypeName = type.Name; @@ -124,17 +125,21 @@ 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 to check the type and delegate to IEquatable.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())); - 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))); @@ -142,11 +147,13 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella 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 to delegate to IEquatable.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 to delegate to IEquatable.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) && @@ -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()) @@ -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} @@ -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!; diff --git a/DomainModeling.Generator/SourceGeneratedAttributeAnalyzer.cs b/DomainModeling.Generator/SourceGeneratedAttributeAnalyzer.cs index 19f8827..db0406b 100644 --- a/DomainModeling.Generator/SourceGeneratedAttributeAnalyzer.cs +++ b/DomainModeling.Generator/SourceGeneratedAttributeAnalyzer.cs @@ -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 diff --git a/DomainModeling.Generator/TypeSyntaxExtensions.cs b/DomainModeling.Generator/TypeSyntaxExtensions.cs index 36a40d3..01a8998 100644 --- a/DomainModeling.Generator/TypeSyntaxExtensions.cs +++ b/DomainModeling.Generator/TypeSyntaxExtensions.cs @@ -6,8 +6,12 @@ namespace Architect.DomainModeling.Generator; /// Provides extensions on . /// internal static class TypeSyntaxExtensions -{ - public static bool HasArityAndName(this TypeSyntax typeSyntax, int arity, string unqualifiedName) +{ + /// + /// Returns whether the given has the given arity (type parameter count) and (unqualified) name. + /// + /// Pass null to accept any arity. + public static bool HasArityAndName(this TypeSyntax typeSyntax, int? arity, string unqualifiedName) { int actualArity; string actualUnqualifiedName; @@ -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; + } + + /// + /// Returns the given 's name, or null if no name can be obtained. + /// + 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, + }; } } diff --git a/DomainModeling.Generator/ValueObjectGenerator.cs b/DomainModeling.Generator/ValueObjectGenerator.cs index 35c3eb1..2146de7 100644 --- a/DomainModeling.Generator/ValueObjectGenerator.cs +++ b/DomainModeling.Generator/ValueObjectGenerator.cs @@ -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; @@ -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 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) diff --git a/DomainModeling.Tests/IdentityTests.cs b/DomainModeling.Tests/IdentityTests.cs index c3053cf..8257593 100644 --- a/DomainModeling.Tests/IdentityTests.cs +++ b/DomainModeling.Tests/IdentityTests.cs @@ -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)] @@ -495,14 +517,10 @@ internal partial struct IntId : IIdentity } [SourceGenerated] - internal partial struct DecimalId : IIdentity - { - } + internal partial record struct DecimalId : IIdentity; [SourceGenerated] - internal partial struct StringId : IIdentity - { - } + internal partial record struct StringId : IIdentity; [SourceGenerated] internal partial struct IgnoreCaseStringId : IIdentity diff --git a/DomainModeling.Tests/ValueObjectTests.cs b/DomainModeling.Tests/ValueObjectTests.cs index 7f660f5..cdc00e4 100644 --- a/DomainModeling.Tests/ValueObjectTests.cs +++ b/DomainModeling.Tests/ValueObjectTests.cs @@ -399,6 +399,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)] @@ -964,6 +1042,11 @@ public ManualValueObject(int id) return ValueObject.ContainsNonPrintableCharacters(text, flagNewLinesAndTabs); } + public static new bool ContainsNonPrintableCharactersOrDoubleQuotes(ReadOnlySpan text, bool flagNewLinesAndTabs) + { + return ValueObject.ContainsNonPrintableCharactersOrDoubleQuotes(text, flagNewLinesAndTabs); + } + public static new bool ContainsWhitespaceOrNonPrintableCharacters(ReadOnlySpan text) { return ValueObject.ContainsWhitespaceOrNonPrintableCharacters(text); diff --git a/DomainModeling/DomainModeling.csproj b/DomainModeling/DomainModeling.csproj index 20ca875..8870537 100644 --- a/DomainModeling/DomainModeling.csproj +++ b/DomainModeling/DomainModeling.csproj @@ -13,7 +13,7 @@ - 2.0.1 + 2.1.0 A complete Domain-Driven Design (DDD) toolset for implementing domain models, including base types and source generators. @@ -21,6 +21,11 @@ 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. diff --git a/DomainModeling/Entity.cs b/DomainModeling/Entity.cs index 95b621a..64676ce 100644 --- a/DomainModeling/Entity.cs +++ b/DomainModeling/Entity.cs @@ -100,6 +100,6 @@ public virtual bool Equals(Entity? other) /// /// [Serializable] -public abstract class Entity : DomainObject +public abstract class Entity : DomainObject, IEntity { } diff --git a/DomainModeling/IEntity.cs b/DomainModeling/IEntity.cs new file mode 100644 index 0000000..11762e3 --- /dev/null +++ b/DomainModeling/IEntity.cs @@ -0,0 +1,10 @@ +namespace Architect.DomainModeling; + +/// +/// +/// 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. +/// +/// +public interface IEntity : IDomainObject +{ +} diff --git a/DomainModeling/ValueObject.ValidationHelpers.cs b/DomainModeling/ValueObject.ValidationHelpers.cs index 9d38eef..b97db04 100644 --- a/DomainModeling/ValueObject.ValidationHelpers.cs +++ b/DomainModeling/ValueObject.ValidationHelpers.cs @@ -372,6 +372,59 @@ static bool EvaluateOverlookingNewLinesAndTabs(ReadOnlySpan text) } } + /// + /// + /// This method detects double quotes (") and non-printable characters, such as control characters. + /// It does not detect whitespace characters, even if they are zero-width. + /// + /// + /// It returns true, unless the given consists exclusively of printable characters other than doubles quotes ("). + /// + /// + /// + /// + /// A parameter controls whether this method flags newline and tab characters, allowing single-line vs. multiline input to be validated. + /// + /// + /// Pass true to flag \r, \n, and \t as non-printable characters. Pass false to overlook them. + protected static bool ContainsNonPrintableCharactersOrDoubleQuotes(ReadOnlySpan text, bool flagNewLinesAndTabs) + { + return flagNewLinesAndTabs + ? EvaluateIncludingNewLinesAndTabs(text) + : EvaluateOverlookingNewLinesAndTabs(text); + + // Local function that performs the work while including \r, \n, and \t characters + static bool EvaluateIncludingNewLinesAndTabs(ReadOnlySpan text) + { + foreach (var chr in text) + { + var category = Char.GetUnicodeCategory(chr); + + if (category == UnicodeCategory.Control | category == UnicodeCategory.PrivateUse | category == UnicodeCategory.OtherNotAssigned | chr == '"') + return true; + } + + return false; + } + + // Local function that performs the work while overlooking \r, \n, and \t characters + static bool EvaluateOverlookingNewLinesAndTabs(ReadOnlySpan text) + { + foreach (var chr in text) + { + var category = Char.GetUnicodeCategory(chr); + + if (category == UnicodeCategory.Control | category == UnicodeCategory.PrivateUse | category == UnicodeCategory.OtherNotAssigned | chr == '"') + { + if (chr == '\r' | chr == '\n' | chr == '\t') continue; // Exempt + return true; + } + } + + return false; + } + } + /// /// /// This method detects whitespace characters and non-printable characters. diff --git a/README.md b/README.md index 7929d68..774ca67 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ public partial class Description : WrapperValueObject } ``` -The `IComparable` interface can optionally be added, if the type is considered to have a natural order. +To also have comparison methods generated, the `IComparable` interface can optionally be added, if the type is considered to have a natural order. ## Entity @@ -147,18 +147,48 @@ public class Payment : Entity The `Entity` base class is what triggers source generation of the `TId`, if no such type exists. The `TIdPrimitive` type parameter specifies the underlying primitive to use. Note that the generated ID type is itself a value object, as is customary in DDD. +For performance reasons, the `Entity` base class is only recognized when inherited from _directly_. If it is _indirectly_ inherited from (i.e. via a custom base class), then the ID type must be [explicitly declared](#identity). + The entity could then be modified as follows to create a new, unique ID on construction: ```cs - public Payment(string currency, decimal amount) - : base(new PaymentId(Guid.NewGuid().ToString("N"))) - { - // Snip - } +public Payment(string currency, decimal amount) + : base(new PaymentId(Guid.NewGuid().ToString("N"))) +{ + // Snip +} ``` For a more database-friendly alternative to GUIDs, see [Distributed IDs](https://github.com/TheArchitectDev/Architect.Identities#distributed-ids). +## Identity + +Identity types are a special case of value objects. Unlike other value objects, they are perfectly suitable to be implemented as structs, for the following reasons: + +- The enforced default constructor is unproblematic, because there is hardly such a thing as an invalid ID value: although ID 0 or -1 might not _exist_, the same might be true for ID 999999, which would still be valid as a value. +- The possibility of an ID variable containing `null` is often undesirable. Structs avoid this complication. (Where we _want_ nullability, a nullable struct (e.g. `PaymentId?`) can be used.) +- If the underlying type is `string`, the generator ensures that its `Value` property returns the empty string instead of null. This way, even `string`-wrapping identities know only one "empty" value and avoid representing null. + +Since an application is expected to work with many ID instances, using structs for them is a nice optimization that reduces heap allocations. + +Source-generated identities implement both `IEquatable` and `IComparable` automatically. They are declared as follows: + +```cs +[SourceGenerated] +public readonly partial struct PaymentId : IIdentity +{ +} +``` + +Records may be used for an even shorter syntax: + +```cs +[SourceGenerated] +public readonly partial record struct ExternalId : IIdentity; +``` + +Note that an [entity](#entity) has the option of having its own ID type generated implicitly, with practically no code at all. + ## DummyBuilder Domain objects have parameterized constructors, so that they can guarantee a valid state. For many value objects, such constructors are unlikely to ever change: `new PaymentId(1)`, `new Description("Example")`, `new Color(1, 1, 1)`. From 819542a4a2448c68ef11e0fb49fc00bf61e88cd4 Mon Sep 17 00:00:00 2001 From: Timo van Zijll Langhout <655426+Timovzl@users.noreply.github.com> Date: Sat, 12 Aug 2023 19:57:49 +0200 Subject: [PATCH 2/5] Minor corrections. --- DomainModeling.Tests/ValueObjectTests.cs | 1 - DomainModeling/ValueObject.ValidationHelpers.cs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/DomainModeling.Tests/ValueObjectTests.cs b/DomainModeling.Tests/ValueObjectTests.cs index cdc00e4..93686d3 100644 --- a/DomainModeling.Tests/ValueObjectTests.cs +++ b/DomainModeling.Tests/ValueObjectTests.cs @@ -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; diff --git a/DomainModeling/ValueObject.ValidationHelpers.cs b/DomainModeling/ValueObject.ValidationHelpers.cs index b97db04..51acaf5 100644 --- a/DomainModeling/ValueObject.ValidationHelpers.cs +++ b/DomainModeling/ValueObject.ValidationHelpers.cs @@ -378,7 +378,7 @@ static bool EvaluateOverlookingNewLinesAndTabs(ReadOnlySpan text) /// It does not detect whitespace characters, even if they are zero-width. /// /// - /// It returns true, unless the given consists exclusively of printable characters other than doubles quotes ("). + /// It returns true, unless the given consists exclusively of printable characters that are not double quotes ("). /// /// /// From 8f6d2677b6efc8e143c48af7cb0de45a48650ba6 Mon Sep 17 00:00:00 2001 From: Timo van Zijll Langhout <655426+Timovzl@users.noreply.github.com> Date: Sat, 12 Aug 2023 20:00:49 +0200 Subject: [PATCH 3/5] Clarifications. --- DomainModeling.Generator/IdentityGenerator.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/DomainModeling.Generator/IdentityGenerator.cs b/DomainModeling.Generator/IdentityGenerator.cs index 455d150..164558f 100644 --- a/DomainModeling.Generator/IdentityGenerator.cs +++ b/DomainModeling.Generator/IdentityGenerator.cs @@ -133,7 +133,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella 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 to check the type and delegate to IEquatable.Equals(T) + // Records irrevocably and correctly override this, checking the type and delegating to IEquatable.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())); @@ -147,13 +147,13 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella 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 to delegate to IEquatable.Equals(T) + // Records irrevocably and correctly override this, delegating to IEquatable.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 to delegate to IEquatable.Equals(T) + // Records irrevocably and correctly override this, delegating to IEquatable.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) && From 7b042d472ebc9aab8c86b302c6cbbbbe19236690 Mon Sep 17 00:00:00 2001 From: Timo van Zijll Langhout <655426+Timovzl@users.noreply.github.com> Date: Sat, 12 Aug 2023 20:08:47 +0200 Subject: [PATCH 4/5] Minor readme improvements. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 774ca67..576a779 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ The `Entity` base class is what triggers source generation of For performance reasons, the `Entity` base class is only recognized when inherited from _directly_. If it is _indirectly_ inherited from (i.e. via a custom base class), then the ID type must be [explicitly declared](#identity). -The entity could then be modified as follows to create a new, unique ID on construction: +Next, the entity could be modified as follows to create a new, unique ID on construction: ```cs public Payment(string currency, decimal amount) @@ -159,7 +159,7 @@ public Payment(string currency, decimal amount) } ``` -For a more database-friendly alternative to GUIDs, see [Distributed IDs](https://github.com/TheArchitectDev/Architect.Identities#distributed-ids). +For a more database-friendly alternative to UUIDs, see [Distributed IDs](https://github.com/TheArchitectDev/Architect.Identities#distributed-ids). ## Identity From 6d5f6216b6c03b6f39054b37bc403e5f2b09fe0f Mon Sep 17 00:00:00 2001 From: Timo van Zijll Langhout <655426+Timovzl@users.noreply.github.com> Date: Sat, 12 Aug 2023 20:12:11 +0200 Subject: [PATCH 5/5] Minor readme improvements. --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 576a779..0c475c7 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ The `Entity` base class is what triggers source generation of For performance reasons, the `Entity` base class is only recognized when inherited from _directly_. If it is _indirectly_ inherited from (i.e. via a custom base class), then the ID type must be [explicitly declared](#identity). -Next, the entity could be modified as follows to create a new, unique ID on construction: +Next, the entity could be modified to create a new, unique ID on construction: ```cs public Payment(string currency, decimal amount) @@ -163,11 +163,11 @@ For a more database-friendly alternative to UUIDs, see [Distributed IDs](https:/ ## Identity -Identity types are a special case of value objects. Unlike other value objects, they are perfectly suitable to be implemented as structs, for the following reasons: +Identity types are a special case of value objects. Unlike other value objects, they are perfectly suitable to be implemented as structs: -- The enforced default constructor is unproblematic, because there is hardly such a thing as an invalid ID value: although ID 0 or -1 might not _exist_, the same might be true for ID 999999, which would still be valid as a value. -- The possibility of an ID variable containing `null` is often undesirable. Structs avoid this complication. (Where we _want_ nullability, a nullable struct (e.g. `PaymentId?`) can be used.) -- If the underlying type is `string`, the generator ensures that its `Value` property returns the empty string instead of null. This way, even `string`-wrapping identities know only one "empty" value and avoid representing null. +- The enforced default constructor is unproblematic, because there is hardly such a thing as an invalid ID value. Although ID 0 or -1 might not _exist_, the same might be true for ID 999999, which would still be valid as a value. +- The possibility of an ID variable containing `null` is often undesirable. Structs avoid this complication. (Where we _want_ nullability, a nullable struct can be used, e.g. `PaymentId?`. +- If the underlying type is `string`, the generator ensures that its `Value` property returns the empty string instead of `null`. This way, even `string`-wrapping identities know only one "empty" value and avoid representing `null`. Since an application is expected to work with many ID instances, using structs for them is a nice optimization that reduces heap allocations.