7

We have 3 model classes:

  • Host
  • TournamentBatch
  • TournamentBatchItem

Host has many TournamentBatch. TournamentBatch has many TournamentBatchItem. In the TournamentBatch table will have FK Host.

We did override for SaveChangesAsync in ApplicationDbContext to allow soft-delete as following:

public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default(CancellationToken))
    {
        OnBeforeSaving();

        return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
    }

    private void OnBeforeSaving()
    {

        if (_httpContextAccessor.HttpContext != null)
        {
            var userName = _httpContextAccessor.HttpContext.User.Identity.Name;
            var userId = _httpContextAccessor.HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);


            // Added
            var added = ChangeTracker.Entries().Where(v => v.State == EntityState.Added && typeof(IBaseEntity).IsAssignableFrom(v.Entity.GetType())).ToList();

            added.ForEach(entry =>
            {
                ((IBaseEntity)entry.Entity).DateCreated = DateTime.UtcNow;
                ((IBaseEntity)entry.Entity).CreatedBy = userId;

                ((IBaseEntity)entry.Entity).LastDateModified = DateTime.UtcNow;
                ((IBaseEntity)entry.Entity).LastModifiedBy = userId;
            });

            // Modified
            var modified = ChangeTracker.Entries().Where(v => v.State == EntityState.Modified &&
            typeof(IBaseEntity).IsAssignableFrom(v.Entity.GetType())).ToList();

            modified.ForEach(entry =>
            {
                ((IBaseEntity)entry.Entity).LastDateModified = DateTime.UtcNow;
                ((IBaseEntity)entry.Entity).LastModifiedBy = userId;
            });

            // Deleted
            var deleted = ChangeTracker.Entries().Where(v => v.State == EntityState.Deleted &&
           typeof(IBaseEntity).IsAssignableFrom(v.Entity.GetType())).ToList();

            // var deleted = ChangeTracker.Entries().Where(v => v.State == EntityState.Deleted).ToList();

            deleted.ForEach(entry =>
            {
                ((IBaseEntity)entry.Entity).DateDeleted = DateTime.UtcNow;
                ((IBaseEntity)entry.Entity).DeletedBy = userId;
            });

            foreach (var entry in ChangeTracker.Entries()
                                    .Where(e => e.State == EntityState.Deleted &&
                                    e.Metadata.GetProperties().Any(x => x.Name == "IsDeleted")))
            {
                switch (entry.State)
                {
                    case EntityState.Added:
                        entry.CurrentValues["IsDeleted"] = false;
                        break;

                    case EntityState.Deleted:
                        entry.State = EntityState.Modified;
                        entry.CurrentValues["IsDeleted"] = true;
                        break;
                }
            }
        }
        else
        {
            // DbInitializer kicks in
        }
    }

In our model:

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Threading.Tasks;

namespace AthlosifyWebArchery.Models
{
  public class TournamentBatch : IBaseEntity
  {
    [Key]
    public Guid TournamentBatchID { get; set; }

    public Guid HostID { get; set; }

    public string Name { get; set; }

    public string BatchFilePath { get; set; }

    [Display(Name = "Batch File Size (bytes)")]
    [DisplayFormat(DataFormatString = "{0:N1}")]
    public long BatchFileSize { get; set; }

    [Display(Name = "Uploaded (UTC)")]
    [DisplayFormat(DataFormatString = "{0:F}")]
    public DateTime DateUploaded { get; set; }

    public DateTime DateCreated { get; set; }

    public string CreatedBy { get; set; }

    public DateTime LastDateModified { get; set; }

    public string LastModifiedBy { get; set; }

    public DateTime? DateDeleted { get; set; }

    public string DeletedBy { get; set; }

    public bool IsDeleted { get; set; }

    public Host Host { get; set; }

    public ICollection<TournamentBatchItem> TournamentBatchItems { get; set; }

    [Timestamp]
    public byte[] RowVersion { get; set; }

    [ForeignKey("CreatedBy")]
    public ApplicationUser ApplicationCreatedUser { get; set; }

    [ForeignKey("LastModifiedBy")]
    public ApplicationUser ApplicationLastModifiedUser { get; set; }


}

}

