Skip to content

Commit bf4b5f6

Browse files
authored
Merge pull request #152 from CommunityToolkit/dev/also-broadcast-change-attribute
Add new [AlsoBroadcastChange] attribute
2 parents 3763eee + 4edcd88 commit bf4b5f6

File tree

9 files changed

+307
-19
lines changed

9 files changed

+307
-19
lines changed

CommunityToolkit.Mvvm.SourceGenerators/AnalyzerReleases.Unshipped.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,4 @@ MVVMTK0018 | CommunityToolkit.Mvvm.SourceGenerators.ObservableObjectGenerator |
2626
MVVMTK0019 | CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator | Error | See https://aka.ms/mvvmtoolkit/error
2727
MVVMTK0020 | CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator | Error | See https://aka.ms/mvvmtoolkit/error
2828
MVVMTK0021 | CommunityToolkit.Mvvm.SourceGenerators.ObservableRecipientGenerator | Error | See https://aka.ms/mvvmtoolkit/error
29+
MVVMTK0022 | CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator | Error | See https://aka.ms/mvvmtoolkit/error

CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/PropertyInfo.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ namespace CommunityToolkit.Mvvm.SourceGenerators.ComponentModel.Models;
2121
/// <param name="PropertyChangingNames">The sequence of property changing properties to notify.</param>
2222
/// <param name="PropertyChangedNames">The sequence of property changed properties to notify.</param>
2323
/// <param name="NotifiedCommandNames">The sequence of commands to notify.</param>
24+
/// <param name="AlsoBroadcastChange">Whether or not the generated property also broadcasts changes.</param>
2425
/// <param name="ValidationAttributes">The sequence of validation attributes for the generated property.</param>
2526
internal sealed record PropertyInfo(
2627
string TypeName,
@@ -30,6 +31,7 @@ internal sealed record PropertyInfo(
3031
ImmutableArray<string> PropertyChangingNames,
3132
ImmutableArray<string> PropertyChangedNames,
3233
ImmutableArray<string> NotifiedCommandNames,
34+
bool AlsoBroadcastChange,
3335
ImmutableArray<AttributeInfo> ValidationAttributes)
3436
{
3537
/// <summary>
@@ -47,6 +49,7 @@ protected override void AddToHashCode(ref HashCode hashCode, PropertyInfo obj)
4749
hashCode.AddRange(obj.PropertyChangingNames);
4850
hashCode.AddRange(obj.PropertyChangedNames);
4951
hashCode.AddRange(obj.NotifiedCommandNames);
52+
hashCode.Add(obj.AlsoBroadcastChange);
5053
hashCode.AddRange(obj.ValidationAttributes, AttributeInfo.Comparer.Default);
5154
}
5255

@@ -61,6 +64,7 @@ protected override bool AreEqual(PropertyInfo x, PropertyInfo y)
6164
x.PropertyChangingNames.SequenceEqual(y.PropertyChangingNames) &&
6265
x.PropertyChangedNames.SequenceEqual(y.PropertyChangedNames) &&
6366
x.NotifiedCommandNames.SequenceEqual(y.NotifiedCommandNames) &&
67+
x.AlsoBroadcastChange == y.AlsoBroadcastChange &&
6468
x.ValidationAttributes.SequenceEqual(y.ValidationAttributes, AttributeInfo.Comparer.Default);
6569
}
6670
}

CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservablePropertyGenerator.Execute.cs

Lines changed: 87 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ internal static class Execute
7575
ImmutableArray<string>.Builder propertyChangingNames = ImmutableArray.CreateBuilder<string>();
7676
ImmutableArray<string>.Builder notifiedCommandNames = ImmutableArray.CreateBuilder<string>();
7777
ImmutableArray<AttributeInfo>.Builder validationAttributes = ImmutableArray.CreateBuilder<AttributeInfo>();
78+
bool alsoBroadcastChange = false;
7879

