dotnet / efcore

EF Core is a modern object-database mapper for .NET. It supports LINQ queries, change tracking, updates, and schema migrations.
https://docs.microsoft.com/ef/
MIT License
13.79k stars 3.19k forks source link

Projecting an Entity with Nested JSON Collections Causes a `NullReferenceException` #33518

Closed arex388 closed 4 months ago

arex388 commented 7 months ago

I'm running into an issue when attempting to project an entity into a DTO, where the entity has a collection of JSON objects that has a nested collection of JSON objects, causes a NullReferenceException to be thrown. If I ignore the nested collection, the projection works as expected. The following is some code snippets to show the models, configurations, and the commands and queries I'm using. If needed I can try and make a standalone project, but for now I'm posting snippets of my attempt to reproduce with a smaller entity within my existing project.

The project is an ASP.NET Core MVC app on .NET 8. It uses StronglyTypedIds, MediatR, AutoMapper, and FluentValidation. The database JSON payload does not have any null values.

Table Definition and Data
CREATE TABLE [Shared].[Tests] (
    [ConcurrencyToken] ROWVERSION,
    [Id] SMALLINT NOT NULL IDENTITY(1, 1) CONSTRAINT [PK_Shared_Tests] PRIMARY KEY CLUSTERED WITH (
        FILLFACTOR = 100
    ),
    [Name] NVARCHAR(255) NOT NULL,
    [Objects] NVARCHAR(MAX) NOT NULL CHECK(ISJSON([Objects]) = 1)
);
GO
ConcurrencyToken Id Name Objects
0x0000000000000A31 2 Test [{"n":"Account","f":[{"k":"Name","t":1}]}]
EF Core Models
[StronglyTypedId(TypedIds.Int16, TypedIds.Int16EfCore)]
public readonly partial struct SharedTestId;

public sealed class SharedTest {
    public static readonly byte NameLength = byte.MaxValue;

    public long ConcurrencyToken { get; set; }
    public SharedTestId Id { get; set; }
    public string Name { get; set; } = null!;
    public ICollection<SharedTestObject> Objects { get; set; } = [];
}

public sealed class SharedTestObject {
    public static readonly byte NameLength = byte.MaxValue;

    [JsonPropertyName("f")]
    public ICollection<SharedTestObjectField> Fields { get; set; } = [];

    [JsonPropertyName("n")]
    public string Name { get; set; } = null!;
}

public sealed class SharedTestObjectField {
    public static readonly byte KeyLength = byte.MaxValue;

    [JsonPropertyName("k")]
    public string Key { get; set; } = null!;

    [JsonPropertyName("t")]
    public SharedTestObjectFieldType Type { get; set; }
}

public enum SharedTestObjectFieldType :
    byte {
    None = 0,
    Text = 1
}
EF Core Configurations
internal sealed class SharedTestConfiguration :
    IEntityTypeConfiguration<SharedTest> {
    public void Configure(
        EntityTypeBuilder<SharedTest> builder) {
        builder.ToTable(nameof(SharedContext.Tests), Schemas.Shared).HasKey(
            st => st.Id);

        builder.Property(
            st => st.ConcurrencyToken).HasConversion<byte[]>().IsRowVersion();

        builder.Property(
            st => st.Id).HasConversion<SharedTestId.EfCoreValueConverter>().HasValueGenerator<SharedTestId.EfCoreValueGenerator>().ValueGeneratedOnAdd();

        builder.Property(
            st => st.Name).HasMaxLength(SharedTest.NameLength).IsRequired();

        //  ========================================================================
        //  Relationships
        //  ========================================================================

        builder.OwnsMany(
            st => st.Objects,
            b => {
                b.ToJson();
                b.OwnsMany(
                    sto => sto.Fields).ToJson();
            });
    }
}
EF Core Context
public class SharedContext(
    DbContextOptions<SharedContext> options) :
    DbContext(options) {
    public DbSet<SharedTest> Tests => Set<SharedTest>();

    protected override void OnModelCreating(
        ModelBuilder builder) {
        builder.ApplyConfiguration(new SharedTestConfiguration());
    }
}
Edit Command and Query
public static class Edit {
    public sealed class Command :
        IRequest<IActionResult> {
        public required SharedTestId Id { get; init; }
        public required string Name { get; init; }
        public required IEnumerable<CommandObject> Objects { get; init; } = [];
    }

    public sealed class CommandObject {
        public required IEnumerable<CommandObjectField> Fields { get; init; } = [];
        public required string Name { get; init; }
    }

    public sealed class CommandObjectField {
        public required string Key { get; init; }
        public required SharedTestObjectFieldType Type { get; init; }
    }