In our Razorpage, we have a page to delete TournamentBatch including TournamentBatchItem by doing this:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using AthlosifyWebArchery.Data;
using AthlosifyWebArchery.Models;
using Microsoft.Extensions.Logging;

namespace AthlosifyWebArchery.Pages.Administrators.TournamentBatches
{
  public class DeleteModel : PageModel
   {
    private readonly AthlosifyWebArchery.Data.ApplicationDbContext _context;


    private readonly ILogger _logger;


    public DeleteModel(AthlosifyWebArchery.Data.ApplicationDbContext context,
                        ILogger<DeleteModel> logger)
    {
        _context = context;
        _logger = logger;
    }

    [BindProperty]
    public TournamentBatch TournamentBatch { get; set; }

    public IList<TournamentBatchItem> tournamentBatchItems { get; set; }

    public string ConcurrencyErrorMessage { get; set; }

    public async Task<IActionResult> OnGetAsync(Guid? id, bool? concurrencyError)
    {
        if (id == null)
        {
            return NotFound();
        }

        TournamentBatch = await _context.TournamentBatch
                                    .AsNoTracking() //Addded
                                    .FirstOrDefaultAsync(m => m.TournamentBatchID == id);



        if (TournamentBatch == null)
        {
            return NotFound();
        }

        if (concurrencyError.GetValueOrDefault())
        {
            ConcurrencyErrorMessage = "The record you attempted to delete "
              + "was modified by another user after you selected delete. "
              + "The delete operation was canceled and the current values in the "
              + "database have been displayed. If you still want to delete this "
              + "record, click the Delete button again.";
        }

        return Page();
    }

    public async Task<IActionResult> OnPostAsync(Guid? id)
    {
        try
        {
            //var tournamentBatchItems = await _context.TournamentBatchItem.Where(m => m.TournamentBatchID == id).ToListAsync();
            //_context.TournamentBatchItem.RemoveRange(tournamentBatchItems);
            //await _context.SaveChangesAsync();


            if (await _context.TournamentBatch.AnyAsync(
                m => m.TournamentBatchID == id))
            {
                // Department.rowVersion value is from when the entity
                // was fetched. If it doesn't match the DB, a
                // DbUpdateConcurrencyException exception is thrown.
                _context.TournamentBatch.Remove(TournamentBatch);
                _logger.LogInformation($"TournamentBatch.BeforeSaveChangesAsync ... ");
                await _context.SaveChangesAsync();
                _logger.LogInformation($"DbInitializer.AfterSaveChangesAsync ... ");
            }
            return RedirectToPage("./Index");
        }
        catch(DbUpdateException)
        {
            return RedirectToPage("./Delete",
                new { concurrencyError = true, id = id });

        }
        //catch (DbUpdateConcurrencyException)
        //{
        //    return RedirectToPage("./Delete",
        //        new { concurrencyError = true, id = id });
        //}
    }
}

}

... and we have the following error which is a bit odd.

System.Data.SqlClient.SqlException (0x80131904): The UPDATE statement conflicted with the FOREIGN KEY constraint "FK_TournamentBatch_Host_HostID". The conflict occurred in database "aspnet-AthlosifyWebArchery-53bc9b9d-9d6a-45d4-8429-2a2761773502", table "dbo.Host", column 'HostID'. The statement has been terminated.

Any ideas?

Things we did:

  • If we removed OnBeforeSaving(); from the SaveChangesAsyc()method, the code is deleting (hard-delete) successfully the TournamentBatch as well as TournamentBatchItem.

  • If we included OnBeforeSaving(); from the SaveChangesAsyc()method AND tested with deleting Host and TournamentBatchItem (Not TournamentBatch), the code is deleting (soft-delete) successfully.

It seems it has something to do with the relationship between Host and TournamentBatch

Environment:

  • .Net Core 2.1
  • Ms SQL Server
