Using Ardalis.Specification to query CosmosDb using Linq syntax

2.8k views Asked by At

We have a working solution that uses the specification pattern to access CosmosDb using plain text SQL statements.

We are attempting to use the latest version of Ardalis.Specification (5.1.0) to do the same, but using LINQ to provide type safety in our sql.

For a collection foo we have a specification:

using System.Linq;

using Ardalis.Specification;
using Example.Sample.Core.Entities;

namespace Example.Sample.Core.Specifications
{
    public class FooFromIdSpecification : Specification<Foo>
    {
        public FooFromIdSpecification(string id)
        {
            Query.Where(x => x.Id == id);
        }
    }
}

Where we are having problems is in the base generic repository ... getting the code to generate the sql from specification:

public async IAsyncEnumerable<T> GetItemsAsyncEnumerable(ISpecification<T> specification)
{
    # This is the line that is not working
    var foo = (IQueryable<T>)specification.Evaluate(_container.GetItemLinqQueryable<T>());

    using var iterator = foo.ToFeedIterator<T>();

    while (iterator.HasMoreResults)
    {
        var response = await iterator.ReadNextAsync();
        foreach (var item in response)
        {
            yield return item;
        }
    }
}

Hit a wall getting the evaluator to work. Likely missing something obvious.

The problem

The code above when called does not hit any try-catch blocks, but foo is null.

Some sources we have referenced

1

There are 1 answers

1
Ruskin On BEST ANSWER

We 'got something working' ... not elegant but does the job.

Gotcha - there is no way my colleagues and I discovered of using SelectMany using main implementation, which is needed when getting arrays from separate collections e.g. in SQL world:

select s.foo from c join s in c.someArray

This is what worked:

  • Created own sub-class off Specification
  • Implemented hacky evaluator
using Ardalis.Specification;
using System;
using System.Collections.Generic;
using System.Linq.Expressions;

namespace Whatever.Namespace.You.Want
{
    public interface ICosmosDbSpecification<T, TMapped> : ISpecification<T, TMapped>
    {
        Expression<Func<T, IEnumerable<TMapped>>>? ManySelector { get; }
    }
}

Implementing that interface to get the SelectMany functionality down the stack:

using Ardalis.Specification;
using System;
using System.Collections.Generic;
using System.Linq.Expressions;

namespace Whatever.Namespace.You.Want
{
    public abstract class CosmosDbSpecification<T, TMapped> : Specification<T, TMapped>, ICosmosDbSpecification<T, TMapped>
    {
        protected new virtual ICosmosDbSpecificationBuilder<T, TMapped> Query { get; }

        public Expression<Func<T, IEnumerable<TMapped>>>? ManySelector { get; internal set; }

        protected CosmosDbSpecification()
            : this(InMemorySpecificationEvaluator.Default)
        {
        }

        protected CosmosDbSpecification(IInMemorySpecificationEvaluator inMemorySpecificationEvaluator)
            : base(inMemorySpecificationEvaluator)
        {
            this.Query = new CosmosDbSpecificationBuilder<T, TMapped>(this);
        }

    }

    public interface ICosmosDbSpecificationBuilder<T, TResult> : ISpecificationBuilder<T, TResult>
    {
        new CosmosDbSpecification<T, TResult> Specification { get; }
    }

    public class CosmosDbSpecificationBuilder<T, TResult> : SpecificationBuilder<T, TResult>, ICosmosDbSpecificationBuilder<T, TResult>
    {
        public new CosmosDbSpecification<T, TResult> Specification { get; }

        public CosmosDbSpecificationBuilder(CosmosDbSpecification<T, TResult> specification)
            : base(specification)
        {
            this.Specification = specification;
        }
    }


    public static class CosmosDbSpecificationBuilderExtensions
    {
        /// <summary>
        /// Allows CosmosDb SelectMany methods. WARNING can only have Select OR SelectMany ... using both may throw
        /// </summary>
        public static ICosmosDbSpecificationBuilder<T, TResult> SelectMany<T, TResult>(
            this ICosmosDbSpecificationBuilder<T, TResult> specificationBuilder,
            Expression<Func<T, IEnumerable<TResult>>> manySelector)
        {
            specificationBuilder.Specification.ManySelector = manySelector;

            return specificationBuilder;
        }
    }
}

Should probably have changed the InMemorySpecificationEvaluator.Default singleton to our own ... but current implementation works and getting on with things.

