efcore: ValueGeneratedOnUpdate() and ValueGeneratedOnAddOrUpdate() not working for DateTimeOffset property

I’m having trouble getting ValueGeneratedOnUpdate and ValueGeneratedOnAddOrUpdate() to work for a DateTimeOffset property in my model. When I call AddAsync() or Update(), the entity local reflects the newly generated values. However, calling SaveChangesAsync() results in either an exception or no change to the values in question. ValueGeneratedOnAdd() appears to be working properly.

Steps to reproduce

My model has two DateTimeOffset properties.

public class Account
{
    public int Id { get; set; }
    public Guid Guid { get; set; }
    public DateTimeOffset CreatedDateTime { get; set; }
    public DateTimeOffset LastModifiedDateTime { get; set; }
}

I’m using the fluent API to configure my context.

builder.HasKey(account => account.Id);
builder.Property(a => a.Guid)
    .ValueGeneratedOnAdd()
    .HasValueGenerator(typeof(GuidValueGenerator))
    .IsRequired();
builder.Property(a => a.CreatedDateTime)
    .ValueGeneratedOnAdd()
    .HasValueGenerator(typeof(DateTimeOffsetValueGenerator))
    .IsRequired();
builder.Property(a => a.LastModifiedDateTime)
    .ValueGeneratedOnAddOrUpdate()
    .HasValueGenerator(typeof(DateTimeOffsetValueGenerator))

And I have a value generator (the GuidValueGenerator is similar, but returns Guid.NewGuid() instead).

internal class DateTimeOffsetValueGenerator : ValueGenerator<DateTimeOffset>
{
    public override bool GeneratesTemporaryValues => false;

    public override DateTimeOffset Next(EntityEntry entry)
    {
        if (entry is null)
        {
            throw new ArgumentNullException(nameof(entry));
        }

        return DateTimeOffset.UtcNow;
    }
}

Finally, I have my controller actions.

public async Task<IActionResult> Post([FromBody] Account account)
{
    ...

    await dbContext.Accounts.AddAsync(account);
    await dbContext.SaveChangesAsync();

    ...
}

public async Task<IActionResult> Put([FromRoute] int id, [FromBody] Account account)
{
    //retrieving accountToUpdate, error handling, assigning changed properties, etc.

    dbContext.Accounts.Update(accountToUpdate);
    await dbContext.SaveChangesAsync();

    ...
}

If:

  • CreatedDateTime uses ValueGeneratedOnAdd()
  • LastModifiedDateTime uses ValueGeneratedOnAddOrUpdate()
  • Both use HasValueGenerator(typeof(DateTimeOffsetValueGenerator))

Then the following occurs: After dbContext.AddAsync(account) completes, I can see that both CreatedDateTime and LastModifiedDateTime have the correct values in my locals. LastModifiedDateTime is not null. However, dbContext.SaveChangesAsync() throws a DbUpdateException, claiming it is null. Here is the stack trace (I’ve changed some names/paths to protect info):

Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while updating the entries. See the inner exception for details.
 ---> Microsoft.Data.SqlClient.SqlException (0x80131904): Cannot insert the value NULL into column 'LastModifiedDateTime', table 'DatabaseName.dbo.Accounts'; column does not allow nulls. INSERT fails.
The statement has been terminated.
   at Microsoft.Data.SqlClient.SqlCommand.<>c.<ExecuteDbDataReaderAsync>b__164_0(Task`1 result)
   at System.Threading.Tasks.ContinuationResultTaskFromResultTask`2.InnerInvoke()
   at System.Threading.Tasks.Task.<>c.<.cctor>b__274_0(Object obj)
   at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)
--- End of stack trace from previous location where exception was thrown ---
   at System.Threading.Tasks.Task.ExecuteWithThreadLocal(Task& currentTaskSlot, Thread threadPoolThread)
--- End of stack trace from previous location where exception was thrown ---
   at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