dcpartners
  • 5,176
  • 13
  • 50
  • 73
  • I tried your code , but no exception throws for me. However, the error info indicates that your code is updating the Foreign Key behind the scene. I suggest you could check the payload sent by client side and inspect the sql log. – itminus Dec 10 '18 at 09:51
  • @itminus ... not quite understand as we didn't update the Foreign Key at all. As you can see in our code. – dcpartners Dec 12 '18 at 08:23
  • When you post to the action method, what's the `TournamentBatch` ? – itminus Dec 12 '18 at 08:42
  • It's property of ApplicationDbContext class. `public DbSet TournamentBatch { get; set; }` – dcpartners Dec 12 '18 at 08:57
  • I mean when you invoke ` await _context.SaveChangesAsync();` there must be some changes to TournamentBatch or some other Entity right ? So what do those entity look like ? And when you save changes and an exception is thrown, what's the sql statement behind the scenes? – itminus Dec 12 '18 at 09:02
  • I've just added the TournamentBatch model already (see above) – dcpartners Dec 12 '18 at 09:03
  • Could you please post the code of Razor Page and Razor Page if possible? – itminus Dec 12 '18 at 09:09
  • @itminus I've updated the complete razor page (see above) – dcpartners Dec 12 '18 at 09:13

4 Answers4

3

Reason

I guess the reason is you're having your TournamentBatch bind from client side .

Let's review the OnPostAsync() method:

public async Task<IActionResult> OnPostAsync(Guid? id)
{
    try
    {
        if (await _context.TournamentBatch.AnyAsync(
            m => m.TournamentBatchID == id))
        {
            _context.TournamentBatch.Remove(TournamentBatch);
            _logger.LogInformation($"TournamentBatch.BeforeSaveChangesAsync ... ");
            await _context.SaveChangesAsync();
            _logger.LogInformation($"DbInitializer.AfterSaveChangesAsync ... ");
        }
        return RedirectToPage("./Index");
    }
    // ....
}

Here the TournamentBatch is a property of PageModel:

    [BindProperty]
    public Models.TournamentBatch TournamentBatch{ get; set; }

Note you didn't retrieve it from the database according to the id, and you just remove it by _context.TournamentBatch.Remove(TournamentBatch); directly.

In other words, the other properties of TournamentBatch will be set by ModelBinding. Let's say if you submit only the Id, all the other property will be the default value. For example, Host will be null and the HostID will be the default 00000000-0000-0000-0000-000000000000. So when you save changes, the EF Core will update the model as below :

UPDATE [TournamentBatch]
SET [HostID] = '00000000-0000-0000-0000-000000000000' , 
    [IsDeleted] = 1 ,
    # ... other fields
WHERE [TournamentBatchID] = 'A6F5002A-60CA-4B45-D343-08D660167B06'

Because there's no Host record whose id equals 00000000-0000-0000-0000-000000000000, the database will complains :

The UPDATE statement conflicted with the FOREIGN KEY constraint "FK_TournamentBatch_Host_HostID". The conflict occurred in database "App-93a194ca-9622-487c-94cf-bcbe648c6556", table "dbo.Host", column 'Id'. The statement has been terminated.

How to fix

Instead of binding the TournamentBatch from client side, you need retrieve the TournamentBatch from server by TournamentBatch = await _context.TournamentBatch.FindAsync(id); . Thus you will have all the properties set correctly so that EF will update the field correctly :

    try
    {
        //var tournamentBatchItems = await _context.TournamentBatchItem.Where(m => m.TournamentBatchID == id).ToListAsync();
        //_context.TournamentBatchItem.RemoveRange(tournamentBatchItems);
        //await _context.SaveChangesAsync();
        TournamentBatch = await _context.TournamentBatch.FindAsync(id);

        if (TournamentBatch != null)
        {
            // Department.rowVersion value is from when the entity
            // was fetched. If it doesn't match the DB, a
            // DbUpdateConcurrencyException exception is thrown.
            _context.TournamentBatch.Remove(TournamentBatch);
            _logger.LogInformation($"TournamentBatch.BeforeSaveChangesAsync ... ");
            await _context.SaveChangesAsync();
            _logger.LogInformation($"DbInitializer.AfterSaveChangesAsync ... ");
        }
        return RedirectToPage("./Index");
    }
    // ...