7980
// Track the property changing event for the property, if the type supports it
8081
if (shouldInvokeOnPropertyChanging)
@@ -90,7 +91,8 @@ internal static class Execute
9091
{
9192
// Gather dependent property and command names
9293
if (TryGatherDependentPropertyChangedNames(fieldSymbol, attributeData, propertyChangedNames, builder) ||
93-
TryGatherDependentCommandNames(fieldSymbol, attributeData, notifiedCommandNames, builder))
94+
TryGatherDependentCommandNames(fieldSymbol, attributeData, notifiedCommandNames, builder) ||
95+
TryGetIsBroadcastingChanges(fieldSymbol, attributeData, builder, out alsoBroadcastChange))
9496
{
9597
continue;
9698
}
@@ -129,6 +131,7 @@ internal static class Execute
129131
propertyChangingNames.ToImmutable(),
130132
propertyChangedNames.ToImmutable(),
131133
notifiedCommandNames.ToImmutable(),
134+
alsoBroadcastChange,
132135
validationAttributes.ToImmutable());
133136
}
134137

@@ -326,6 +329,48 @@ bool IsCommandNameValidWithGeneratedMembers(string commandName)
326329
return false;
327330
}
328331

332+
/// <summary>
333+
/// Checks whether a given generated property should also broadcast changes.
334+
/// </summary>
335+
/// <param name="fieldSymbol">The input <see cref="IFieldSymbol"/> instance to process.</param>
336+
/// <param name="attributeData">The <see cref="AttributeData"/> instance for <paramref name="fieldSymbol"/>.</param>
337+
/// <param name="diagnostics">The current collection of gathered diagnostics.</param>
338+
/// <param name="alsoBroadcastChange">Whether or not the resulting property should also broadcast changes.</param>
339+
/// <returns>Whether or not the generated property for <paramref name="fieldSymbol"/> used <c>[AlsoBroadcastChange]</c>.</returns>
340+
private static bool TryGetIsBroadcastingChanges(
341+
IFieldSymbol fieldSymbol,
342+
AttributeData attributeData,
343+
ImmutableArray<Diagnostic>.Builder diagnostics,
344+
out bool alsoBroadcastChange)
345+
{
346+
if (attributeData.AttributeClass?.HasFullyQualifiedName("global::CommunityToolkit.Mvvm.ComponentModel.AlsoBroadcastChangeAttribute") == true)
347+
{
348+
// If the containing type is valid, track it
349+
if (fieldSymbol.ContainingType.InheritsFromFullyQualifiedName("global::CommunityToolkit.Mvvm.ComponentModel.ObservableRecipient") ||
350+
fieldSymbol.ContainingType.HasOrInheritsAttributeWithFullyQualifiedName("global::CommunityToolkit.Mvvm.ComponentModel.ObservableRecipientAttribute"))
351+
{
352+
alsoBroadcastChange = true;
353+
354+
return true;
355+
}
356+
357+
// Otherwise just emit the diagnostic and then ignore the attribute
358+
diagnostics.Add(
359+
InvalidContainingTypeForAlsoBroadcastChangeFieldError,
360+
fieldSymbol,
361+
fieldSymbol.ContainingType,
362+
fieldSymbol.Name);
363+
364+
alsoBroadcastChange = false;
365+
366+
return true;
367+
}
368+
369+
alsoBroadcastChange = false;
370+
371+
return false;
372+
}
373+
329374
/// <summary>
330375
/// Gets a <see cref="CompilationUnitSyntax"/> instance with the cached args for property changing notifications.
331376
/// </summary>
@@ -361,6 +406,33 @@ public static MemberDeclarationSyntax GetPropertySyntax(PropertyInfo propertyInf
361406
{
362407
ImmutableArray<StatementSyntax>.Builder setterStatements = ImmutableArray.CreateBuilder<StatementSyntax>();
363408

409+
// Get the property type syntax (adding the nullability annotation, if needed)
410+
TypeSyntax propertyType = propertyInfo.IsNullableReferenceType
411+
? NullableType(IdentifierName(propertyInfo.TypeName))
412+
: IdentifierName(propertyInfo.TypeName);
413+
414+
// In case the backing field is exactly named "value", we need to add the "this." prefix to ensure that comparisons and assignments
415+
// with it in the generated setter body are executed correctly and without conflicts with the implicit value parameter.
416+
ExpressionSyntax fieldExpression = propertyInfo.FieldName switch
417+
{
418+
"value" => MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, ThisExpression(), IdentifierName("value")),
419+
string name => IdentifierName(name)
420+
};
421+
422+
if (propertyInfo.AlsoBroadcastChange)
423+
{
424+
// If broadcasting changes are required, also store the old value.
425+
// This code generates a statement as follows:
426+
//
427+
// <PROPERTY_TYPE> __oldValue = <FIELD_EXPRESSIONS>;
428+
setterStatements.Add(
429+
LocalDeclarationStatement(
430+
VariableDeclaration(propertyType)
431+
.AddVariables(
432+
VariableDeclarator(Identifier("__oldValue"))
433+
.WithInitializer(EqualsValueClause(fieldExpression)))));
434+
}
435+
364436
// Add the OnPropertyChanging() call first:
365437
//
366438
// On<PROPERTY_NAME>Changing(value);
@@ -384,14 +456,6 @@ public static MemberDeclarationSyntax GetPropertySyntax(PropertyInfo propertyInf
384456
IdentifierName(propertyName))))));
385457
}
386458

