Почему EF Core пытается вставить пустые значения при использовании пользовательского генератора значений?

Вопрос обновлен, чтобы отразить более подробную информацию и упрощенный случай воспроизведения, поскольку я копнул глубже с помощью комментариев.

У меня есть собственный ValueGenerator следующим образом:

public class UtcDateTimeGenerator : ValueGenerator<DateTime>
{
    public override DateTime Next(EntityEntry entry)
    {
        // This method never seems to be called
        return DateTime.UtcNow;
    }

    protected override object NextValue(EntityEntry entry)
    {
        // This one is called.
        return DateTime.UtcNow;
    }

    public override bool GeneratesTemporaryValues => false;
}

Моя сущность:

public abstract class AggregateRoot<TId>
    where TId : IComparable
{
    public TId Id { get; set; }
}

public abstract class AuditedAggregateRoot<TId> : AggregateRoot<TId>
    where TId : IComparable
{
    public DateTime Created { get; internal set; }
    public DateTime LastModified { get; internal set; }
}

public class Player : AuditedAggregateRoot<Guid>
{
    public string ExternalId { get; internal set; }
    public string DisplayName { get; internal set; }
    public string Email { get; internal set; }
    public DateTime LastLogin { get; internal set; }

    /// <summary>
    /// Required for Entity Framework Core
    /// </summary>
    private Player()
    {
    }

    public Player([NotNull]string externalId, [NotNull]string displayName, [NotNull]string email)
    {
        ExternalId = externalId;
        DisplayName = displayName;
        Email = email;
    }
}

Моя сущность IEntityTypeConfiguration:

public abstract class AggregateRootTypeConfiguration<TEntity, TKey> : IEntityTypeConfiguration<TEntity>
    where TEntity : AggregateRoot<TKey>
    where TKey : IComparable
{
    public virtual void Configure(EntityTypeBuilder<TEntity> builder)
    {
        builder.HasKey(x => x.Id);
        builder.Property(x => x.Id)
            .HasValueGenerator<SequentialGuidValueGenerator>()
            .ValueGeneratedOnAdd()
            .IsRequired();
    }
}

public class AuditedAggregateRootTypeConfiguration<TEntity, TKey> : AggregateRootTypeConfiguration<TEntity, TKey>
    where TEntity : AuditedAggregateRoot<TKey>
    where TKey : IComparable
{
    public override void Configure(EntityTypeBuilder<TEntity> builder)
    {
        base.Configure(builder);
        builder.Property(x => x.Created)
            .HasValueGenerator<UtcDateTimeGenerator>()
            .ValueGeneratedOnAdd()
            .IsRequired();

        builder.Property(x => x.LastModified)
            .HasValueGenerator<UtcDateTimeGenerator>()
            .ValueGeneratedOnAddOrUpdate()
            .IsRequired();
    }
}

public class PlayerEntityTypeConfiguration : AuditedAggregateRootTypeConfiguration<Player, Guid>
{
    public override void Configure(EntityTypeBuilder<Player> builder)
    {
        base.Configure(builder);
        builder.Property(x => x.Email)
            .HasMaxLength(254)
            .IsRequired();

        builder.Property(x => x.ExternalId)
            .HasMaxLength(200)
            .IsRequired();

        builder.Property(x => x.DisplayName)
            .HasMaxLength(200)
            .IsRequired();

        builder.HasIndex(x => x.ExternalId).IsUnique();
        builder.HasIndex(x => x.Email).IsUnique();
        builder.HasIndex(x => x.DisplayName).IsUnique();
    }
}

Дбконтекст:

public class MyDbContext : DbContext
{
    public EmpiresDbContext(DbContextOptions<EmpiresDbContext> dbContextOptions) : base(dbContextOptions)
    {
    }

    public DbSet<Player> Players { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(EmpiresDbContext).Assembly);
        base.OnModelCreating(modelBuilder);
    }
    
    /*
     * Overrides below to fix EF Core not calling ValueGenerator's on update
     * See: https://github.com/dotnet/efcore/issues/19765#issuecomment-617679987
     */
    
    public override int SaveChanges(bool acceptAllChangesOnSuccess)
    {
        GenerateOnUpdate();
        return base.SaveChanges(acceptAllChangesOnSuccess);
    }

    public override Task<int> SaveChangesAsync(
        bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
    {
        GenerateOnUpdate();
        return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
    }

    private void GenerateOnUpdate()
    {
        foreach (var entityEntry in ChangeTracker.Entries())
        {
            foreach (var propertyEntry in entityEntry.Properties)
            {
                var property = propertyEntry.Metadata;
                var valueGeneratorFactory =
                    property.GetValueGeneratorFactory();
                var generatedOnUpdate = (property.ValueGenerated & ValueGenerated.OnUpdate)
                                        == ValueGenerated.OnUpdate;
                if (!generatedOnUpdate || valueGeneratorFactory == null)
                {
                    continue;
                }

                var valueGenerator = valueGeneratorFactory.Invoke(
                    property,
                    entityEntry.Metadata);
                propertyEntry.CurrentValue = valueGenerator.Next(entityEntry);
            }
        }
    }
}

