diff --git a/src/CommunityToolkit.Mvvm.SourceGenerators/AnalyzerReleases.Unshipped.md b/src/CommunityToolkit.Mvvm.SourceGenerators/AnalyzerReleases.Unshipped.md index f2b7fad65..8ae9094ba 100644 --- a/src/CommunityToolkit.Mvvm.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/src/CommunityToolkit.Mvvm.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -1,2 +1,9 @@ ; Unshipped analyzer release ; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +MVVMTK0057 | CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator | Error | Invalid source name for [DependsOn] +MVVMTK0058 | CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator | Error | Dependency cycle for [DependsOn] +MVVMTK0059 | CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator | Error | Invalid sub-property source for [DependsOn] diff --git a/src/CommunityToolkit.Mvvm.SourceGenerators/CommunityToolkit.Mvvm.SourceGenerators.projitems b/src/CommunityToolkit.Mvvm.SourceGenerators/CommunityToolkit.Mvvm.SourceGenerators.projitems index c3294780f..876ac5b43 100644 --- a/src/CommunityToolkit.Mvvm.SourceGenerators/CommunityToolkit.Mvvm.SourceGenerators.projitems +++ b/src/CommunityToolkit.Mvvm.SourceGenerators/CommunityToolkit.Mvvm.SourceGenerators.projitems @@ -25,6 +25,7 @@ + @@ -113,4 +114,4 @@ - \ No newline at end of file + diff --git a/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/ChildPropertyChangedSubscriptionInfo.cs b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/ChildPropertyChangedSubscriptionInfo.cs new file mode 100644 index 000000000..a4bb56ec6 --- /dev/null +++ b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/ChildPropertyChangedSubscriptionInfo.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Mvvm.SourceGenerators.Helpers; + +namespace CommunityToolkit.Mvvm.SourceGenerators.ComponentModel.Models; + +/// +/// A model representing generated child PropertyChanged forwarding for an observable property. +/// +/// The dependent property names to notify when the child instance changes. +/// The dependent command names to notify when the child instance changes. +internal sealed record ChildPropertyChangedSubscriptionInfo( + EquatableArray PropertyChangedNames, + EquatableArray NotifiedCommandNames); diff --git a/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/PropertyInfo.cs b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/PropertyInfo.cs index 1fee5b622..d580fdfb1 100644 --- a/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/PropertyInfo.cs +++ b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/Models/PropertyInfo.cs @@ -22,6 +22,7 @@ namespace CommunityToolkit.Mvvm.SourceGenerators.ComponentModel.Models; /// The sequence of property changing properties to notify. /// The sequence of property changed properties to notify. /// The sequence of commands to notify. +/// The child property changed subscriptions to generate. /// Whether or not the generated property also broadcasts changes. /// Whether or not the generated property also validates its value. /// Whether the old property value is being directly referenced. @@ -41,6 +42,7 @@ internal sealed record PropertyInfo( EquatableArray PropertyChangingNames, EquatableArray PropertyChangedNames, EquatableArray NotifiedCommandNames, + EquatableArray ChildPropertyChangedSubscriptions, bool NotifyPropertyChangedRecipients, bool NotifyDataErrorInfo, bool IsOldPropertyValueDirectlyReferenced, diff --git a/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservablePropertyGenerator.Execute.cs b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservablePropertyGenerator.Execute.cs index 2b2756ec4..c66f5b2ab 100644 --- a/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservablePropertyGenerator.Execute.cs +++ b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservablePropertyGenerator.Execute.cs @@ -234,6 +234,7 @@ public static bool TryGetInfo( using ImmutableArrayBuilder propertyChangedNames = ImmutableArrayBuilder.Rent(); using ImmutableArrayBuilder notifiedCommandNames = ImmutableArrayBuilder.Rent(); + using ImmutableArrayBuilder childPropertyChangedSubscriptions = ImmutableArrayBuilder.Rent(); using ImmutableArrayBuilder forwardedAttributes = ImmutableArrayBuilder.Rent(); bool notifyRecipients = false; @@ -359,6 +360,15 @@ public static bool TryGetInfo( token.ThrowIfCancellationRequested(); + GatherDependencyGraphInfo( + memberSymbol, + propertyName, + in propertyChangedNames, + in notifiedCommandNames, + in childPropertyChangedSubscriptions); + + token.ThrowIfCancellationRequested(); + // We should generate [RequiresUnreferencedCode] on the setter if [NotifyDataErrorInfo] was used and the attribute is available bool includeRequiresUnreferencedCodeOnSetAccessor = notifyDataErrorInfo && @@ -409,6 +419,7 @@ public static bool TryGetInfo( effectivePropertyChangingNames, effectivePropertyChangedNames, notifiedCommandNames.ToImmutable(), + childPropertyChangedSubscriptions.ToImmutable(), notifyRecipients, notifyDataErrorInfo, isOldPropertyValueDirectlyReferenced, @@ -622,6 +633,534 @@ bool IsCommandNameValidWithGeneratedMembers(string commandName) return false; } + /// + /// Gets diagnostics for [DependsOn] usages on a target property. + /// + /// The target property annotated with [DependsOn]. + /// The attributes that were matched for the target property. + /// The cancellation token for the current operation. + /// The diagnostics produced for . + public static EquatableArray GetDependsOnDiagnostics( + IPropertySymbol targetPropertySymbol, + ImmutableArray attributeData, + CancellationToken token) + { + using ImmutableArrayBuilder diagnostics = ImmutableArrayBuilder.Rent(); + + foreach (AttributeData attribute in attributeData) + { + token.ThrowIfCancellationRequested(); + + if (attribute.AttributeClass?.HasFullyQualifiedMetadataName("CommunityToolkit.Mvvm.ComponentModel.DependsOnAttribute") != true) + { + continue; + } + + bool notifyOnSubPropertyChanges = attribute.GetNamedArgument("NotifyOnSubPropertyChanges", false); + + foreach (string? sourcePropertyName in attribute.GetConstructorArguments()) + { + if (!TryGetPropertyDependencySource(targetPropertySymbol.ContainingType, sourcePropertyName, out ITypeSymbol? sourceType, out bool isGeneratedSource)) + { + diagnostics.Add(DependsOnInvalidSourceError, targetPropertySymbol, sourcePropertyName ?? "", targetPropertySymbol.ContainingType); + + continue; + } + + if (sourcePropertyName == targetPropertySymbol.Name) + { + diagnostics.Add(DependsOnInvalidSourceError, targetPropertySymbol, sourcePropertyName, targetPropertySymbol.ContainingType); + + continue; + } + + if (notifyOnSubPropertyChanges && + (!isGeneratedSource || + sourceType is null || + !IsINotifyPropertyChangedType(sourceType))) + { + diagnostics.Add(DependsOnInvalidSubPropertySourceError, targetPropertySymbol, sourcePropertyName ?? "", targetPropertySymbol.ContainingType); + } + } + } + + Dictionary> sourceToTargets = GetDependsOnSourceToTargetsMap(targetPropertySymbol.ContainingType, includeOnlyValidEdges: true); + + if (IsInDependsOnCycle(targetPropertySymbol.Name, sourceToTargets) && + IsCanonicalCycleDiagnosticTarget(targetPropertySymbol.Name, sourceToTargets)) + { + diagnostics.Add(DependsOnCycleError, targetPropertySymbol, targetPropertySymbol.Name, targetPropertySymbol.ContainingType); + } + + return diagnostics.ToImmutable().AsEquatableArray(); + } + + /// + /// Gathers property and command notifications declared through the generated dependency graph. + /// + /// The source member being generated. + /// The generated property name. + /// The collection of property changed names to update. + /// The collection of command names to update. + /// The collection of child subscriptions to update. + private static void GatherDependencyGraphInfo( + ISymbol memberSymbol, + string propertyName, + in ImmutableArrayBuilder propertyChangedNames, + in ImmutableArrayBuilder notifiedCommandNames, + in ImmutableArrayBuilder childPropertyChangedSubscriptions) + { + INamedTypeSymbol containingType = memberSymbol.ContainingType; + Dictionary> sourceToTargets = GetDependsOnSourceToTargetsMap(containingType, includeOnlyValidEdges: true); + Dictionary> canExecutePropertyToCommandNames = GetCanExecutePropertyToCommandNamesMap(containingType); + + ImmutableArray graphPropertyChangedNames = GetTransitiveDependsOnTargets(propertyName, sourceToTargets); + + foreach (string dependentPropertyName in graphPropertyChangedNames) + { + AddIfMissing(in propertyChangedNames, dependentPropertyName); + } + + AddInferredCommandNames(propertyName, graphPropertyChangedNames, canExecutePropertyToCommandNames, in notifiedCommandNames); + + ImmutableArray childGraphPropertyChangedNames = GetTransitiveChildDependsOnTargets(propertyName, sourceToTargets); + + if (childGraphPropertyChangedNames.Length > 0) + { + using ImmutableArrayBuilder childPropertyNames = ImmutableArrayBuilder.Rent(); + using ImmutableArrayBuilder childCommandNames = ImmutableArrayBuilder.Rent(); + + foreach (string dependentPropertyName in childGraphPropertyChangedNames) + { + AddIfMissing(in childPropertyNames, dependentPropertyName); + AddInferredCommandNamesForProperty(dependentPropertyName, canExecutePropertyToCommandNames, in childCommandNames); + } + + childPropertyChangedSubscriptions.Add(new ChildPropertyChangedSubscriptionInfo( + childPropertyNames.ToImmutable(), + childCommandNames.ToImmutable())); + } + } + + /// + /// Gets all valid transitive property targets for a given source property. + /// + /// The source property name. + /// The source to target graph. + /// The transitive targets for . + private static ImmutableArray GetTransitiveDependsOnTargets(string sourcePropertyName, Dictionary> sourceToTargets) + { + using ImmutableArrayBuilder propertyNames = ImmutableArrayBuilder.Rent(); + HashSet visitedNames = new(StringComparer.Ordinal); + Queue pendingNames = new(); + + visitedNames.Add(sourcePropertyName); + pendingNames.Enqueue(sourcePropertyName); + + while (pendingNames.Count > 0) + { + string currentPropertyName = pendingNames.Dequeue(); + + if (!sourceToTargets.TryGetValue(currentPropertyName, out List? targetEdges)) + { + continue; + } + + foreach (DependsOnEdge targetEdge in targetEdges) + { + if (visitedNames.Add(targetEdge.TargetName)) + { + propertyNames.Add(targetEdge.TargetName); + pendingNames.Enqueue(targetEdge.TargetName); + } + } + } + + return propertyNames.ToImmutable(); + } + + /// + /// Gets all transitive child property targets from direct edges that opted into child forwarding. + /// + /// The source property name. + /// The source to target graph. + /// The transitive child property targets for . + private static ImmutableArray GetTransitiveChildDependsOnTargets(string sourcePropertyName, Dictionary> sourceToTargets) + { + using ImmutableArrayBuilder propertyNames = ImmutableArrayBuilder.Rent(); + HashSet visitedNames = new(StringComparer.Ordinal) { sourcePropertyName }; + Queue pendingNames = new(); + + if (!sourceToTargets.TryGetValue(sourcePropertyName, out List? rootTargetEdges)) + { + return ImmutableArray.Empty; + } + + foreach (DependsOnEdge targetEdge in rootTargetEdges) + { + if (targetEdge.NotifyOnSubPropertyChanges && + visitedNames.Add(targetEdge.TargetName)) + { + propertyNames.Add(targetEdge.TargetName); + pendingNames.Enqueue(targetEdge.TargetName); + } + } + + while (pendingNames.Count > 0) + { + string currentPropertyName = pendingNames.Dequeue(); + + if (!sourceToTargets.TryGetValue(currentPropertyName, out List? targetEdges)) + { + continue; + } + + foreach (DependsOnEdge targetEdge in targetEdges) + { + if (visitedNames.Add(targetEdge.TargetName)) + { + propertyNames.Add(targetEdge.TargetName); + pendingNames.Enqueue(targetEdge.TargetName); + } + } + } + + return propertyNames.ToImmutable(); + } + + /// + /// Adds inferred command names for all property names produced by the dependency graph. + /// + /// The source property name. + /// The transitive graph property names. + /// The map of can execute properties to command names. + /// The command notification collection to update. + private static void AddInferredCommandNames( + string sourcePropertyName, + ImmutableArray graphPropertyChangedNames, + Dictionary> canExecutePropertyToCommandNames, + in ImmutableArrayBuilder notifiedCommandNames) + { + AddInferredCommandNamesForProperty(sourcePropertyName, canExecutePropertyToCommandNames, in notifiedCommandNames); + + foreach (string propertyName in graphPropertyChangedNames) + { + AddInferredCommandNamesForProperty(propertyName, canExecutePropertyToCommandNames, in notifiedCommandNames); + } + } + + /// + /// Adds inferred command names for a single property. + /// + /// The property name to inspect. + /// The map of can execute properties to command names. + /// The command notification collection to update. + private static void AddInferredCommandNamesForProperty( + string propertyName, + Dictionary> canExecutePropertyToCommandNames, + in ImmutableArrayBuilder notifiedCommandNames) + { + if (canExecutePropertyToCommandNames.TryGetValue(propertyName, out List? commandNames)) + { + foreach (string commandName in commandNames) + { + AddIfMissing(in notifiedCommandNames, commandName); + } + } + } + + /// + /// Builds the source to target map for all [DependsOn] declarations in a type. + /// + /// The containing type to inspect. + /// Whether invalid edges should be skipped. + /// The source to target map for . + private static Dictionary> GetDependsOnSourceToTargetsMap(INamedTypeSymbol containingType, bool includeOnlyValidEdges) + { + Dictionary> sourceToTargets = new(StringComparer.Ordinal); + + foreach (IPropertySymbol propertySymbol in containingType.GetAllMembers().OfType()) + { + foreach (AttributeData attributeData in propertySymbol.GetAttributes()) + { + if (attributeData.AttributeClass?.HasFullyQualifiedMetadataName("CommunityToolkit.Mvvm.ComponentModel.DependsOnAttribute") != true) + { + continue; + } + + bool notifyOnSubPropertyChanges = attributeData.GetNamedArgument("NotifyOnSubPropertyChanges", false); + + foreach (string? sourcePropertyName in attributeData.GetConstructorArguments()) + { + if (!TryGetPropertyDependencySource(containingType, sourcePropertyName, out _, out _) || + sourcePropertyName == propertySymbol.Name) + { + if (includeOnlyValidEdges) + { + continue; + } + } + + if (sourcePropertyName is null or "") + { + continue; + } + + if (!sourceToTargets.TryGetValue(sourcePropertyName, out List? targetEdges)) + { + sourceToTargets.Add(sourcePropertyName, targetEdges = new List()); + } + + targetEdges.Add(new DependsOnEdge(sourcePropertyName, propertySymbol.Name, notifyOnSubPropertyChanges)); + } + } + } + + return sourceToTargets; + } + + /// + /// Builds the map of CanExecute properties to generated command property names. + /// + /// The containing type to inspect. + /// The map of CanExecute properties to command property names. + private static Dictionary> GetCanExecutePropertyToCommandNamesMap(INamedTypeSymbol containingType) + { + Dictionary> canExecutePropertyToCommandNames = new(StringComparer.Ordinal); + + foreach (IMethodSymbol methodSymbol in containingType.GetAllMembers().OfType()) + { + AttributeData? relayCommandAttribute = null; + + foreach (AttributeData attributeData in methodSymbol.GetAttributes()) + { + if (attributeData.AttributeClass?.HasFullyQualifiedMetadataName("CommunityToolkit.Mvvm.Input.RelayCommandAttribute") == true) + { + relayCommandAttribute = attributeData; + + break; + } + } + + if (relayCommandAttribute is null || + !relayCommandAttribute.TryGetNamedArgument("CanExecute", out string? canExecuteMemberName) || + canExecuteMemberName is null or "" || + !IsBooleanProperty(containingType, canExecuteMemberName)) + { + continue; + } + + string commandPropertyName = RelayCommandGenerator.Execute.GetGeneratedFieldAndPropertyNames(methodSymbol).PropertyName; + + if (!canExecutePropertyToCommandNames.TryGetValue(canExecuteMemberName, out List? commandNames)) + { + canExecutePropertyToCommandNames.Add(canExecuteMemberName, commandNames = new List()); + } + + commandNames.Add(commandPropertyName); + } + + return canExecutePropertyToCommandNames; + } + + /// + /// Checks whether a property dependency source exists. + /// + /// The containing type to inspect. + /// The property name to resolve. + /// The resolved source property type, if any. + /// Whether the source property is generated from [ObservableProperty]. + /// Whether resolves to a valid source property. + private static bool TryGetPropertyDependencySource( + INamedTypeSymbol containingType, + string? propertyName, + [NotNullWhen(true)] out ITypeSymbol? propertyType, + out bool isGeneratedSource) + { + if (propertyName is null or "") + { + propertyType = null; + isGeneratedSource = false; + + return false; + } + + foreach (IPropertySymbol propertySymbol in containingType.GetAllMembers(propertyName).OfType()) + { + propertyType = propertySymbol.Type; + isGeneratedSource = propertySymbol.HasAttributeWithFullyQualifiedMetadataName("CommunityToolkit.Mvvm.ComponentModel.ObservablePropertyAttribute"); + + return true; + } + + foreach (ISymbol memberSymbol in containingType.GetAllMembers()) + { + if (memberSymbol is IFieldSymbol fieldSymbol && + fieldSymbol.HasAttributeWithFullyQualifiedMetadataName("CommunityToolkit.Mvvm.ComponentModel.ObservablePropertyAttribute") && + propertyName == GetGeneratedPropertyName(fieldSymbol)) + { + propertyType = fieldSymbol.Type; + isGeneratedSource = true; + + return true; + } + } + + propertyType = null; + isGeneratedSource = false; + + return false; + } + + /// + /// Checks whether a property name resolves to a bool property. + /// + /// The containing type to inspect. + /// The property name to resolve. + /// Whether resolves to a bool property. + private static bool IsBooleanProperty(INamedTypeSymbol containingType, string propertyName) + { + return + TryGetPropertyDependencySource(containingType, propertyName, out ITypeSymbol? propertyType, out _) && + propertyType.SpecialType == SpecialType.System_Boolean; + } + + /// + /// Checks whether a type is or implements . + /// + /// The type symbol to inspect. + /// Whether is or implements . + private static bool IsINotifyPropertyChangedType(ITypeSymbol typeSymbol) + { + return + typeSymbol.HasFullyQualifiedMetadataName("System.ComponentModel.INotifyPropertyChanged") || + typeSymbol.HasInterfaceWithFullyQualifiedMetadataName("System.ComponentModel.INotifyPropertyChanged"); + } + + /// + /// Checks whether a target property participates in a cycle. + /// + /// The target property name. + /// The source to target graph. + /// Whether participates in a cycle. + private static bool IsInDependsOnCycle(string propertyName, Dictionary> sourceToTargets) + { + HashSet visitedNames = new(StringComparer.Ordinal); + + bool Visit(string currentPropertyName) + { + if (!sourceToTargets.TryGetValue(currentPropertyName, out List? targetEdges)) + { + return false; + } + + foreach (DependsOnEdge targetEdge in targetEdges) + { + if (targetEdge.TargetName == propertyName) + { + return true; + } + + if (visitedNames.Add(targetEdge.TargetName) && Visit(targetEdge.TargetName)) + { + return true; + } + } + + return false; + } + + return Visit(propertyName); + } + + /// + /// Checks whether a property is the canonical diagnostic location for a cycle. + /// + /// The target property name. + /// The source to target graph. + /// Whether is the canonical diagnostic target. + private static bool IsCanonicalCycleDiagnosticTarget(string propertyName, Dictionary> sourceToTargets) + { + List cycleNames = new() { propertyName }; + + foreach (string candidateName in sourceToTargets.Keys) + { + if (candidateName != propertyName && + IsReachable(propertyName, candidateName, sourceToTargets) && + IsReachable(candidateName, propertyName, sourceToTargets)) + { + cycleNames.Add(candidateName); + } + } + + cycleNames.Sort(StringComparer.Ordinal); + + return cycleNames[0] == propertyName; + } + + /// + /// Checks whether one property is reachable from another. + /// + /// The source property name. + /// The target property name. + /// The source to target graph. + /// Whether is reachable from . + private static bool IsReachable(string sourcePropertyName, string targetPropertyName, Dictionary> sourceToTargets) + { + HashSet visitedNames = new(StringComparer.Ordinal); + + bool Visit(string currentPropertyName) + { + if (!sourceToTargets.TryGetValue(currentPropertyName, out List? targetEdges)) + { + return false; + } + + foreach (DependsOnEdge targetEdge in targetEdges) + { + if (targetEdge.TargetName == targetPropertyName) + { + return true; + } + + if (visitedNames.Add(targetEdge.TargetName) && Visit(targetEdge.TargetName)) + { + return true; + } + } + + return false; + } + + return Visit(sourcePropertyName); + } + + /// + /// Adds a value to a builder if it is not already present. + /// + /// The builder to update. + /// The value to add. + private static void AddIfMissing(in ImmutableArrayBuilder builder, string value) + { + foreach (string existingValue in builder.WrittenSpan) + { + if (existingValue == value) + { + return; + } + } + + builder.Add(value); + } + + /// + /// A dependency edge from a source property to a target calculated property. + /// + /// The source property name. + /// The target property name. + /// Whether child events should be forwarded. + private sealed record DependsOnEdge(string SourceName, string TargetName, bool NotifyOnSubPropertyChanges); + /// /// Checks whether a given generated property should also notify recipients. /// @@ -1235,6 +1774,17 @@ public static MemberDeclarationSyntax GetPropertySyntax(PropertyInfo propertyInf setterFieldExpression, IdentifierName("value")))); + // Add the child PropertyChanged subscription update, if requested: + // + // __SubscribeToPropertyChanged(value); + if (!propertyInfo.ChildPropertyChangedSubscriptions.IsEmpty) + { + setterStatements.Add( + ExpressionStatement( + InvocationExpression(IdentifierName($"__SubscribeTo{propertyInfo.PropertyName}PropertyChanged")) + .AddArgumentListArguments(Argument(IdentifierName("value"))))); + } + // If validation is requested, add a call to ValidateProperty: // // ValidateProperty(value, ); @@ -1392,6 +1942,26 @@ public static MemberDeclarationSyntax GetPropertySyntax(PropertyInfo propertyInf // Also add any forwarded attributes setAccessor = setAccessor.AddAttributeLists(forwardedSetAccessorAttributes); + AccessorDeclarationSyntax getAccessor = AccessorDeclaration(SyntaxKind.GetAccessorDeclaration) + .WithModifiers(propertyInfo.GetterAccessibility.ToSyntaxTokenList()); + + if (propertyInfo.ChildPropertyChangedSubscriptions.IsEmpty) + { + getAccessor = getAccessor + .WithExpressionBody(ArrowExpressionClause(getterFieldExpression)) + .WithSemicolonToken(Token(SyntaxKind.SemicolonToken)); + } + else + { + getAccessor = getAccessor.WithBody(Block( + ExpressionStatement( + InvocationExpression(IdentifierName($"__SubscribeTo{propertyInfo.PropertyName}PropertyChanged")) + .AddArgumentListArguments(Argument(getterFieldExpression))), + ReturnStatement(getterFieldExpression))); + } + + getAccessor = getAccessor.AddAttributeLists(forwardedGetAccessorAttributes); + // Construct the generated property as follows: // // @@ -1417,11 +1987,7 @@ public static MemberDeclarationSyntax GetPropertySyntax(PropertyInfo propertyInf .AddAttributeLists(forwardedPropertyAttributes) .WithModifiers(GetPropertyModifiers(propertyInfo)) .AddAccessorListAccessors( - AccessorDeclaration(SyntaxKind.GetAccessorDeclaration) - .WithModifiers(propertyInfo.GetterAccessibility.ToSyntaxTokenList()) - .WithExpressionBody(ArrowExpressionClause(getterFieldExpression)) - .WithSemicolonToken(Token(SyntaxKind.SemicolonToken)) - .AddAttributeLists(forwardedGetAccessorAttributes), + getAccessor, setAccessor); } @@ -1449,6 +2015,85 @@ private static SyntaxTokenList GetPropertyModifiers(PropertyInfo propertyInfo) return propertyModifiers; } + /// + /// Gets generated members used to forward child events. + /// + /// The input instance to process. + /// The generated members for child property changed forwarding. + public static ImmutableArray GetChildPropertyChangedSubscriptionMembersSyntax(PropertyInfo propertyInfo) + { + if (propertyInfo.ChildPropertyChangedSubscriptions.IsEmpty) + { + return ImmutableArray.Empty; + } + + using ImmutableArrayBuilder memberDeclarations = ImmutableArrayBuilder.Rent(); + + ChildPropertyChangedSubscriptionInfo childSubscription = propertyInfo.ChildPropertyChangedSubscriptions[0]; + string sourceFieldName = $"__{char.ToLower(propertyInfo.PropertyName[0], CultureInfo.InvariantCulture)}{propertyInfo.PropertyName.Substring(1)}PropertyChangedSource"; + string handlerName = $"__On{propertyInfo.PropertyName}PropertyChanged"; + string subscribeMethodName = $"__SubscribeTo{propertyInfo.PropertyName}PropertyChanged"; + + memberDeclarations.Add(ParseMemberDeclaration($$""" + private global::System.ComponentModel.INotifyPropertyChanged? {{sourceFieldName}}; + """)!); + + memberDeclarations.Add(ParseMemberDeclaration($$""" + private void {{subscribeMethodName}}({{propertyInfo.TypeNameWithNullabilityAnnotations}} value) + { + if (global::System.Object.ReferenceEquals({{sourceFieldName}}, value)) + { + return; + } + + if ({{sourceFieldName}} is object) + { + {{sourceFieldName}}.PropertyChanged -= {{handlerName}}; + } + + {{sourceFieldName}} = value; + + if (value is object) + { + value.PropertyChanged += {{handlerName}}; + } + } + """)!); + + using ImmutableArrayBuilder handlerStatements = ImmutableArrayBuilder.Rent(); + + foreach (string propertyName in childSubscription.PropertyChangedNames) + { + handlerStatements.Add( + ExpressionStatement( + InvocationExpression(IdentifierName("OnPropertyChanged")) + .AddArgumentListArguments(Argument(MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("global::CommunityToolkit.Mvvm.ComponentModel.__Internals.__KnownINotifyPropertyChangedArgs"), + IdentifierName(propertyName)))))); + } + + foreach (string commandName in childSubscription.NotifiedCommandNames) + { + handlerStatements.Add( + ExpressionStatement( + InvocationExpression(MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + IdentifierName(commandName), + IdentifierName("NotifyCanExecuteChanged"))))); + } + + memberDeclarations.Add( + MethodDeclaration(PredefinedType(Token(SyntaxKind.VoidKeyword)), Identifier(handlerName)) + .AddModifiers(Token(SyntaxKind.PrivateKeyword)) + .AddParameterListParameters( + Parameter(Identifier("sender")).WithType(NullableType(PredefinedType(Token(SyntaxKind.ObjectKeyword)))), + Parameter(Identifier("e")).WithType(IdentifierName("global::System.ComponentModel.PropertyChangedEventArgs"))) + .WithBody(Block(handlerStatements.AsEnumerable()))); + + return memberDeclarations.ToImmutable(); + } + /// /// Gets the instances for the OnPropertyChanging and OnPropertyChanged methods for the input field. /// diff --git a/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservablePropertyGenerator.cs b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservablePropertyGenerator.cs index 05bee8c83..5f89bea84 100644 --- a/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservablePropertyGenerator.cs +++ b/src/CommunityToolkit.Mvvm.SourceGenerators/ComponentModel/ObservablePropertyGenerator.cs @@ -22,6 +22,16 @@ public sealed partial class ObservablePropertyGenerator : IIncrementalGenerator /// public void Initialize(IncrementalGeneratorInitializationContext context) { + // Gather diagnostics for [DependsOn] declarations, including cases where no observable property is generated in the target type. + IncrementalValuesProvider> dependsOnDiagnostics = + context.SyntaxProvider + .ForAttributeWithMetadataName( + "CommunityToolkit.Mvvm.ComponentModel.DependsOnAttribute", + static (node, _) => node is PropertyDeclarationSyntax propertyDeclaration && propertyDeclaration.AttributeLists.Count > 0, + static (context, token) => Execute.GetDependsOnDiagnostics((IPropertySymbol)context.TargetSymbol, context.Attributes, token)); + + context.ReportDiagnostics(dependsOnDiagnostics); + // Gather info for all annotated fields IncrementalValuesProvider<(HierarchyInfo Hierarchy, Result Info)> propertyInfoWithErrors = context.ForAttributeWithMetadataNameAndOptions( @@ -86,6 +96,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) item.Properties .Select(Execute.GetPropertySyntax) .Concat(item.Properties.Select(Execute.GetOnPropertyChangeMethodsSyntax).SelectMany(static l => l)) + .Concat(item.Properties.Select(Execute.GetChildPropertyChangedSubscriptionMembersSyntax).SelectMany(static l => l)) .ToImmutableArray(); // Insert all members into the same partial type declaration diff --git a/src/CommunityToolkit.Mvvm.SourceGenerators/Diagnostics/DiagnosticDescriptors.cs b/src/CommunityToolkit.Mvvm.SourceGenerators/Diagnostics/DiagnosticDescriptors.cs index 7c297892d..1ce49407e 100644 --- a/src/CommunityToolkit.Mvvm.SourceGenerators/Diagnostics/DiagnosticDescriptors.cs +++ b/src/CommunityToolkit.Mvvm.SourceGenerators/Diagnostics/DiagnosticDescriptors.cs @@ -944,4 +944,52 @@ internal static class DiagnosticDescriptors isEnabledByDefault: true, description: "Semi-auto properties should be converted to partial properties using [ObservableProperty] when possible, which is recommended (doing so makes the code less verbose and results in more optimized code).", helpLinkUri: "https://aka.ms/mvvmtoolkit/errors/mvvmtk0056"); + + /// + /// Gets a indicating when a [DependsOn] source property is invalid. + /// + /// Format: "The source property "{0}" for [DependsOn] has no valid match in type {1}". + /// + /// + public static readonly DiagnosticDescriptor DependsOnInvalidSourceError = new DiagnosticDescriptor( + id: "MVVMTK0057", + title: "Invalid source name for [DependsOn]", + messageFormat: "The source property \"{0}\" for [DependsOn] has no valid match in type {1}", + category: typeof(ObservablePropertyGenerator).FullName, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "The source properties for [DependsOn] must be different accessible properties in the parent type.", + helpLinkUri: "https://aka.ms/mvvmtoolkit/errors/mvvmtk0057"); + + /// + /// Gets a indicating when [DependsOn] declarations create a cycle. + /// + /// Format: "The [DependsOn] declaration for property "{0}" creates a dependency cycle in type {1}". + /// + /// + public static readonly DiagnosticDescriptor DependsOnCycleError = new DiagnosticDescriptor( + id: "MVVMTK0058", + title: "Dependency cycle for [DependsOn]", + messageFormat: "The [DependsOn] declaration for property \"{0}\" creates a dependency cycle in type {1}", + category: typeof(ObservablePropertyGenerator).FullName, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "The dependency graph declared with [DependsOn] must not contain cycles.", + helpLinkUri: "https://aka.ms/mvvmtoolkit/errors/mvvmtk0058"); + + /// + /// Gets a indicating when [DependsOn(NotifyOnSubPropertyChanges = true)] is used with an invalid source. + /// + /// Format: "The source property "{0}" for [DependsOn(NotifyOnSubPropertyChanges = true)] must be a generated observable property implementing INotifyPropertyChanged in type {1}". + /// + /// + public static readonly DiagnosticDescriptor DependsOnInvalidSubPropertySourceError = new DiagnosticDescriptor( + id: "MVVMTK0059", + title: "Invalid sub-property source for [DependsOn]", + messageFormat: "The source property \"{0}\" for [DependsOn(NotifyOnSubPropertyChanges = true)] must be a generated observable property implementing INotifyPropertyChanged in type {1}", + category: typeof(ObservablePropertyGenerator).FullName, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Sources for [DependsOn(NotifyOnSubPropertyChanges = true)] must be generated observable properties with a type implementing INotifyPropertyChanged.", + helpLinkUri: "https://aka.ms/mvvmtoolkit/errors/mvvmtk0059"); } diff --git a/src/CommunityToolkit.Mvvm/ComponentModel/Attributes/DependsOnAttribute.cs b/src/CommunityToolkit.Mvvm/ComponentModel/Attributes/DependsOnAttribute.cs new file mode 100644 index 000000000..b9ce31676 --- /dev/null +++ b/src/CommunityToolkit.Mvvm/ComponentModel/Attributes/DependsOnAttribute.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Linq; + +namespace CommunityToolkit.Mvvm.ComponentModel; + +/// +/// An attribute that can be used to declare dependencies for calculated properties. +/// When this attribute is used on a property, generated observable properties listed +/// in will also notify the annotated property when changed. +/// +/// +/// This attribute is processed by the MVVM Toolkit source generators. +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = true, Inherited = false)] +public sealed class DependsOnAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The name of the property this calculated property depends on. + public DependsOnAttribute(string propertyName) + { + PropertyNames = new[] { propertyName }; + } + + /// + /// Initializes a new instance of the class. + /// + /// The name of the property this calculated property depends on. + /// The other property names this calculated property depends on. + public DependsOnAttribute(string propertyName, params string[] otherPropertyNames) + { + PropertyNames = new[] { propertyName }.Concat(otherPropertyNames).ToArray(); + } + + /// + /// Gets the property names this calculated property depends on. + /// + public string[] PropertyNames { get; } + + /// + /// Gets or sets whether changes from child instances should also notify dependents. + /// + public bool NotifyOnSubPropertyChanges { get; set; } +} diff --git a/tests/CommunityToolkit.Mvvm.SourceGenerators.UnitTests/Test_SourceGeneratorsCodegen.cs b/tests/CommunityToolkit.Mvvm.SourceGenerators.UnitTests/Test_SourceGeneratorsCodegen.cs index d91dfe2b2..f3a2b2b0c 100644 --- a/tests/CommunityToolkit.Mvvm.SourceGenerators.UnitTests/Test_SourceGeneratorsCodegen.cs +++ b/tests/CommunityToolkit.Mvvm.SourceGenerators.UnitTests/Test_SourceGeneratorsCodegen.cs @@ -230,6 +230,80 @@ partial class MyViewModel VerifyGenerateSources(source, new[] { new ObservablePropertyGenerator() }, ("MyApp.MyViewModel.g.cs", result)); } + [TestMethod] + public void DependsOnGeneratedDependencyGraph_IncludesTransitiveNotificationsAndCommandInvalidation() + { + string source = """ + using CommunityToolkit.Mvvm.ComponentModel; + using CommunityToolkit.Mvvm.Input; + + namespace MyApp; + + partial class MyViewModel : ObservableObject + { + [ObservableProperty] + private string? name; + + [DependsOn(nameof(Name))] + public bool CanSave => !string.IsNullOrWhiteSpace(Name); + + [DependsOn(nameof(CanSave))] + public string State => CanSave ? "Ready" : "Blocked"; + + [RelayCommand(CanExecute = nameof(CanSave))] + private void Save() + { + } + } + """; + + VerifyGeneratedSourceContains( + source, + new IIncrementalGenerator[] { new ObservablePropertyGenerator(), new RelayCommandGenerator() }, + "MyApp.MyViewModel.g.cs", + [ + "OnPropertyChanged(global::CommunityToolkit.Mvvm.ComponentModel.__Internals.__KnownINotifyPropertyChangedArgs.Name);", + "OnPropertyChanged(global::CommunityToolkit.Mvvm.ComponentModel.__Internals.__KnownINotifyPropertyChangedArgs.CanSave);", + "OnPropertyChanged(global::CommunityToolkit.Mvvm.ComponentModel.__Internals.__KnownINotifyPropertyChangedArgs.State);", + "SaveCommand.NotifyCanExecuteChanged();" + ]); + } + + [TestMethod] + public void DependsOnGeneratedDependencyGraph_IncludesChildSubscriptionHelpers() + { + string source = """ + using CommunityToolkit.Mvvm.ComponentModel; + + namespace MyApp; + + partial class ChildViewModel : ObservableObject + { + [ObservableProperty] + private string? name; + } + + partial class MyViewModel : ObservableObject + { + [ObservableProperty] + private ChildViewModel? selectedItem; + + [DependsOn(nameof(SelectedItem), NotifyOnSubPropertyChanges = true)] + public string? SelectedItemName => SelectedItem?.Name; + } + """; + + VerifyGeneratedSourceContains( + source, + new[] { new ObservablePropertyGenerator() }, + "MyApp.MyViewModel.g.cs", + [ + "PropertyChanged +=", + "PropertyChanged -=", + "OnPropertyChanged(global::CommunityToolkit.Mvvm.ComponentModel.__Internals.__KnownINotifyPropertyChangedArgs.SelectedItemName);" + ]); + } + [TestMethod] public void ObservablePropertyWithNonNullableUnconstrainedGenericType_EmitsMemberNotNullAttribute() { @@ -3525,4 +3599,49 @@ from assembly in AppDomain.CurrentDomain.GetAssemblies() GC.KeepAlive(observableObjectType); GC.KeepAlive(validationAttributeType); } + + /// + /// Generates sources and verifies that a generated file contains a set of snippets. + /// + /// The input source to process. + /// The generators to apply to the input syntax tree. + /// The generated filename to inspect. + /// The snippets expected in the generated source. + private static void VerifyGeneratedSourceContains(string source, IIncrementalGenerator[] generators, string filename, string[] snippets) + { + // Ensure CommunityToolkit.Mvvm and System.ComponentModel.DataAnnotations are loaded + Type observableObjectType = typeof(ObservableObject); + Type validationAttributeType = typeof(ValidationAttribute); + + IEnumerable references = + from assembly in AppDomain.CurrentDomain.GetAssemblies() + where !assembly.IsDynamic + let reference = MetadataReference.CreateFromFile(assembly.Location) + select reference; + + SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(source, CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp10)); + + CSharpCompilation compilation = CSharpCompilation.Create( + "original", + [syntaxTree], + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + GeneratorDriver driver = CSharpGeneratorDriver.Create(generators).WithUpdatedParseOptions((CSharpParseOptions)syntaxTree.Options); + + _ = driver.RunGeneratorsAndUpdateCompilation(compilation, out Compilation outputCompilation, out ImmutableArray diagnostics); + + CollectionAssert.AreEquivalent(Array.Empty(), diagnostics); + + SyntaxTree generatedTree = outputCompilation.SyntaxTrees.Single(tree => Path.GetFileName(tree.FilePath) == filename); + string generatedText = generatedTree.ToString(); + + foreach (string snippet in snippets) + { + StringAssert.Contains(generatedText, snippet); + } + + GC.KeepAlive(observableObjectType); + GC.KeepAlive(validationAttributeType); + } } diff --git a/tests/CommunityToolkit.Mvvm.SourceGenerators.UnitTests/Test_SourceGeneratorsDiagnostics.cs b/tests/CommunityToolkit.Mvvm.SourceGenerators.UnitTests/Test_SourceGeneratorsDiagnostics.cs index 69da07125..bacbf9375 100644 --- a/tests/CommunityToolkit.Mvvm.SourceGenerators.UnitTests/Test_SourceGeneratorsDiagnostics.cs +++ b/tests/CommunityToolkit.Mvvm.SourceGenerators.UnitTests/Test_SourceGeneratorsDiagnostics.cs @@ -914,6 +914,113 @@ public partial class SampleViewModel : ObservableObject VerifyGeneratedDiagnostics(source, "MVVMTK0016"); } + [TestMethod] + public void DependsOnInvalidSourceError_Missing() + { + string source = """ + using CommunityToolkit.Mvvm.ComponentModel; + + namespace MyApp + { + public partial class SampleViewModel : ObservableObject + { + [ObservableProperty] + private string name; + + [DependsOn("FooBar")] + public string DisplayName => Name; + } + } + """; + + VerifyGeneratedDiagnostics(source, "MVVMTK0057"); + } + + [TestMethod] + public void DependsOnInvalidSourceError_Self() + { + string source = """ + using CommunityToolkit.Mvvm.ComponentModel; + + namespace MyApp + { + public partial class SampleViewModel : ObservableObject + { + [DependsOn(nameof(DisplayName))] + public string DisplayName => ""; + } + } + """; + + VerifyGeneratedDiagnostics(source, "MVVMTK0057"); + } + + [TestMethod] + public void DependsOnCycleError() + { + string source = """ + using CommunityToolkit.Mvvm.ComponentModel; + + namespace MyApp + { + public partial class SampleViewModel : ObservableObject + { + [DependsOn(nameof(Second))] + public string First => ""; + + [DependsOn(nameof(First))] + public string Second => ""; + } + } + """; + + VerifyGeneratedDiagnostics(source, "MVVMTK0058"); + } + + [TestMethod] + public void DependsOnInvalidSubPropertySourceError_NonGeneratedSource() + { + string source = """ + using CommunityToolkit.Mvvm.ComponentModel; + + namespace MyApp + { + public partial class SampleViewModel : ObservableObject + { + public object Child { get; } = new(); + + [DependsOn(nameof(Child), NotifyOnSubPropertyChanges = true)] + public string DisplayName => ""; + } + } + """; + + VerifyGeneratedDiagnostics(source, "MVVMTK0059"); + } + + [TestMethod] + public void DependsOnSubPropertySourceExactINotifyPropertyChangedTypeIsValid() + { + string source = """ + using System.ComponentModel; + using CommunityToolkit.Mvvm.ComponentModel; + + namespace MyApp + { + public partial class SampleViewModel : ObservableObject + { + [ObservableProperty] + private INotifyPropertyChanged? child; + + [DependsOn(nameof(Child), NotifyOnSubPropertyChanges = true)] + public string DisplayName => ""; + } + } + """; + + VerifyGeneratedDiagnostics(source); + } + [TestMethod] public void InvalidAttributeCombinationForINotifyPropertyChangedAttributeError_InheritingINotifyPropertyChangedAttribute() { diff --git a/tests/CommunityToolkit.Mvvm.UnitTests/Test_ObservablePropertyAttribute.cs b/tests/CommunityToolkit.Mvvm.UnitTests/Test_ObservablePropertyAttribute.cs index 646058a7a..7ae268395 100644 --- a/tests/CommunityToolkit.Mvvm.UnitTests/Test_ObservablePropertyAttribute.cs +++ b/tests/CommunityToolkit.Mvvm.UnitTests/Test_ObservablePropertyAttribute.cs @@ -1069,6 +1069,154 @@ public void Test_ObservableProperty_ModelWithDependentPropertyAndNoPropertyChang CollectionAssert.AreEqual(new[] { nameof(ModelWithDependentPropertyAndNoPropertyChanging.Name), nameof(ModelWithDependentPropertyAndNoPropertyChanging.FullName) }, changedArgs); } + [TestMethod] + public void Test_DependsOn_TransitiveCalculatedProperties() + { + ModelWithDependsOnTransitiveProperties model = new(); + + List propertyNames = new(); + + model.PropertyChanged += (s, e) => propertyNames.Add(e.PropertyName); + + model.FirstName = "Bob"; + + CollectionAssert.AreEqual( + new[] + { + nameof(ModelWithDependsOnTransitiveProperties.FirstName), + nameof(ModelWithDependsOnTransitiveProperties.FullName), + nameof(ModelWithDependsOnTransitiveProperties.DisplayName) + }, + propertyNames); + } + + [TestMethod] + public void Test_DependsOn_CommandCanExecuteIsInvalidated() + { + ModelWithDependsOnCommand model = new(); + + int canExecuteChangedRequests = 0; + + model.SaveCommand.CanExecuteChanged += (s, e) => canExecuteChangedRequests++; + + model.Name = "Bob"; + + Assert.AreEqual(1, canExecuteChangedRequests); + } + + [TestMethod] + public void Test_DependsOn_NotifyPropertyChangedForRemainsDirectOnly() + { + ModelWithNotifyPropertyChangedForDirectOnly model = new(); + + List propertyNames = new(); + + model.PropertyChanged += (s, e) => propertyNames.Add(e.PropertyName); + + model.Name = "Bob"; + + CollectionAssert.AreEqual( + new[] + { + nameof(ModelWithNotifyPropertyChangedForDirectOnly.Name), + nameof(ModelWithNotifyPropertyChangedForDirectOnly.Intermediate) + }, + propertyNames); + } + + [TestMethod] + public void Test_DependsOn_SubPropertyChangesNotifyDependents() + { + ChildModelForDependsOn child = new(); + ModelWithDependsOnChildProperty model = new() { SelectedItem = child }; + + List propertyNames = new(); + + model.PropertyChanged += (s, e) => propertyNames.Add(e.PropertyName); + + child.Name = "Bob"; + + CollectionAssert.AreEqual(new[] { nameof(ModelWithDependsOnChildProperty.SelectedItemName) }, propertyNames); + } + + [TestMethod] + public void Test_DependsOn_SubPropertyChangesOnlyNotifyOptedInDependents() + { + ChildModelForDependsOn child = new(); + ModelWithDependsOnMixedChildProperty model = new() { SelectedItem = child }; + + List propertyNames = new(); + int canExecuteChangedRequests = 0; + + model.PropertyChanged += (s, e) => propertyNames.Add(e.PropertyName); + model.SaveCommand.CanExecuteChanged += (s, e) => canExecuteChangedRequests++; + + child.Name = "Bob"; + + CollectionAssert.AreEqual( + new[] + { + nameof(ModelWithDependsOnMixedChildProperty.SelectedItemName), + nameof(ModelWithDependsOnMixedChildProperty.SelectedItemDisplayName) + }, + propertyNames); + + Assert.AreEqual(0, canExecuteChangedRequests); + } + + [TestMethod] + public void Test_DependsOn_SubPropertyReplacingChildDetachesOldInstance() + { + ChildModelForDependsOn oldChild = new(); + ChildModelForDependsOn newChild = new(); + ModelWithDependsOnChildProperty model = new() { SelectedItem = oldChild }; + + List propertyNames = new(); + + model.PropertyChanged += (s, e) => propertyNames.Add(e.PropertyName); + + model.SelectedItem = newChild; + propertyNames.Clear(); + + oldChild.Name = "Bob"; + newChild.Name = "Alice"; + + CollectionAssert.AreEqual(new[] { nameof(ModelWithDependsOnChildProperty.SelectedItemName) }, propertyNames); + } + + [TestMethod] + public void Test_DependsOn_SubPropertyNullAssignmentIsSafe() + { + ChildModelForDependsOn child = new(); + ModelWithDependsOnChildProperty model = new() { SelectedItem = child }; + + List propertyNames = new(); + + model.PropertyChanged += (s, e) => propertyNames.Add(e.PropertyName); + + model.SelectedItem = null; + propertyNames.Clear(); + + child.Name = "Bob"; + + Assert.IsEmpty(propertyNames); + } + + [TestMethod] + public void Test_DependsOn_SubPropertyInitializedBackingFieldSubscribesAfterGetterAccess() + { + ModelWithInitializedDependsOnChildProperty model = new(); + ChildModelForDependsOn child = model.SelectedItem!; + + List propertyNames = new(); + + model.PropertyChanged += (s, e) => propertyNames.Add(e.PropertyName); + + child.Name = "Bob"; + + CollectionAssert.AreEqual(new[] { nameof(ModelWithInitializedDependsOnChildProperty.SelectedItemName) }, propertyNames); + } + #if NET6_0_OR_GREATER [TestMethod] public void Test_ObservableProperty_MemberNotNullAttributeIsPresent() @@ -1794,6 +1942,89 @@ private sealed partial class ModelWithDependentPropertyAndNoPropertyChanging public string? FullName => ""; } + private sealed partial class ModelWithDependsOnTransitiveProperties : ObservableObject + { + [ObservableProperty] + private string? firstName; + + [DependsOn(nameof(FirstName))] + public string FullName => FirstName ?? ""; + + [DependsOn(nameof(FullName))] + public string DisplayName => FullName.ToUpperInvariant(); + } + + private sealed partial class ModelWithDependsOnCommand : ObservableObject + { + [ObservableProperty] + private string? name; + + [DependsOn(nameof(Name))] + public bool CanSave => !string.IsNullOrWhiteSpace(Name); + + [RelayCommand(CanExecute = nameof(CanSave))] + private void Save() + { + } + } + + private sealed partial class ModelWithNotifyPropertyChangedForDirectOnly : ObservableObject + { + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Intermediate))] + private string? name; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Final))] + private string? intermediate; + + public string Final => Intermediate ?? ""; + } + + private sealed partial class ChildModelForDependsOn : ObservableObject + { + [ObservableProperty] + private string? name; + } + + private sealed partial class ModelWithDependsOnChildProperty : ObservableObject + { + [ObservableProperty] + private ChildModelForDependsOn? selectedItem; + + [DependsOn(nameof(SelectedItem), NotifyOnSubPropertyChanges = true)] + public string? SelectedItemName => SelectedItem?.Name; + } + + private sealed partial class ModelWithDependsOnMixedChildProperty : ObservableObject + { + [ObservableProperty] + private ChildModelForDependsOn? selectedItem; + + [DependsOn(nameof(SelectedItem), NotifyOnSubPropertyChanges = true)] + public string? SelectedItemName => SelectedItem?.Name; + + [DependsOn(nameof(SelectedItemName))] + public string? SelectedItemDisplayName => SelectedItemName?.ToUpperInvariant(); + + [DependsOn(nameof(SelectedItem))] + public bool HasSelection => SelectedItem is not null; + + [RelayCommand(CanExecute = nameof(HasSelection))] + private void Save() + { + } + } + + private sealed partial class ModelWithInitializedDependsOnChildProperty : ObservableObject + { + [ObservableProperty] + private ChildModelForDependsOn? selectedItem = new(); + + [DependsOn(nameof(SelectedItem), NotifyOnSubPropertyChanges = true)] + public string? SelectedItemName => SelectedItem?.Name; + } + #if NET6_0_OR_GREATER // See https://github.com/CommunityToolkit/dotnet/issues/939 public partial class ModelWithSecondaryPropertySetFromGeneratedSetter_DoesNotWarn : ObservableObject