Search code examples
c#asp.net-coreentity-framework-core.net-9.0ef-core-9.0

EF Core 9 : the database operation was expected to affect 1 row(s), but actually affected 0 row(s)


I am using DDD architecture and the following code changes the aggregate root (basically adds a comment which is an entity to the aggregate root).

public sealed class AddCommentToPostCommandHandlers : ICommandHandler<AddCommentToPostCommand, Guid>
{
    private readonly IPostCommandRepository _postRepository;
    private readonly ILogger<AddCommentToPostCommandHandlers> _logger;

    public AddCommentToPostCommandHandlers(IPostCommandRepository postRepository, ILogger<AddCommentToPostCommandHandlers> logger)
    {
        _postRepository = postRepository;
        _logger = logger;
    }

    public async Task<Result<Guid>> Handle(AddCommentToPostCommand request, CancellationToken cancellationToken)
    {
        var post = await _postRepository.GetGraphByAsync(request.PostId, cancellationToken);

        if (post is not null)
        {
            post.AddComment(request.DisplayName, request.Email, request.CommentText);

            if (post.Result.IsSuccess)
            {
                _postRepository.UpdateBy(post);

                await _postRepository.CommitAsync(cancellationToken);

                return post.Id;
            }

            return post.Result;
        }

        return Result.Fail(ErrorMessages.NotFound(request.ToString()));
    }
}

This code worked fine with EF Core 8, but when I upgraded to EF Core 9, I get the following error:

Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException: The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded.

This error also occurs when editing a comment, whereas in the previous version (EF Core 8) this error did not occur.

Aggregate root code:

namespace ContentService.Core.Domain.Aggregates.Posts;

public class Post : AggregateRoot<Post>
{
    public Title Title { get; private set; }
    public Description Description { get; private set; }

    public Text Text { get; private set; }

    private readonly List<GuidId> _categoryIds;

    public virtual IReadOnlyList<GuidId> CategoryIds => _categoryIds;

    #region بارگذاری تنبل در سطح دامنه

    private List<Comment> _comments;

    public virtual IReadOnlyList<Comment> Comments
    {
        get
        {
            if (_comments == null)
            {
                LoadComments();
            }

            return _comments.AsReadOnly();
        }
    }

    private void LoadComments()
    {
        // Load comments from the data source here.
        // This is just a placeholder. You will need to replace this with your actual data loading logic.

        _comments = new List<Comment>();
    }

    #endregion End بارگذاری تنبل در سطح دامنه

    public Post()
    {
        _categoryIds = new List<GuidId>();
    }

    private Post(string? title, string? description, string? text) : this()
    {
        var titleResult = Title.Create(title);

        Result.WithErrors(titleResult.Errors);

        var descriptionResult = Description.Create(description);

        Result.WithErrors(descriptionResult.Errors);

        var contentResult = Text.Create(text);

        Result.WithErrors(contentResult.Errors);

        if (Result.IsSuccess)
        {
            Title = titleResult.Value;
            Description = descriptionResult.Value;
            Text = contentResult.Value;
        }
    }

    public Post Create(string? title, string? description, string? text)
    {
        var checkValidations = new Post(title, description, text);

        Result.WithErrors(checkValidations.Result.Errors);

        if (Result.IsFailed) 
            return this;

        if (Result.IsSuccess)
        {
            this.Text = checkValidations.Text;
            this.Title = checkValidations.Title;
            this.Description = checkValidations.Description;

            RaiseDomainEvent(new PostCreatedEvent(Id, this.Title.Value, this.Description.Value, this.Text.Value));
        }

        return this;
    }

    public Post UpdatePost(string? title, string? description, string? text)
    {
        var checkValidations = new Post(title, description, text);

        Result.WithErrors(checkValidations.Result.Errors);

        if (Result.IsFailed) 
            return this;

        if (Result.IsSuccess)
        {
            this.Title = checkValidations.Title;
            this.Description = checkValidations.Description;
            this.Text = checkValidations.Text;

            RaiseDomainEvent(new PostUpdatedEvent(Id, Title.Value!, Description.Value!, Text.Value!));

            Result.WithSuccess(SuccessMessages.SuccessUpdate(DataDictionary.Post));
        }

        return this;
    }

    public Post RemovePost(Guid? id)
    {
        var guidResult = GuidId.Create(id);

        if (guidResult.IsFailed)
        {
            Result.WithErrors(guidResult.Errors);
            return this;
        }

        // Note: if have IsDeleted property (soft delete) we can change to true here
        RaiseDomainEvent(new PostRemovedEvent(id));

        Result.WithSuccess(SuccessMessages.SuccessDelete(DataDictionary.Post));

        return this;
    }