387-
// In case the backing field is exactly named "value", we need to add the "this." prefix to ensure that comparisons and assignments
388-
// with it in the generated setter body are executed correctly and without conflicts with the implicit value parameter.
389-
ExpressionSyntax fieldExpression = propertyInfo.FieldName switch
390-
{
391-
"value" => MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, ThisExpression(), IdentifierName("value")),
392-
string name => IdentifierName(name)
393-
};
394-
395459
// Add the assignment statement:
396460
//
397461
// <FIELD_EXPRESSION> = value;
@@ -452,10 +516,20 @@ public static MemberDeclarationSyntax GetPropertySyntax(PropertyInfo propertyInf
452516
IdentifierName("NotifyCanExecuteChanged")))));
453517
}
454518

455-
// Get the property type syntax (adding the nullability annotation, if needed)
456-
TypeSyntax propertyType = propertyInfo.IsNullableReferenceType
457-
? NullableType(IdentifierName(propertyInfo.TypeName))
458-
: IdentifierName(propertyInfo.TypeName);
519+
// Also broadcast the change, if requested
520+
if (propertyInfo.AlsoBroadcastChange)
521+
{
522+
// This code generates a statement as follows:
523+
//
524+
// Broadcast(__oldValue, value, "<PROPERTY_NAME>");
525+
setterStatements.Add(
526+
ExpressionStatement(
527+
InvocationExpression(IdentifierName("Broadcast"))
528+
.AddArgumentListArguments(
529+
Argument(IdentifierName("__oldValue")),
530+
Argument(IdentifierName("value")),
531+
Argument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(propertyInfo.PropertyName))))));
532+
}
459533

460534
// Generate the inner setter block as follows:
461535
//

CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservablePropertyGenerator.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,13 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
3939
fieldSymbols
4040
.Where(static item => item.HasAttributeWithFullyQualifiedName("global::CommunityToolkit.Mvvm.ComponentModel.ObservablePropertyAttribute"));
4141

