Search code examples
entity-framework-core.net-8.0xaftable-per-type

EF Core SaveChanges fails (v8.01 on .NET 8)


After upgrading from .NET Framework with EF to .NET 8 with EF Core, I am unable to modify my SalesQuotes.

I am using table per type strategy.

This code fails:

var connect = HandyDefaults.MakeConnectFromConnectionString(connectionString);

var quote = connect.SalesQuotes.SingleOrDefault(x => x.DocumentNumber == "SQ000017");

var guidString = Guid.NewGuid().ToString();
quote.CustomerReference = guidString;

connect.SaveChanges();

connect = HandyDefaults.MakeConnectFromConnectionString(connectionString);
var quote2 = connect.SalesQuotes.SingleOrDefault(x => x.DocumentNumber == "SQ000017");

Assert.AreEqual(guidString, quote2.CustomerReference); // fails

Where the DbContext is constructed as:

public static MyEFCoreDbContext MakeConnectFromConnectionString(string connectionString)
{
    var optionsBuilder = new DbContextOptionsBuilder<MyEFCoreDbContext>()
            .UseSqlServer(connectionString)
            .UseChangeTrackingProxies()
            .UseObjectSpaceLinkProxies();

    return new MyEFCoreDbContext(optionsBuilder.Options);
}

And in OnModelCreating, I have:

modelBuilder.SetOneToManyAssociationDeleteBehavior(DeleteBehavior.SetNull, DeleteBehavior.Cascade);
modelBuilder.HasChangeTrackingStrategy(ChangeTrackingStrategy.ChangingAndChangedNotificationsWithOriginalValues);
modelBuilder.UsePropertyAccessMode(PropertyAccessMode.PreferFieldDuringConstruction);

as well as

modelBuilder.Entity<SalesQuoteLine>()
            .HasOne(j => j.SalesQuote)
            .WithMany(x => x.Lines)
            .HasForeignKey(x => x.SalesQuote_Id)
            .OnDelete(DeleteBehavior.Cascade);

The business model is something like

public class SalesQuote : BaseSalesHeader
{ 
    public SalesQuote()
    {
       Lines = new ObservableCollection<SalesQuoteLine>();
    }

    public virtual ObservableCollection<SalesQuoteLine> Lines { get; set; }
    // other properties
}

With BaseSalesHeader similar to:

public abstract class BaseSalesHeader 
{
    // contains properties common to different sales documents.

    public virtual string CustomerReference { get; set; }
}  

I am using Entity Framework Core v8.01, .NET 8, C#, SQL Server.

I am able to add SalesQuotes but not modify them.

Update: I changed the unit test code as follows:

var connect = HandyDefaults.MakeConnectFromConnectionString(connectionString);

var quote = connect.SalesQuotes.SingleOrDefault(x => x.DocumentNumber == "SQ000017");

var guidString = Guid.NewGuid().ToString();
Debug.WriteLine($"Original CustomerReference is {quote.CustomerReference}");
quote.CustomerReference = guidString;

Debug.WriteLine($"Modified CustomerReference is {quote.CustomerReference}");

connect.SaveChanges();

connect = HandyDefaults.MakeConnectFromConnectionString(connectionString);
var quote2 = connect.SalesQuotes.SingleOrDefault(x => x.DocumentNumber == "SQ000017");
Debug.WriteLine($"Retrieved CustomerReference is {quote2.CustomerReference}");
Assert.AreEqual(guidString, quote2.CustomerReference); // fails

The debug output is

Original CustomerReference is DC Replacement
Modified CustomerReference is 8944f1f4-0df4-4a3b-bdd8-85b0d80c792f
Retrieved CustomerReference is DC Replacement

Update #2: hovering over .UseChangeTrackingProxies(), I see this:

this message

linking here for notification entries

I have added the xaf tag because the code is in a DevExpress XAF solution.

Update #3: I tried calling

connect.ChangeTracker.DetectChanges();

before

connect.SaveChanges();

but it made no difference.

Update #4

After reading Steve Py's very helpful answer I realised that I had added not mentioned my full declaration of BaseSalesHeader

public abstract class BaseSalesHeader : BaseDocumentBo, IObjectSpaceLink, IHasSequencedLines , INotifyPropertyChanged

