最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

sql server - Manage the order of updates when using a filtered unique index? - Stack Overflow

programmeradmin1浏览0评论

I have a filtered unique index defined as:

builder
    .HasIndex(e => new { e.UserId, e.IsDefault })
    .HasFilter($"[{nameof(MyEntity.IsDefault)}] = 1")
    .IsUnique()
    .HasDatabaseName($"UX_{nameof(MyEntity)}_{nameof(MyEntity.UserId)}_{nameof(MyEntity.IsDefault)}");

It translates into:

CREATE UNIQUE NONCLUSTERED INDEX [UX_MyEntity_UserId_IsDefault] 
ON [dbo].[MyEntity] ([UserId] ASC,
                     [IsDefault] ASC)
WHERE ([IsDefault] = (1))

Because I can only have 1 record with the flag IsDefault set to true, the order of updates is important, I first need to set non default records to false, and then set the default one. Since the EF Core doesn't really know the condition/filter, it doesn't order the updates correctly and I often get an error with the message

Cannot insert duplicate key row in object 'dbo.MyEntity' with unique index 'UX_MyEntity_UserId_IsDefault'

Example

In the database I have:

Id UserId IsDefault
1 5 0
2 5 1
3 5 0

I have a filtered unique index defined as:

builder
    .HasIndex(e => new { e.UserId, e.IsDefault })
    .HasFilter($"[{nameof(MyEntity.IsDefault)}] = 1")
    .IsUnique()
    .HasDatabaseName($"UX_{nameof(MyEntity)}_{nameof(MyEntity.UserId)}_{nameof(MyEntity.IsDefault)}");

It translates into:

CREATE UNIQUE NONCLUSTERED INDEX [UX_MyEntity_UserId_IsDefault] 
ON [dbo].[MyEntity] ([UserId] ASC,
                     [IsDefault] ASC)
WHERE ([IsDefault] = (1))

Because I can only have 1 record with the flag IsDefault set to true, the order of updates is important, I first need to set non default records to false, and then set the default one. Since the EF Core doesn't really know the condition/filter, it doesn't order the updates correctly and I often get an error with the message

Cannot insert duplicate key row in object 'dbo.MyEntity' with unique index 'UX_MyEntity_UserId_IsDefault'

Example

In the database I have:

Id UserId IsDefault
1 5 0
2 5 1
3 5 0

In the code, we have a Parent and a Child object, the Parent has a collection of Children and it goes like this:

var parent = await parentEfRepository.GetByIdAsync(5, cancellationToken);

var newDefaultChild = parent.Children.Where(c => ...);
newDefaultChild.SetAsDefault();

await context.SaveChangesAsync(cancellationToken);

The SetAsDefault() method contains the logic to set other Children as non default and the result is that only 1 Child is marked as being the default one.

When we get to SaveChanges(), EF Core generates a SQL script that contains the updates which look like this:

SET NOCOUNT ON;

UPDATE [MyEntity] 
SET [IsDefault] = @p0
OUTPUT INSERTED.[PeriodEnd], INSERTED.[PeriodStart]
WHERE [Id] = @p1;

UPDATE [MyEntity] 
SET [IsDefault] = @p2
OUTPUT INSERTED.[PeriodEnd], INSERTED.[PeriodStart]
WHERE [Id] = @p3;

Because it's trying to update record with ID 1 first, we would end up with the following:

Id UserId IsDefault
1 5 1
2 5 1
3 5 0

And that's when the unique constraint kicks in and throws an error. I have started experimenting with interceptors but it's getting dirty, surely someone had this issue before me?

Thanks!

Provider and version information:

  • EF Core version: 8.0.11
  • Database provider: Microsoft.EntityFrameworkCore.SqlServer
  • Target framework: .NET 8.0
  • Operating system: Windows
  • IDE: Rider
Share Improve this question edited Dec 2, 2024 at 7:08 Zhi Lv 21.7k1 gold badge27 silver badges37 bronze badges asked Nov 20, 2024 at 18:52 SebSeb 8301 gold badge11 silver badges28 bronze badges 2
  • 1 This might be solvable by (1) picking the updates apart: Clear flags + Save, Set single flag + Save again, and (2) wrapping it all together in a TransactionScope to ensure overall consistency. Example here from a somewhat similar scenario: stackoverflow/a/66978976/1220550 – Peter B Commented Nov 20, 2024 at 19:28
  • We are trying to avoid that as we’re going for a more DDD approach where DB concerns are not limiting our domain model. – Seb Commented Nov 20, 2024 at 19:31
Add a comment  | 

2 Answers 2

Reset to default 0

I had a similar requirement for a single selection that could be changed. The most reliable solution I could come up with was using a trigger on the DB. The code didn't need to bother clearing the flag, just setting it. In my case it was for saved searches where a default selection could be recorded. I avoid triggers, but for a scenario like this it was what made the most sense for reliability & simplicity.

Edit: The AFTER trigger won't work with the original unique constraint which wasn't something my scenario had. To support the unique constraint Trigger can be updated to an INSTEAD OF variant:


CREATE TRIGGER [dbo].[SearchesSingleRowIsSelected] 
   ON  [dbo].[Searches] 
   INSTEAD OF INSERT, UPDATE