42-
// Get diagnostics for fields using [AlsoNotifyChangeFor] and [AlsoNotifyCanExecuteFor], but not [ObservableProperty]
42+
// Get diagnostics for fields using [AlsoNotifyChangeFor], [AlsoNotifyCanExecuteFor] and [AlsoBroadcastChange], but not [ObservableProperty]
4343
IncrementalValuesProvider<Diagnostic> fieldSymbolsWithOrphanedDependentAttributeWithErrors =
4444
fieldSymbols
4545
.Where(static item =>
4646
(item.HasAttributeWithFullyQualifiedName("global::CommunityToolkit.Mvvm.ComponentModel.AlsoNotifyChangeForAttribute") ||
47-
item.HasAttributeWithFullyQualifiedName("global::CommunityToolkit.Mvvm.ComponentModel.AlsoNotifyCanExecuteForAttribute")) &&
47+
item.HasAttributeWithFullyQualifiedName("global::CommunityToolkit.Mvvm.ComponentModel.AlsoNotifyCanExecuteForAttribute") ||
48+
item.HasAttributeWithFullyQualifiedName("global::CommunityToolkit.Mvvm.ComponentModel.AlsoBroadcastChangeAttribute")) &&
4849
!item.HasAttributeWithFullyQualifiedName("global::CommunityToolkit.Mvvm.ComponentModel.ObservablePropertyAttribute"))
4950
.Select(static (item, _) => Execute.GetDiagnosticForFieldWithOrphanedDependentAttributes(item));
5051

CommunityToolkit.Mvvm.SourceGenerators/Diagnostics/DiagnosticDescriptors.cs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -319,17 +319,17 @@ internal static class DiagnosticDescriptors
319319
/// <summary>
320320
/// Gets a <see cref="DiagnosticDescriptor"/> indicating when <c>[ObservableProperty]</c> is applied to a field in an invalid type.
321321
/// <para>
322-
/// Format: <c>"The field {0}.{1} needs to be annotated with [ObservableProperty] in order to enable using [AlsoNotifyChangeFor] and [AlsoNotifyCanExecuteFor]"</c>.
322+
/// Format: <c>"The field {0}.{1} needs to be annotated with [ObservableProperty] in order to enable using [AlsoNotifyChangeFor], [AlsoNotifyCanExecuteFor] and [AlsoBroadcastChange]"</c>.
323323
/// </para>
324324
/// </summary>
325325
public static readonly DiagnosticDescriptor FieldWithOrphanedDependentObservablePropertyAttributesError = new DiagnosticDescriptor(
326326
id: "MVVMTK0020",
327327
title: "Invalid use of attributes dependent on [ObservableProperty]",
328-
messageFormat: "The field {0}.{1} needs to be annotated with [ObservableProperty] in order to enable using [AlsoNotifyChangeFor] and [AlsoNotifyCanExecuteFor]",
328+
messageFormat: "The field {0}.{1} needs to be annotated with [ObservableProperty] in order to enable using [AlsoNotifyChangeFor], [AlsoNotifyCanExecuteFor] and [AlsoBroadcastChange]",
329329
category: typeof(ObservablePropertyGenerator).FullName,
330330
defaultSeverity: DiagnosticSeverity.Error,
331331
isEnabledByDefault: true,
332-
description: "Fields not annotated with [ObservableProperty] cannot use [AlsoNotifyChangeFor] and [AlsoNotifyCanExecuteFor].",
332+
description: "Fields not annotated with [ObservableProperty] cannot use [AlsoNotifyChangeFor], [AlsoNotifyCanExecuteFor] and [AlsoBroadcastChange].",
333333
helpLinkUri: "https://aka.ms/mvvmtoolkit");
334334

