Skip to content

Commit

Permalink
Feature/3.0.0 (#22)
Browse files Browse the repository at this point in the history
* Implemented major version 3.0.0.
* Fixed null returns in FormattingHelper methods.
* Fixed warnings/messages caused by new .NET and package versions.
* Fixed a typo.
* Shortened change log to fit in NuGet's maximum.
* Fixed generated deserialization constructors for System.Text.Json and Newtonsoft.Json, which ignored them for being private.

3.0.0:

- BREAKING: Platform support: Dropped support for .NET 5.0 (EOL).
- BREAKING: Marker attributes: [SourceGenerated] attribute is refactored into [Entity], [ValueObject], [WrapperValueObject<TValue>], etc. Obsolete marking helps with migrating.
- BREAKING: DummyBuilder base class: The DummyBuilder<TModel, TModelBuilder> base class is deprecated in favor of the new [DummyBuilder<TModel>] attribute. Obsolete marking helps with migrating.
- BREAKING: Private ctors: Source-generated ValueObject types now generate a private default ctor with [JsonConstructor], for logic-free deserialization. This may break deserialization if properties lack an init/set. Analyzer included.
- BREAKING: Init properties: A new analyzer warns if a WrapperValueObject's Value property lacks an init/set, because logic-free deserialization then requires a workaround.
- BREAKING: ISerializableDomainObject interface: Wrapper value objects and identities now require the new ISerializableDomainObject<TModel, TValue> interface (generated automatically).
- Feature: Custom inheritance: Source generation with custom base classes is now easy, with marker attributes identifying the concrete types.
- Feature: Optional inheritance: For source-generated value objects, wrappers, and identities, the base type or interface is generated and can be omitted.
- Feature: DomainObjectSerializer (.NET 7+): The new DomainObjectSerializer type can be used to (de)serialize identities and wrappers without running any domain logic (such as parameterized ctors), and customizable per type.
- Feature: Entity Framework mappings (.NET 7+): If Entity Framework is used, mappings by convention (that also bypass ctors) can be generated. Override DbContext.ConfigureConventions() and call ConfigureDomainModelConventions(). Its action param allows all identities, wrapper value objects, entities, and/or domain events to be mapped, even in a trimmer-safe way.
- Feature: Miscellaneous mappings: Other third party components can similarly map domain objects. See the readme.
- Feature: Marker attributes: Non-partial types with the new marker attributes skip source generation, but can still participate in mappings.
- Feature: Record struct identities: Explicitly declared identity types now support "record struct", allowing their curly braces to be omitted: `public partial record struct GeneratedId;`
- Feature: ValueObject validation helpers: Added ValueObject.ContainsNonPrintableCharactersOrDoubleQuotes(), a common validation requirement for proper names.
- Feature: Formattable and parsable interfaces (.NET 7+): Generated identities and wrappers now implement IFormattable, IParsable<TSelf>, ISpanFormattable, and ISpanParsable<TSelf>, recursing into the wrapped type's implementation.
- Feature: UTF-8 formattable and parsable interfaces (.NET 8+): Generated identities and wrappers now implement IUtf8SpanFormattable and IUtf8SpanParsable<TSelf>, recursing into the wrapped type's implementation.
- Enhancement: JSON converters (.NET 7+): All generated JSON converters now pass through the new Serialize() and Deserialize() methods, for customizable and logic-free (de)serialization.
- Enhancement: JSON converters (.NET 7+): ReadAsPropertyName() and WriteAsPropertyName() in generated JSON converters now recurse into the wrapped type's converter and also pass through the new Serialize() and Deserialize() methods.
- Bug fix: IDE stability: Fixed a compile-time bug that could cause some of the IDE's features to crash, such as certain analyzers.
- Minor feature: Additional interfaces: IEntity and IWrapperValueObject<TValue> interfaces are now available.
  • Loading branch information
Timovzl authored Dec 22, 2023
1 parent 26f8df1 commit c4aacab
Show file tree
Hide file tree
Showing 68 changed files with 4,598 additions and 1,078 deletions.
6 changes: 3 additions & 3 deletions DomainModeling.Example/CharacterSet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ namespace Architect.DomainModeling.Example;
/// <summary>
/// Demonstrates structural equality with collections.
/// </summary>
[SourceGenerated]
public partial class CharacterSet : ValueObject
[ValueObject]
public partial class CharacterSet
{
public override string ToString() => $"[{String.Join(", ", this.Characters)}]";

public IReadOnlySet<char> Characters { get; }
public IReadOnlySet<char> Characters { get; private init; }

public CharacterSet(IEnumerable<char> characters)
{
Expand Down
10 changes: 5 additions & 5 deletions DomainModeling.Example/Color.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ namespace Architect.DomainModeling.Example;

// Use "Go To Definition" on the type to view the source-generated partial
// Uncomment the IComparable interface to see how the generated code changes
[SourceGenerated]
public partial class Color : ValueObject//, IComparable<Color>
[ValueObject]
public partial class Color //: IComparable<Color>
{
public static Color RedColor { get; } = new Color(red: UInt16.MaxValue, green: 0, blue: 0);
public static Color GreenColor { get; } = new Color(red: 0, green: UInt16.MaxValue, blue: 0);
public static Color BlueColor { get; } = new Color(red: 0, green: 0, blue: UInt16.MaxValue);

public ushort Red { get; }
public ushort Green { get; }
public ushort Blue { get; }
public ushort Red { get; private init; }
public ushort Green { get; private init; }
public ushort Blue { get; private init; }

public Color(ushort red, ushort green, ushort blue)
{
Expand Down
6 changes: 3 additions & 3 deletions DomainModeling.Example/Description.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ namespace Architect.DomainModeling.Example;

// Use "Go To Definition" on the type to view the source-generated partial
// Uncomment the IComparable interface to see how the generated code changes
[SourceGenerated]
public partial class Description : WrapperValueObject<string>//, IComparable<Description>
[WrapperValueObject<string>]
public partial class Description //: IComparable<Description>
{
// For string wrappers, we must define how they are compared
protected override StringComparison StringComparison => StringComparison.OrdinalIgnoreCase;

// Any component that we define manually is omitted by the generated code
// For example, we can explicitly define the Value property to have greater clarity, since it is quintessential
public string Value { get; }
public string Value { get; private init; }

// An explicitly defined constructor allows us to enforce the domain rules and invariants
public Description(string value)
Expand Down
8 changes: 7 additions & 1 deletion DomainModeling.Example/DomainModeling.Example.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net6.0</TargetFramework>
<AssemblyName>Architect.DomainModeling.Example</AssemblyName>
<RootNamespace>Architect.DomainModeling.Example</RootNamespace>
<Nullable>Enable</Nullable>
<ImplicitUsings>Enable</ImplicitUsings>
<IsPackable>False</IsPackable>
<IsTrimmable>True</IsTrimmable>
<LangVersion>12</LangVersion>
</PropertyGroup>

<PropertyGroup>
<!-- IDE0290: Use primary constructor - domain objects tend to have complex ctor logic, and we want to be consistent even when ctors are simple -->
<NoWarn>IDE0290</NoWarn>
</PropertyGroup>

<ItemGroup>
Expand Down
4 changes: 2 additions & 2 deletions DomainModeling.Example/PaymentDummyBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
namespace Architect.DomainModeling.Example;

// The source-generated partial provides an appropriate type summary
[SourceGenerated]
public sealed partial class PaymentDummyBuilder : DummyBuilder<Payment, PaymentDummyBuilder>
[DummyBuilder<Payment>]
public sealed partial class PaymentDummyBuilder
{
// The source-generated partial defines a default value for each property, along with a fluent method to change it

Expand Down
40 changes: 40 additions & 0 deletions DomainModeling.Generator/AssemblyInspectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using Microsoft.CodeAnalysis;

namespace Architect.DomainModeling.Generator;

/// <summary>
/// Provides extension methods that help inspect assemblies.
/// </summary>
public static class AssemblyInspectionExtensions
{
/// <summary>
/// Enumerates the given <see cref="IAssemblySymbol"/> and all of its referenced <see cref="IAssemblySymbol"/> instances, recursively.
/// Does not deduplicate.
/// </summary>
/// <param name="predicate">A predicate that can filter out assemblies and prevent further recursion into them.</param>
public static IEnumerable<IAssemblySymbol> EnumerateAssembliesRecursively(this IAssemblySymbol assemblySymbol, Func<IAssemblySymbol, bool>? predicate = null)
{
if (predicate is not null && !predicate(assemblySymbol))
yield break;

yield return assemblySymbol;

foreach (var module in assemblySymbol.Modules)
foreach (var assembly in module.ReferencedAssemblySymbols)
foreach (var nestedAssembly in EnumerateAssembliesRecursively(assembly, predicate))
yield return nestedAssembly;
}

/// <summary>
/// Enumerates all non-nested types in the given <see cref="INamespaceSymbol"/>, recursively.
/// </summary>
public static IEnumerable<INamedTypeSymbol> EnumerateNonNestedTypes(this INamespaceSymbol namespaceSymbol)
{
foreach (var type in namespaceSymbol.GetTypeMembers())
yield return type;

foreach (var childNamespace in namespaceSymbol.GetNamespaceMembers())
foreach (var type in EnumerateNonNestedTypes(childNamespace))
yield return type;
}
}
34 changes: 34 additions & 0 deletions DomainModeling.Generator/Common/SimpleLocation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;

namespace Architect.DomainModeling.Generator.Common;

/// <summary>
/// Represents a <see cref="Location"/> as a simple, serializable structure.
/// </summary>
internal sealed record class SimpleLocation
{
public string FilePath { get; }
public TextSpan TextSpan { get; }
public LinePositionSpan LineSpan { get; }

public SimpleLocation(Location location)
{
var lineSpan = location.GetLineSpan();
this.FilePath = lineSpan.Path;
this.TextSpan = location.SourceSpan;
this.LineSpan = lineSpan.Span;
}

public SimpleLocation(string filePath, TextSpan textSpan, LinePositionSpan lineSpan)
{
this.FilePath = filePath;
this.TextSpan = textSpan;
this.LineSpan = lineSpan;
}

#nullable disable
public static implicit operator SimpleLocation(Location location) => location is null ? null : new SimpleLocation(location);
public static implicit operator Location(SimpleLocation location) => location is null ? null : Location.Create(location.FilePath, location.TextSpan, location.LineSpan);
#nullable enable
}
55 changes: 55 additions & 0 deletions DomainModeling.Generator/Common/StructuralList.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
namespace Architect.DomainModeling.Generator.Common;

/// <summary>
/// Wraps an <see cref="IReadOnlyList{T}"/> in a wrapper with structural equality using the collection's elements.
/// </summary>
/// <typeparam name="TCollection">The type of the collection to wrap.</typeparam>
/// <typeparam name="TElement">The type of the collection's elements.</typeparam>
internal sealed class StructuralList<TCollection, TElement>(
TCollection value)
: IEquatable<StructuralList<TCollection, TElement>>
where TCollection : IReadOnlyList<TElement>
{
public TCollection Value { get; } = value ?? throw new ArgumentNullException(nameof(value));

public override int GetHashCode() => this.Value is TCollection value && value.Count > 0
? CombineHashCodes(
value.Count,
value[0]?.GetHashCode() ?? 0,
value[value.Count - 1]?.GetHashCode() ?? 0)
: 0;
public override bool Equals(object obj) => obj is StructuralList<TCollection, TElement> other && this.Equals(other);

public bool Equals(StructuralList<TCollection, TElement> other)
{
if (other is null)
return false;

var left = this.Value;
var right = other.Value;

if (right.Count != left.Count)
return false;

for (var i = 0; i < left.Count; i++)
if (left[i] is not TElement leftElement ? right[i] is not null : !leftElement.Equals(right[i]))
return false;

return true;
}

private static int CombineHashCodes(int count, int firstHashCode, int lastHashCode)
{
var countInHighBits = (ulong)count << 16;

// In the upper half, combine the count with the first hash code
// In the lower half, combine the count with the last hash code
var combined = ((ulong)firstHashCode ^ countInHighBits) << 33; // Offset by 1 additional bit, because UInt64.GetHashCode() XORs its halves, which would cause 0 for identical first and last (e.g. single element)
combined |= (ulong)lastHashCode ^ countInHighBits;

return combined.GetHashCode();
}

public static implicit operator TCollection(StructuralList<TCollection, TElement> instance) => instance.Value;
public static implicit operator StructuralList<TCollection, TElement>(TCollection value) => new StructuralList<TCollection, TElement>(value);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;

namespace Architect.DomainModeling.Generator.Configurators;

public partial class DomainModelConfiguratorGenerator
{
internal static void GenerateSourceForDomainEvents(SourceProductionContext context, (ImmutableArray<DomainEventGenerator.Generatable> Generatables, (bool HasConfigureConventions, string AssemblyName) Metadata) input)
{
context.CancellationToken.ThrowIfCancellationRequested();

// Generate the method only if we have any generatables, or if we are an assembly in which ConfigureConventions() is called
if (!input.Generatables.Any() && !input.Metadata.HasConfigureConventions)
return;

var targetNamespace = input.Metadata.AssemblyName;

var configurationText = String.Join($"{Environment.NewLine}\t\t\t", input.Generatables.Select(generatable =>
$"configurator.ConfigureDomainEvent<{generatable.ContainingNamespace}.{generatable.TypeName}>({Environment.NewLine} new {Constants.DomainModelingNamespace}.Configuration.IDomainEventConfigurator.Args() {{ HasDefaultConstructor = {(generatable.ExistingComponents.HasFlag(DomainEventGenerator.DomainEventTypeComponents.DefaultConstructor) ? "true" : "false")} }});"));

var source = $@"
using {Constants.DomainModelingNamespace};
#nullable enable
namespace {targetNamespace}
{{
public static class DomainEventDomainModelConfigurator
{{
/// <summary>
/// <para>
/// Invokes a callback on the given <paramref name=""configurator""/> for each marked domain event type in the current assembly.
/// </para>
/// <para>
/// For example, this can be used to have Entity Framework configure a convention for every matching type in the domain model, in a trim-safe way.
/// </para>
/// </summary>
public static void ConfigureDomainEvents({Constants.DomainModelingNamespace}.Configuration.IDomainEventConfigurator configurator)
{{
{configurationText}
}}
}}
}}
";

AddSource(context, source, "DomainEventDomainModelConfigurator", targetNamespace);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;

namespace Architect.DomainModeling.Generator.Configurators;

public partial class DomainModelConfiguratorGenerator
{
internal static void GenerateSourceForEntities(SourceProductionContext context, (ImmutableArray<EntityGenerator.Generatable> Generatables, (bool HasConfigureConventions, string AssemblyName) Metadata) input)
{
context.CancellationToken.ThrowIfCancellationRequested();

// Generate the method only if we have any generatables, or if we are an assembly in which ConfigureConventions() is called
if (!input.Generatables.Any() && !input.Metadata.HasConfigureConventions)
return;

var targetNamespace = input.Metadata.AssemblyName;

var configurationText = String.Join($"{Environment.NewLine}\t\t\t", input.Generatables.Select(generatable =>
$"configurator.ConfigureEntity<{generatable.ContainingNamespace}.{generatable.TypeName}>({Environment.NewLine} new {Constants.DomainModelingNamespace}.Configuration.IEntityConfigurator.Args() {{ HasDefaultConstructor = {(generatable.ExistingComponents.HasFlag(EntityGenerator.EntityTypeComponents.DefaultConstructor) ? "true" : "false")} }});"));

var source = $@"
using {Constants.DomainModelingNamespace};
#nullable enable
namespace {targetNamespace}
{{
public static class EntityDomainModelConfigurator
{{
/// <summary>
/// <para>
/// Invokes a callback on the given <paramref name=""configurator""/> for each marked <see cref=""IEntity""/> type in the current assembly.
/// </para>
/// <para>
/// For example, this can be used to have Entity Framework configure a convention for every matching type in the domain model, in a trim-safe way.
/// </para>
/// </summary>
public static void ConfigureEntities({Constants.DomainModelingNamespace}.Configuration.IEntityConfigurator configurator)
{{
{configurationText}
}}
}}
}}
";

AddSource(context, source, "EntityDomainModelConfigurator", targetNamespace);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;

namespace Architect.DomainModeling.Generator.Configurators;

public partial class DomainModelConfiguratorGenerator
{
internal static void GenerateSourceForIdentities(SourceProductionContext context, (ImmutableArray<IdentityGenerator.Generatable> Generatables, (bool HasConfigureConventions, string AssemblyName) Metadata) input)
{
context.CancellationToken.ThrowIfCancellationRequested();

// Generate the method only if we have any generatables, or if we are an assembly in which ConfigureConventions() is called
if (!input.Generatables.Any() && !input.Metadata.HasConfigureConventions)
return;

var targetNamespace = input.Metadata.AssemblyName;

var configurationText = String.Join($"{Environment.NewLine}\t\t\t", input.Generatables.Select(generatable => $"""
configurator.ConfigureIdentity<{generatable.ContainingNamespace}.{generatable.IdTypeName}, {generatable.UnderlyingTypeFullyQualifiedName}>({Environment.NewLine} new {Constants.DomainModelingNamespace}.Configuration.IIdentityConfigurator.Args());
"""));

var source = $@"
using {Constants.DomainModelingNamespace};
#nullable enable
namespace {targetNamespace}
{{
public static class IdentityDomainModelConfigurator
{{
/// <summary>
/// <para>
/// Invokes a callback on the given <paramref name=""configurator""/> for each marked <see cref=""IIdentity{{T}}""/> type in the current assembly.
/// </para>
/// <para>
/// For example, this can be used to have Entity Framework configure a convention for every matching type in the domain model, in a trim-safe way.
/// </para>
/// </summary>
public static void ConfigureIdentities({Constants.DomainModelingNamespace}.Configuration.IIdentityConfigurator configurator)
{{
{configurationText}
}}
}}
}}
";

AddSource(context, source, "IdentityDomainModelConfigurator", targetNamespace);
}
}
Loading

0 comments on commit c4aacab

Please sign in to comment.