Я внедряю MyDbContext в сервис, а затем в методе CreatePlayer:

var player = new Player(externalId, displayName, email);
_dbContext.Players.Add(player);
await _dbContext.SaveChangesAsync();

Однако при вызове await _dbContext.SaveChangesAsync(); я получаю исключение:

SqlException: невозможно вставить значение NULL в столбец «LastModified»

С помощью точки останова я проверил, что строка propertyEntry.CurrentValue = valueGenerator.Next(entityEntry); в Dbcontext срабатывает и что она присваивает правильное значение свойству LastModified.

С помощью трассировки SQL я вижу, что сгенерированный SQL выглядит следующим образом:

exec sp_executesql N'SET NOCOUNT ON;
INSERT INTO [Players] ([Id], [Created], [DisplayName], [Email], [ExternalId], [LastLogin])
VALUES (@p0, @p1, @p2, @p3, @p4, @p5);
SELECT [LastModified]
FROM [Players]
WHERE @@ROWCOUNT = 1 AND [Id] = @p0;

',N'@p0 uniqueidentifier,@p1 datetime2(7),@p2 nvarchar(200),@p3 nvarchar(254),@p4 nvarchar(4000),@p5 datetime2(7)',@p0='C17E4EC8-CDD6-458A-8CEF-08D8C5D6F63A',@p1='2021-01-31 11:00:27.1068666',@p2=N'Kyr',@p3=N'[email protected]',@p4=N'my-iDp-id-removed-for-security',@p5='0001-01-01 00:00:00'

Как вы можете видеть выше, столбец LastModified даже не упоминается.


person Hades    schedule 30.01.2021    source источник
comment
Пожалуйста, отредактируйте свой вопрос, чтобы включить полный исходный код, который у вас есть, как минимальный воспроизводимый пример, который может быть скомпилирован и протестирован другими.   -  person Progman    schedule 30.01.2021
comment
Не могли бы вы поделиться кодом, откуда вы звоните Insert() и SaveAsync()?   -  person atiyar    schedule 31.01.2021
comment
Некоторый дополнительный код добавлен выше по запросу.   -  person Hades    schedule 31.01.2021
comment
После еще нескольких поисков и упрощений, которые были предложены, я обновил вопрос выше, чтобы лучше отразить проблему. На самом деле это свойство LastModified, которое сообщается как нулевое, а не созданное (я неправильно понял ошибку), но у меня есть вещи, которые должны генерировать значение этого свойства (обновлено в вопросе)   -  person Hades    schedule 31.01.2021


Ответы (1)


Ваш ValueGenerator отлично работает со мной.

Чтобы сузить причину проблемы, возьмите зависимость вашего DbContext непосредственно в классе обслуживания, временно -

public class PlayerService : IPlayerService
{
    private EmpiresDbContext _dbContext;
    
    public PlayerService(EmpiresDbContext context)
    {
        _dbContext = context;
    }
}

и посмотрите, работает ли следующий код -

var player = new Player(externalId, displayName, email);
try
{
    _dbContext.Players.Add(player);
    await _dbContext.SaveChangesAsync();
}
catch(DbUpdateException ex)
{
    var message = ex.Message;
    throw;
}

Два предложения (не имеющие прямого отношения к вашей проблеме) -

  1. Удалите все .ConfigureAwait(false) из вашего кода. Проверьте этот ответ, чтобы понять, почему.
  2. Не используйте транзакцию для одной операции вставки, подобной этой; это будет стоить производительности без какой-либо выгоды.

EDIT:
Просматривая код в обновленном сообщении, я вижу, что вы используете конфигурацию .ValueGeneratedOnAddOrUpdate() с полем LastModified, и, насколько я помню, в этой конфигурации есть известная проблема. Вы даже можете найти проблемы, опубликованные в репозитории EF Core GitHub по этому вопросу.

Я бы предложил использовать конфигурацию .ValueGeneratedOnAdd(), как с полем Created, а затем обрабатывать сценарии обновления вручную.

person atiyar    schedule 31.01.2021
comment
После еще нескольких копаний и упрощений, как вы предложили, я обновил вопрос выше, чтобы лучше отразить проблему. На самом деле это свойство LastModified, которое сообщается как нулевое, а не созданное (я неправильно понял ошибку), но у меня есть вещи, которые должны генерировать значение этого свойства (обновлено в вопросе) - person Hades; 31.01.2021
comment
@Hades Добавлено редактирование. Посмотрите, поможет ли это. - person atiyar; 31.01.2021