    #region Category

    public Post AddCategory(Guid? categoryId)
    {
        var guidResult = GuidId.Create(categoryId);

        if (guidResult.IsFailed)
        {
            Result.WithErrors(guidResult.Errors);
            return this;
        }

        if (!_categoryIds.Contains(guidResult.Value))       //جلوگیری از تکراری بودن دسته بندی  
        {
            _categoryIds.Add(guidResult.Value);

            RaiseDomainEvent(new PostCategoryAddedEvent(Id, (Guid)categoryId!));
        }

        return this;
    }

    public Post ChangeCategory(Guid? oldCategoryId, Guid? newCategoryId)
    {
        var oldGuidResult = GuidId.Create(oldCategoryId);
        var newGuidResult = GuidId.Create(newCategoryId);

        if (oldGuidResult.IsFailed)
        {
            Result.WithErrors(oldGuidResult.Errors);
            return this;
        }

        if (newGuidResult.IsFailed)
        {
            Result.WithErrors(newGuidResult.Errors);
            return this;
        }

        if (_categoryIds.Contains(oldGuidResult.Value))
        {
            var indexOldCategory = _categoryIds.IndexOf(oldGuidResult.Value);

            if (!_categoryIds.Contains(newGuidResult.Value))
            {
                _categoryIds.RemoveAt(indexOldCategory);
                _categoryIds.Insert(indexOldCategory, newGuidResult.Value);
            }
            else
            {
                _categoryIds.RemoveAt(indexOldCategory);
            }

            RaiseDomainEvent(new CategoryPostChangedEvent(Id, (Guid)oldCategoryId!, (Guid)newCategoryId!));
        }
        else
        {
            Result.WithError(ErrorMessages.NotFound(DataDictionary.Category));
        }

        return this;
    }

    public Post RemoveCategory(Guid? categoryId)
    {
        var guidResult = GuidId.Create(categoryId);

        if (guidResult.IsFailed)
        {
            Result.WithErrors(guidResult.Errors);
            return this;
        }

        if (_categoryIds.Contains(guidResult.Value))
        {
            _categoryIds.Remove(guidResult.Value);

            RaiseDomainEvent(new CategoryPostRemovedEvent(Id, (Guid)categoryId!));
        }

        return this;
    }

    #endregion End Category

    #region Comments

    public Post AddComment(string? name, string? email, string? text)

    {

        var commentResult = Comment.Create(this, name, email, text);

        Result.WithErrors(commentResult.Errors);

        if (Result.IsFailed)

        {

            return this;

        }

        var hasAny = Comments

            .Any(c => c.Name == commentResult.Value.Name

                      && c.Email == commentResult.Value.Email

                      && c.CommentText == commentResult.Value.CommentText);

        if (hasAny)

        {

            var errorMessage = ValidationMessages.Repetitive(DataDictionary.Comment);

            Result.WithError(errorMessage);

            return this;

        }

        _comments.Add(commentResult.Value);

        RaiseDomainEvent(new CommentAddedEvent(this.Id, commentResult.Value.Id, commentResult.Value.Name.Value, commentResult.Value.Email.Value, commentResult.Value.CommentText.Value));

        return this;

    }

    public Post ChangeCommentText(string? name, string? email, string? text, string? newText)

