using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; using System.Reflection; namespace GB5Shared.ChangeTracking { /// /// Compares two instances of the same DTO type and returns only the fields that changed. /// Uses compiled delegates cached per type — zero reflection on /// the hot path after the first call for a given type. /// /// Only primitive-value properties are tracked: bool, numeric, string, decimal, /// DateTime, DateTimeOffset, Guid, and their nullable equivalents. /// Complex navigation properties, collections, and byte arrays are intentionally skipped. /// /// Usage (UPDATE path only — do not call for INSERT): /// /// var changes = ChangeTracker.Compare(oldDto, newDto); /// if (changes.Count > 0) /// outboxMessage.Changes = JsonSerializer.Serialize(changes); /// /// public static class ChangeTracker { // ── Cache ──────────────────────────────────────────────────────────────── // Keyed by concrete Type; each value is an array of (PropertyName, compiled getter). // Built once per type on first call; never mutated after population. private static readonly ConcurrentDictionary _cache = new(); // ── Public API ─────────────────────────────────────────────────────────── /// /// Returns the list of properties whose value changed between /// and . /// Returns an empty list — not null — when there are no differences. /// Returns an empty list when is null (INSERT path guard). /// public static List Compare(T? oldObj, T newObj) where T : class { if (oldObj is null || newObj is null) return []; var accessors = _cache.GetOrAdd(typeof(T), BuildAccessors); var changes = new List(accessors.Length); foreach (var acc in accessors) { var oldVal = acc.Get(oldObj)?.ToString(); var newVal = acc.Get(newObj)?.ToString(); if (!string.Equals(oldVal, newVal, StringComparison.Ordinal)) changes.Add(new ChangeRecordDTO(acc.Name, oldVal, newVal)); } return changes; } // ── Internal — compiled delegate builder ──────────────────────────────── private static PropertyAccessor[] BuildAccessors(Type type) { var param = Expression.Parameter(typeof(object), "obj"); var cast = Expression.Convert(param, type); return type .GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(p => p.CanRead && IsTrackedType(p.PropertyType)) .Select(p => { // Build: (object obj) => (object)((T)obj).PropertyName var propAccess = Expression.Property(cast, p); var boxed = Expression.Convert(propAccess, typeof(object)); var getter = Expression.Lambda>(boxed, param).Compile(); return new PropertyAccessor(p.Name, getter); }) .ToArray(); } /// /// Returns true for types whose string representation is meaningful for diffing. /// Nullable<T> is unwrapped to its underlying type for the check. /// private static bool IsTrackedType(Type t) { var underlying = Nullable.GetUnderlyingType(t) ?? t; return underlying.IsPrimitive // bool, int, long, double, float, char, byte, … || underlying == typeof(string) || underlying == typeof(decimal) || underlying == typeof(DateTime) || underlying == typeof(DateTimeOffset) || underlying == typeof(Guid); } // ── Inner record ───────────────────────────────────────────────────────── private sealed record PropertyAccessor(string Name, Func Get); } }