    public sealed class Query :
        IRequest<IActionResult> {
        public required SharedTestId Id { get; init; }
        public Command? Test { get; set; }
        public ValidationResult? ValidationResult { get; init; }
    }

    public sealed class QueryResponse {
        public required Command Test { get; init; }
        public ValidationResult? ValidationResult { get; init; }
    }
}

//  ================================================================================
//  Handlers
//  ================================================================================

file sealed class CommandHandler(
    IMapper mapper,
    IMediator mediator,
    SharedContext shared,
    IValidator<Command> validator) :
    IRequestHandler<Command, IActionResult> {
    private readonly IMapper _mapper = mapper;
    private readonly IMediator _mediator = mediator;
    private readonly SharedContext _shared = shared;
    private readonly IValidator<Command> _validator = validator;

    public async Task<IActionResult> Handle(
        Command command,
        CancellationToken cancellationToken) {
        try {
            var validationResult = await _validator.ValidateAsync(command, cancellationToken).ConfigureAwait(false);

            if (!validationResult.IsValid) {
                return await _mediator.Send(new Query {
                    Id = command.Id,
                    Test = command,
                    ValidationResult = validationResult
                }, cancellationToken).ConfigureAwait(false);
            }

            var test = await _shared.Tests.FindAsync([
                command.Id
            ], cancellationToken).ConfigureAwait(false);

            if (test is null) {
                return new NotFoundResult();
            }

            UpdateTest(command, test);

            if (cancellationToken.IsCancellationRequested) {
                return await _mediator.Send(new Query {
                    Id = command.Id,
                    Test = command,
                    ValidationResult = ValidationResultHelper.Cancelled
                }, cancellationToken).ConfigureAwait(false);
            }

            await _shared.SaveChangesAsync(cancellationToken).ConfigureAwait(false);

            return new OkResult();
        } catch {
#if DEBUG
            throw;
#endif

            return new BadRequestResult();
        }
    }

    //  ============================================================================
    //  Utilities
    //  ============================================================================

    private void UpdateTest(
        Command command,
        SharedTest test) => _mapper.Map(command, test);
}

file sealed class QueryHandler(
    IMapper mapper,
    SharedContext shared,
    IValidator<Query> validator,
    ViewDataProvider viewData) :
    IRequestHandler<Query, IActionResult> {
    private readonly IMapper _mapper = mapper;
    private readonly SharedContext _shared = shared;
    private readonly IValidator<Query> _validator = validator;
    private readonly ViewDataProvider _viewData = viewData;

    public async Task<IActionResult> Handle(
        Query query,
        CancellationToken cancellationToken) {
        try {
            var validationResult = await _validator.ValidateAsync(query, cancellationToken).ConfigureAwait(false);

            if (!validationResult.IsValid) {
                //  Redirect?

                return new BadRequestResult();
            }

            var test = await QueryTestAsync(query, cancellationToken).ConfigureAwait(false);

            if (test is null) {
                return new NotFoundResult();
            }

            var response = new QueryResponse {
                Test = query.Test ?? test,
                ValidationResult = query.ValidationResult
            };

            return new ViewResult {
                StatusCode = query.ValidationResult is null
                    ? StatusCodes.Status200OK
                    : StatusCodes.Status400BadRequest,
                ViewData = _viewData.Create(response),
                ViewName = nameof(Edit)
            };
        } catch {
#if DEBUG
            throw;
#endif

            //  Redirect?

            return new BadRequestResult();
        }
    }

    //  ============================================================================
    //  Queries
    //  ============================================================================

    private Task<Command?> QueryTestAsync(
        Query query,
        CancellationToken cancellationToken) => _shared.Tests.Where(
        t => t.Id == query.Id).ProjectTo<Command>(_mapper.ConfigurationProvider).FirstOrDefaultAsync(cancellationToken);
}

//  ================================================================================
//  Mappers
//  ================================================================================

file sealed class CommandMappings :
    Profile {
    public CommandMappings() {
        CreateMap<Command, SharedTest>()
            .Ignore(t => t.Id);

        CreateMap<CommandObject, SharedTestObject>();

        CreateMap<CommandObjectField, SharedTestObjectField>();
    }
}

file sealed class QueryMappings :
    Profile {
    public QueryMappings() {
        CreateProjection<SharedTest, Command>();

        CreateProjection<SharedTestObject, CommandObject>();

        CreateProjection<SharedTestObjectField, CommandObjectField>();
    }
}

//  ================================================================================
//  Validators
//  ================================================================================

