< Summary

Information
Class: MyNet.Observable.PropertyNotificationSuspension
Assembly: MyNet.Observable
File(s): https://raw.githubusercontent.com/sandre58/MyNet/85372080fe102cd9ee155ceab49ae000e7f66103/src/MyNet.Observable/Base/ObservableObject.cs
Tag: 323_28699572109
Line coverage
93%
Covered lines: 43
Uncovered lines: 3
Coverable lines: 46
Total lines: 478
Line coverage: 93.4%
Branch coverage
77%
Covered branches: 14
Total branches: 18
Branch coverage: 77.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor()100%11100%
get_IsSuspended()100%11100%
get_CurrentMode()100%11100%
Enter(...)100%11100%
Dispose()100%11100%
TryHandlePropertyChanged(...)100%66100%
Exit()50%4471.42%
FlushPending()83.33%66100%
ClearPending()100%11100%
Dispose()50%2280%

File(s)

https://raw.githubusercontent.com/sandre58/MyNet/85372080fe102cd9ee155ceab49ae000e7f66103/src/MyNet.Observable/Base/ObservableObject.cs

#LineLine coverage
 1// -----------------------------------------------------------------------
 2// <copyright file="ObservableObject.cs" company="Stéphane ANDRE">
 3// Copyright (c) Stéphane ANDRE. All rights reserved.
 4// </copyright>
 5// -----------------------------------------------------------------------
 6
 7using System;
 8using System.Collections.Concurrent;
 9using System.Collections.Generic;
 10using System.ComponentModel;
 11using System.Diagnostics.CodeAnalysis;
 12using System.Linq;
 13using System.Reactive.Disposables;
 14using System.Runtime.CompilerServices;
 15using MyNet.Observable.Behaviors;
 16using MyNet.Utilities.Suspending;
 17
 18#pragma warning disable IDE0130 // Namespace does not match folder structure
 19namespace MyNet.Observable;
 20#pragma warning restore IDE0130 // Namespace does not match folder structure
 21
 22/// <summary>
 23/// A base class for objects of which the properties must be observable.
 24/// </summary>
 25public abstract class ObservableObject : IObservableObject
 26{
 27    private static readonly ConcurrentDictionary<string, PropertyChangedEventArgs> PropertyChangedEventArgsCache = new(S
 28    private static readonly ConcurrentDictionary<string, PropertyChangingEventArgs> PropertyChangingEventArgsCache = new
 29
 30    private readonly PropertyNotificationSuspension _notificationSuspension = new();
 31
 32    [SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", Justification = "Disposed in DisposeManaged
 33    private readonly BehaviorRegistry _behaviors = new();
 34
 35    private readonly Suspender _deliveringDeferredNotificationsSuspender = new();
 36
 37    /// <summary>
 38    /// Initializes a new instance of the <see cref="ObservableObject"/> class and applies metadata-driven behaviors.
 39    /// </summary>
 40    protected ObservableObject()
 41    {
 42        _notificationSuspension.PendingFlush = DeliverDeferredPropertyChanged;
 43        Behaviors = new(_behaviors);
 44        MetadataBehaviorApplicator.Apply(this);
 45    }
 46
 47    /// <summary>
 48    /// Gets the behavior registry facade for this instance.
 49    /// </summary>
 50    public ObservableBehaviors Behaviors { get; }
 51
 52    /// <summary>
 53    /// Gets the collection of disposables that will be disposed when the object is disposed.
 54    /// </summary>
 55    protected CompositeDisposable Disposables { get; } = [];
 56
 57    /// <summary>
 58    /// Gets a value indicating whether property notifications are currently suspended.
 59    /// </summary>
 60    protected bool AreNotificationsSuspended => _notificationSuspension.IsSuspended;
 61
 62    /// <summary>
 63    /// Gets a value indicating whether the object has been disposed.
 64    /// </summary>
 65    public bool IsDisposed { get; private set; }
 66
 67    #region INotifyPropertyChanging
 68
 69    /// <summary>
 70    /// Occurs when a property value is changing. This event is raised before the property value changes, allowing subsc
 71    /// </summary>
 72    public event PropertyChangingEventHandler? PropertyChanging;
 73
 74    /// <summary>
 75    /// Handles the property changing event by executing custom logic before a property changes. This method is called b
 76    /// </summary>
 77    /// <param name="propertyName">The name of the property that is changing.</param>
 78    /// <param name="before">The value of the property before the change.</param>
 79    /// <param name="after">The value of the property after the change.</param>
 80    /// <returns><c>false</c> when the mutation was vetoed via <see cref="PropertyMutationContext.Cancel"/>; otherwise <
 81    protected virtual bool ProcessPropertyChanging(string propertyName, object? before, object? after)
 82    {
 83        if (ShouldSkipNotification(propertyName))
 84            return true;
 85
 86        if (!_deliveringDeferredNotificationsSuspender.IsSuspended && _notificationSuspension.IsSuspended)
 87            return true;
 88
 89        var context = new PropertyMutationContext
 90        {
 91            Sender = this,
 92            PropertyName = propertyName,
 93            OldValue = before,
 94            NewValue = after
 95        };
 96
 97        foreach (var behavior in _behaviors.Changing)
 98        {
 99            behavior.OnPropertyChanging(context);
 100
 101            if (context.Cancel)
 102                return false;
 103        }
 104
 105        OnPropertyChangingCore(context);
 106
 107        if (context.Cancel)
 108            return false;
 109
 110        RaisePropertyChanging(propertyName);
 111
 112        return true;
 113    }
 114
 115    /// <summary>
 116    /// Raises the PropertyChanging event for the specified property name, with the provided before and after values. Th
 117    /// </summary>
 118    /// <param name="propertyName">The name of the property that is changing.</param>
 119    /// <param name="before">The value of the property before the change.</param>
 120    /// <param name="after">The value of the property after the change.</param>
 121    /// <returns><c>false</c> when the mutation was vetoed; otherwise <c>true</c>.</returns>
 122    protected virtual bool OnPropertyChanging(string propertyName, object? before, object? after) => ProcessPropertyChan
 123
 124    /// <summary>
 125    /// Raises the PropertyChanging event for the specified property name. This method is called to notify subscribers t
 126    /// </summary>
 127    /// <param name="propertyName">The name of the property that is changing.</param>
 128    private void RaisePropertyChanging(string propertyName) => PropertyChanging?.Invoke(this, GetPropertyChangingEventAr
 129
 130    /// <summary>
 131    /// Provides a core implementation for handling the property changing event. This method is called after all behavio
 132    /// </summary>
 133    /// <param name="context">The context of the property changing event, containing information about the sender, prope
 134    protected virtual void OnPropertyChangingCore(PropertyMutationContext context)
 135    {
 136    }
 137
 138    #endregion
 139
 140    #region INotifyPropertyChanged
 141
 142    /// <summary>
 143    /// Occurs when a property value has changed. This event is raised after the property value has changed, allowing su
 144    /// </summary>
 145    public event PropertyChangedEventHandler? PropertyChanged;
 146
 147    /// <summary>
 148    /// Handles the property changed event by executing custom logic after a property has changed. This method is called
 149    /// </summary>
 150    /// <param name="propertyName">The name of the property that has changed.</param>
 151    /// <param name="before">The value of the property before the change.</param>
 152    /// <param name="after">The value of the property after the change.</param>
 153    protected virtual void ProcessPropertyChanged(string propertyName, object? before, object? after)
 154    {
 155        if (ShouldSkipNotification(propertyName))
 156            return;
 157
 158        if (!_deliveringDeferredNotificationsSuspender.IsSuspended && _notificationSuspension.TryHandlePropertyChanged(p
 159            return;
 160
 161        var context = new PropertyMutationContext
 162        {
 163            Sender = this,
 164            PropertyName = propertyName,
 165            OldValue = before,
 166            NewValue = after
 167        };
 168
 169        foreach (var behavior in _behaviors.Changed)
 170        {
 171            behavior.OnPropertyChanged(context);
 172        }
 173
 174        OnPropertyChangedCore(context);
 175
 176        RaisePropertyChanged(propertyName);
 177    }
 178
 179    /// <summary>
 180    /// Raises the PropertyChanged event for the specified property name, with the provided before and after values. Thi
 181    /// </summary>
 182    /// <param name="propertyName">The name of the property that has changed.</param>
 183    /// <param name="before">The value of the property before the change.</param>
 184    /// <param name="after">The value of the property after the change.</param>
 185    protected virtual void OnPropertyChanged(string propertyName, object? before, object? after) => ProcessPropertyChang
 186
 187    /// <summary>
 188    /// Raises the PropertyChanged event for the specified property name. This method is called to notify subscribers th
 189    /// </summary>
 190    /// <param name="propertyName">The name of the property that has changed.</param>
 191    private void RaisePropertyChanged(string propertyName) => PropertyChanged?.Invoke(this, GetPropertyChangedEventArgs(
 192
 193    /// <summary>
 194    /// Provides a core implementation for handling the property changed event. This method is called after all behavior
 195    /// </summary>
 196    /// <param name="context">The context of the property changed event, containing information about the sender, proper
 197    protected virtual void OnPropertyChangedCore(PropertyMutationContext context)
 198    {
 199    }
 200
 201    /// <summary>
 202    /// Notifies subscribers that a property has changed when old and new values are not available (for example relayed 
 203    /// Prefer <see cref="NotifyPropertyChanged(string, object?, object?)"/> from property setters when mutation values 
 204    /// </summary>
 205    /// <param name="propertyName">The name of the property that has changed.</param>
 206    protected internal void NotifyPropertyChanged(string propertyName) => ProcessPropertyChanged(propertyName, UnknownVa
 207
 208    /// <summary>
 209    /// Notifies subscribers that a property has changed by raising the PropertyChanged event for the specified property
 210    /// </summary>
 211    /// <param name="propertyName">The name of the property that has changed.</param>
 212    /// <param name="before">The value of the property before the change.</param>
 213    /// <param name="after">The value of the property after the change.</param>
 214    protected internal void NotifyPropertyChanged(string propertyName, object? before, object? after) => ProcessProperty
 215
 216    #endregion
 217
 218    #region Notification Suspension
 219
 220    /// <summary>
 221    /// Suspends property notifications until the returned scope is disposed.
 222    /// By default, changes are coalesced per property and replayed when the outermost scope ends.
 223    /// </summary>
 224    /// <param name="mode">How notifications are handled while suspended.</param>
 225    /// <returns>A disposable suspension scope.</returns>
 226    protected IDisposable SuspendNotifications(NotificationSuspensionMode mode = NotificationSuspensionMode.CoalesceOnRe
 227
 228    private void DeliverDeferredPropertyChanged(string propertyName, object? before, object? after)
 229    {
 230        if (IsDisposed)
 231            return;
 232
 233        using (_deliveringDeferredNotificationsSuspender.Suspend())
 234            ProcessPropertyChanged(propertyName, before, after);
 235    }
 236
 237    /// <summary>
 238    /// Determines whether property change notifications should be skipped based on the current state of the object and 
 239    /// </summary>
 240    /// <param name="propertyName">The name of the property to check.</param>
 241    /// <returns>True if notifications should be skipped; otherwise, false.</returns>
 242    private bool ShouldSkipNotification(string? propertyName) => IsDisposed || string.IsNullOrWhiteSpace(propertyName);
 243
 244    #endregion
 245
 246    #region IDisposable Support
 247
 248    /// <summary>
 249    /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
 250    /// </summary>
 251    protected virtual void DisposeManagedResources()
 252    {
 253        _notificationSuspension.Dispose();
 254        _behaviors.Dispose();
 255
 256        Disposables.Dispose();
 257    }
 258
 259    /// <summary>
 260    /// Disposes the object and releases all resources.
 261    /// </summary>
 262    /// <param name="disposing">Indicates whether the method is called from Dispose (true) or from a finalizer (false).<
 263    protected virtual void Dispose(bool disposing)
 264    {
 265        if (IsDisposed)
 266            return;
 267
 268        if (disposing)
 269            DisposeManagedResources();
 270
 271        IsDisposed = true;
 272    }
 273
 274    /// <summary>
 275    /// Disposes the object and releases all resources.
 276    /// </summary>
 277    public void Dispose()
 278    {
 279        Dispose(true);
 280        GC.SuppressFinalize(this);
 281    }
 282
 283    #endregion IDisposable Support
 284
 285    #region SetProperty
 286
 287    /// <summary>
 288    /// Assigns the field and raises changing/changed notifications through the behavior pipeline when the value differs
 289    /// </summary>
 290    /// <typeparam name="T">The type of the property.</typeparam>
 291    /// <param name="field">Reference to the backing field.</param>
 292    /// <param name="value">The new value.</param>
 293    /// <param name="propertyName">The name of the property (inferred from the caller by default).</param>
 294    /// <returns><c>true</c> if the value changed and notifications were raised; otherwise, <c>false</c> (unchanged valu
 295    protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
 296    {
 297        if (EqualityComparer<T>.Default.Equals(field, value))
 298            return false;
 299
 300        ArgumentException.ThrowIfNullOrWhiteSpace(propertyName);
 301
 302        var before = field;
 303
 304        if (!OnPropertyChanging(propertyName, before, value))
 305            return false;
 306
 307        field = value;
 308        OnPropertyChanged(propertyName, before, value);
 309        return true;
 310    }
 311
 312    #endregion
 313
 314    #region Helpers
 315
 316    /// <summary>
 317    /// Gets a cached PropertyChangedEventArgs instance for the specified property name.
 318    /// </summary>
 319    /// <param name="propertyName">The name of the property.</param>
 320    /// <returns>A cached PropertyChangedEventArgs instance.</returns>
 321    private static PropertyChangedEventArgs GetPropertyChangedEventArgs(string propertyName) => PropertyChangedEventArgs
 322
 323    /// <summary>
 324    /// Gets a cached PropertyChangingEventArgs instance for the specified property name.
 325    /// </summary>
 326    /// <param name="propertyName">The name of the property.</param>
 327    /// <returns>A cached PropertyChangingEventArgs instance.</returns>
 328    private static PropertyChangingEventArgs GetPropertyChangingEventArgs(string propertyName) => PropertyChangingEventA
 329
 330    #endregion
 331}
 332
 333/// <summary>
 334/// Defines how <see cref="ObservableObject"/> handles property notifications while a suspension scope is active.
 335/// </summary>
 336public enum NotificationSuspensionMode
 337{
 338    /// <summary>
 339    /// Notifications are suppressed and not replayed when the scope ends.
 340    /// </summary>
 341    Drop,
 342
 343    /// <summary>
 344    /// Notifications are coalesced per property name and replayed once when the outermost scope ends.
 345    /// </summary>
 346    CoalesceOnResume
 347}
 348
 349/// <summary>
 350/// Tracks nested notification suspension scopes and coalesced property mutations.
 351/// </summary>
 352internal sealed class PropertyNotificationSuspension
 353{
 1935354    private readonly List<NotificationSuspensionMode> _modes = [];
 1935355    private readonly List<string> _pendingOrder = [];
 1935356    private readonly Dictionary<string, PendingPropertyMutation> _pending = new(StringComparer.Ordinal);
 357
 358    /// <summary>
 359    /// Gets a value indicating whether at least one suspension scope is active.
 360    /// </summary>
 4767361    public bool IsSuspended => _modes.Count > 0;
 362
 363    /// <summary>
 364    /// Gets the mode of the innermost active scope.
 365    /// </summary>
 15366    public NotificationSuspensionMode CurrentMode => _modes[^1];
 367
 368    /// <summary>
 369    /// Begins a suspension scope with the specified mode.
 370    /// </summary>
 371    public IDisposable Enter(NotificationSuspensionMode mode)
 372    {
 9373        _modes.Add(mode);
 9374        return new Scope(this);
 375    }
 376
 377    /// <summary>
 378    /// Clears pending mutations without replaying them.
 379    /// </summary>
 380    public void Dispose()
 381    {
 303382        _modes.Clear();
 303383        ClearPending();
 303384    }
 385
 386    /// <summary>
 387    /// Handles a property-changed notification while suspended.
 388    /// </summary>
 389    /// <returns><c>true</c> if the notification was handled and must not be delivered now.</returns>
 390    public bool TryHandlePropertyChanged(string propertyName, object? before, object? after)
 391    {
 3162392        if (!IsSuspended)
 3147393            return false;
 394
 15395        if (CurrentMode == NotificationSuspensionMode.Drop)
 3396            return true;
 397
 12398        if (_pending.TryGetValue(propertyName, out var pending))
 399        {
 6400            pending.NewValue = after;
 6401            return true;
 402        }
 403
 6404        _pendingOrder.Add(propertyName);
 6405        _pending[propertyName] = new(before, after);
 6406        return true;
 407    }
 408
 409    private void Exit()
 410    {
 9411        if (_modes.Count == 0)
 0412            return;
 413
 9414        _modes.RemoveAt(_modes.Count - 1);
 415
 9416        if (IsSuspended)
 0417            return;
 418
 9419        FlushPending();
 9420    }
 421
 422    private void FlushPending()
 423    {
 9424        if (_pendingOrder.Count == 0)
 3425            return;
 426
 6427        var snapshot = new List<(string Name, object? Before, object? After)>(_pendingOrder.Count);
 6428        snapshot.AddRange(from propertyName in _pendingOrder
 6429            let pending = _pending[propertyName]
 6430            where !Equals(pending.OldValue, pending.NewValue)
 6431            select (propertyName, pending.OldValue, pending.NewValue));
 432
 6433        ClearPending();
 434
 24435        foreach (var (name, before, after) in snapshot)
 6436            PendingFlush?.Invoke(name, before, after);
 6437    }
 438
 439    private void ClearPending()
 440    {
 309441        _pendingOrder.Clear();
 309442        _pending.Clear();
 309443    }
 444
 445    /// <summary>
 446    /// Gets or sets the callback invoked for each coalesced property when the outermost scope ends.
 447    /// Must deliver without re-entering suspension.
 448    /// </summary>
 449    public Action<string, object?, object?>? PendingFlush { get; set; }
 450
 451    private sealed class PendingPropertyMutation(object? oldValue, object? newValue)
 452    {
 453        /// <summary>
 454        /// Gets the first observed value before coalescing.
 455        /// </summary>
 456        public object? OldValue { get; } = oldValue;
 457
 458        /// <summary>
 459        /// Gets or sets the last observed value after coalescing.
 460        /// </summary>
 461        public object? NewValue { get; set; } = newValue;
 462    }
 463
 464    private sealed class Scope(PropertyNotificationSuspension owner) : IDisposable
 465    {
 466        private bool _disposed;
 467
 468        public void Dispose()
 469        {
 9470            if (_disposed)
 0471                return;
 472
 9473            _disposed = true;
 9474            owner.Exit();
 9475        }
 476    }
 477}
 478