This then all gets stitched together in repository using bespoke evaluator-like thing:

using Ardalis.Specification;
using Microsoft.Azure.Cosmos;
using System;
using System.Linq;
using System.Linq.Expressions;

namespace Whatever.Namespace.You.Want
{
    public static class SpecificationEvaluator <T>
    {
        public static IOrderedQueryable<TResult> ApplySpecification<TResult>(Container container, ISpecification<T, TResult> specification)
        {
            var queryable = container.GetItemLinqQueryable<T>(
                true, default, default,
                new CosmosLinqSerializerOptions { PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase });

            foreach (var criteria in specification.WhereExpressions)
            {
                queryable = (IOrderedQueryable<T>)queryable.Where(criteria);
            }

            if (specification.OrderExpressions != null)
            {
                if (specification.OrderExpressions.Where(x => x.OrderType == OrderTypeEnum.OrderBy ||
                                                            x.OrderType == OrderTypeEnum.OrderByDescending).Count() > 1)
                {
                    throw new DuplicateOrderChainException();
                }

                IOrderedQueryable<T> orderedQuery = null;
                foreach (var orderExpression in specification.OrderExpressions)
                {
                    if (orderExpression.OrderType == OrderTypeEnum.OrderBy)
                    {
                        orderedQuery = Queryable.OrderBy((dynamic)queryable, (dynamic)RemoveConvert(orderExpression.KeySelector));
                    }
                    else if (orderExpression.OrderType == OrderTypeEnum.OrderByDescending)
                    {
                        orderedQuery = Queryable.OrderByDescending((dynamic)queryable, (dynamic)RemoveConvert(orderExpression.KeySelector));
                    }
                    else if (orderExpression.OrderType == OrderTypeEnum.ThenBy)
                    {
                        orderedQuery = Queryable.ThenBy((dynamic)orderedQuery, (dynamic)RemoveConvert(orderExpression.KeySelector));
                    }
                    else if (orderExpression.OrderType == OrderTypeEnum.ThenByDescending)
                    {
                        orderedQuery = Queryable.ThenByDescending((dynamic)orderedQuery, (dynamic)RemoveConvert(orderExpression.KeySelector));
                    }
                }
                if (orderedQuery != null)
                {
                    queryable = orderedQuery;
                }
            }

            if (specification.Skip != null && specification.Skip != 0)
            {
                queryable = (IOrderedQueryable<T>)queryable.Skip(specification.Skip.Value);
            }

            if (specification.Take != null)
            {
                queryable = (IOrderedQueryable<T>)queryable.Take(specification.Take.Value);
            }

            if (typeof(ICosmosDbSpecification<T, TResult>).IsAssignableFrom(specification.GetType()))
            {
                var selectMany = ((ICosmosDbSpecification<T, TResult>)specification).ManySelector;
                if (selectMany != null)
                {
                    if (specification.Selector != null)
                    {
                        throw new ApplicationException("Cannot set both Selector and ManySelector on same specification");
                    }

                    if (specification.Take != null || specification.Skip != null)
                    {
                        // until figured out how to implement this on final solution instead of inner root request (gives not supported error in sdk)
                        throw new ApplicationException("Select many does not support take or skip ...");
                    }

                    return (IOrderedQueryable<TResult>)queryable.SelectMany(selectMany);
                }
            }

            return (IOrderedQueryable<TResult>)queryable.Select(specification.Selector);
        }

        private static LambdaExpression RemoveConvert(LambdaExpression source)
        {
            var body = source.Body;
            while (body.NodeType == ExpressionType.Convert)
                body = ((UnaryExpression)body).Operand;

            return Expression.Lambda(body, source.Parameters);
        }
    }
}

and use as so in your generic base repository:

IOrderedQueryable<TResult> queryable = 
    SpecificationEvaluator<T>.ApplySpecification(_container, specification);

using var iterator = queryable.ToFeedIterator<TResult>();
...

With a specification something like:

public class GetDetailSpecification : CosmosDbSpecification<TypeOfData, TypeOfOutput>
    {
public GetFooBarSpecification(YourParameterisedFilterObject filter)
        {
            if (filter == null) throw new ArgumentNullException(nameof(filter));
            Query.Select(x => new TypeOfOutput { Foo = x.Bar });
            Query.Where(x => x.Id == filter.Id && x.PartitionKey == filter.PartitionKeyValue);
        }
}