Open Closed

EntityChange on ValueObjects / InternalEntities always pointing to the new values in AuditLogContributionContext context #10154


User avatar
0
dcsimoes created

Description When implementing a custom IAuditLogContributor to support audit logging of ValueObjects, we discovered that the ABP Framework does not provide the correct InternalEntity on OriginalValues during the audit logging process while the NewValue is resolved correctly, the InternalEntity for original values is already overwritten by the time the contributor executes. All EF Core sources that should expose the previous state (e.g., EntityEntry.OriginalValues) already reflect the new value.

Technical Context

[AttributeUsage(AttributeTargets.Property)]
public class EntityChangeTrackedValueObjectAttribute : Attribute
{
    public string MemberName { get; }
    public bool IsMethod { get; }

    public EntityChangeTrackedValueObjectAttribute(string memberName, bool isMethod = true)
    {
        MemberName = memberName;
        IsMethod = isMethod;
    }
}

*Custom AuditLogContributor* - Implemented so newValue is fetched

public override void PostContribute(AuditLogContributionContext context)
{
     if (context?.AuditInfo?.EntityChanges == null)
     {
         base.PostContribute(context);
         return;
     }
    
     foreach (var entityChange in context.AuditInfo.EntityChanges)
     {
         if (entityChange?.PropertyChanges == null) continue;
    
         var entityTypeFullName = entityChange.EntityTypeFullName;
         var entityTypeName = entityChange.EntityTypeFullName?.Split('.').LastOrDefault()?.Trim();
    
         foreach (var propertyChange in entityChange.PropertyChanges)
         {
             if (propertyChange == null) continue;
    
             var propertyPath = propertyChange.PropertyName ?? string.Empty;
             if (string.IsNullOrWhiteSpace(propertyPath)) continue;
    
             var propertyLast = propertyPath.Split('.').Last();
    
             if (!string.IsNullOrEmpty(propertyChange.NewValue))
                 continue;
    
             if (!string.IsNullOrEmpty(entityTypeName) &&
                 VoTrackedPropertyCache.TryGet(entityTypeName, propertyLast, out var voType, out var memberName, out var isMethod, out var declaringType))
             {
                 var entryWrapper = entityChange.EntityEntry;
                 var wrapperType = entryWrapper.GetType();
    
                 object newResult = null;
                 var entityProp = wrapperType.GetProperty("Entity");
                 var currentEntity = entityProp?.GetValue(entryWrapper);
    
                 if (currentEntity != null && declaringType.IsInstanceOfType(currentEntity))
                 {
                     var voProp = declaringType.GetProperty(propertyLast);
                     var currentVoInstance = voProp?.GetValue(currentEntity);
    
                     if (currentVoInstance != null)
                     {
                         newResult = GetResultFromVo(currentVoInstance, voType, memberName, isMethod);
                     }
                 }
    
                 if (newResult != null)
                 {
                     propertyChange.NewValue = newResult.ToString();
                     propertyChange.PropertyTypeFullName = "System.String";
                 }
         }
      }
  }
}

Observed Behavior

During the PostContribute phase, we attempted to retrieve the old values from: entityChange.EntityEntry, without success beecause all InternalEntities already ahd the new values.

Expected Behavior ABP should expose the actual original value of the Value Object within the audit context, when accessing the InternalEntity of a change.

So, we want to know if there is already in the framework a way to handle value objects obtaining similar results without using our implementation, and, if not, if you can provide a solution so we can use this aproach also for OriginalValues.

Thanks in advance