    {

        var commentOldResult = Comment.Create(this, name, email, text);

        var commentNewResult = Comment.Create(this, name, email, newText);

        Result.WithErrors(commentOldResult.Errors);

        Result.WithErrors(commentNewResult.Errors);

        var emailGuardResult = Guard.CheckIf(commentNewResult.Value.Email, DataDictionary.Email)

            .Equal(commentOldResult.Value.Email);

        Result.WithErrors(emailGuardResult.Errors);

        var nameGuardResult = Guard.CheckIf(commentNewResult.Value.Name, DataDictionary.Name)

            .Equal(commentOldResult.Value.Name);

        Result.WithErrors(nameGuardResult.Errors);

        var commentTextGuardResult = Guard.CheckIf(commentNewResult.Value.CommentText, DataDictionary.CommentText)

            .NotEqual(commentOldResult.Value.CommentText);

        Result.WithErrors(commentTextGuardResult.Errors);

        if (Result.IsFailed)

        {

            return this;

        }

        LoadComments();

        var hasAny = Comments

            .Any(c => c.Name == commentNewResult.Value.Name

                      && c.Email == commentNewResult.Value.Email

                      && c.CommentText == commentNewResult.Value.CommentText);

        if (hasAny)

        {

            var errorMessage = ValidationMessages.Repetitive(DataDictionary.Comment);

            Result.WithError(errorMessage);

            return this;

        }

        //var commentIndex = _comments

        //  .FindIndex(c => c.Name == commentOldResult.Value.Name

        //            && c.Email == commentOldResult.Value.Email

        //            && c.CommentText == commentOldResult.Value.CommentText);

        var commentIndex = Comments

            .Select((c, i) => new { Comment = c, Index = i })

            .FirstOrDefault(x => x.Comment.Name == commentOldResult.Value.Name

                                 && x.Comment.Email == commentOldResult.Value.Email

                                 && x.Comment.CommentText == commentOldResult.Value.CommentText)?.Index;

        if (commentIndex >= 0)

        {

            _comments.RemoveAt((int)commentIndex);

            _comments.Insert((int)commentIndex, commentNewResult.Value);

            RaiseDomainEvent(new CommentEditedEvent(this.Id, commentNewResult.Value.Id, commentNewResult.Value.Name.Value, commentNewResult.Value.Email.Value, commentNewResult.Value.CommentText.Value));

        }

        return this;

    }

    public Post RemoveComment(string? name, string? email, string? text)

    {

        var commentResult = Comment.Create(this, name, email, text);

        Result.WithErrors(commentResult.Errors);

        if (Result.IsFailed)

        {

            return this;

        }

        var commentFounded = Comments

            .FirstOrDefault(c => c.Name?.Value?.ToLower() == commentResult.Value.Name?.Value?.ToLower()

                                 && c.Email?.Value?.ToLower() == commentResult.Value?.Email?.Value?.ToLower()

                                 && c.CommentText.Value?.ToLower() == commentResult?.Value?.CommentText.Value?.ToLower());

        if (commentFounded is null)

        {

            var errorMessage = ErrorMessages.NotFound(DataDictionary.Comment);

            Result.WithError(errorMessage);

            return this;

        }

        _comments.Remove(commentFounded);

        Result.WithSuccess(SuccessMessages.SuccessDelete(DataDictionary.Comment));

        RaiseDomainEvent(new CommentRemovedEvent(Id, name, email, text));

        return this;

    }

    #endregion

}

and comment entity is:

namespace ContentService.Core.Domain.Aggregates.Posts.Entities;

public class Comment : Entity

{

    public DisplayName Name { get; private set; }

    public Email Email { get; private set; }

    public CommentText CommentText { get; private set; }

    public Guid PostId { get; private set; }

    private Comment()

    {

    }

    private Comment(Guid postId, DisplayName name, Email email, CommentText text) : this()

    {

        PostId = postId;

        Name = name;

        Email = email;

        CommentText = text;

    }

    public static Result<Comment> Create(Guid? postId, string? name, string? email, string? text)

    {

        Result<Comment> result = new();

        if (!postId.HasValue || postId == Guid.Empty)

        {

            var errorMessage = ValidationMessages.Required(DataDictionary.Post);

            result.WithError(errorMessage);

        }

        var displayNameResult = DisplayName.Create(name);

        result.WithErrors(displayNameResult.Errors);

        var emailResult = Email.Create(email);

        result.WithErrors(emailResult.Errors);

        var textResult = CommentText.Create(text);

        result.WithErrors(textResult.Errors);

        if (result.IsFailed)

        {

            return result;

        }

        var returnValue = new Comment((Guid)postId!, displayNameResult.Value, emailResult.Value, textResult.Value);

        result.WithValue(returnValue);

        return result;

    }

}

and ef config:

internal sealed class PostConfiguration : IEntityTypeConfiguration<Post>

{

    public void Configure(EntityTypeBuilder<Post> builder)

