We have 3 model classes:
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:
FIX: A conflict with the foreign key constraint occurs when you update the case of the column values in the primary key table or you pad column values in the primary key table in SQL Server 2005
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.
Right-click the constraint and select Modify. In the grid under Table Designer, click Enforce Foreign Key Constraint and select No from the drop-down menu. Click Close. Kindly note this is unsupported change what we are doing and we are not sure what complications it can cause.
This error is encountered when the primary key of a table is updated but it is referenced by a foreign key from another table and the update specific is set to No action. The No action is the default option. If this is your case and No action is set on the update operation you can change the foreign-key definition to Cascade.
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");
}
// ...
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];
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With