3 Answer(s)
  • User Avatar
    0
    AI-Bot created
    Support Team ABP Agent
    1. Solution
    • Why you see “new” values in PostContribute

      • ABP builds the AuditLog (including EntityChanges) after EF Core SaveChanges pipeline applies the changes. At PostContribute time, the tracked EntityEntry and any InternalEntity references already reflect current (new) values. This is by design: EF’s OriginalValues are updated after SaveChanges and navigation/value-object instances on the entity are the new ones.
      • Therefore, reading “old” value-object members from entityChange.EntityEntry (or its “Entity”) in PostContribute cannot yield the previous state.
    • What ABP exposes for original values today

      • ABP populates EntityChange.PropertyChanges with OriginalValue and NewValue for scalar properties it can detect. If your target is a ValueObject’s derived member (e.g., a calculated or nested member), ABP doesn’t automatically compute it. You need to compute and store it yourself before EF overwrites the tracked state.
    • Recommended approach: compute OriginalValue in PreContribute using the snapshot already captured by ABP

      1. Use PreContribute to read the ABP-captured original scalar values for the VO’s underlying properties from entityChange.PropertyChanges (these are still available).
      2. Reconstruct a lightweight “previous VO” from those original scalar property values.
      3. Compute the derived value (method/property) from this reconstructed VO and assign it to propertyChange.OriginalValue.
      4. Compute NewValue from the current entity instance (as you already do) and assign to propertyChange.NewValue.

      Code sketch:

    public class ValueObjectAuditContributor : AuditLogContributor
      {
          public override void PreContribute(AuditLogContributionContext context)
          {
              if (context?.AuditInfo?.EntityChanges == null) return;
    
              foreach (var entityChange in context.AuditInfo.EntityChanges)
              {
                  if (entityChange.PropertyChanges == null) continue;
    
                  // Example: Suppose Address is a VO with Street, City and you want Original/New of a derived member Full.
                  // First, gather originals from ABP’s recorded scalar changes.
                  var originalVoParts = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
                  foreach (var pc in entityChange.PropertyChanges)
                  {
                      // Match VO sub-properties like "Address.Street", "Address.City"
                      if (pc.PropertyName != null && pc.PropertyName.StartsWith("Address.", StringComparison.Ordinal))
                      {
                          var sub = pc.PropertyName.Substring("Address.".Length);
                          originalVoParts[sub] = pc.OriginalValue; // ABP’s captured original scalar value
                      }
                  }
    
                  // If there is enough info to reconstruct the VO, build it and compute the derived value
                  if (originalVoParts.Count > 0)
                  {
                      var originalVo = new Address(
                          street: originalVoParts.TryGetValue("Street", out var s) ? s : null,
                          city: originalVoParts.TryGetValue("City", out var c) ? c : null
                      );
    
                      // Find or create a synthetic propertyChange entry for the derived member
                      var fullPc = entityChange.PropertyChanges
                          .FirstOrDefault(x => x.PropertyName == "Address.Full");
                      if (fullPc == null)
                      {
                          fullPc = new EntityPropertyChangeInfo
                          {
                              PropertyName = "Address.Full",
                              PropertyTypeFullName = "System.String"
                          };
                          entityChange.PropertyChanges.Add(fullPc);
                      }
    
                      // Set Original derived value from reconstructed VO
                      fullPc.OriginalValue = originalVo.Full; // computed at PreContribute time
                  }
              }
          }
    
          public override void PostContribute(AuditLogContributionContext context)
          {
              if (context?.AuditInfo?.EntityChanges == null) return;
    
              foreach (var entityChange in context.AuditInfo.EntityChanges)
              {
                  if (entityChange.PropertyChanges == null) continue;
    
                  // Compute New derived value from the current entity (which now holds the new VO)
                  var entryWrapper = entityChange.EntityEntry;
                  var entity = entryWrapper?.GetType().GetProperty("Entity")?.GetValue(entryWrapper);
                  if (entity == null) continue;
    
                  var addressProp = entity.GetType().GetProperty("Address");
                  var newVo = addressProp?.GetValue(entity);
                  if (newVo == null) continue;
    
                  var fullPc = entityChange.PropertyChanges
                      .FirstOrDefault(x => x.PropertyName == "Address.Full");
                  if (fullPc == null)
                  {
                      fullPc = new EntityPropertyChangeInfo
                      {
                          PropertyName = "Address.Full",
                          PropertyTypeFullName = "System.String"
                      };
                      entityChange.PropertyChanges.Add(fullPc);
                  }
    
                  var fullMember = newVo.GetType().GetProperty("Full"); // or method invoke if needed
                  var newResult = fullMember?.GetValue(newVo)?.ToString();
                  if (!string.IsNullOrEmpty(newResult))
                  {
                      fullPc.NewValue = newResult;
                      fullPc.PropertyTypeFullName = "System.String";
                  }
              }
          }
      }
    

    Notes:

    • The key is PreContribute: it runs before ABP finalizes the audit info and while ABP’s captured OriginalValue for scalar properties is still in the PropertyChanges list. You cannot rely on EF’s EntityEntry to have old in-memory VO instances after SaveChanges.

    • If the VO is stored as an owned entity with its scalar columns, ABP should record property changes for those columns when they actually change. If not, add the owned scalar properties to the EntityHistory selectors and ensure EF detects the changes.

    • Alternative: capture a pre-save snapshot in a SaveChanges interceptor

      • If your VO changes aren’t resulting in ABP property changes at the scalar level, introduce a DbContext interceptor or override AbpDbContext.ChangeTracker-based capture just before ApplyAbpConcepts and SaveChanges, stash the original VO snapshot into the current UnitOfWork’s Items, then read it in PreContribute to fill propertyChange.OriginalValue. This keeps timing correct and avoids relying on EntityEntry after SaveChanges.
    • Is there an existing built-in way for VO-derived members?

      • ABP does not calculate derived/orchestrated ValueObject members into EntityChange.PropertyChanges out of the box. You need a contributor (as shown) or a custom interception to reconstruct the originals and set OriginalValue yourself.
      • This approach aligns with common ABP conventions, but specific official documentation for “value-object derived member original values in contributors” is not available.
    • Practical checklist

      • Use PreContribute to fill OriginalValue for VO-derived members from ABP’s scalar PropertyChanges.
      • Use PostContribute (or PreContribute) to fill NewValue from the current entity/VO instance.
      • Ensure EF marks the owned/VO scalar columns as modified so ABP records Original/New for them.
      • Avoid trying to read original VO instances from EntityEntry in PostContribute—those are already replaced.
    1. Sources
    • https://abp.io/docs/latest/framework/infrastructure/audit-logging
    • https://abp.io/docs/latest/framework/infrastructure/audit-logging#entity-changes
    • https://github.com/abpframework/abp/blob/dev/framework/src/Volo.Abp.Auditing/Volo/Abp/Auditing/AuditLogContributor.cs
    • https://github.com/abpframework/abp/blob/dev/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/AbpDbContext.cs
    • https://github.com/abpframework/abp/search?q=EntityChange.EntityEntry

    This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.

  • User Avatar
    0
    dcsimoes created

    Use PreContribute to fill OriginalValue for VO-derived members from ABP’s scalar PropertyChanges: PreContribute is not bringing any EntityChanges on the context. More assistance required.

  • User Avatar
    0
    maliming created
    Support Team Fullstack Developer

    hi

    Can you share your test project?

    I will download and debug it.

    liming.ma@volosoft.com

    Thanks.

Boost Your Development
ABP Live Training
Packages
See Trainings
Mastering ABP Framework Book
The Official Guide
Mastering
ABP Framework
Learn More
Mastering ABP Framework Book
Made with ❤️ on ABP v10.1.0-preview. Updated on December 03, 2025, 13:19
1
ABP Assistant
🔐 You need to be logged in to use the chatbot. Please log in first.