ClientConnectionId:1c5e1618-9998-46c8-967d-6b0ed12f44f5
Error Number:515,State:2,Class:16
   --- End of inner exception stack trace ---
   at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(DbContext _, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal.SqlServerExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
   at Application.Api.Controllers.AccountsController.Post(Account account) in PathToProject\Controllers\AccountsController.cs:line 95
   at Microsoft.AspNetCore.Mvc.Infrastructure.ActionMethodExecutor.TaskOfIActionResultExecutor.Execute(IActionResultTypeMapper mapper, ObjectMethodExecutor executor, Object controller, Object[] arguments)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.<InvokeActionMethodAsync>g__Awaited|12_0(ControllerActionInvoker invoker, ValueTask`1 actionResultValueTask)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.<InvokeNextActionFilterAsync>g__Awaited|10_0(ControllerActionInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Rethrow(ActionExecutedContextSealed context)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.<InvokeInnerFilterAsync>g__Awaited|13_0(ControllerActionInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeNextResourceFilter>g__Awaited|24_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Rethrow(ResourceExecutedContextSealed context)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|19_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
   at Microsoft.AspNetCore.Builder.RouterMiddleware.Invoke(HttpContext httpContext)
   at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
   at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
   at Microsoft.AspNetCore.Session.SessionMiddleware.Invoke(HttpContext context)
   at Microsoft.AspNetCore.Session.SessionMiddleware.Invoke(HttpContext context)
   at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware.Invoke(HttpContext context)

Okay, so ValueGeneratedOnAddOrUpdate() throws an exception. I tried changing the configuration to use ValueGeneratedOnUpdate() for LastModifiedDateTime instead. This results in the correct value being generated and saved when I create the entity, but when I try to update the entity, LastModifiedDateTime still has the old value. No new value is generated and saved. Any properties I manually update do successfully save.

I did try calling both ValueGeneratedOnAdd() and ValueGeneratedOnUpdate() on the configuration of LastModifiedDateTime and it behaved the same as if only ValueGeneratedOnUpdate() was present.

I did find issue #3955, but I’d prefer to generate the value in code instead of passing in a SQL function.

The CreatedDateTime property has no trouble generating the value, so I believe the value generation is being implemented properly. Am I missing something?

Further technical details

EF Core version: 3.1.1 Database provider: Microsoft.EntityFrameworkCore.SqlServer Target framework: .NET Core 3.1 Operating system: Windows 10 Version 1903 Build 18362.592) IDE: Visual Studio 2019 16.4.3

About this issue

  • Original URL
  • State: closed
  • Created 4 years ago
  • Comments: 33 (8 by maintainers)

Most upvoted comments

Here is my workaround

Continue to configure the generator with your Fluent API:

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

Update your DbContext to add the following methods:

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 (EntityEntry entityEntry in ChangeTracker.Entries())
    {
        foreach (PropertyEntry propertyEntry in entityEntry.Properties)
        {
            IProperty property = propertyEntry.Metadata;
            Func<IProperty, IEntityType, ValueGenerator> valueGeneratorFactory =
                property.GetValueGeneratorFactory();
            bool generatedOnUpdate = (property.ValueGenerated & ValueGenerated.OnUpdate)
                == ValueGenerated.OnUpdate;
            if (!generatedOnUpdate || valueGeneratorFactory == null)
            {
                continue;
            }

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

Hi, if you still haven’t found a solution, you can try adding .Metadata.SetAfterSaveBehavior(PropertySaveBehavior.Save) in the property config along with the suggested workaround. overriding-value-generation

You’re right - something isn’t adding up here. I now have this and am still getting the exception:

private void GenerateOnUpdate()
{
    foreach (var entityEntry in ChangeTracker.Entries())
    {
        foreach (var propertyEntry in entityEntry.Properties)
        {
            if (propertyEntry.Metadata.Name == "LastModified")
            {
                propertyEntry.CurrentValue = DateTime.UtcNow;
            }
        }
    }
}

This also fails:

private void GenerateOnUpdate()
{
    foreach (var entityEntry in ChangeTracker.Entries())
    {
        if (entityEntry.Properties.Any(x => x.Metadata.Name == "LastModified"))
        {
            entityEntry.Property("LastModified").CurrentValue = DateTime.UtcNow;
        }
    }
}

And now, even the previously working examples don’t work. I’m so confused.

In addition, when I put a break-point on the start of the GenerateOnUpdate() method and inspect the entity properties for each entity in the change tracker (only one at this point) the LastModified property has a legitimate value in both CurrentValue and OriginalValue - both the same… yet I’ve not set them yet and it still throws the null SQL exception.

I feel like I’m going insane.