Skip to content

Commit

Permalink
Unsaved changes bubble-up pattern (#18)
Browse files Browse the repository at this point in the history
  • Loading branch information
niklasstich authored Oct 8, 2024
1 parent 9faed64 commit a253313
Show file tree
Hide file tree
Showing 21 changed files with 757 additions and 1 deletion.
16 changes: 16 additions & 0 deletions Moyou.Aspects/Moyou.Aspects.UnsavedChanges/IUnsavedChanges.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Metalama.Framework.Aspects;

namespace Moyou.Aspects.UnsavedChanges;

[RunTime]
public interface IUnsavedChanges
{
/// <summary>
/// True if this object or any child object has unsaved changes.
/// </summary>
bool UnsavedChanges { get; }
/// <summary>
/// Resets unsaved changes in this object and all child objects that implement <see cref="IUnsavedChanges"/>.
/// </summary>
void ResetUnsavedChanges();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Metalama.Framework" Version="2024.2.23" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\Moyou.Extensions\Moyou.Extensions.csproj" />
</ItemGroup>

</Project>
198 changes: 198 additions & 0 deletions Moyou.Aspects/Moyou.Aspects.UnsavedChanges/UnsavedChangesAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
using Metalama.Framework.Advising;
using Metalama.Framework.Aspects;
using Metalama.Framework.Code;
using Metalama.Framework.Code.SyntaxBuilders;
using Moyou.Extensions;

namespace Moyou.Aspects.UnsavedChanges;

/// <summary>
/// Implements a bubbling-up unsaved changes field and a method to reset via <see cref="IUnsavedChanges"/>.
/// All members in a type marked with this attribute that are either themselves marked with this attribute or are an
/// IEnumerable of a type marked with this attribute will be considered for <see cref="GetUnsavedChanges"/> and
/// <see cref="ResetUnsavedChanges"/>.
/// </summary>
/// <remarks>You must still determine yourself when the object itself has unsaved changes and set
/// <see cref="_internalUnsavedChanges"/> yourself accordingly (for example, in your property setters).</remarks>
/// <seealso cref="IUnsavedChanges"/>
public class UnsavedChangesAttribute : TypeAspect
{
public override void BuildAspect(IAspectBuilder<INamedType> builder)
{
base.BuildAspect(builder);

builder.ImplementInterface(typeof(IUnsavedChanges), OverrideStrategy.Ignore);

builder.IntroduceField(nameof(_internalUnsavedChanges), IntroductionScope.Instance, buildField:
fbuilder => { fbuilder.Accessibility = Accessibility.Private; });

//find all members whose type implements UnsavedChangesAttribute themselves (via interface)
var relevantMembers = builder.Target.AllFieldsAndProperties
.Where(member => member.TypeHasAttribute(typeof(UnsavedChangesAttribute)))
//exclude auto backing fields
.Where(member => member is not IField field || !field.IsAutoBackingField())
.ToList();

//find all members whose type is ienumerable of a type that implements UnsavedChangesAttribute (via interface)
var relevantIEnumerableMembers = builder.Target.AllFieldsAndProperties
.Where(member => member.Type is INamedType ntype &&
//strings are IEnumerable<char> so we need to exclude them
!ntype.Is(SpecialType.String) &&
ntype.Is(typeof(IEnumerable<>), ConversionKind.TypeDefinition))
.Where(member => member.TypeArgumentOfEnumerableHasAttribute(typeof(UnsavedChangesAttribute)))
.Where(member => member is not IField field || !field.IsAutoBackingField())
.ToList();


//pass all of these to the method template
builder.IntroduceMethod(nameof(GetUnsavedChanges), IntroductionScope.Instance,
buildMethod: mBuilder => { mBuilder.Accessibility = Accessibility.Private; },
args: new { relevantMembers, relevantIEnumerableMembers });

builder.IntroduceMethod(nameof(ResetUnsavedChanges), IntroductionScope.Instance,
buildMethod: mBuilder => { mBuilder.Accessibility = Accessibility.Public; },
args: new { relevantMembers, relevantIEnumerableMembers });

builder.IntroduceProperty(nameof(UnsavedChanges), IntroductionScope.Instance,
buildProperty: pBuilder => { pBuilder.Accessibility = Accessibility.Public; });
}

/// <summary>
/// Whether this object itself has unsaved changes (regardless of child members).
/// </summary>
[Template] private bool _internalUnsavedChanges = false;

[Template] public bool UnsavedChanges => meta.This.GetUnsavedChanges();

[Template]
private static bool GetUnsavedChanges(
[CompileTime] IEnumerable<IFieldOrProperty> relevantMembers,
[CompileTime] IEnumerable<IFieldOrProperty> relevantIEnumerableMembers
)
{
var exprBuilder = new ExpressionBuilder();
exprBuilder.AppendExpression(meta.This._internalUnsavedChanges);
foreach (var member in relevantMembers)
{
exprBuilder.AppendVerbatim("||");
if (member.Type.IsNullable!.Value)
// ReSharper disable once ArrangeRedundantParentheses because we need the parantheses in the expression
exprBuilder.AppendExpression((member.Value?.UnsavedChanges ?? false));
else
exprBuilder.AppendExpression(member.Value?.UnsavedChanges);
}

foreach (var member in relevantIEnumerableMembers)
{
exprBuilder.AppendVerbatim("||");
var enumerableNullable = meta.CompileTime(member.Type.IsNullable!.Value);
var genericTypeNullable = meta.CompileTime((INamedType)member.Type).TypeArguments[0].IsNullable!.Value;
GetUnsavedChangesHandleIEnumerable(enumerableNullable, genericTypeNullable, member, exprBuilder);
}

return exprBuilder.ToExpression().Value;
}

/// <summary>
/// Handles code generation for IEnumerable members for <see cref="GetUnsavedChanges"/>.
/// </summary>
/// <param name="enumerableNullable">The IEnumerable itself is nullable, i.e. <c>IEnumerable&lt;Foobar&gt;?</c>.</param>
/// <param name="genericTypeNullable">The type inside the IEnumerable is nullable, i.e. <c>IEnumerable&lt;Foobar?&gt;</c>.</param>
/// <param name="member">The member itself.</param>
/// <param name="exprBuilder">The current expression builder.</param>
[Template]
private static void GetUnsavedChangesHandleIEnumerable(
[CompileTime] bool enumerableNullable,
[CompileTime] bool genericTypeNullable,
[CompileTime] IFieldOrProperty member,
[CompileTime] ExpressionBuilder exprBuilder
)
{
if (enumerableNullable)
{
//TODO: maybe un-verbatim-ify this?
exprBuilder.AppendVerbatim(genericTypeNullable
? $"({member.Name} is null ? false : {member.Name}.Any(v => v?.UnsavedChanges ?? false))"
: $"({member.Name} is null ? false : {member.Name}.Any(v => v.UnsavedChanges))");
}
else
{
exprBuilder.AppendExpression(genericTypeNullable
? ((IEnumerable<IUnsavedChanges?>)member.Value!).Any(v => v?.UnsavedChanges ?? false)
: ((IEnumerable<IUnsavedChanges>)member.Value!).Any(v => v.UnsavedChanges));
}
}

[Template]
public static void ResetUnsavedChanges(
[CompileTime] IEnumerable<IFieldOrProperty> relevantMembers,
[CompileTime] IEnumerable<IFieldOrProperty> relevantIEnumerableMembers
)
{
meta.This._internalUnsavedChanges = false;
foreach (var member in relevantMembers)
{
member.Value?.ResetUnsavedChanges();
}

foreach (var member in relevantIEnumerableMembers)
{
var enumerableNullable = meta.CompileTime(member.Type.IsNullable!.Value);
var genericTypeNullable = meta.CompileTime((INamedType)member.Type).TypeArguments[0].IsNullable!.Value;
ResetUnsavedChangesHandleIEnumerable(enumerableNullable, genericTypeNullable, member);
}
}

/// <summary>
/// Handles code generation for IEnumerable members for <see cref="ResetUnsavedChanges"/>.
/// </summary>
/// <param name="enumerableNullable">The IEnumerable itself is nullable, i.e. <c>IEnumerable&lt;Foobar&gt;?</c>.</param>
/// <param name="genericTypeNullable">The type inside the IEnumerable is nullable, i.e. <c>IEnumerable&lt;Foobar?&gt;</c>.</param>
/// <param name="member">The member itself.</param>
[Template]
private static void ResetUnsavedChangesHandleIEnumerable(
[CompileTime] bool enumerableNullable,
[CompileTime] bool genericTypeNullable,
[CompileTime] IFieldOrProperty member
)
{
if (enumerableNullable)
{
// ReSharper disable once InvertIf
// limitation: https://doc.postsharp.net/metalama/conceptual/aspects/templates/auxilliary-templates see note
if (member.Value is not null)
{
ResetUnsavedChangesHandleIEnumerableInternal(genericTypeNullable, member);
}
}
else
{
ResetUnsavedChangesHandleIEnumerableInternal(genericTypeNullable, member);
}
}

/// <summary>
/// Helper method for <see cref="ResetUnsavedChangesHandleIEnumerable"/>.
/// </summary>
/// <param name="genericTypeNullable">The type inside the IEnumerable is nullable, i.e. <c>IEnumerable&lt;Foobar?&gt;</c>.</param>
/// <param name="member">The member itself.</param>
[Template]
private static void ResetUnsavedChangesHandleIEnumerableInternal([CompileTime] bool genericTypeNullable,
[CompileTime] IFieldOrProperty member)
{
if (genericTypeNullable)
{
foreach (var val in member.Value!)
{
val?.ResetUnsavedChanges();
}
}
else
{
foreach (var val in member.Value!)
{
val.ResetUnsavedChanges();
}
}
}
}
22 changes: 21 additions & 1 deletion Moyou.Extensions/FieldOrPropertyExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,24 @@ public static class FieldOrPropertyExtensions
{
public static bool HasAttribute(this IFieldOrProperty fieldOrProperty, Type attributeType) =>
fieldOrProperty.Attributes.Any(attribute => attribute.Type.FullName == attributeType.FullName);
}

public static bool TypeHasAttribute(this IFieldOrProperty fieldOrProperty, Type attributeType) =>
((INamedType)fieldOrProperty.Type).Attributes.Any(attribute =>
attribute.Type.FullName == attributeType.FullName);

/// <summary>
/// Checks if the first type argument of an IEnumerable field or property has a given attribute.
/// </summary>
/// <param name="fieldOrProperty">The field or property to check.</param>
/// <param name="attributeType">The type of the attribute to check for.</param>
/// <exception cref="ArgumentException"><paramref name="fieldOrProperty"/> is not of type <see cref="IEnumerable{T}"/></exception>
/// <returns>True if the first type argument has the attribute, false otherwise.</returns>
public static bool TypeArgumentOfEnumerableHasAttribute(this IFieldOrProperty fieldOrProperty, Type attributeType)
{
var memberType = (INamedType)fieldOrProperty.Type;
if (!memberType.Is(typeof(IEnumerable<>), ConversionKind.TypeDefinition))
throw new ArgumentException("Field or property must be of type IEnumerable<T>.", nameof(fieldOrProperty));
var firstTypeArgument = (INamedType)memberType.TypeArguments[0];
return firstTypeArgument.Attributes.Any(attribute => attribute.Type.FullName == attributeType.FullName);
}
}
1 change: 1 addition & 0 deletions Moyou.Test/Moyou.CompileTimeTest.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
<ItemGroup>
<ProjectReference Include="..\Moyou.Aspects\Moyou.Aspects.Memento\Moyou.Aspects.Memento.csproj" />
<ProjectReference Include="..\Moyou.Aspects\Moyou.Aspects.Singleton\Moyou.Aspects.Singleton.csproj" />
<ProjectReference Include="..\Moyou.Aspects\Moyou.Aspects.UnsavedChanges\Moyou.Aspects.UnsavedChanges.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using Moyou.Aspects.UnsavedChanges;

namespace Moyou.CompileTimeTest.UnsavedChanges.UnsavedChangesAttributeTest;

[UnsavedChanges]
public class NestedIEnumerable
{
public IEnumerable<B> Bs { get; set; }
public D D { get; set; }
}

[UnsavedChanges]
public class B
{

}

[UnsavedChanges]
public class D
{

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using Moyou.Aspects.UnsavedChanges;
namespace Moyou.CompileTimeTest.UnsavedChanges.UnsavedChangesAttributeTest;
[UnsavedChanges]
public class NestedIEnumerable : global::Moyou.Aspects.UnsavedChanges.IUnsavedChanges
{
public IEnumerable<B> Bs { get; set; }
public D D { get; set; }
private global::System.Boolean _internalUnsavedChanges = (global::System.Boolean)false;
public global::System.Boolean UnsavedChanges
{
get
{
return (global::System.Boolean)this.GetUnsavedChanges();
}
}
private global::System.Boolean GetUnsavedChanges()
{
return (global::System.Boolean)(this._internalUnsavedChanges || this.D.UnsavedChanges || global::System.Linq.Enumerable.Any(((global::System.Collections.Generic.IEnumerable<global::Moyou.Aspects.UnsavedChanges.IUnsavedChanges>)this.Bs), v_1 => v_1.UnsavedChanges));
}
public void ResetUnsavedChanges()
{
this._internalUnsavedChanges = false;
this.D.ResetUnsavedChanges();
foreach (var val in this.Bs)
{
val.ResetUnsavedChanges();
}
}
}
[UnsavedChanges]
public class B : global::Moyou.Aspects.UnsavedChanges.IUnsavedChanges
{
private global::System.Boolean _internalUnsavedChanges = (global::System.Boolean)false;
public global::System.Boolean UnsavedChanges
{
get
{
return (global::System.Boolean)this.GetUnsavedChanges();
}
}
private global::System.Boolean GetUnsavedChanges()
{
return (global::System.Boolean)this._internalUnsavedChanges;
}
public void ResetUnsavedChanges()
{
this._internalUnsavedChanges = false;
}
}
[UnsavedChanges]
public class D : global::Moyou.Aspects.UnsavedChanges.IUnsavedChanges
{
private global::System.Boolean _internalUnsavedChanges = (global::System.Boolean)false;
public global::System.Boolean UnsavedChanges
{
get
{
return (global::System.Boolean)this.GetUnsavedChanges();
}
}
private global::System.Boolean GetUnsavedChanges()
{
return (global::System.Boolean)this._internalUnsavedChanges;
}
public void ResetUnsavedChanges()
{
this._internalUnsavedChanges = false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Moyou.Aspects.UnsavedChanges;

namespace Moyou.CompileTimeTest.UnsavedChanges.UnsavedChangesAttributeTest;

[UnsavedChanges]
public class NestedNullable
{
public C C { get; set; }
public C? CNullable { get; set; }
public IEnumerable<C> CNotNullableEnumerable { get; set; }
public IEnumerable<C?> CNotNullableEnumerableNullableT { get; set; }
public IEnumerable<C>? CNullableEnumerable { get; set; }
public IEnumerable<C?>? CNullableEnumerableNullableT { get; set; }
public List<C>? CNullableList { get; set; }
}

[UnsavedChanges]
public class C
{

}
Loading

0 comments on commit a253313

Please sign in to comment.