itminus
  • 23,772
  • 2
  • 53
  • 88
  • thanks for the solution. Works well. Didn't realise that [BindProperty] causing this. It didn't explain how come the HARD-DELETE working ok though. Anyway thanks – dcpartners Dec 14 '18 at 00:17
  • Also ... with this approach that the child object won't get update (UPDATE XX SET IsDelete=1) only the parent. Is this correct behaviour? So I just to apply similar for the child object. Cause if you do the hard-delete, the child will get deleted automatically. – dcpartners Dec 14 '18 at 00:19
  • @dcpartners It does explain `HARD-DELETE` . `SOFT-DELETE` uses `update`, which means the SQL will be `update ... set hostid = .. where ...[TournamentBatchID] = ...` , but `HARD-DELETE` uses `delete ...where [TournamentBatchID] = ... '`. – itminus Dec 14 '18 at 00:55
  • @dcpartners if you do want to the feature of cascading soft-delete , you can refer this [walkaround here ](https://github.com/aspnet/EntityFrameworkCore/issues/11240#issuecomment-388932379). I'll test that walkaround later. – itminus Dec 14 '18 at 01:11
  • @dcpartners as for the second question, the EF Core will automatically delete the child because it knows this dependent record should be deleted along with the principal record removed. But if you use soft-delete, EF doesn't know you want to delete that principal record at all – itminus Dec 14 '18 at 01:17
  • what do you think about the above workaround of cascading soft-delete? As I'm not sure about `this HandleDependent(EntityEntry entry)` in this context? – dcpartners Dec 14 '18 at 06:02
  • @dcpartners I just tried.I think that walkaround is similar to the graph iterator ( or visitor pattern ?). But the problem is : if I have Entity A which has a dependency on B, and B has a dependency on C , .... . Now I want to delete C, firstly I iterate through the dependencies, and and find out I need remove B. and then I need iterate again to find C. It might happen recursively. So I need setup a state and track the state myself. That's really horrible. Another thing is that I'm worrying about the performance. – itminus Dec 14 '18 at 06:12
  • @dcpartners Another way is to use a graph (matrix) to store all the dependencies . But I don't know whether there's a built-in way to get all the `Dependents` – itminus Dec 14 '18 at 06:18
  • what do you suggest by using matrix to store all the dependencies? In the code? – dcpartners Dec 14 '18 at 06:23
  • @dcpartners I don't know whether it's a good way to do that. But if I must apply cascading soft-delete, and I cann't find other ways to do that, I would create a graph to store the dependencies. For example, A -> B ( means A has a reference to B ), B -> C , B-> D . this will build a dependencies graph that can be represent as 4*4 matrix . I guess This can be done by EF Core api. So we can store it into our own AppDbContext. When I need softly delete C , I just call getDependents(C) to get a `list {B,A}` and then we can iterate the list to set `IsDeleted=true` – itminus Dec 14 '18 at 06:31
  • Back to Hard-Delete again, how come this can be done to iterate the dependencies and deleted ? What EF have done then? – dcpartners Dec 14 '18 at 06:35
  • Alternative I guess .. I know the model is and their dependencies so I just soft-delete the main and then following the dependencies manually. – dcpartners Dec 14 '18 at 06:38
  • @dcpartners I'm not sure how EF Core implements that feature. But according to the document, the EF Core will delete dependent records for us automatically. It just happens like a charm :( – itminus Dec 14 '18 at 06:39
  • @dcpartners That surely works. But what if you add another dependency ? – itminus Dec 14 '18 at 06:40
  • I think for the hard-delete is already implemented . I tested this already. But not soft-delete. – dcpartners Dec 14 '18 at 06:41
  • @dcpartners cascading soft-delete is not implemented yet. Maybe it's better to file a issue to ask about this feature. – itminus Dec 14 '18 at 06:43
2

Can you try the following and change how you implemeted the soft-delete.

Change the code below in your ApplicationDBContext OnBeforeSaving method

foreach (var entry in ChangeTracker.Entries()
                                    .Where(e => e.State == EntityState.Deleted &&
                                    e.Metadata.GetProperties().Any(x => x.Name == "IsDeleted")))
{
    switch (entry.State)
    {
        case EntityState.Added:
            entry.CurrentValues["IsDeleted"] = false;
            break;

        case EntityState.Deleted:
            entry.State = EntityState.Modified;
            entry.CurrentValues["IsDeleted"] = true;
            break;
    }
}

---- TO -----

foreach (var entry in ChangeTracker.Entries()
                                    .Where(e => e.State == EntityState.Deleted &&
                                    e.Metadata.GetProperties().Any(x => x.Name == "IsDeleted")))
{
    SoftDelete(entry);
}

SoftDelete method:

private void SoftDelete(DbEntityEntry entry)
{
    Type entryEntityType = entry.Entity.GetType();

    string tableName = GetTableName(entryEntityType);
    string primaryKeyName = GetPrimaryKeyName(entryEntityType);

    string sql =
        string.Format(
            "UPDATE {0} SET IsDeleted = true WHERE {1} = @id",
                tableName, primaryKeyName);

    Database.ExecuteSqlCommand(
        sql,
        new SqlParameter("@id", entry.OriginalValues[primaryKeyName]));

    // prevent hard delete            
    entry.State = EntityState.Detached;
}

This method will execute sql query over each removed entity:

UPDATE TournamentBatch SET IsDeleted = true WHERE TournamentBatchID = 123

To make it versatile and compatible with any entity (not just TournamentBatch) we need to know two additional properties, Table name and Primary Key name

There are two functions inside of SoftDelete method for this purpose: GetTableName and GetPrimaryKeyName. I have defined them in separate file and marked class as partial. So be sure to make your context class partial in order for things to work. Here is GetTableName and GetPrimaryKeyName with caching mechanism:

public partial class ApplicationDBContext
{
    private static Dictionary<Type, EntitySetBase> _mappingCache =
        new Dictionary<Type, EntitySetBase>();

    private string GetTableName(Type type)
    {
        EntitySetBase es = GetEntitySet(type);

        return string.Format("[{0}].[{1}]",
            es.MetadataProperties["Schema"].Value,
            es.MetadataProperties["Table"].Value);
    }

    private string GetPrimaryKeyName(Type type)
    {
        EntitySetBase es = GetEntitySet(type);

        return es.ElementType.KeyMembers[0].Name;
    }

    private EntitySetBase GetEntitySet(Type type)
    {
        if (!_mappingCache.ContainsKey(type))
        {
            ObjectContext octx = ((IObjectContextAdapter)this).ObjectContext;

            string typeName = ObjectContext.GetObjectType(type).Name;

            var es = octx.MetadataWorkspace
                            .GetItemCollection(DataSpace.SSpace)
                            .GetItems<EntityContainer>()
                            .SelectMany(c => c.BaseEntitySets
                                            .Where(e => e.Name == typeName))
                            .FirstOrDefault();

            if (es == null)
                throw new ArgumentException("Entity type not found in GetTableName", typeName);

            _mappingCache.Add(type, es);
        }

        return _mappingCache[type];
    }
}
Tarik Tutuncu
  • 790
  • 4
  • 12
0

Don't forget that a foreign key is a reference to a unique value in a different table. SQL will ensure referential integrity if there is a foreign key present, so it won't let you use orphaned key references.

When you insert a value to a foreign key column it must be a null or a existing reference to a row in the other table, and when you delete, you must delete the row containing the foreign key first, then the row it references.

If you don't, you will get an error as you stated.

So enter the row into the "main" table first, then enter the "dependant" table information after.

Tarik Tutuncu
  • 790
  • 4
  • 12
  • We are aware of that BUT everything handles behind the scene. We didn't do any update on Foreign Key ... only do the soft-delete which is changing the status – dcpartners Dec 12 '18 at 08:25
  • When deleting delete from the child table first then the parent table – Tarik Tutuncu Dec 12 '18 at 08:43
  • We are not deleting the record ... we do the soft-delete (UPDATE xx SET IsDeleted = 1) – dcpartners Dec 12 '18 at 08:45
  • I have added another answer, because this answer may also help others having the same error, and my second answer is irrelevant with this answer – Tarik Tutuncu Dec 12 '18 at 10:37
0

When you update anything regarding primary or foreign keys in EF, more often than not, an error is thrown. It is possible to fix this manually.

However the thing I personally do is to drop the entire database, add a migration and update the DB. Possibly generating an insert script if i have a lot of test data. (this obviously doesn't work in a production environment, but then again you should not change the db like that in a production environment anyway and instead add a nullable column with a timestamp which indicates the time of deletion or be null if its an active record.)

Daan
  • 373
  • 2
  • 9