ardalis / Specification

Base class with tests for adding specifications to a DDD model
MIT License
1.84k stars 240 forks source link

DbSet<T>.WithSpecification together with ISpecification<T, TDestination> throws SelectorNotFoundException #371

Closed tomaforn closed 9 months ago

tomaforn commented 9 months ago

I have the following specifications defined:

    public class BlogPostByTitleSpec : Specification<Post>
    {
        public BlogPostByTitleSpec(string title)
        {
            Query.Where(c => c.Title.Contains(title));
        }
    }

    public class BlogPostByTitleDtoSpec : Specification<Post, PostDto>
    {
        public BlogPostByTitleDtoSpec(string title)
        {
            Query.Where(c => c.Title.Contains(title));
        }
    }

I use them like so (working example):

var blogPostByTitleSpec = new BlogPostByTitleSpec("Hello");
var postsByTitle = await db.Posts.WithSpecification(blogPostByTitleSpec).ToListAsync();

And like this (throws exception):

var blogPostByTitleDtoSpec = new BlogPostByTitleDtoSpec("Hello");
var postsDtoByTitle = await db.Posts.WithSpecification(blogPostByTitleDtoSpec).ToListAsync();

The exception thrown is:

Ardalis.Specification.SelectorNotFoundException: 'The specification must have a selector transform defined. Ensure either Select() or SelectMany() is used in the specification!'

I thought it was related somehow to the type change/mapping defined in the specification, so I've tried fiddeling with both Mapster and Automapper, for instance like this:

var postsDtoByTitle = await db.Posts.WithSpecification(blogPostByTitleDtoSpec).ProjectToType<PostDto>().ToListAsync();

But that gives the same error.

The only way I have found around this problem is to define a Select directly in the specification, like this:

public BlogPostByTitleDtoSpec(string title)
        {
            Query
                .Select(x => new PostDto()
                {
                    Id = x.PostId,
                    BlogId = x.BlogId,
                    Title = x.Title,
                    Content = x.Content
                })
                .Where(c => c.Title.Contains(title));
        }

Can anyone explain what I'm doing wrong?

fiseni commented 9 months ago

Hi @tomaforn,

There are two types of base specification classes, Specification<T> and Specification<T, TResult>. If you are using the latter, then the specification must contain the Select expression. That's by design. That's precisely what the error message says. We're not doing any automatic mapping if that's what you expected.

So, the usage will be as follows:

Let me know if this helps to clarify the confusion.

tomaforn commented 9 months ago

Hi @fiseni,

Ah, so that explains why it wasn't working, thanks!

I was writing an extension method for IQueryable when i ran into the issue, something like this:

public static async Task<PaginationResponse<TDestination>> ProjectToPaginatedListAsync<T, TDestination>(
        this IQueryable<T> query, ISpecification<T, TDestination> spec, int pageNumber, int pageSize, CancellationToken cancellationToken = default)
        where T : class
        where TDestination : class
    {
        int count = await query.WithSpecification(spec).CountAsync(cancellationToken);
        if (count > 0)
        {
            var list = await query.WithSpecification(spec).ProjectToType<TDestination>().ToListAsync(cancellationToken);
            return new PaginationResponse<TDestination>(list, count, pageNumber, pageSize);
        }
        return new PaginationResponse<TDestination>(new List<TDestination>(), 0, 0, 0);
    }

I was hoping I could require the ISpecification<T, TDestination> base as input to deduce the target type and handle the projection inside, but from your answer I'm not sure that's possible?

I guess I could change it into something like this instead:

public static async Task<PaginationResponse<TDestination>> ProjectToPaginatedListAsync<T, TDestination>(
        this IQueryable<T> query, ISpecification<T> spec, int pageNumber, int pageSize, CancellationToken cancellationToken = default)
        where T : class
        where TDestination : class
    {
        int count = await query.WithSpecification(spec).CountAsync(cancellationToken);
        if (count > 0)
        {
            var list = await query.WithSpecification(spec).ProjectToType<TDestination>().ToListAsync(cancellationToken);
            return new PaginationResponse<TDestination>(list, count, pageNumber, pageSize);
        }
        return new PaginationResponse<TDestination>(new List<TDestination>(), 0, 0, 0);
    }

But it would be nice to be able to do

var pagedAndMappedResultByTitle = await db.Posts.ProjectToPaginatedListAsync(blogPostByTitleDtoSpec, 1, 5, CancellationToken.None);

instead of

var pagedAndMappedResultByTitle = await db.Posts.ProjectToPaginatedListAsync<Post, PostDto>(blogPostByTitleSpec, 1, 5, CancellationToken.None);

Do you know if it's possible to extend like in the first example, without having to manually map the types in the specification?

fiseni commented 9 months ago

I see you're trying to automate the projection using Automapper (or other mapping tools) and also apply pagination. We already have samples for that (you can find them under samples folder in the repository). The App3 sample is exactly what you're trying to accomplish. You can find it here.

The usage becomes something as follows

var spec = new CustomerByIdSpec(id);
var result = await repo.ProjectToFirstOrDefaultAsync<CustomerDto>(spec, cancellationToken);

or

var spec = new CustomerSpec(filter);
var result = await repo.ProjectToListAsync<CustomerDto>(spec, filter, cancellationToken);

You want to define the return type as a generic parameter, since for a single customer specification you may want to project it to CustomerDto, CustomerSimpleDto, etc.

tomaforn commented 9 months ago

I had totally missed those samples, they look to be exactly what I was looking for as you said. Thanks for pointing me there!