My Test passes when I remove INotifyPropertyChanged

I created a new Business Object from the Dev Express Wizard as follows, and notice the code mentions

You do not need to implement the INotifyPropertyChanged interface - EF Core implements it automatically. And links to Change Detection and Notification.

  // Register this entity in your DbContext (usually in the BusinessObjects folder of your project) with the "public DbSet<EntityObject1> EntityObject1s { get; set; }" syntax.
  [DefaultClassOptions]
  //[ImageName("BO_Contact")]
  //[DefaultProperty("Name")]
  //[DefaultListViewOptions(MasterDetailMode.ListViewOnly, false, NewItemRowPosition.None)]
  // Specify more UI options using a declarative approach (https://documentation.devexpress.com/#eXpressAppFramework/CustomDocument112701).
  // **You do not need to implement the INotifyPropertyChanged interface - EF Core implements it automatically.**
  // (see [https://learn.microsoft.com/en-us/ef/core/change-tracking/change-detection#change-tracking-proxies][3] for details).
  public class EntityObject1 : BaseObject
  {
      public EntityObject1()
      {
          // In the constructor, initialize collection properties, e.g.: 
          // this.AssociatedEntities = new ObservableCollection<AssociatedEntityObject>();
      }

      // You can use the regular Code First syntax.
      // Property change notifications will be created automatically.
      // (see https://learn.microsoft.com/en-us/ef/core/change-tracking/change-detection#change-tracking-proxies for details)
      //public virtual string Name { get; set; }

     // Alternatively, specify more UI options:
      [XafDisplayName("My display name"), ToolTip("My hint message")]
      [ModelDefault("EditMask", "(000)-00"), VisibleInListView(false)]
      [RuleRequiredField(DefaultContexts.Save)]
      public virtual string Name { get; set; }

      // Collection property:
      //public virtual IList<AssociatedEntityObject> AssociatedEntities { get; set; }

      //[Action(Caption = "My UI Action", ConfirmationMessage = "Are you sure?", ImageName = "Attention", AutoCommit = true)]
      //public void ActionMethod() {
      //    // Trigger custom business logic for the current record in the UI (https://documentation.devexpress.com/eXpressAppFramework/CustomDocument112619.aspx).
      //    this.PersistentProperty = "Paid";
      //}
  }

Solution

  • From the documentation from EF, I suspect the issue is your use of:

    modelBuilder.HasChangeTrackingStrategy(ChangeTrackingStrategy.ChangingAndChangedNotificationsWithOriginalValues);
    

    https://learn.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.changetrackingstrategy?view=efcore-8.0

    ChangingAndChangedNotificationsWithOriginalValues To use this strategy, the entity class must implement INotifyPropertyChanged and INotifyPropertyChanging. Original values are recorded when the entity raises the PropertyChanging. Properties are marked as modified when the entity raises the PropertyChanged event.

    This setting expects entities to use INotifyPropertyChanged & INotifyProperyChanging. Removing this should use Snapshot which relies on the DetectChanges call automatically.

    Edit: Additionally if you want to use that change tracking strategy, it expects both INotifyPropertyChanged and INotifyPropertyChanging which AFAIK is not going to work with auto-properties:

    public virtual string CustomerReference { get; set; }
    

    ... Those events work with field-based property accessors so that the NotifiyPropertyChanged/ing event listeners can be wored it. Which makes combined with PropertyAccessMode.PreferFieldDuringConstruction If using a change tracker that ties into NotifyPropertyChanged/ing you wouldn't want it constantly tripping event handlers when populating every property on every entity:

    public event PropertyChangingEventHandler PropertyChanging;  
    public event PropertyChangedEventHandler PropertyChanged;  
    
    
    private string _customerReference;
    public virtual string CustomerReference 
    { 
        get => _customerReference; 
        set
        {
            if(value == _customerReference) return;
            var result = RaisePropertyChanging(nameof(CustomerReference), value);
            if (result)
            {
                _customerReference = value;
                RaisePropertyChanged(nameof(CustomerReference), value);
            }
        } 
    }
    

    where the "RaisePropertyChanging" and "RaisePropertyChanged" methods are wrappers to check for whether the event handlers are set and pass-through to the event handlers. Changing would check for a cancellation and return back a True or False based on if any listener rejected the change or not.