diff --git a/src/Cosmonaut/CosmosStore.cs b/src/Cosmonaut/CosmosStore.cs index c2d19ec..46b448f 100644 --- a/src/Cosmonaut/CosmosStore.cs +++ b/src/Cosmonaut/CosmosStore.cs @@ -1,14 +1,14 @@ -using System; +using Cosmonaut.Extensions; +using Cosmonaut.Response; +using Cosmonaut.Storage; +using Microsoft.Azure.Documents; +using Microsoft.Azure.Documents.Client; +using System; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; using System.Threading; using System.Threading.Tasks; -using Cosmonaut.Extensions; -using Cosmonaut.Response; -using Cosmonaut.Storage; -using Microsoft.Azure.Documents; -using Microsoft.Azure.Documents.Client; namespace Cosmonaut { @@ -17,11 +17,11 @@ public sealed class CosmosStore : ICosmosStore where TEntity : public bool IsShared { get; internal set; } public string CollectionName { get; private set; } - + public string DatabaseName { get; } public CosmosStoreSettings Settings { get; } - + public ICosmonautClient CosmonautClient { get; } private readonly IDatabaseCreator _databaseCreator; @@ -80,7 +80,8 @@ internal CosmosStore(ICosmonautClient cosmonautClient, public IQueryable Query(FeedOptions feedOptions = null) { var queryable = - CosmonautClient.Query(DatabaseName, CollectionName, GetFeedOptionsForQuery(feedOptions)); + CosmonautClient.Query(DatabaseName, CollectionName, GetFeedOptionsForQuery(feedOptions)) + .ApplyInterception(Settings.Interceptors); return IsShared ? queryable.Where(ExpressionExtensions.SharedCollectionExpression()) : queryable; } @@ -105,7 +106,7 @@ public async Task QuerySingleAsync(string sql, object parameters = null, F var queryable = CosmonautClient.Query(DatabaseName, CollectionName, collectionSharingFriendlySql, parameters, GetFeedOptionsForQuery(feedOptions)); return await queryable.SingleOrDefaultAsync(cancellationToken); } - + public async Task> QueryMultipleAsync(string sql, object parameters = null, FeedOptions feedOptions = null, CancellationToken cancellationToken = default) { var collectionSharingFriendlySql = sql.EnsureQueryIsCollectionSharingFriendly(); @@ -125,14 +126,14 @@ public async Task> AddAsync(TEntity entity, RequestOptio return await CosmonautClient.CreateDocumentAsync(DatabaseName, CollectionName, entity, GetRequestOptions(requestOptions, entity), cancellationToken); } - + public async Task> AddRangeAsync(IEnumerable entities, Func requestOptions = null, CancellationToken cancellationToken = default) { return await ExecuteMultiOperationAsync(entities, x => AddAsync(x, requestOptions?.Invoke(x), cancellationToken)); } - + public async Task> RemoveAsync( - Expression> predicate, + Expression> predicate, FeedOptions feedOptions = null, Func requestOptions = null, CancellationToken cancellationToken = default) @@ -148,7 +149,7 @@ public async Task> RemoveAsync(TEntity entity, RequestOp return await CosmonautClient.DeleteDocumentAsync(DatabaseName, CollectionName, documentId, GetRequestOptions(requestOptions, entity), cancellationToken).ExecuteCosmosCommand(entity); } - + public async Task> RemoveRangeAsync(IEnumerable entities, Func requestOptions = null, CancellationToken cancellationToken = default) { return await ExecuteMultiOperationAsync(entities, x => RemoveAsync(x, requestOptions?.Invoke(x), cancellationToken)); @@ -161,7 +162,7 @@ public async Task> UpdateAsync(TEntity entity, RequestOp return await CosmonautClient.UpdateDocumentAsync(DatabaseName, CollectionName, document, GetRequestOptions(requestOptions, entity), cancellationToken).ExecuteCosmosCommand(entity); } - + public async Task> UpdateRangeAsync(IEnumerable entities, Func requestOptions = null, CancellationToken cancellationToken = default) { return await ExecuteMultiOperationAsync(entities, x => UpdateAsync(x, requestOptions?.Invoke(x), cancellationToken)); @@ -178,7 +179,7 @@ public async Task> UpsertRangeAsync(IEnumerable< { return await ExecuteMultiOperationAsync(entities, x => UpsertAsync(x, requestOptions?.Invoke(x), cancellationToken)); } - + public async Task> RemoveByIdAsync(string id, RequestOptions requestOptions = null, CancellationToken cancellationToken = default) { var response = await CosmonautClient.DeleteDocumentAsync(DatabaseName, CollectionName, id, @@ -208,12 +209,12 @@ public async Task FindAsync(string id, object partitionKeyValue, Cancel : null; return await FindAsync(id, requestOptions, cancellationToken); } - + private void InitialiseCosmosStore(string overridenCollectionName) { IsShared = typeof(TEntity).UsesSharedCollection(); CollectionName = GetCosmosStoreCollectionName(overridenCollectionName); - + _databaseCreator.EnsureCreatedAsync(DatabaseName).ConfigureAwait(false).GetAwaiter().GetResult(); _collectionCreator.EnsureCreatedAsync(DatabaseName, CollectionName, Settings.DefaultCollectionThroughput, Settings.IndexingPolicy) .ConfigureAwait(false).GetAwaiter().GetResult(); @@ -235,7 +236,7 @@ private async Task> ExecuteMultiOperationAsync(I var entitiesList = entities.ToList(); if (!entitiesList.Any()) return multipleResponse; - + var results = (await entitiesList.Select(operationFunc).WhenAllTasksAsync()).ToList(); multipleResponse.SuccessfulEntities.AddRange(results.Where(x => x.IsSuccess)); multipleResponse.FailedEntities.AddRange(results.Where(x => !x.IsSuccess)); @@ -277,7 +278,7 @@ private RequestOptions GetRequestOptions(string id, RequestOptions requestOption private FeedOptions GetFeedOptionsForQuery(FeedOptions feedOptions) { - var shouldEnablePartitionQuery = (typeof(TEntity).HasPartitionKey() && feedOptions?.PartitionKey == null) + var shouldEnablePartitionQuery = (typeof(TEntity).HasPartitionKey() && feedOptions?.PartitionKey == null) || (feedOptions != null && feedOptions.EnableCrossPartitionQuery); if (feedOptions == null) diff --git a/src/Cosmonaut/CosmosStoreSettings.cs b/src/Cosmonaut/CosmosStoreSettings.cs index 5af202f..50e2ca9 100644 --- a/src/Cosmonaut/CosmosStoreSettings.cs +++ b/src/Cosmonaut/CosmosStoreSettings.cs @@ -1,7 +1,10 @@ -using System; +using Cosmonaut.Interception; using Microsoft.Azure.Documents; using Microsoft.Azure.Documents.Client; using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq.Expressions; namespace Cosmonaut { @@ -19,7 +22,7 @@ public class CosmosStoreSettings public IndexingPolicy IndexingPolicy { get; set; } = CosmosConstants.DefaultIndexingPolicy; - public int DefaultCollectionThroughput { get; set; } = CosmosConstants.MinimumCosmosThroughput; + public int DefaultCollectionThroughput { get; set; } = CosmosConstants.MinimumCosmosThroughput; public JsonSerializerSettings JsonSerializerSettings { get; set; } @@ -27,6 +30,8 @@ public class CosmosStoreSettings public string CollectionPrefix { get; set; } = string.Empty; + public List Interceptors { get; } + public CosmosStoreSettings(string databaseName, string endpointUrl, string authKey, @@ -42,6 +47,7 @@ public CosmosStoreSettings(string databaseName, DatabaseName = databaseName ?? throw new ArgumentNullException(nameof(databaseName)); EndpointUrl = endpointUrl ?? throw new ArgumentNullException(nameof(endpointUrl)); AuthKey = authKey ?? throw new ArgumentNullException(nameof(authKey)); + Interceptors = new List(); settings?.Invoke(this); } @@ -52,18 +58,18 @@ public CosmosStoreSettings( ConnectionPolicy connectionPolicy = null, IndexingPolicy indexingPolicy = null, int defaultCollectionThroughput = CosmosConstants.MinimumCosmosThroughput) - : this(databaseName, - new Uri(endpointUrl), + : this(databaseName, + new Uri(endpointUrl), authKey, connectionPolicy, indexingPolicy, defaultCollectionThroughput) { } - + public CosmosStoreSettings( - string databaseName, - Uri endpointUrl, + string databaseName, + Uri endpointUrl, string authKey, ConnectionPolicy connectionPolicy = null, IndexingPolicy indexingPolicy = null, @@ -75,6 +81,17 @@ public CosmosStoreSettings( ConnectionPolicy = connectionPolicy; DefaultCollectionThroughput = defaultCollectionThroughput; IndexingPolicy = indexingPolicy ?? CosmosConstants.DefaultIndexingPolicy; + Interceptors = new List(); + } + + public void AddInterceptor(Expression> filter) where T : class + { + Interceptors.Add(new QueryInterceptor(filter)); } } + + public interface IQueryInterceptor + { + Type Type { get; } + } } \ No newline at end of file diff --git a/src/Cosmonaut/Extensions/ExpressionExtensions.cs b/src/Cosmonaut/Extensions/ExpressionExtensions.cs index 85253ff..8724f29 100644 --- a/src/Cosmonaut/Extensions/ExpressionExtensions.cs +++ b/src/Cosmonaut/Extensions/ExpressionExtensions.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq.Expressions; namespace Cosmonaut.Extensions diff --git a/src/Cosmonaut/Extensions/IQueryableExtensions.cs b/src/Cosmonaut/Extensions/IQueryableExtensions.cs new file mode 100644 index 0000000..77b4fe5 --- /dev/null +++ b/src/Cosmonaut/Extensions/IQueryableExtensions.cs @@ -0,0 +1,30 @@ +using Cosmonaut.Interception.QueryTranslation; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; + +namespace Cosmonaut.Extensions +{ + public static class IQueryableExtensions + { + public static IQueryable ApplyInterception(this IQueryable queryable, IEnumerable interceptors) + { + interceptors = interceptors?.Where(x => x.Type == typeof(TEntity)); + + if (!interceptors?.Any() ?? true) + { + return queryable; + } + + var visitors = interceptors.Cast(); + return queryable.InterceptWith(visitors.ToArray()); + } + + public static IQueryable InterceptWith(this IQueryable source, params ExpressionVisitor[] visitors) + { + return new QueryTranslator(source, visitors); + } + } +} + diff --git a/src/Cosmonaut/Interception/QueryInterceptor.cs b/src/Cosmonaut/Interception/QueryInterceptor.cs new file mode 100644 index 0000000..21330bf --- /dev/null +++ b/src/Cosmonaut/Interception/QueryInterceptor.cs @@ -0,0 +1,69 @@ +using System; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; + +namespace Cosmonaut.Interception +{ + public class QueryInterceptor : ExpressionVisitor, IQueryInterceptor where TEntity : class + { + public Type Type { get; } + + public bool Applied { get; private set; } + + private readonly Expression> _predicate; + private readonly ConstantExpression _constant; + + public QueryInterceptor(Expression> interceptor) + { + if (interceptor == null) + { + throw new ArgumentException(nameof(interceptor)); + } + + Type = typeof(TEntity); + _predicate = interceptor; + } + + public override Expression Visit(Expression node) + { + if (!(node is ConstantExpression constant)) + { + return base.Visit(node); + } + + if (!(constant.Value is IQueryable)) + { + return base.Visit(node); + } + + var method = GetLinqWhere(); + + Applied = true; + + return Expression.Call(method, constant, _predicate); + } + + private MethodInfo GetLinqWhere() + { + var method = typeof(Queryable).GetRuntimeMethods() + .Where(x => x.Name == nameof(Queryable.Where)) + .Select(x => x.MakeGenericMethod(new[] { typeof(TEntity) })) + .Single(methodInfo => + { + var parameters = methodInfo.GetParameters(); + + if (parameters.Count() == 2 + && parameters[0].ParameterType == typeof(IQueryable) + && parameters[1].ParameterType == typeof(Expression>)) + { + return true; + } + + return false; + }); + + return method; + } + } +} \ No newline at end of file diff --git a/src/Cosmonaut/Interception/QueryTranslation/QueryTranslator.cs b/src/Cosmonaut/Interception/QueryTranslation/QueryTranslator.cs new file mode 100644 index 0000000..180197a --- /dev/null +++ b/src/Cosmonaut/Interception/QueryTranslation/QueryTranslator.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; + +namespace Cosmonaut.Interception.QueryTranslation +{ + internal class QueryTranslator : IOrderedQueryable + { + private readonly Expression _expression; + private readonly QueryTranslatorProviderAsync _provider; + + /// + /// Initializes a new instance of the class. + /// + /// The source. + /// The visitors. + public QueryTranslator(IQueryable source, IEnumerable visitors) + { + _expression = Expression.Constant(this); + _provider = new QueryTranslatorProviderAsync(source, visitors); + } + + /// + /// Initializes a new instance of the class. + /// + /// The source. + /// The expression. + /// The visitors. + public QueryTranslator(IQueryable source, Expression expression, IEnumerable visitors) + { + _expression = expression; + _provider = new QueryTranslatorProviderAsync(source, visitors); + } + + /// + /// Returns an enumerator that iterates through the collection. + /// + /// + /// A that can be used to iterate through the collection. + /// + public IEnumerator GetEnumerator() + { + return ((IEnumerable)_provider.ExecuteEnumerable(_expression)).GetEnumerator(); + } + + /// + /// Returns an enumerator that iterates through a collection. + /// + /// + /// An object that can be used to iterate through the collection. + /// + IEnumerator IEnumerable.GetEnumerator() + { + return _provider.ExecuteEnumerable(_expression).GetEnumerator(); + } + + /// + /// Gets the type of the element(s) that are returned when the expression tree associated with this instance of is executed. + /// + /// A that represents the type of the element(s) that are returned when the expression tree associated with this object is executed. + public Type ElementType => typeof(T); + + /// + /// Gets the expression tree that is associated with the instance of . + /// + /// The that is associated with this instance of . + public Expression Expression => _expression; + + /// + /// Gets the query provider that is associated with this data source. + /// + /// The that is associated with this data source. + public IQueryProvider Provider => _provider; + } +} diff --git a/src/Cosmonaut/Interception/QueryTranslation/QueryTranslatorProviderAsync.cs b/src/Cosmonaut/Interception/QueryTranslation/QueryTranslatorProviderAsync.cs new file mode 100644 index 0000000..f8256f4 --- /dev/null +++ b/src/Cosmonaut/Interception/QueryTranslation/QueryTranslatorProviderAsync.cs @@ -0,0 +1,122 @@ +using Cosmonaut.Extensions; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +namespace Cosmonaut.Interception.QueryTranslation +{ + + internal class QueryTranslatorProviderAsync : ExpressionVisitor, IQueryProvider + { + private static readonly TraceSource _traceSource = new TraceSource(typeof(QueryTranslatorProviderAsync).Name); + private readonly IEnumerable _visitors; + + internal IQueryable Source { get; } + + public IQueryProvider OriginalProvider { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The source. + /// The visitors. + public QueryTranslatorProviderAsync(IQueryable source, IEnumerable visitors) + { + Source = source; + OriginalProvider = source.Provider; + _visitors = visitors; + } + + public IQueryable CreateQuery(Expression expression) + { + return new QueryTranslator(Source, expression, _visitors); + } + + public IQueryable CreateQuery(Expression expression) + { + Type elementType = expression.Type.GenericTypeArguments.First(); + return (IQueryable)Activator.CreateInstance(typeof(QueryTranslator<>).MakeGenericType(elementType), Source, expression, _visitors); + } + + public TResult Execute(Expression expression) + { + var translated = VisitAllAndOptimize(expression); + + return Source.Provider.Execute(translated); + } + + public object Execute(Expression expression) + { + return Execute(expression); + } + + public Task ExecuteAsync(Expression expression) + { + return ExecuteAsync(expression, CancellationToken.None); + } + + public Task ExecuteAsync(Expression expression, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // In case Source.Provider is not a IDbAsyncQueryProvider or EntityQueryProvider, just start a new Task + return Task.Factory.StartNew(() => Execute(expression), cancellationToken); + } + + public Task ExecuteAsync(Expression expression) + { + return ExecuteAsync(expression, CancellationToken.None); + } + + public Task ExecuteAsync(Expression expression, CancellationToken cancellationToken) + { + return ExecuteAsync(expression, cancellationToken); + } + + internal IEnumerable ExecuteEnumerable(Expression expression) + { + var translated = VisitAllAndOptimize(expression); + + return Source.Provider.CreateQuery(translated); + } + + private Expression VisitAllAndOptimize(Expression expression) + { + // Run all visitors in order + var visitors = new ExpressionVisitor[] { this }.Concat(_visitors); + + var translated = visitors.Aggregate(expression, (expr, visitor) => visitor.Visit(expr)); + + return translated; + } + + /// + /// Visits the . + /// + /// The expression to visit. + /// The modified expression, if it or any subexpression was modified; otherwise, returns the original expression. + protected override Expression VisitConstant(ConstantExpression node) + { + // Fix up the Expression tree to work with the underlying LINQ provider + if (node.Type.GetTypeInfo().IsGenericType && node.Type.GetGenericTypeDefinition() == typeof(QueryTranslator<>)) + { + + if (((IQueryable)node.Value).Provider is QueryTranslatorProviderAsync provider) + { + return provider.Source.Expression; + } + + return Source.Expression; + } + + return base.VisitConstant(node); + } + } +} + diff --git a/tests/Cosmonaut.System/CosmosQueryInterceptionTests.cs b/tests/Cosmonaut.System/CosmosQueryInterceptionTests.cs new file mode 100644 index 0000000..1f4cf92 --- /dev/null +++ b/tests/Cosmonaut.System/CosmosQueryInterceptionTests.cs @@ -0,0 +1,203 @@ +using Cosmonaut.System.Models; +using FluentAssertions; +using Microsoft.Azure.Documents.Client; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Xunit; +using Cosmonaut.Extensions; + +namespace Cosmonaut.System +{ + public class CosmosQueryInterceptionTests : IDisposable + { + private readonly ICosmonautClient _cosmonautClient; + private readonly Uri _emulatorUri = new Uri("https://localhost:8081"); + private readonly string _databaseId = $"DB{nameof(CosmosStoreTests)}"; + private readonly string _collectionName = $"COL{nameof(CosmosStoreTests)}"; + private readonly string _emulatorKey = + "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="; + + private readonly ConnectionPolicy _connectionPolicy = new ConnectionPolicy + { + ConnectionProtocol = Protocol.Tcp, + ConnectionMode = ConnectionMode.Direct + }; + + public CosmosQueryInterceptionTests() + { + _cosmonautClient = new CosmonautClient(_emulatorUri, _emulatorKey, _connectionPolicy); + Seed().GetAwaiter().GetResult(); + } + + [Fact] + public async Task WhenInteceptorIsAppliedToQuery_ThenResultsShouldHaveOneItem() + { + var cosmosStoreSettings = new CosmosStoreSettings(_databaseId, _emulatorUri, _emulatorKey, + settings => + { + settings.AddInterceptor(x => x.Name == "Darren"); + }); + + var cosmosStore = new CosmosStore(cosmosStoreSettings, _collectionName); + var results = await cosmosStore.Query().ToListAsync(); + results.ToList().Should().HaveCount(1); + } + + [Fact] + public async Task WhenInteceptorIsAppliedToAnotherType_ThenResultsShouldBeUnaffected() + { + var cosmosStoreSettings = new CosmosStoreSettings(_databaseId, _emulatorUri, _emulatorKey, + settings => + { + settings.AddInterceptor(x => x.Name == "Darren"); + }); + + var cosmosStore = new CosmosStore(cosmosStoreSettings, _collectionName); + + var results = await cosmosStore.Query().ToListAsync(); + results.Should().HaveCount(_cats.Count); + } + + [Fact] + public async Task WhenInteceptorIsAppliedAndQueryHaveFilterLogic_ThenThereShouldBeOneResult() + { + var cosmosStoreSettings = new CosmosStoreSettings(_databaseId, _emulatorUri, _emulatorKey, + settings => + { + settings.AddInterceptor(x => x.Name == "Nick"); + }); + + var cosmosStore = new CosmosStore(cosmosStoreSettings, _collectionName); + + var results = await cosmosStore.Query() + .Where(x => x.Name == "Nick") + .ToListAsync(); + + results.Should().HaveCount(1); + } + + [Fact] + public async Task WhenInteceptorIsAppliedAndHasMultipleQueryHaveFilterLogic_ThenThereShouldBeOneResult() + { + var cosmosStoreSettings = new CosmosStoreSettings(_databaseId, _emulatorUri, _emulatorKey, + settings => + { + settings.AddInterceptor(x => x.Name == "Nick"); + }); + + var cosmosStore = new CosmosStore(cosmosStoreSettings, _collectionName); + + var results = await cosmosStore.Query() + .Where(x => x.Name == "Nick") + .Where(x => x.Name == "Nick") + .ToListAsync(); + + results.Should().HaveCount(1); + results.Single().Name.Should().BeEquivalentTo("Nick"); + } + + [Fact] + public async Task WhenInteceptorIsAppliedAndHasSelect_ThenThereShouldBeOneResults() + { + var cosmosStoreSettings = new CosmosStoreSettings(_databaseId, _emulatorUri, _emulatorKey, + settings => + { + settings.AddInterceptor(x => x.Name == "Nick"); + }); + + var cosmosStore = new CosmosStore(cosmosStoreSettings, _collectionName); + + var results = await cosmosStore.Query().Select(x => x.Name).ToListAsync(); + results.Should().HaveCount(1); + results.Single().Equals("Nick"); + } + + [Fact] + public async Task WhenInteceptorIsAppliedAndHasWhereAndSelect_ThenThereShouldBeOneResults() + { + var cosmosStoreSettings = new CosmosStoreSettings(_databaseId, _emulatorUri, _emulatorKey, + settings => + { + settings.AddInterceptor(x => x.Name == "Nick"); + }); + + var cosmosStore = new CosmosStore(cosmosStoreSettings, _collectionName); + + var results = await cosmosStore.Query() + .Where(x => x.Name == "Nick") + .Select(x => x.Name) + .ToListAsync(); + + results.Should().HaveCount(1); + results.Single().Equals("Nick"); + } + + [Fact] + public async Task WhenQueryInterceptorIsAppliedAndHasOrderBy_EnsureOneResult() + { + var cosmosStoreSettings = new CosmosStoreSettings(_databaseId, _emulatorUri, _emulatorKey, + settings => + { + settings.AddInterceptor(x => x.Name == "Nick"); + }); + + var cosmosStore = new CosmosStore(cosmosStoreSettings, _collectionName); + + var results = await cosmosStore.Query() + .OrderBy(x => x.Name) + .ToListAsync(); + + results.Should().HaveCount(1); + results.Single().Name.Should().BeEquivalentTo("Nick"); + } + + [Fact] + public async Task WhenInceptorUsesDateArithmetic_EnsureResultsAreFiltered() + { + var cosmosStoreSettings = new CosmosStoreSettings(_databaseId, _emulatorUri, _emulatorKey, + settings => + { + settings.AddInterceptor(x => x.DateOfBirth > new DateTime(1994, 1, 1)); + }); + + var cosmosStore = new CosmosStore(cosmosStoreSettings, _collectionName); + var results = await cosmosStore.Query().ToListAsync(); + results.Should().HaveCount(2); + results.Should().Contain(x => x.Name == "Darren"); + results.Should().Contain(x => x.Name == "Nick"); + } + + private async Task Seed() + { + var settings = new CosmosStoreSettings(_databaseId, _emulatorUri, _emulatorKey); + var store = new CosmosStore(settings, _collectionName); + await store.AddRangeAsync(_cats); + } + + public void Dispose() + { + _cosmonautClient.DeleteDatabaseAsync(_databaseId).GetAwaiter().GetResult(); + } + + private readonly List _cats = new List + { + new Cat + { + Name = "Darren", + DateOfBirth = new DateTime(2000, 1, 1) + }, + new Cat + { + Name = "Nick", + DateOfBirth = new DateTime(1995, 1, 1) + }, + new Cat + { + Name = "Tom", + DateOfBirth = new DateTime(1990, 1, 1) + } + }; + } +} diff --git a/tests/Cosmonaut.System/CosmosStoreTests.cs b/tests/Cosmonaut.System/CosmosStoreTests.cs index 47c3b50..ce36489 100644 --- a/tests/Cosmonaut.System/CosmosStoreTests.cs +++ b/tests/Cosmonaut.System/CosmosStoreTests.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Threading.Tasks; -using Cosmonaut.Extensions; +using Cosmonaut.Extensions; using Cosmonaut.Extensions.Microsoft.DependencyInjection; using Cosmonaut.Response; using Cosmonaut.System.Models; @@ -13,6 +8,11 @@ using Microsoft.Azure.Documents.Client; using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; using Xunit; namespace Cosmonaut.System @@ -163,7 +163,7 @@ public async Task WhenEntitiesAreAddedAndIdExists_ThenTheyFail() addedResults.SuccessfulEntities.Count.Should().Be(0); addedResults.FailedEntities.Select(x => x.Exception).Should().AllBeAssignableTo(); addedResults.FailedEntities.Select(x => x.Exception).Cast().Select(x => x.StatusCode).Should().AllBeEquivalentTo(HttpStatusCode.Conflict); - addedResults.FailedEntities.Select(x=>x.CosmosOperationStatus).Should().AllBeEquivalentTo(CosmosOperationStatus.Conflict); + addedResults.FailedEntities.Select(x => x.CosmosOperationStatus).Should().AllBeEquivalentTo(CosmosOperationStatus.Conflict); } [Fact] @@ -171,17 +171,20 @@ public async Task WhenEntitiesAreAddedAndTheyChangedWithAccessCondition_ThenThey { var cosmosStore = _serviceProvider.GetService>(); var response = await ExecuteMultipleAddOperationsForType(list => cosmosStore.AddRangeAsync(list), 10); - + var addedCats = response.SuccessfulEntities .Select(x => JsonConvert.DeserializeObject(x.ResourceResponse.Resource.ToString())).ToList(); - addedCats.ForEach(x => x.Name = "different Name"); + addedCats.ForEach(x => x.Name = "different Name"); await cosmosStore.UpdateRangeAsync(addedCats); - var updatedResults = await cosmosStore.UpdateRangeAsync(addedCats, cat => new RequestOptions{AccessCondition = new AccessCondition + var updatedResults = await cosmosStore.UpdateRangeAsync(addedCats, cat => new RequestOptions { - Type = AccessConditionType.IfMatch, - Condition = cat.Etag - }}); + AccessCondition = new AccessCondition + { + Type = AccessConditionType.IfMatch, + Condition = cat.Etag + } + }); response.IsSuccess.Should().BeTrue(); response.FailedEntities.Count.Should().Be(0); @@ -190,7 +193,7 @@ public async Task WhenEntitiesAreAddedAndTheyChangedWithAccessCondition_ThenThey updatedResults.FailedEntities.Count.Should().Be(10); updatedResults.SuccessfulEntities.Count.Should().Be(0); updatedResults.FailedEntities.Select(x => x.Exception).Should().AllBeAssignableTo(); - updatedResults.FailedEntities.Select(x => x.Exception).Cast().Select(x=>x.StatusCode).Should().AllBeEquivalentTo(HttpStatusCode.PreconditionFailed); + updatedResults.FailedEntities.Select(x => x.Exception).Cast().Select(x => x.StatusCode).Should().AllBeEquivalentTo(HttpStatusCode.PreconditionFailed); updatedResults.FailedEntities.Select(x => x.CosmosOperationStatus).Should().AllBeEquivalentTo(CosmosOperationStatus.PreconditionFailed); } @@ -206,7 +209,7 @@ public async Task WhenValidEntitiesAreRemoved_ThenRemovedResultsAreSuccessful() var addedLions = await ExecuteMultipleAddOperationsForType(list => lionStore.AddRangeAsync(list)); var addedBirds = await ExecuteMultipleAddOperationsForType(list => birdStore.AddRangeAsync(list)); - await ExecuteMultipleAddOperationsForType(() => catStore.RemoveRangeAsync(addedCats.SuccessfulEntities.Select(x=>x.Entity)), HttpStatusCode.NoContent, addedCats.SuccessfulEntities.Select(x => x.Entity).ToList()); + await ExecuteMultipleAddOperationsForType(() => catStore.RemoveRangeAsync(addedCats.SuccessfulEntities.Select(x => x.Entity)), HttpStatusCode.NoContent, addedCats.SuccessfulEntities.Select(x => x.Entity).ToList()); await ExecuteMultipleAddOperationsForType(() => dogStore.RemoveRangeAsync(addedDogs.SuccessfulEntities.Select(x => x.Entity)), HttpStatusCode.NoContent, addedDogs.SuccessfulEntities.Select(x => x.Entity).ToList()); await ExecuteMultipleAddOperationsForType(() => lionStore.RemoveRangeAsync(addedLions.SuccessfulEntities.Select(x => x.Entity)), HttpStatusCode.NoContent, addedLions.SuccessfulEntities.Select(x => x.Entity).ToList()); await ExecuteMultipleAddOperationsForType(() => birdStore.RemoveRangeAsync(addedBirds.SuccessfulEntities.Select(x => x.Entity)), HttpStatusCode.NoContent, addedBirds.SuccessfulEntities.Select(x => x.Entity).ToList()); @@ -266,7 +269,7 @@ void ValidateBadUpdateResponse(CosmosMultipleResponse cosmosMultipleRespon var dogStore = _serviceProvider.GetService>(); var lionStore = _serviceProvider.GetService>(); var birdStore = _serviceProvider.GetService>(); - var addedCats = new List {new Cat {CatId = Guid.NewGuid().ToString()}}; + var addedCats = new List { new Cat { CatId = Guid.NewGuid().ToString() } }; var addedDogs = new List { new Dog { Id = Guid.NewGuid().ToString() } }; var addedLions = new List { new Lion { Id = Guid.NewGuid().ToString() } }; var addedBirds = new List { new Bird { Id = Guid.NewGuid().ToString() } }; @@ -281,7 +284,7 @@ void ValidateBadUpdateResponse(CosmosMultipleResponse cosmosMultipleRespon ValidateBadUpdateResponse(lionResults); ValidateBadUpdateResponse(birdResults); } - + [Fact] public async Task WhenValidEntitiesAreUpserted_ThenUpsertedResultsAreSuccessful() { @@ -317,7 +320,7 @@ public async Task WhenValidEntitiesAreAdded_ThenTheyCanBeQueriedFor() var lions = await lionStore.QueryMultipleAsync("select * from c"); var birds = await birdStore.Query().ToListAsync(); - cats.Should().BeEquivalentTo(addedCats.SuccessfulEntities.Select(x=>x.Entity), ExcludeEtagCheck()); + cats.Should().BeEquivalentTo(addedCats.SuccessfulEntities.Select(x => x.Entity), ExcludeEtagCheck()); dogs.Should().BeEquivalentTo(addedDogs.SuccessfulEntities.Select(x => x.Entity)); lions.Should().BeEquivalentTo(addedLions.SuccessfulEntities.Select(x => x.Entity), config => { @@ -373,7 +376,7 @@ public async Task WhenValidSingleEntitiesAreAdded_ThenTheyCanBeFoundAsSinglesAsy var catFound = await catStore.Query().FirstAsync(); var dogFound = await dogStore.Query().FirstOrDefaultAsync(); var lionFound = await lionStore.Query().SingleAsync(); - var birdFound = await birdStore.Query(new FeedOptions{MaxItemCount = 100}).SingleOrDefaultAsync(); + var birdFound = await birdStore.Query(new FeedOptions { MaxItemCount = 100 }).SingleOrDefaultAsync(); catFound.Should().BeEquivalentTo(JsonConvert.DeserializeObject(addedCat.ResourceResponse.Resource.ToString())); dogFound.Should().BeEquivalentTo(JsonConvert.DeserializeObject(addedDog.ResourceResponse.Resource.ToString())); @@ -474,9 +477,9 @@ public async Task WhenPaginatedQueryExecutesWithSkipTake_ThenPaginatedResultsAre { var catStore = _serviceProvider.GetService>(); var addedCats = (await ExecuteMultipleAddOperationsForType(list => catStore.AddRangeAsync(list), 15)) - .SuccessfulEntities.Select(x=>x.Entity).OrderBy(x=>x.Name).ToList(); + .SuccessfulEntities.Select(x => x.Entity).OrderBy(x => x.Name).ToList(); - var firstPage = await catStore.Query().WithPagination(1, 5).OrderBy(x=>x.Name).ToListAsync(); + var firstPage = await catStore.Query().WithPagination(1, 5).OrderBy(x => x.Name).ToListAsync(); var secondPage = await catStore.Query().WithPagination(2, 5).OrderBy(x => x.Name).ToListAsync(); var thirdPage = await catStore.Query().WithPagination(3, 5).OrderBy(x => x.Name).ToListAsync(); var fourthPage = await catStore.Query().WithPagination(4, 5).OrderBy(x => x.Name).ToListAsync(); @@ -512,7 +515,7 @@ public async Task WhenPaginatedQueryAndFeedOptionsExecutesWithNextPageAsync_Then var addedCats = (await ExecuteMultipleAddOperationsForType(list => catStore.AddRangeAsync(list), 15)) .SuccessfulEntities.Select(x => x.Entity).OrderBy(x => x.Name).ToList(); - var firstPage = await catStore.Query(new FeedOptions{ RequestContinuation = "SomethingBad", MaxItemCount = 666}).WithPagination(1, 5).OrderBy(x => x.Name).ToPagedListAsync(); + var firstPage = await catStore.Query(new FeedOptions { RequestContinuation = "SomethingBad", MaxItemCount = 666 }).WithPagination(1, 5).OrderBy(x => x.Name).ToPagedListAsync(); var secondPage = await firstPage.GetNextPageAsync(); var thirdPage = await secondPage.GetNextPageAsync(); var fourthPage = await thirdPage.GetNextPageAsync(); @@ -554,12 +557,12 @@ public async Task WhenPaginatedQueryExecutesWithContinuationToken_ThenPaginatedR } private async Task> ExecuteMultipleAddOperationsForType( - Func, Task>> operationFunc, int itemCount = 50) + Func, Task>> operationFunc, int itemCount = 50) where T : Animal, new() { var items = new List(); - - for (var i = 0; i < itemCount; i++){items.Add(new T { Name = Guid.NewGuid().ToString() });} + + for (var i = 0; i < itemCount; i++) { items.Add(new T { Name = Guid.NewGuid().ToString() }); } var addedCats = await operationFunc(items); @@ -598,7 +601,7 @@ public void Dispose() { _cosmonautClient.DeleteDatabaseAsync(_databaseId).GetAwaiter().GetResult(); } - + private void AddCosmosStores(ServiceCollection serviceCollection) { serviceCollection diff --git a/tests/Cosmonaut.System/Models/Animal.cs b/tests/Cosmonaut.System/Models/Animal.cs index b0c6f70..9592d6a 100644 --- a/tests/Cosmonaut.System/Models/Animal.cs +++ b/tests/Cosmonaut.System/Models/Animal.cs @@ -1,7 +1,11 @@ -namespace Cosmonaut.System.Models +using System; + +namespace Cosmonaut.System.Models { public class Animal { public string Name { get; set; } + + public DateTime? DateOfBirth { get; set; } } } \ No newline at end of file