AS 
BEGIN

    SET NOCOUNT ON;

    DECLARE @isSelected AS BIT,
        @searchId AS INT,
        @userId AS INT,
        @appId 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(IsSelected)
        RETURN;

    SELECT @searchId = SearchId, @isSelected = IsSelected, @userId = UserId, @appId = AppId, @typeName = TypeName 
    FROM INSERTED;

    IF @isSelected = 1
    BEGIN
        UPDATE [dbo].Searches SET IsSelected = 0 
        WHERE UserId = @userId AND AppId = @appId AND TypeName = @typeName AND IsSelected = 1 AND SearchId <> @searchId

        UPDATE [dbo].Searches SET IsSelected = 1 
        WHERE SearchId = @searchId
    END
    ELSE
    BEGIN
        UPDATE [dbo].Searches SET IsSelected = 0 
        WHERE SearchId = @searchId
    END;

END
GO

ALTER TABLE [dbo].[Searches] ENABLE TRIGGER [SearchesSingleRowIsSelected]
GO

This ensures the rows are updated in the correct order.

Then from the entity side it just needed to be configured to accommodate the trigger:

// example from EntityTypeConfiguration 
builder.ToTable(tb => tb.UseSqlOutputClause(false));

(https://learn.microsoft/en-us/dotnet/api/microsoft.entityframeworkcore.sqlserverentitytypeextensions.usesqloutputclause?view=efcore-8.0)

The benefit of the trigger approach was that it was applied regardless of what updated the row, and dead simple from setting the flag via EF without worrying about order of operations, etc.

Original trigger approach without filtered unique constraint:


CREATE TRIGGER [dbo].[SearchesSingleRowIsSelected] 
   ON  [dbo].[Searches] 
   AFTER INSERT, UPDATE
AS 
BEGIN

    SET NOCOUNT ON;

    DECLARE @isSelected AS BIT,
        @searchId AS INT,
        @userId AS INT,
        @appId 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(IsSelected)
        RETURN;

    SELECT @searchId = SearchId, @isSelected = IsSelected, @userId = UserId, @appId = AppId, @typeName = TypeName 
    FROM INSERTED;

    IF @isSelected = 1
    BEGIN
        UPDATE [dbo].Searches SET IsSelected = 0 
        WHERE UserId = @userId AND AppId = @appId AND TypeName = @typeName AND IsSelected = 1 AND SearchId <> @searchId
    END;

END
GO

ALTER TABLE [dbo].[Searches] ENABLE TRIGGER [SearchesSingleRowIsSelected]
GO

This would need to be modified if you wanted to enforce that one row must be selected. In my case you could have no selection.

I have ended up creating a few extensions to the DbCommand object, to check if re-ordering is necessary, and to do the actual re-ordering:

public static class DbCommandExtensions
{
    public static void ReorderUpdateStatementsIfNecessaryFor<T>(this DbCommand command, string propertyName)
    {
        if (command.ContainsUpdatesFor<T>(propertyName))
        {
            command.ReorderUpdateStatementsFor<T>(propertyName);
        }
    }

    private static bool ContainsUpdatesFor<T>(this DbCommand command, string propertyName)
    {
        var updateMyEntityRegex = new Regex(
            $@"UPDATE\s+(?:\[\w+\]\.)?\[?{typeof(T).Name}\]?[^\;]*SET\s+\[?(?:{propertyName})\]?\s*=",
            RegexOptions.IgnoreCase | RegexOptions.Compiled,
            TimeSpan.FromMilliseconds(100)
        );
        return updateMyEntityRegex.IsMatch(command.CommandText);
    }

    private static void ReorderUpdateStatementsFor<T>(this DbCommand command, string propertyName)
    {
        var updateRegex = new Regex(
            $@"UPDATE\s+(?:\[\w+\]\.)?\[?{typeof(T).Name}\]?[^\;]*SET\s+\[?{propertyName}\]?\s*=\s*(@[\w\d]+)[^\;]*;",
            RegexOptions.IgnoreCase,
            TimeSpan.FromMilliseconds(100)
        );
        var matches = updateRegex.Matches(command.CommandText);
        var setToFalse = new List<string>();
        var setToTrue = new List<string>();

        foreach (Match match in matches)
        {
            var updateStatement = match.Value;
            var parameterName = match.Groups[1].Value;

            if (command.Parameters.Contains(parameterName))
            {
                var parameter = command.Parameters[parameterName];

                if (parameter.Value is bool isSelected)
                {
                    if (!isSelected)
                    {
                        setToFalse.Add(updateStatement);
                    }
                    else
                    {
                        setToTrue.Add(updateStatement);
                    }
                }
                else
                {
                    // If the parameter is missing or its value is not a bool, consider it false
                    setToFalse.Add(updateStatement);
                }
            }
            else
            {
                // If the parameter is not found, treat it as false
                setToFalse.Add(updateStatement);
            }
        }

        // Reassemble the command text, replacing the matched UPDATE statements
        var reorderedUpdates = setToFalse.Concat(setToTrue).ToList();
        var updateIndex = 0;

        // Replace each match in order with the corresponding reordered update
        command.CommandText = updateRegex.Replace(
            command.CommandText,
            match => updateIndex < reorderedUpdates.Count
                ? reorderedUpdates[updateIndex++]
                : match.Value
        );
    }
}

It is then used in an interceptor this way:

public class MyEntityIsDefaultInterceptor : DbCommandInterceptor
{
    public override InterceptionResult<DbDataReader> ReaderExecuting(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<DbDataReader> result
    )
    {
        command.ReorderUpdateStatementsIfNecessaryFor<MyEntity>(nameof(MyEntity.IsDefault));
        return base.ReaderExecuting(command, eventData, result);
    }

    public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<DbDataReader> result,
        CancellationToken cancellationToken = new()
    )
    {
        command.ReorderUpdateStatementsIfNecessaryFor<MyEntity>(nameof(MyEntity.IsDefault));
        return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
    }
}
发布评论

评论列表(0)

  1. 暂无评论