335335
/// <summary>
@@ -347,4 +347,20 @@ internal static class DiagnosticDescriptors
347347
isEnabledByDefault: true,
348348
description: "Cannot apply [ObservableRecipient] to a type that already inherits this attribute from a base type.",
349349
helpLinkUri: "https://aka.ms/mvvmtoolkit");
350+
351+
/// <summary>
352+
/// Gets a <see cref="DiagnosticDescriptor"/> indicating when <c>[AlsoBroadcastChange]</c> is applied to a field in an invalid type.
353+
/// <para>
354+
/// Format: <c>"The field {0}.{1} cannot be annotated with [AlsoBroadcastChange], as its containing type doesn't inherit from ObservableRecipient, nor does it use [ObservableRecipient]"</c>.
355+
/// </para>
356+
/// </summary>
357+
public static readonly DiagnosticDescriptor InvalidContainingTypeForAlsoBroadcastChangeFieldError = new DiagnosticDescriptor(
358+
id: "MVVMTK0022",
359+
title: "Invalid containing type for [ObservableProperty] field",
360+
messageFormat: "The field {0}.{1} cannot be annotated with [AlsoBroadcastChange], as its containing type doesn't inherit from ObservableRecipient, nor does it use [ObservableRecipient]",
361+
category: typeof(ObservablePropertyGenerator).FullName,
362+
defaultSeverity: DiagnosticSeverity.Error,
363+
isEnabledByDefault: true,
364+
description: "Fields annotated with [AlsoBroadcastChange] must be contained in a type that inherits from ObservableRecipient or that is annotated with [ObservableRecipient] (including base types).",
365+
helpLinkUri: "https://aka.ms/mvvmtoolkit");
350366
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using System;
6+
using System.Diagnostics;
7+
8+
namespace CommunityToolkit.Mvvm.ComponentModel;
9+
10+
/// <summary>
11+
/// An attribute that can be used to support <see cref="ObservablePropertyAttribute"/> in generated properties, when applied to fields
12+
/// contained in a type that is either inheriting from <see cref="ObservableRecipient"/>, or annotated with <see cref="ObservableRecipientAttribute"/>.
13+
/// When this attribute is used, the generated property setter will also call <see cref="ObservableRecipient.Broadcast{T}(T, T, string?)"/>.
14+
/// This allows generated properties to opt-in into broadcasting behavior without having to fallback into a full explicit observable property.
15+
/// <para>
16+
/// This attribute can be used as follows:
17+
/// <code>
18+
/// partial class MyViewModel : ObservableRecipient
19+
/// {
20+
/// [ObservableProperty]
21+
/// [AlsoBroadcastChange]
22+
/// private string username;
23+
/// }
24+
/// </code>
25+
/// </para>
26+
/// And with this, code analogous to this will be generated:
27+
/// <code>
28+
/// partial class MyViewModel
29+
/// {
30+
/// public string Username
31+
/// {
32+
/// get => username;
33+
/// set => SetProperty(ref username, value, broadcast: true);
34+
/// }
35+
/// }
36+
/// </code>
37+
/// </summary>
38+
[AttributeUsage(AttributeTargets.Field, AllowMultiple = false, Inherited = false)]
39+
[Conditional("MVVMTOOLKIT_KEEP_SOURCE_GENERATOR_ATTRIBUTES")]
40+
public sealed class AlsoBroadcastChangeAttribute : Attribute
41+
{
42+
}

tests/CommunityToolkit.Mvvm.KeepSourceGeneratorAttributes.UnitTests/Test_SourceGeneratorAttributes.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public void Test_AttributeMetadata()
2121
Assert.Fail();
2222
#endif
2323
Assert.AreEqual(3, typeof(TestModel).GetCustomAttributes().Count(a => a.GetType().FullName!.Contains("CommunityToolkit.Mvvm")));
24-
Assert.AreEqual(3, typeof(TestModel).GetField(nameof(TestModel.name))!.GetCustomAttributes().Count(a => a.GetType().FullName!.Contains("CommunityToolkit.Mvvm")));
24+
Assert.AreEqual(4, typeof(TestModel).GetField(nameof(TestModel.name))!.GetCustomAttributes().Count(a => a.GetType().FullName!.Contains("CommunityToolkit.Mvvm")));
2525
Assert.AreEqual(1, typeof(TestModel).GetMethod(nameof(TestModel.Test))!.GetCustomAttributes().Count(a => a.GetType().FullName!.Contains("CommunityToolkit.Mvvm")));
2626
}
2727

@@ -33,6 +33,7 @@ public class TestModel
3333
[ObservableProperty]
3434
[AlsoNotifyChangeFor("")]
3535
[AlsoNotifyCanExecuteFor("")]
36+
[AlsoBroadcastChange]
3637
public string? name;
3738

3839
[ICommand]

0 commit comments

Comments
 (0)