1

Background

I've recently inherited this ASP.NET 4.8 project. I'm implementing functionality that sets one row in the database as a default record. This is backed by a SQL Server which has an UNIQUE index on the CustomerID column when the IsDefault bit column is set.

Problem

When a new default record is requested, I must set the existing default to False before updating the new record, otherwise the UNIQUE database constraint will be violated. Occasionally, the correct updates happen, but I cannot pinpoint the state in which this does happen. Sometimes it was when the application first started up, or when a database record was edited outside of the application.

Most subsequent requests never update the existing default record. Inspecting the SQL statement that is generated shows that the SQL UPDATE statement for the current default record is never actually generated by Entity Framework.

Here is the controller code, it is wrapped in the same using statement so each query uses the same dbContext:

CustomerPO existingPO = (from item in dbContext.CustomerPO 
                         where (item.PurchaseOrderID == purchaseOrder.PurchaseOrderID) 
                         select item).FirstOrDefault();

var currentDefault = (from item in dbContext.CustomerPO
                      where item.IsDefault && item.CustomerID == existingPO.CustomerID
                      select item).FirstOrDefault();

if (currentDefault != null && currentDefault.PurchaseOrderID != existingPO.PurchaseOrderID)
{
    currentDefault.IsDefault = false;
}

dbContext.Entry(existingPO).CurrentValues.SetValues(purchaseOrder);
dbContext.SaveChanges();

return Ok(new { PurchaseOrderID = purchaseOrder.PurchaseOrderID });

What's Been Tried

  1. Specifying that the entire entity is modified. This results in the same issue.
dbContext.Entry(currentDefault).State = System.Data.Entity.EntityState.Modified;
  1. Specifying that the specific column has been modified. This results in the same issue.
dbContext.Entry(currentDefault).Property(e => e.IsDefault).IsModified = true;
  1. Iterating through the dbContext.ChangeTracker.Entries() shows that the currentDefault entity is marked as Modified and the IsDefault property is marked the same.

I've read through a few other posts online which describe the above. What I'm trying to accomplish seems to be possible, I'm just not getting what I expect to be the output.

Questions

To clarify, I can save the record just fine by making two SaveChanges calls to the dbContext. The fact that my above code works sporadically makes me think there is a caveat I am missing about Entity Framework.

  1. Is there some limitation that I'm encountering related to UNIQUE indexes?
  2. Are there other database context settings that need to be set to perform this kind of operation?
  3. Does EF not support multiple UPDATE calls in the way that this code is written?
  4. Is a transaction necessary in this situation?

I appreciate any insight into this - documentation, links, personal experience, etc. Thanks.

1
  • Still, in theory, existingPO and currentDefault can be the same object, in which case it will probably be default again after CurrentValues.SetValues. You have to be 100% certain that this doesn't occur by validating it in code. Commented Nov 5, 2024 at 22:17

1 Answer 1

0

A lot can go wrong when attempting to update two records in accordance to a unique constraint where one must surrender a value such as "IsDefault" before the other can reserve it. Not only is there the issue of concurrent updates by other systems/sessions, but also that without a direct relation we cannot guarantee the updates will occur in a predictable order.

Option 1: Handle the exception. As there are bound to be scenarios outside of our control that could result in an error, one option is to handle the error and either retry or bring it to the user's attention to refresh and retry the operation.

Option 2: Explicitly commit the changes separately to ensure they are done in order.

CustomerPO existingPO = (from item in dbContext.CustomerPO 
    where (item.PurchaseOrderID == purchaseOrder.PurchaseOrderID) 
    select item).FirstOrDefault();

var currentDefault = (from item in dbContext.CustomerPO
    where item.IsDefault && item.CustomerID == existingPO.CustomerID
    select item).FirstOrDefault();

using var transaction = dbContext.Database.BeginTransaction();

if (currentDefault != null && currentDefault.PurchaseOrderID != existingPO.PurchaseOrderID)
{
    currentDefault.IsDefault = false;
    dbContext.SaveChanges();
}

dbContext.Entry(existingPO).CurrentValues.SetValues(purchaseOrder);
dbContext.SaveChanges();
transaction.Commit();

This isn't ideal given the two saves, and we'd need a transaction to ensure that both changes are committed or rolled back together. Otherwise we could have a situation where we remove an IsDefault state without successfully setting the new record.

Option 3: Manage the IsDefault with a trigger. I recently needed to do something similar for saved searches where I was recording the last selected search. As a user selected a saved search criteria I would update it to mark it as selected so the next time they returned it would be the default.

ALTER TRIGGER [dto].[CustomersSingleIsDefault] 
   ON  [dro].[Customers] 
   AFTER INSERT, UPDATE
AS 
BEGIN

    SET NOCOUNT ON;

    DECLARE @isSelected AS BIT,
        @customerId AS INT,
        @typeName AS VARCHAR(500);

    -- Don't allow this trigger to trip recursively as we will be updating rows.
    IF TRIGGER_NESTLEVEL(@@PROCID) > 1
        RETURN;

    IF NOT UPDATE(IsDefault)
        RETURN;

    SELECT @customerId = CustomerId, @isDefault = IsDefault 
    FROM INSERTED;

    IF @isDefault = 1
    BEGIN
        UPDATE [dto].Customers SET IsDefault = 0 
        WHERE CustomerId = @customerId AND IsDefault = 1; 
    END;

END

The advantage of the trigger is that it runs for any process that alters the table to ensure that one record is selected. For EF Core entities you need to mark the customer entity to tell EF that it has a trigger associated to it. Typically with:

builder.ToTable(tb => tb.UseSqlOutputClause(false));

The caveat is adding a trigger to the table and potential performance implications of that.

One other detail to check over given you have a unique constraint involved with this IsDefault flag is the potential to have situations resulting in multiple non-default (IsDefault = false) rows attempting to be created. This may happen if code you assume should find a non-default row and mark it as default isn't working as expected, and instead falls through to inserting a new "IsDefault=true" row. I had recently had to track down a bug in a system similar to this with a soft-delete where the IsActive flag was made part of a unique constraint. The system was working for the most part until you went to re-activate a previously inactive row. A bug saw it not find the inactive row to reactivate and inserted a new active one. (no error) However later down the road when a user goes to deactivate that new item, the constraint fails due to now having two inactive rows. It was an annoying error given it only manifested long after the initial bug responsible created the invalid data scenario. (inserting instead of re-activating)

Sign up to request clarification or add additional context in comments.

1 Comment

I appreciate the thorough response. I am presently handling an exception in the way you describe just in case anything does manage to slip through. I'm not so sure I want to go the trigger route, but it seems like a viable solution. I think the transaction is most likely the way I will proceed. Still, even if I were to remove the constraint and the problem was a bit different, I don't understand why the UPDATE statements aren't being generated as I would expect. It makes me want to wrap everything in a BeginTransaction statement if there are these weird edge cases.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.