    {

        builder.Property(p => p.CategoryIds)

            .HasConversion(

                v => string.Join(',', v.Select(c => c.Value)),

                v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(c => GuidId.Create(c).Value).ToList()

            );

        builder.Property(p => p.Title)

            .IsRequired(true)

            .HasMaxLength(Title.Maximum)

            .HasConversion(p => p.Value, p => Title.Create(p).Value);

        builder.Property(p => p.Description)

            .IsRequired(true)

            .HasMaxLength(Description.Maximum)

            .HasConversion(d => d.Value, d => Description.Create(d).Value);

        builder.Property(p => p.Text)

            .IsRequired(true)

            .HasConversion(t => t.Value, t => Text.Create(t).Value);

        builder.OwnsMany<Comment>(c => c.Comments, cc =>

        {

            cc.ToTable("Comments");

            cc.Property(c => c.Email)

                .IsRequired(true)

                .HasConversion(e => e.Value, e => Email.Create(e).Value);

            cc.Property(c => c.Name)

                .IsRequired(true)

                .HasMaxLength(DisplayName.Maximum)

                .HasConversion(e => e.Value, e => DisplayName.Create(e).Value);

            cc.Property(c => c.CommentText)

                .IsRequired(true)

                .HasMaxLength(CommentText.Maximum)

                .HasConversion(e => e.Value, e => CommentText.Create(e).Value);

        });

    }

}

Solution

  • I found the problem. Since I have defined a single entity in the database Post and Comment. And on the other hand, the DDD rules say that it should be a reference to the id and not the object itself. In case I mistakenly considered Post and Comment as two different entities. For this reason, the entity code (entity in DDD) Comment was a reference to the id:

    using ContentService.Core.Domain.Aggregates.Posts.ValueObjects;
    
    using FluentResults;
    
    using MDF.Framework.SeedWork;
    using MDF.Resources.Common;
    using MDF.Resources.Common.FormattedMessages;
    
    namespace ContentService.Core.Domain.Aggregates.Posts.Entities;
    public class Comment : Entity
    {
        public DisplayName Name { get; private set; }
        public Email Email { get; private set; }
        public CommentText CommentText { get; private set; }
        public Guid PostId { get; private set; }
        private Comment()
        {
    
        }
        private Comment(Guid postId, DisplayName name, Email email, CommentText text) : this()
        {
            PostId = postId;
            Name = name;
            Email = email;
            CommentText = text;
        }
    
        public static Result<Comment> Create(Guid? postId, string? name, string? email, string? text)
        {
            Result<Comment> result = new();
    
            if (!postId.HasValue || postId == Guid.Empty)
            {
                var errorMessage = ValidationMessages.Required(DataDictionary.Post);
                result.WithError(errorMessage);
            }
    
            var displayNameResult = DisplayName.Create(name);
            result.WithErrors(displayNameResult.Errors);
    
            var emailResult = Email.Create(email);
            result.WithErrors(emailResult.Errors);
    
            var textResult = CommentText.Create(text);
            result.WithErrors(textResult.Errors);
    
            if (result.IsFailed)
            {
                return result;
            }
    
            var returnValue = new Comment((Guid)postId!, displayNameResult.Value, emailResult.Value, textResult.Value);
            result.WithValue(returnValue);
            return result;
        }
    
    }
    

    Notice the Guid postId in the code above. But I should have referenced the object like this:

    using ContentService.Core.Domain.Aggregates.Posts.ValueObjects;
    
    using FluentResults;
    
    using MDF.Framework.SeedWork;
    using MDF.Resources.Common;
    using MDF.Resources.Common.FormattedMessages;
    
    namespace ContentService.Core.Domain.Aggregates.Posts.Entities;
    public class Comment : Entity
    {
        public DisplayName Name { get; private set; }
        public Email Email { get; private set; }
        public CommentText CommentText { get; private set; }
        public Post Post { get; private set; }
        private Comment()
        {
    
        }
        private Comment(Post post, DisplayName name, Email email, CommentText text) : this()
        {
            Post = post;
            Name = name;
            Email = email;
            CommentText = text;
        }
    
        public static Result<Comment> Create(Post? post, string? name, string? email, string? text)
        {
            Result<Comment> result = new();
    
            if (post is null)
            {
                var errorMessage = ValidationMessages.Required(DataDictionary.Post);
                result.WithError(errorMessage);
            }
    
            var displayNameResult = DisplayName.Create(name);
            result.WithErrors(displayNameResult.Errors);
    
            var emailResult = Email.Create(email);
            result.WithErrors(emailResult.Errors);
    
            var textResult = CommentText.Create(text);
            result.WithErrors(textResult.Errors);
    
            if (result.IsFailed)
            {
                return result;
            }
    
            var returnValue = new Comment(post!, displayNameResult.Value, emailResult.Value, textResult.Value);
            result.WithValue(returnValue);
            return result;
        }
    
    }
    

    In the above code which works correctly, instead of referencing the id in the Comment class, there is a reference to the Post object. Because Post and Comment are one entity and not different entities.