file sealed class CommandValidator :
    AbstractValidator<Command> {
    public CommandValidator(
        IValidator<CommandObject> objectValidator) {
        RuleFor(c => c.Name).MaximumLength(SharedIndustry.NameLength).NotEmpty();
        RuleFor(c => c.Objects).ForEach(o => o.SetValidator(objectValidator)).NotEmpty();
    }
}

file sealed class CommandObjectValidator :
    AbstractValidator<CommandObject> {
    public CommandObjectValidator(
        IValidator<CommandObjectField> objectFieldValidator) {
        RuleFor(co => co.Fields).ForEach(f => f.SetValidator(objectFieldValidator)).NotEmpty();
        RuleFor(co => co.Name).MaximumLength(TenantObject.NameLength).NotEmpty();
    }
}

file sealed class CommandObjectFieldValidator :
    AbstractValidator<CommandObjectField> {
    public CommandObjectFieldValidator() {
        RuleFor(cof => cof.Key).MaximumLength(TenantObjectField.KeyLength).NotEmpty();
        RuleFor(cof => cof.Type).NotEmpty();
    }
}

file sealed class QueryValidator :
    AbstractValidator<Query> {
    public QueryValidator() {
        RuleFor(q => q.Id).NotEmpty();
    }
}

Stack Trace

SelectExpression.ctor(JsonQueryExpression jsonQueryExpression, TableExpressionBase tableExpressionBase, String identifierColumnName, Type identifierColumnType, RelationalTypeMapping identifierColumnTypeMapping)
SqlServerQueryableMethodTranslatingExpressionVisitor.TransformJsonQueryToTable(JsonQueryExpression jsonQueryExpression)
RelationalQueryableMethodTranslatingExpressionVisitor.VisitExtension(Expression extensionExpression)
SqlServerQueryableMethodTranslatingExpressionVisitor.VisitExtension(Expression extensionExpression)
QueryableMethodTranslatingExpressionVisitor.VisitMethodCall(MethodCallExpression methodCallExpression)
RelationalQueryableMethodTranslatingExpressionVisitor.VisitMethodCall(MethodCallExpression methodCallExpression)
QueryableMethodTranslatingExpressionVisitor.VisitMethodCall(MethodCallExpression methodCallExpression)
RelationalQueryableMethodTranslatingExpressionVisitor.VisitMethodCall(MethodCallExpression methodCallExpression)
QueryableMethodTranslatingExpressionVisitor.Translate(Expression expression)
RelationalQueryableMethodTranslatingExpressionVisitor.Translate(Expression expression)
<39 more frames...>
QueryCompiler.ExecuteAsync[TResult](Expression query, CancellationToken cancellationToken)
EntityQueryProvider.ExecuteAsync[TResult](Expression expression, CancellationToken cancellationToken)
EntityFrameworkQueryableExtensions.ExecuteAsync[TSource,TResult](MethodInfo operatorMethodInfo, IQueryable`1 source, Expression expression, CancellationToken cancellationToken)
EntityFrameworkQueryableExtensions.ExecuteAsync[TSource,TResult](MethodInfo operatorMethodInfo, IQueryable`1 source, CancellationToken cancellationToken)
EntityFrameworkQueryableExtensions.FirstOrDefaultAsync[TSource](IQueryable`1 source, CancellationToken cancellationToken)
<Edit>F60BE28EBC5BFC06E9B842371DE5A4101470F37A0FC9FC2FAD97C0BC8472CC183__QueryHandler.QueryTestAsync(Query query, CancellationToken cancellationToken) line 171
<Edit>F60BE28EBC5BFC06E9B842371DE5A4101470F37A0FC9FC2FAD97C0BC8472CC183__QueryHandler.Handle(Query query, CancellationToken cancellationToken) line 136
<Edit>F60BE28EBC5BFC06E9B842371DE5A4101470F37A0FC9FC2FAD97C0BC8472CC183__CommandHandler.Handle(Command command, CancellationToken cancellationToken) line 67
EditTests.Edit_Command_Fails_WhenInvalid() line 51
--- End of stack trace from previous location ---

Include provider and version information

EF Core version: 8.0.4 Database provider: Microsoft.EntityFrameworkCore.SqlServer Target framework: .NET 8.0 Operating system: Windows 10 Pro 22H2 IDE: Visual Studio 2022 17.9.6

cincuranet commented 7 months ago

This issue is lacking enough information for us to be able to fully understand what is happening. Please attach a small, runnable project or post a small, runnable code listing that reproduces what you are seeing so that we can investigate.

arex388 commented 7 months ago

Here is a project that is as close as I could get it to my actual project that demonstrates the error. There is a database creation script and readme. The database creation script has to be ran twice to work correctly.

Arex388JsonException.zip

maumar commented 4 months ago

dupe of https://github.com/dotnet/efcore/issues/32513