From 9ddea5b5c3dfeba599192ee70e8ad055d4a4cfba Mon Sep 17 00:00:00 2001 From: Simon Hofer Date: Tue, 13 Sep 2016 08:26:32 +0200 Subject: [PATCH 01/10] a first try to make queryable modifiable by customer --- .../Data/CommentSearch.csv | 6 ++ .../Data/PostSearch.csv | 5 + .../FetchingResourcesQueryResolverTests.cs | 54 +++++++++++ .../GetAllResponse.json | 96 +++++++++++++++++++ .../GetWithFilterResponse.json | 27 ++++++ .../GetWithSearchFilterResponse.json | 73 ++++++++++++++ ...sts.EntityFrameworkTestWebApp.Tests.csproj | 10 ++ .../Helper/PredicateBuilder.cs | 48 ++++++++++ ...anceTests.EntityFrameworkTestWebApp.csproj | 5 + .../Models/CommentSearch.cs | 28 ++++++ .../Models/PostSearch.cs | 29 ++++++ .../Models/TestDbContext.cs | 2 + .../PostSearchResourceQueryResolver.cs | 40 ++++++++ .../Startup.cs | 2 + JSONAPI.Autofac/JsonApiAutofacModule.cs | 17 +++- .../EntityFrameworkDocumentMaterializer.cs | 14 ++- .../IResourceTypeConfiguration.cs | 2 + .../IResourceTypeConfigurator.cs | 5 +- JSONAPI/Configuration/JsonApiConfiguration.cs | 3 +- .../ResourceTypeConfiguration.cs | 8 ++ JSONAPI/JSONAPI.csproj | 1 + .../IResourceCollectionResolver.cs | 15 +++ 22 files changed, 483 insertions(+), 7 deletions(-) create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/CommentSearch.csv create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/PostSearch.csv create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/FetchingResourcesQueryResolverTests.cs create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResourcesQueryResolver/GetAllResponse.json create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResourcesQueryResolver/GetWithFilterResponse.json create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResourcesQueryResolver/GetWithSearchFilterResponse.json create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Helper/PredicateBuilder.cs create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/CommentSearch.cs create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/PostSearch.cs create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/PostSearchResourceQueryResolver.cs create mode 100644 JSONAPI/QueryableResolvers/IResourceCollectionResolver.cs diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/CommentSearch.csv b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/CommentSearch.csv new file mode 100644 index 00000000..f36413a2 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/CommentSearch.csv @@ -0,0 +1,6 @@ +Id,Text,Created,PostId,AuthorId +"101","Comment 1","2015-01-31T14:30Z","201","403" +"102","efgComment","2015-01-31T14:35Z","201","402" +"103","Comment 3","2015-01-31T14:41Z","201","403" +"104","Comment 4","2015-02-05T09:08Z","202","403" +"105","Comment 5","2015-02-06T14:19Z","203","401" \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/PostSearch.csv b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/PostSearch.csv new file mode 100644 index 00000000..fe70ae43 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/PostSearch.csv @@ -0,0 +1,5 @@ +Id,Title,Content,Created,AuthorId +"201","Post abc","Post def content","2015-01-31T14:00Z","401" +"202","Post efg","Post cde content","2015-02-05T08:10Z","401" +"203","Post hij","Post efg content","2015-02-07T11:11Z","401" +"204","Post klm","Post hij content","2015-02-08T06:59Z","402" \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/FetchingResourcesQueryResolverTests.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/FetchingResourcesQueryResolverTests.cs new file mode 100644 index 00000000..9fce0954 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/FetchingResourcesQueryResolverTests.cs @@ -0,0 +1,54 @@ +using System.Net; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests +{ + [TestClass] + public class FetchingResourcesQueryResolverTests : AcceptanceTestsBase + { + [TestMethod] + [DeploymentItem(@"Data\CommentSearch.csv", @"Data")] + [DeploymentItem(@"Data\PostSearch.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task GetAll() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitGet(effortConnection, "post-searchs"); + + await AssertResponseContent(response, @"Fixtures\FetchingResourcesQueryResolver\GetAllResponse.json", HttpStatusCode.OK); + } + } + + [TestMethod] + [DeploymentItem(@"Data\CommentSearch.csv", @"Data")] + [DeploymentItem(@"Data\PostSearch.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task GetWithFilter() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitGet(effortConnection, "post-searchs?filter[title]=Post efg"); + + await AssertResponseContent(response, @"Fixtures\FetchingResourcesQueryResolver\GetWithFilterResponse.json", HttpStatusCode.OK); + } + } + + + [TestMethod] + [DeploymentItem(@"Data\CommentSearch.csv", @"Data")] + [DeploymentItem(@"Data\PostSearch.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task GetWithSearchFilter() // this enables logic in Query resolver + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitGet(effortConnection, "post-searchs?searchterm=efg"); + + await AssertResponseContent(response, @"Fixtures\FetchingResourcesQueryResolver\GetWithSearchFilterResponse.json", HttpStatusCode.OK); + } + } + + } +} diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResourcesQueryResolver/GetAllResponse.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResourcesQueryResolver/GetAllResponse.json new file mode 100644 index 00000000..a9d4745e --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResourcesQueryResolver/GetAllResponse.json @@ -0,0 +1,96 @@ +{ + "data": [ + { + "type": "post-searchs", + "id": "201", + "attributes": { + "content": "Post def content", + "created": "2015-01-31T14:00:00.0000000+00:00", + "title": "Post abc" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/post-searchs/201/relationships/author", + "related": "https://www.example.com/post-searchs/201/author" + } + }, + "comments": { + "links": { + "self": "https://www.example.com/post-searchs/201/relationships/comments", + "related": "https://www.example.com/post-searchs/201/comments" + } + } + } + }, + { + "type": "post-searchs", + "id": "202", + "attributes": { + "content": "Post cde content", + "created": "2015-02-05T08:10:00.0000000+00:00", + "title": "Post efg" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/post-searchs/202/relationships/author", + "related": "https://www.example.com/post-searchs/202/author" + } + }, + "comments": { + "links": { + "self": "https://www.example.com/post-searchs/202/relationships/comments", + "related": "https://www.example.com/post-searchs/202/comments" + } + } + } + }, + { + "type": "post-searchs", + "id": "203", + "attributes": { + "content": "Post efg content", + "created": "2015-02-07T11:11:00.0000000+00:00", + "title": "Post hij" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/post-searchs/203/relationships/author", + "related": "https://www.example.com/post-searchs/203/author" + } + }, + "comments": { + "links": { + "self": "https://www.example.com/post-searchs/203/relationships/comments", + "related": "https://www.example.com/post-searchs/203/comments" + } + } + } + }, + { + "type": "post-searchs", + "id": "204", + "attributes": { + "content": "Post hij content", + "created": "2015-02-08T06:59:00.0000000+00:00", + "title": "Post klm" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/post-searchs/204/relationships/author", + "related": "https://www.example.com/post-searchs/204/author" + } + }, + "comments": { + "links": { + "self": "https://www.example.com/post-searchs/204/relationships/comments", + "related": "https://www.example.com/post-searchs/204/comments" + } + } + } + } + ] +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResourcesQueryResolver/GetWithFilterResponse.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResourcesQueryResolver/GetWithFilterResponse.json new file mode 100644 index 00000000..08edb94f --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResourcesQueryResolver/GetWithFilterResponse.json @@ -0,0 +1,27 @@ +{ + "data": [ + { + "type": "post-searchs", + "id": "202", + "attributes": { + "content": "Post cde content", + "created": "2015-02-05T08:10:00.0000000+00:00", + "title": "Post efg" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/post-searchs/202/relationships/author", + "related": "https://www.example.com/post-searchs/202/author" + } + }, + "comments": { + "links": { + "self": "https://www.example.com/post-searchs/202/relationships/comments", + "related": "https://www.example.com/post-searchs/202/comments" + } + } + } + } + ] +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResourcesQueryResolver/GetWithSearchFilterResponse.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResourcesQueryResolver/GetWithSearchFilterResponse.json new file mode 100644 index 00000000..4d1a71b2 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResourcesQueryResolver/GetWithSearchFilterResponse.json @@ -0,0 +1,73 @@ +{ + "data": [ + { + "type": "post-searchs", + "id": "201", + "attributes": { + "content": "Post def content", + "created": "2015-01-31T14:00:00.0000000+00:00", + "title": "Post abc" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/post-searchs/201/relationships/author", + "related": "https://www.example.com/post-searchs/201/author" + } + }, + "comments": { + "links": { + "self": "https://www.example.com/post-searchs/201/relationships/comments", + "related": "https://www.example.com/post-searchs/201/comments" + } + } + } + }, + { + "type": "post-searchs", + "id": "202", + "attributes": { + "content": "Post cde content", + "created": "2015-02-05T08:10:00.0000000+00:00", + "title": "Post efg" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/post-searchs/202/relationships/author", + "related": "https://www.example.com/post-searchs/202/author" + } + }, + "comments": { + "links": { + "self": "https://www.example.com/post-searchs/202/relationships/comments", + "related": "https://www.example.com/post-searchs/202/comments" + } + } + } + }, + { + "type": "post-searchs", + "id": "203", + "attributes": { + "content": "Post efg content", + "created": "2015-02-07T11:11:00.0000000+00:00", + "title": "Post hij" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/post-searchs/203/relationships/author", + "related": "https://www.example.com/post-searchs/203/author" + } + }, + "comments": { + "links": { + "self": "https://www.example.com/post-searchs/203/relationships/comments", + "related": "https://www.example.com/post-searchs/203/comments" + } + } + } + } + ] +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj index 35d82430..83a3a13f 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj @@ -106,6 +106,7 @@ + @@ -144,12 +145,18 @@ Always + + Always + Always Always + + Always + Always @@ -276,6 +283,9 @@ + + + diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Helper/PredicateBuilder.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Helper/PredicateBuilder.cs new file mode 100644 index 00000000..2a2b8283 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Helper/PredicateBuilder.cs @@ -0,0 +1,48 @@ +using System; +using System.Linq.Expressions; + +namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Helper +{ + public static class PredicateBuilder + { + public static Expression> True() { return f => true; } + public static Expression> False() { return f => false; } + + public static Expression> Or( + this Expression> expr1, + Expression> expr2) + { + var secondBody = expr2.Body.Replace(expr2.Parameters[0], expr1.Parameters[0]); + return Expression.Lambda> + (Expression.OrElse(expr1.Body, secondBody), expr1.Parameters); + } + + public static Expression> And( + this Expression> expr1, + Expression> expr2) + { + var secondBody = expr2.Body.Replace(expr2.Parameters[0], expr1.Parameters[0]); + return Expression.Lambda> + (Expression.AndAlso(expr1.Body, secondBody), expr1.Parameters); + } + public static Expression Replace(this Expression expression, Expression searchEx, Expression replaceEx) + { + return new ReplaceVisitor(searchEx, replaceEx).Visit(expression); + } + } + + + internal class ReplaceVisitor : ExpressionVisitor + { + private readonly Expression _from, _to; + public ReplaceVisitor(Expression from, Expression to) + { + _from = from; + _to = to; + } + public override Expression Visit(Expression node) + { + return node == _from ? _to : base.Visit(node); + } + } +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.csproj b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.csproj index 0a5148ea..b733b686 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.csproj +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.csproj @@ -21,6 +21,7 @@ ..\ true + true @@ -130,14 +131,17 @@ + + + @@ -153,6 +157,7 @@ + diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/CommentSearch.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/CommentSearch.cs new file mode 100644 index 00000000..94870773 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/CommentSearch.cs @@ -0,0 +1,28 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Models +{ + public class CommentSearch + { + [Key] + public string Id { get; set; } + + public string Text { get; set; } + + public DateTimeOffset Created { get; set; } + + [Required] + [JsonIgnore] + public string PostId { get; set; } + + [Required] + [JsonIgnore] + public string AuthorId { get; set; } + + [ForeignKey("PostId")] + public virtual PostSearch Post { get; set; } + } +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/PostSearch.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/PostSearch.cs new file mode 100644 index 00000000..889b5135 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/PostSearch.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Models +{ + public class PostSearch + { + [Key] + public string Id { get; set; } + + public string Title { get; set; } + + public string Content { get; set; } + + public DateTimeOffset Created { get; set; } + + [JsonIgnore] + public string AuthorId { get; set; } + + [ForeignKey("AuthorId")] + public virtual User Author { get; set; } + + public virtual ICollection Comments { get; set; } + + } +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/TestDbContext.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/TestDbContext.cs index 4850d0a0..b2ba9afa 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/TestDbContext.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/TestDbContext.cs @@ -38,8 +38,10 @@ protected override void OnModelCreating(DbModelBuilder modelBuilder) public DbSet Buildings { get; set; } public DbSet Companies { get; set; } public DbSet Comments { get; set; } + public DbSet CommentSearchs { get; set; } public DbSet Languages { get; set; } public DbSet Posts { get; set; } + public DbSet PostSearchs { get; set; } public DbSet PostsID { get; set; } public DbSet PostsLongId { get; set; } public DbSet Tags { get; set; } diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/PostSearchResourceQueryResolver.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/PostSearchResourceQueryResolver.cs new file mode 100644 index 00000000..a17a4676 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/PostSearchResourceQueryResolver.cs @@ -0,0 +1,40 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Helper; +using JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Models; +using JSONAPI.QueryableResolvers; + +namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp +{ + public class PostSearchResourceQueryResolver: IResourceCollectionResolver + { + public Task> GetQueryForResourceCollection(IQueryable queryable, HttpRequestMessage request, CancellationToken cancellationToken) + { + var queryPairs = request.GetQueryNameValuePairs(); + foreach (var queryPair in queryPairs) + { + if (String.IsNullOrWhiteSpace(queryPair.Key)) + continue; + + if (!queryPair.Key.StartsWith("searchterm")) + continue; + + var searchTerm = queryPair.Value; + var predicate = PredicateBuilder.False(); + + foreach (var str in Regex.Split(searchTerm, "\\s+")) + { + predicate = predicate.Or(x => x.Title.Contains(str)); + predicate = predicate.Or(x => x.Content.ToString().Contains(str)); + predicate = predicate.Or(x => x.Comments.Any(y => y.Text.Contains(str))); + } + queryable= queryable.Where(predicate); + } + return Task.FromResult(queryable); + } + } +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Startup.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Startup.cs index 678f8532..267a82c2 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Startup.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Startup.cs @@ -93,6 +93,8 @@ internal JsonApiConfiguration BuildConfiguration() configuration.RegisterResourceType(); configuration.RegisterEntityFrameworkResourceType(); configuration.RegisterEntityFrameworkResourceType(); + configuration.RegisterResourceType().ResolveCollectionWith(); + configuration.RegisterEntityFrameworkResourceType(); return configuration; } diff --git a/JSONAPI.Autofac/JsonApiAutofacModule.cs b/JSONAPI.Autofac/JsonApiAutofacModule.cs index 9c57ca4a..685a9bb6 100644 --- a/JSONAPI.Autofac/JsonApiAutofacModule.cs +++ b/JSONAPI.Autofac/JsonApiAutofacModule.cs @@ -8,6 +8,7 @@ using JSONAPI.Documents.Builders; using JSONAPI.Http; using JSONAPI.Json; +using JSONAPI.QueryableResolvers; using JSONAPI.QueryableTransformers; namespace JSONAPI.Autofac @@ -38,6 +39,8 @@ protected override void Load(ContainerBuilder builder) if (resourceTypeConfiguration.DocumentMaterializerType != null) builder.RegisterType(resourceTypeConfiguration.DocumentMaterializerType); + if (resourceTypeConfiguration.ResourceCollectionResolverType != null) + builder.RegisterType(resourceTypeConfiguration.ResourceCollectionResolverType); foreach (var relationship in resourceTypeRegistration.Relationships) { @@ -68,7 +71,12 @@ protected override void Load(ContainerBuilder builder) { var configuration = context.ResolveKeyed(resourceTypeName); var registration = registry.GetRegistrationForResourceTypeName(resourceTypeName); - var parameters = new Parameter[] { new TypedParameter(typeof (IResourceTypeRegistration), registration) }; + var parameters = new Parameter[] { new TypedParameter(typeof (IResourceTypeRegistration), registration)}; + if (configuration.ResourceCollectionResolverType != null) + { + var collectionResolver = context.Resolve(configuration.ResourceCollectionResolverType, parameters); + parameters = new Parameter[] { new TypedParameter(typeof(IResourceTypeRegistration), registration), new NamedParameter("collectionResolver", collectionResolver), }; + } if (configuration.DocumentMaterializerType != null) return (IDocumentMaterializer)context.Resolve(configuration.DocumentMaterializerType, parameters); return context.Resolve(parameters); @@ -82,7 +90,12 @@ protected override void Load(ContainerBuilder builder) { var configuration = context.ResolveKeyed(clrType); var registration = registry.GetRegistrationForType(clrType); - var parameters = new Parameter[] { new TypedParameter(typeof(IResourceTypeRegistration), registration) }; + var parameters = new Parameter[] { new TypedParameter(typeof(IResourceTypeRegistration), registration)}; + if (configuration.ResourceCollectionResolverType != null) + { + var collectionResolver = context.Resolve(configuration.ResourceCollectionResolverType, parameters); + parameters = new Parameter[] { new TypedParameter(typeof(IResourceTypeRegistration), registration), new NamedParameter("collectionResolver", collectionResolver), }; + } if (configuration.DocumentMaterializerType != null) return (IDocumentMaterializer)context.Resolve(configuration.DocumentMaterializerType, parameters); return context.Resolve(parameters); diff --git a/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs b/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs index 114f7877..b30a89a9 100644 --- a/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs +++ b/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs @@ -11,6 +11,7 @@ using JSONAPI.Documents.Builders; using JSONAPI.Extensions; using JSONAPI.Http; +using JSONAPI.QueryableResolvers; namespace JSONAPI.EntityFramework.Http { @@ -27,6 +28,7 @@ public class EntityFrameworkDocumentMaterializer : IDocumentMaterializer wher private readonly ISortExpressionExtractor _sortExpressionExtractor; private readonly IIncludeExpressionExtractor _includeExpressionExtractor; private readonly IBaseUrlService _baseUrlService; + private IResourceCollectionResolver _collectionResolver; /// /// Creates a new EntityFrameworkDocumentMaterializer @@ -39,7 +41,8 @@ public EntityFrameworkDocumentMaterializer( IEntityFrameworkResourceObjectMaterializer entityFrameworkResourceObjectMaterializer, ISortExpressionExtractor sortExpressionExtractor, IIncludeExpressionExtractor includeExpressionExtractor, - IBaseUrlService baseUrlService) + IBaseUrlService baseUrlService, + IResourceCollectionResolver collectionResolver = null) { DbContext = dbContext; _resourceTypeRegistration = resourceTypeRegistration; @@ -49,14 +52,19 @@ public EntityFrameworkDocumentMaterializer( _sortExpressionExtractor = sortExpressionExtractor; _includeExpressionExtractor = includeExpressionExtractor; _baseUrlService = baseUrlService; + _collectionResolver = collectionResolver; } - public virtual Task GetRecords(HttpRequestMessage request, CancellationToken cancellationToken) + public virtual async Task GetRecords(HttpRequestMessage request, CancellationToken cancellationToken) { var sortExpressions = _sortExpressionExtractor.ExtractSortExpressions(request); var includes = _includeExpressionExtractor.ExtractIncludeExpressions(request); var query = QueryIncludeNavigationProperties(null, GetNavigationPropertiesIncludes(includes)); - return _queryableResourceCollectionDocumentBuilder.BuildDocument(query, request, sortExpressions, cancellationToken, includes); + if (_collectionResolver != null) + { + query = await _collectionResolver.GetQueryForResourceCollection(query, request, cancellationToken); + } + return await _queryableResourceCollectionDocumentBuilder.BuildDocument(query, request, sortExpressions, cancellationToken, includes); } public virtual async Task GetRecordById(string id, HttpRequestMessage request, CancellationToken cancellationToken) diff --git a/JSONAPI/Configuration/IResourceTypeConfiguration.cs b/JSONAPI/Configuration/IResourceTypeConfiguration.cs index 9ee2a8ee..343990f1 100644 --- a/JSONAPI/Configuration/IResourceTypeConfiguration.cs +++ b/JSONAPI/Configuration/IResourceTypeConfiguration.cs @@ -50,5 +50,7 @@ public interface IResourceTypeConfiguration /// /// IResourceTypeRegistration BuildResourceTypeRegistration(); + + Type ResourceCollectionResolverType { get; } } } \ No newline at end of file diff --git a/JSONAPI/Configuration/IResourceTypeConfigurator.cs b/JSONAPI/Configuration/IResourceTypeConfigurator.cs index a861bbfe..aea2dd95 100644 --- a/JSONAPI/Configuration/IResourceTypeConfigurator.cs +++ b/JSONAPI/Configuration/IResourceTypeConfigurator.cs @@ -2,6 +2,7 @@ using System.Linq.Expressions; using JSONAPI.Core; using JSONAPI.Http; +using JSONAPI.QueryableResolvers; namespace JSONAPI.Configuration { @@ -42,7 +43,9 @@ void ConfigureRelationship(Expression> property, /// /// Specifies a function to use build expressions that allow sorting resources of this type by ID /// - void OverrideDefaultSortById(Func sortByIdExpressionFactory); + void OverrideDefaultSortById(Func sortByIdExpressionFactory); + IResourceTypeConfigurator ResolveCollectionWith() + where TCollectionResolver : IResourceCollectionResolver; } } \ No newline at end of file diff --git a/JSONAPI/Configuration/JsonApiConfiguration.cs b/JSONAPI/Configuration/JsonApiConfiguration.cs index 27752c1a..74437cf8 100644 --- a/JSONAPI/Configuration/JsonApiConfiguration.cs +++ b/JSONAPI/Configuration/JsonApiConfiguration.cs @@ -57,12 +57,13 @@ public JsonApiConfiguration(IResourceTypeRegistrar resourceTypeRegistrar) /// /// Registers a resource type with the configuration /// - public void RegisterResourceType(Action> configurationAction = null) + public IResourceTypeConfigurator RegisterResourceType(Action> configurationAction = null) { var configuration = new ResourceTypeConfiguration(_resourceTypeRegistrar); if (configurationAction != null) configurationAction(configuration); _resourceTypeConfigurations.Add(configuration); + return configuration; } /// diff --git a/JSONAPI/Configuration/ResourceTypeConfiguration.cs b/JSONAPI/Configuration/ResourceTypeConfiguration.cs index 5bc0cc14..437ede78 100644 --- a/JSONAPI/Configuration/ResourceTypeConfiguration.cs +++ b/JSONAPI/Configuration/ResourceTypeConfiguration.cs @@ -5,6 +5,7 @@ using System.Reflection; using JSONAPI.Core; using JSONAPI.Http; +using JSONAPI.QueryableResolvers; namespace JSONAPI.Configuration { @@ -30,6 +31,7 @@ internal ResourceTypeConfiguration(IResourceTypeRegistrar resourceTypeRegistrar) public IDictionary RelationshipConfigurations { get; private set; } public Func FilterByIdExpressionFactory { get; private set; } public Func SortByIdExpressionFactory { get; private set; } + public Type ResourceCollectionResolverType { get; private set; } public void ConfigureRelationship(Expression> property, Action configurationAction) @@ -72,6 +74,12 @@ public void OverrideDefaultSortById(Func sortBy SortByIdExpressionFactory = sortByIdExpressionFactory; } + public IResourceTypeConfigurator ResolveCollectionWith() where TCollectionResolver : IResourceCollectionResolver + { + ResourceCollectionResolverType = typeof(TCollectionResolver); + return this; + } + public IResourceTypeRegistration BuildResourceTypeRegistration() { return _resourceTypeRegistrar.BuildRegistration(ClrType, ResourceTypeName, FilterByIdExpressionFactory, diff --git a/JSONAPI/JSONAPI.csproj b/JSONAPI/JSONAPI.csproj index 9bd3d2e2..a03d6d21 100644 --- a/JSONAPI/JSONAPI.csproj +++ b/JSONAPI/JSONAPI.csproj @@ -101,6 +101,7 @@ + diff --git a/JSONAPI/QueryableResolvers/IResourceCollectionResolver.cs b/JSONAPI/QueryableResolvers/IResourceCollectionResolver.cs new file mode 100644 index 00000000..98898d3a --- /dev/null +++ b/JSONAPI/QueryableResolvers/IResourceCollectionResolver.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace JSONAPI.QueryableResolvers +{ + public interface IResourceCollectionResolver + { + Task> GetQueryForResourceCollection(IQueryable queryable, HttpRequestMessage request, CancellationToken cancellationToken); + } +} From 0459ed8a9902085cfa14a03733a684323695accb Mon Sep 17 00:00:00 2001 From: Simon Hofer Date: Wed, 14 Sep 2016 10:36:29 +0200 Subject: [PATCH 02/10] make queryable modifiable on related --- JSONAPI.Autofac/JsonApiAutofacModule.cs | 14 ++++++++++---- .../Http/EntityFrameworkDocumentMaterializer.cs | 2 +- ...orkToManyRelatedResourceDocumentMaterializer.cs | 10 +++++++++- ...bleToManyRelatedResourceDocumentMaterializer.cs | 6 ++++++ 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/JSONAPI.Autofac/JsonApiAutofacModule.cs b/JSONAPI.Autofac/JsonApiAutofacModule.cs index 685a9bb6..dee18c31 100644 --- a/JSONAPI.Autofac/JsonApiAutofacModule.cs +++ b/JSONAPI.Autofac/JsonApiAutofacModule.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using Autofac; using Autofac.Core; using JSONAPI.ActionFilters; @@ -90,11 +91,11 @@ protected override void Load(ContainerBuilder builder) { var configuration = context.ResolveKeyed(clrType); var registration = registry.GetRegistrationForType(clrType); - var parameters = new Parameter[] { new TypedParameter(typeof(IResourceTypeRegistration), registration)}; + var parameters = new List { new TypedParameter(typeof(IResourceTypeRegistration), registration)}; if (configuration.ResourceCollectionResolverType != null) { var collectionResolver = context.Resolve(configuration.ResourceCollectionResolverType, parameters); - parameters = new Parameter[] { new TypedParameter(typeof(IResourceTypeRegistration), registration), new NamedParameter("collectionResolver", collectionResolver), }; + parameters.Add(new NamedParameter("collectionResolver", collectionResolver)); } if (configuration.DocumentMaterializerType != null) return (IDocumentMaterializer)context.Resolve(configuration.DocumentMaterializerType, parameters); @@ -114,12 +115,17 @@ protected override void Load(ContainerBuilder builder) throw JsonApiException.CreateForNotFound( string.Format("No relationship `{0}` exists for the resource type `{1}`.", relationshipName, resourceTypeName)); - var parameters = new Parameter[] + var parameters = new List { new TypedParameter(typeof(IResourceTypeRegistration), registration), new TypedParameter(typeof(ResourceTypeRelationship), relationship) }; - + var relConfiguration = context.ResolveKeyed(relationshipName); + if (relConfiguration.ResourceCollectionResolverType != null) + { + var collectionResolver = context.Resolve(relConfiguration.ResourceCollectionResolverType, parameters); + parameters.Add(new NamedParameter("collectionResolver", collectionResolver)); + } // First, see if they have set an explicit materializer for this relationship IResourceTypeRelationshipConfiguration relationshipConfiguration; if (configuration.RelationshipConfigurations.TryGetValue(relationship.Property.Name, diff --git a/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs b/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs index b30a89a9..a1996e36 100644 --- a/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs +++ b/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs @@ -28,7 +28,7 @@ public class EntityFrameworkDocumentMaterializer : IDocumentMaterializer wher private readonly ISortExpressionExtractor _sortExpressionExtractor; private readonly IIncludeExpressionExtractor _includeExpressionExtractor; private readonly IBaseUrlService _baseUrlService; - private IResourceCollectionResolver _collectionResolver; + private readonly IResourceCollectionResolver _collectionResolver; /// /// Creates a new EntityFrameworkDocumentMaterializer diff --git a/JSONAPI.EntityFramework/Http/EntityFrameworkToManyRelatedResourceDocumentMaterializer.cs b/JSONAPI.EntityFramework/Http/EntityFrameworkToManyRelatedResourceDocumentMaterializer.cs index 142075a0..37756ccc 100644 --- a/JSONAPI.EntityFramework/Http/EntityFrameworkToManyRelatedResourceDocumentMaterializer.cs +++ b/JSONAPI.EntityFramework/Http/EntityFrameworkToManyRelatedResourceDocumentMaterializer.cs @@ -9,6 +9,7 @@ using JSONAPI.Documents.Builders; using JSONAPI.Extensions; using JSONAPI.Http; +using JSONAPI.QueryableResolvers; namespace JSONAPI.EntityFramework.Http { @@ -21,6 +22,7 @@ public class EntityFrameworkToManyRelatedResourceDocumentMaterializer _collectionResolver; /// /// Builds a new EntityFrameworkToManyRelatedResourceDocumentMaterializer. @@ -31,12 +33,14 @@ public EntityFrameworkToManyRelatedResourceDocumentMaterializer( IQueryableResourceCollectionDocumentBuilder queryableResourceCollectionDocumentBuilder, ISortExpressionExtractor sortExpressionExtractor, IIncludeExpressionExtractor includeExpressionExtractor, - IResourceTypeRegistration primaryTypeRegistration) + IResourceTypeRegistration primaryTypeRegistration, + IResourceCollectionResolver collectionResolver = null) : base(queryableResourceCollectionDocumentBuilder, sortExpressionExtractor, includeExpressionExtractor) { _relationship = relationship; _dbContext = dbContext; _primaryTypeRegistration = primaryTypeRegistration; + _collectionResolver = collectionResolver; } protected override async Task> GetRelatedQuery(string primaryResourceId, @@ -56,6 +60,10 @@ protected override async Task> GetRelatedQuery(string prima _primaryTypeRegistration.ResourceTypeName, primaryResourceId)); var includes = GetNavigationPropertiesIncludes(Includes); var query = primaryEntityQuery.SelectMany(lambda); + if (_collectionResolver != null) + { + query = await _collectionResolver.GetQueryForResourceCollection(query, Request, cancellationToken); + } if (includes != null && includes.Any()) query = includes.Aggregate(query, (current, include) => current.Include(include)); diff --git a/JSONAPI/Http/QueryableToManyRelatedResourceDocumentMaterializer.cs b/JSONAPI/Http/QueryableToManyRelatedResourceDocumentMaterializer.cs index 6aa2286f..d9c21247 100644 --- a/JSONAPI/Http/QueryableToManyRelatedResourceDocumentMaterializer.cs +++ b/JSONAPI/Http/QueryableToManyRelatedResourceDocumentMaterializer.cs @@ -20,6 +20,11 @@ public abstract class QueryableToManyRelatedResourceDocumentMaterializer protected string[] Includes = {}; + /// + /// The current http-request. + /// + protected HttpRequestMessage Request; + /// /// Creates a new QueryableRelatedResourceDocumentMaterializer @@ -38,6 +43,7 @@ public async Task GetRelatedResourceDocument(string primaryRes CancellationToken cancellationToken) { Includes = _includeExpressionExtractor.ExtractIncludeExpressions(request); + Request = request; var query = await GetRelatedQuery(primaryResourceId, cancellationToken); var sortExpressions = _sortExpressionExtractor.ExtractSortExpressions(request); if (sortExpressions == null || sortExpressions.Length < 1) From 0f326723200454b7679d97fae367f05d5abb8256 Mon Sep 17 00:00:00 2001 From: Simon Hofer Date: Wed, 14 Sep 2016 11:21:30 +0200 Subject: [PATCH 03/10] added tests for related / improvements --- .../FetchingResourcesQueryResolverTests.cs | 15 ++++++++ ...SearchFilter_related_to_many_Response.json | 20 ++++++++++ ...sts.EntityFrameworkTestWebApp.Tests.csproj | 1 + .../CommentSearchResourceQueryResolver.cs | 38 +++++++++++++++++++ ...anceTests.EntityFrameworkTestWebApp.csproj | 1 + .../Startup.cs | 2 +- JSONAPI.Autofac/JsonApiAutofacModule.cs | 18 ++++++--- 7 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResourcesQueryResolver/GetWithSearchFilter_related_to_many_Response.json create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/CommentSearchResourceQueryResolver.cs diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/FetchingResourcesQueryResolverTests.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/FetchingResourcesQueryResolverTests.cs index 9fce0954..89e9b4f0 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/FetchingResourcesQueryResolverTests.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/FetchingResourcesQueryResolverTests.cs @@ -50,5 +50,20 @@ public async Task GetWithSearchFilter() // this enables logic in Query resolver } } + + [TestMethod] + [DeploymentItem(@"Data\CommentSearch.csv", @"Data")] + [DeploymentItem(@"Data\PostSearch.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task GetWithSearchFilter_related_to_many() // this enables logic in Query resolver for related + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitGet(effortConnection, "post-searchs/201/comments?searchterm=efg"); + + await AssertResponseContent(response, @"Fixtures\FetchingResourcesQueryResolver\GetWithSearchFilter_related_to_many_Response.json", HttpStatusCode.OK); + } + } + } } diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResourcesQueryResolver/GetWithSearchFilter_related_to_many_Response.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResourcesQueryResolver/GetWithSearchFilter_related_to_many_Response.json new file mode 100644 index 00000000..3ad5dc11 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResourcesQueryResolver/GetWithSearchFilter_related_to_many_Response.json @@ -0,0 +1,20 @@ +{ + "data": [ + { + "type": "comment-searchs", + "id": "102", + "attributes": { + "created": "2015-01-31T14:35:00.0000000+00:00", + "text": "efgComment" + }, + "relationships": { + "post": { + "links": { + "self": "https://www.example.com/comment-searchs/102/relationships/post", + "related": "https://www.example.com/comment-searchs/102/post" + } + } + } + } + ] +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj index 83a3a13f..e1cc107f 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj @@ -291,6 +291,7 @@ + diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/CommentSearchResourceQueryResolver.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/CommentSearchResourceQueryResolver.cs new file mode 100644 index 00000000..bfd6011c --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/CommentSearchResourceQueryResolver.cs @@ -0,0 +1,38 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Helper; +using JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Models; +using JSONAPI.QueryableResolvers; + +namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp +{ + public class CommentSearchResourceQueryResolver : IResourceCollectionResolver + { + public Task> GetQueryForResourceCollection(IQueryable queryable, HttpRequestMessage request, CancellationToken cancellationToken) + { + var queryPairs = request.GetQueryNameValuePairs(); + foreach (var queryPair in queryPairs) + { + if (String.IsNullOrWhiteSpace(queryPair.Key)) + continue; + + if (!queryPair.Key.StartsWith("searchterm")) + continue; + + var searchTerm = queryPair.Value; + var predicate = PredicateBuilder.False(); + + foreach (var str in Regex.Split(searchTerm, "\\s+")) + { + predicate = predicate.Or(y => y.Text.Contains(str)); + } + queryable= queryable.Where(predicate); + } + return Task.FromResult(queryable); + } + } +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.csproj b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.csproj index b733b686..39893c40 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.csproj +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.csproj @@ -157,6 +157,7 @@ + diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Startup.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Startup.cs index 267a82c2..f444f287 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Startup.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Startup.cs @@ -94,7 +94,7 @@ internal JsonApiConfiguration BuildConfiguration() configuration.RegisterEntityFrameworkResourceType(); configuration.RegisterEntityFrameworkResourceType(); configuration.RegisterResourceType().ResolveCollectionWith(); - configuration.RegisterEntityFrameworkResourceType(); + configuration.RegisterResourceType().ResolveCollectionWith(); return configuration; } diff --git a/JSONAPI.Autofac/JsonApiAutofacModule.cs b/JSONAPI.Autofac/JsonApiAutofacModule.cs index dee18c31..6b81653c 100644 --- a/JSONAPI.Autofac/JsonApiAutofacModule.cs +++ b/JSONAPI.Autofac/JsonApiAutofacModule.cs @@ -92,11 +92,14 @@ protected override void Load(ContainerBuilder builder) var configuration = context.ResolveKeyed(clrType); var registration = registry.GetRegistrationForType(clrType); var parameters = new List { new TypedParameter(typeof(IResourceTypeRegistration), registration)}; + + // add parameter for collectionResolver if (configuration.ResourceCollectionResolverType != null) { var collectionResolver = context.Resolve(configuration.ResourceCollectionResolverType, parameters); parameters.Add(new NamedParameter("collectionResolver", collectionResolver)); } + if (configuration.DocumentMaterializerType != null) return (IDocumentMaterializer)context.Resolve(configuration.DocumentMaterializerType, parameters); return context.Resolve(parameters); @@ -120,12 +123,17 @@ protected override void Load(ContainerBuilder builder) new TypedParameter(typeof(IResourceTypeRegistration), registration), new TypedParameter(typeof(ResourceTypeRelationship), relationship) }; - var relConfiguration = context.ResolveKeyed(relationshipName); - if (relConfiguration.ResourceCollectionResolverType != null) - { - var collectionResolver = context.Resolve(relConfiguration.ResourceCollectionResolverType, parameters); - parameters.Add(new NamedParameter("collectionResolver", collectionResolver)); + + // add parameter for collectionResolver + if (context.IsRegisteredWithKey(relationship.RelatedType)) { + var relConfiguration = context.ResolveKeyed(relationship.RelatedType); + if (relConfiguration.ResourceCollectionResolverType != null) + { + var collectionResolver = context.Resolve(relConfiguration.ResourceCollectionResolverType, parameters); + parameters.Add(new NamedParameter("collectionResolver", collectionResolver)); + } } + // First, see if they have set an explicit materializer for this relationship IResourceTypeRelationshipConfiguration relationshipConfiguration; if (configuration.RelationshipConfigurations.TryGetValue(relationship.Property.Name, From 33d213fd3ac7521aacabf774a95dbdbfd338ec2f Mon Sep 17 00:00:00 2001 From: Simon Hofer Date: Wed, 28 Sep 2016 19:08:39 +0200 Subject: [PATCH 04/10] added wildcard filtering --- .../DefaultFilteringTransformerTests.cs | 52 +++++++++++++++++++ .../DefaultFilteringTransformer.cs | 49 +++++++++++++++-- 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/JSONAPI.Tests/ActionFilters/DefaultFilteringTransformerTests.cs b/JSONAPI.Tests/ActionFilters/DefaultFilteringTransformerTests.cs index 6b458e2a..e55eb0df 100644 --- a/JSONAPI.Tests/ActionFilters/DefaultFilteringTransformerTests.cs +++ b/JSONAPI.Tests/ActionFilters/DefaultFilteringTransformerTests.cs @@ -582,6 +582,58 @@ public void Filters_by_matching_string_property() returnedArray[0].Id.Should().Be("100"); } + [TestMethod] + public void Filters_by_wildcard_string_property_end() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[string-field]=String value 1%"); + returnedArray.Length.Should().Be(1); + returnedArray[0].Id.Should().Be("100"); + } + [TestMethod] + public void Filters_by_wildcard_string_property_start() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[string-field]=%String value 1"); + returnedArray.Length.Should().Be(1); + returnedArray[0].Id.Should().Be("100"); + } + [TestMethod] + public void Filters_by_wildcard_string_property_start_end() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[string-field]=%String value 1%"); + returnedArray.Length.Should().Be(1); + returnedArray[0].Id.Should().Be("100"); + } + + [TestMethod] + public void Filters_by_wildcard_string_property_start_end_ignoreCase() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[string-field]=%string value 1%"); + returnedArray.Length.Should().Be(1); + returnedArray[0].Id.Should().Be("100"); + } + + [TestMethod] + public void Filters_by_wildcard_string_property_end_part() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[string-field]=%value 2"); + returnedArray.Length.Should().Be(2); + returnedArray[0].Id.Should().Be("101"); + returnedArray[1].Id.Should().Be("102"); + } + [TestMethod] + public void Filters_by_wildcard_string_property_end_part_no_match() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[string-field]=%value 3"); + returnedArray.Length.Should().Be(0); + } + + [TestMethod] + public void Filters_by_wildcard_string_property_start_part_no_match() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[string-field]=String vall%"); + returnedArray.Length.Should().Be(0); + } + [TestMethod] public void Filters_by_missing_string_property() { diff --git a/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs b/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs index f449d1bb..a9a0d478 100644 --- a/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs +++ b/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs @@ -16,6 +16,13 @@ public class DefaultFilteringTransformer : IQueryableFilteringTransformer { private readonly IResourceTypeRegistry _resourceTypeRegistry; + private static readonly MethodInfo ContainsMethod = typeof(string).GetMethod("Contains"); + private static readonly MethodInfo StartsWithMethod = typeof(string).GetMethod("StartsWith", new[] { typeof(string) }); + private static readonly MethodInfo EndsWithMethod = typeof(string).GetMethod("EndsWith", new[] { typeof(string) }); + private static readonly MethodInfo ToLowerMethod = typeof(string).GetMethod("ToLower", new Type[] {}); + + + /// /// Creates a new FilteringQueryableTransformer /// @@ -135,9 +142,45 @@ private Expression GetPredicateBodyForProperty(PropertyInfo prop, string queryVa expr = Expression.Equal(propertyExpr, Expression.Constant(null)); } else - { - Expression propertyExpr = Expression.Property(param, prop); - expr = Expression.Equal(propertyExpr, Expression.Constant(queryValue)); + { // inspired by http://stackoverflow.com/questions/5374481/like-operator-in-linq + if (queryValue.StartsWith("%") || queryValue.EndsWith("%")) + { + var startWith = queryValue.StartsWith("%"); + var endsWith = queryValue.EndsWith("%"); + + if (startWith) // remove % + queryValue = queryValue.Remove(0, 1); + + if (endsWith) // remove % + queryValue = queryValue.Remove(queryValue.Length - 1, 1); + + var constant = Expression.Constant(queryValue.ToLower()); + Expression propertyExpr = Expression.Property(param, prop); + + Expression nullCheckExpression = Expression.NotEqual(propertyExpr, Expression.Constant(null)); + + if (endsWith && startWith) + { + expr = Expression.AndAlso(nullCheckExpression, Expression.Call(Expression.Call(propertyExpr,ToLowerMethod), ContainsMethod, constant)); + } + else if (startWith) + { + expr = Expression.AndAlso(nullCheckExpression, Expression.Call(Expression.Call(propertyExpr, ToLowerMethod), EndsWithMethod, constant)); + } + else if (endsWith) + { + expr = Expression.AndAlso(nullCheckExpression, Expression.Call(Expression.Call(propertyExpr, ToLowerMethod), StartsWithMethod, constant)); + } + else + { + expr = Expression.Equal(propertyExpr, constant); + } + } + else + { + Expression propertyExpr = Expression.Property(param, prop); + expr = Expression.Equal(propertyExpr, Expression.Constant(queryValue)); + } } } else if (propertyType == typeof(Boolean)) From 819c476d33686168bff5dfa262b93b2a3a4548fc Mon Sep 17 00:00:00 2001 From: Simon Hofer Date: Thu, 29 Sep 2016 11:51:50 +0200 Subject: [PATCH 05/10] made filtering more generic an support multiple values separated by comma --- .../DefaultFilteringTransformerTests.cs | 201 ++++++++- .../DefaultFilteringTransformer.cs | 417 ++++++++---------- 2 files changed, 389 insertions(+), 229 deletions(-) diff --git a/JSONAPI.Tests/ActionFilters/DefaultFilteringTransformerTests.cs b/JSONAPI.Tests/ActionFilters/DefaultFilteringTransformerTests.cs index e55eb0df..df49a463 100644 --- a/JSONAPI.Tests/ActionFilters/DefaultFilteringTransformerTests.cs +++ b/JSONAPI.Tests/ActionFilters/DefaultFilteringTransformerTests.cs @@ -22,7 +22,10 @@ private enum SomeEnum { EnumValue1 = 1, EnumValue2 = 2, - EnumValue3 = 3 + EnumValue3 = 3, + EnumValue4 = 4, + EnumValue5 = 5, + EnumValue6 = 6 } private class SomeUnknownType @@ -101,7 +104,26 @@ public void SetupFixtures() StringField = "String value 2", EnumField = SomeEnum.EnumValue3 }, - + new Dummy + { + Id = "103", + StringField = "abc", + }, + new Dummy + { + Id = "104", + StringField = "bcd", + }, + new Dummy + { + Id = "105", + StringField = "def", + }, + new Dummy + { + Id = "106", + StringField = "sentence containing a comma, which can happen", + }, #endregion #region DateTimeField @@ -151,6 +173,11 @@ public void SetupFixtures() Id = "140", NullableDateTimeOffsetField = new DateTime(2014, 5, 5) }, + new Dummy + { + Id = "141", + NullableDateTimeOffsetField = new DateTime(2015, 6, 13) + }, #endregion @@ -166,6 +193,41 @@ public void SetupFixtures() Id = "151", EnumField = SomeEnum.EnumValue2 }, + new Dummy + { + Id = "152", + EnumField = SomeEnum.EnumValue3 + }, + new Dummy + { + Id = "153", + EnumField = SomeEnum.EnumValue4 + }, + new Dummy + { + Id = "154", + EnumField = SomeEnum.EnumValue5 + }, + new Dummy + { + Id = "155", + EnumField = SomeEnum.EnumValue6 + }, + new Dummy + { + Id = "156", + EnumField = SomeEnum.EnumValue5 + }, + new Dummy + { + Id = "157", + EnumField = SomeEnum.EnumValue6 + }, + new Dummy + { + Id = "158", + EnumField = SomeEnum.EnumValue5 + }, #endregion @@ -176,6 +238,11 @@ public void SetupFixtures() Id = "160", NullableEnumField = SomeEnum.EnumValue3 }, + new Dummy + { + Id = "161", + NullableEnumField = SomeEnum.EnumValue6 + }, #endregion @@ -191,6 +258,16 @@ public void SetupFixtures() Id = "171", DecimalField = (decimal) 6.37 }, + new Dummy + { + Id = "172", + DecimalField = (decimal) 5.08 + }, + new Dummy + { + Id = "173", + DecimalField = (decimal) 17.3 + }, #endregion @@ -582,6 +659,30 @@ public void Filters_by_matching_string_property() returnedArray[0].Id.Should().Be("100"); } + [TestMethod] + public void Filters_by_matching_string_property_comma_not_matching() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[string-field]=sentence containing a comma, which can happen"); + returnedArray.Length.Should().Be(0); + } + + [TestMethod] + public void Filters_by_matching_string_property_comma_matching() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[string-field]=abc,bcd"); + returnedArray.Length.Should().Be(2); + returnedArray[0].Id.Should().Be("103"); + returnedArray[1].Id.Should().Be("104"); + } + + [TestMethod] + public void Filters_by_matching_string_property_comma() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[string-field]=\"sentence containing a comma, which can happen\""); + returnedArray.Length.Should().Be(1); + returnedArray[0].Id.Should().Be("106"); + } + [TestMethod] public void Filters_by_wildcard_string_property_end() { @@ -604,6 +705,7 @@ public void Filters_by_wildcard_string_property_start_end() returnedArray[0].Id.Should().Be("100"); } + [TestMethod] public void Filters_by_wildcard_string_property_start_end_ignoreCase() { @@ -612,6 +714,14 @@ public void Filters_by_wildcard_string_property_start_end_ignoreCase() returnedArray[0].Id.Should().Be("100"); } + [TestMethod] + public void Filters_by_wildcard_string_property_start_end_comma() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[string-field]=\"%,%\""); + returnedArray.Length.Should().Be(1); + returnedArray[0].Id.Should().Be("106"); + } + [TestMethod] public void Filters_by_wildcard_string_property_end_part() { @@ -620,6 +730,25 @@ public void Filters_by_wildcard_string_property_end_part() returnedArray[0].Id.Should().Be("101"); returnedArray[1].Id.Should().Be("102"); } + + [TestMethod] + public void Filters_by_wildcard_string_property_end_part_quote() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[string-field]=\"%value 2\""); + returnedArray.Length.Should().Be(2); + returnedArray[0].Id.Should().Be("101"); + returnedArray[1].Id.Should().Be("102"); + } + + + [TestMethod] + public void Filters_by_wildcard_string_property_end_part_quote_comma() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[string-field]=\"%, which can happen\""); + returnedArray.Length.Should().Be(1); + returnedArray[0].Id.Should().Be("106"); + } + [TestMethod] public void Filters_by_wildcard_string_property_end_part_no_match() { @@ -634,12 +763,21 @@ public void Filters_by_wildcard_string_property_start_part_no_match() returnedArray.Length.Should().Be(0); } + [TestMethod] + public void Filters_by_multiple_string_property() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[string-field]=\"abc\",\"def\""); + returnedArray.Length.Should().Be(2); + returnedArray[0].Id.Should().Be("103"); + returnedArray[1].Id.Should().Be("105"); + } + [TestMethod] public void Filters_by_missing_string_property() { var returnedArray = GetArray("http://api.example.com/dummies?filter[string-field]="); - returnedArray.Length.Should().Be(_fixtures.Count - 3); - returnedArray.Any(d => d.Id == "100" || d.Id == "101" || d.Id == "102").Should().BeFalse(); + returnedArray.Length.Should().Be(_fixtures.Count - 7); + returnedArray.Any(d => d.Id == "100" || d.Id == "101" || d.Id == "102" || d.Id == "103" || d.Id == "104" || d.Id == "105" || d.Id == "106").Should().BeFalse(); } #endregion @@ -704,12 +842,21 @@ public void Filters_by_matching_nullable_datetimeoffset_property() returnedArray[0].Id.Should().Be("140"); } + [TestMethod] + public void Filters_by_multiple_matching_nullable_datetimeoffset_property() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[nullable-date-time-offset-field]=2014-05-05,2015-06-13"); + returnedArray.Length.Should().Be(2); + returnedArray[0].Id.Should().Be("140"); + returnedArray[1].Id.Should().Be("141"); + } + [TestMethod] public void Filters_by_missing_nullable_datetimeoffset_property() { var returnedArray = GetArray("http://api.example.com/dummies?filter[nullable-date-time-offset-field]="); - returnedArray.Length.Should().Be(_fixtures.Count - 1); - returnedArray.Any(d => d.Id == "140").Should().BeFalse(); + returnedArray.Length.Should().Be(_fixtures.Count - 2); + returnedArray.Any(d => d.Id == "140" || d.Id == "141").Should().BeFalse(); } #endregion @@ -724,6 +871,26 @@ public void Filters_by_matching_enum_property() returnedArray[0].Id.Should().Be("150"); } + [TestMethod] + public void Filters_by_multiple_matching_enum_property() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[enum-field]=1,2"); + returnedArray.Length.Should().Be(2); + returnedArray[0].Id.Should().Be("150"); + returnedArray[1].Id.Should().Be("151"); + } + + [TestMethod] + public void Filters_by_multiple_matching_enum_property2() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[enum-field]=1,5"); + returnedArray.Length.Should().Be(4); + returnedArray[0].Id.Should().Be("150"); + returnedArray[1].Id.Should().Be("154"); + returnedArray[2].Id.Should().Be("156"); + returnedArray[3].Id.Should().Be("158"); + } + [TestMethod] public void Filters_by_missing_enum_property() { @@ -739,12 +906,21 @@ public void Filters_by_matching_nullable_enum_property() returnedArray[0].Id.Should().Be("160"); } + [TestMethod] + public void Filters_by_multiple_matching_nullable_enum_property() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[nullable-enum-field]=3,6"); + returnedArray.Length.Should().Be(2); + returnedArray[0].Id.Should().Be("160"); + returnedArray[1].Id.Should().Be("161"); + } + [TestMethod] public void Filters_by_missing_nullable_enum_property() { var returnedArray = GetArray("http://api.example.com/dummies?filter[nullable-enum-field]="); - returnedArray.Length.Should().Be(_fixtures.Count - 1); - returnedArray.Any(d => d.Id == "160").Should().BeFalse(); + returnedArray.Length.Should().Be(_fixtures.Count - 2); + returnedArray.Any(d => d.Id == "160" || d.Id == "161").Should().BeFalse(); } #endregion @@ -759,6 +935,15 @@ public void Filters_by_matching_decimal_property() returnedArray[0].Id.Should().Be("170"); } + [TestMethod] + public void Filters_by_matching_multiple_decimal_property() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[decimal-field]=4.03,5.08"); + returnedArray.Length.Should().Be(2); + returnedArray[0].Id.Should().Be("170"); + returnedArray[1].Id.Should().Be("172"); + } + [TestMethod] public void Filters_by_matching_decimal_property_non_en_US() { diff --git a/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs b/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs index a9a0d478..c72a08dc 100644 --- a/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs +++ b/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs @@ -1,9 +1,11 @@ using System; -using System.Globalization; +using System.Collections.Generic; +using System.ComponentModel; using System.Linq; using System.Linq.Expressions; using System.Net.Http; using System.Reflection; +using System.Text.RegularExpressions; using JSONAPI.Core; using JSONAPI.Documents.Builders; @@ -20,7 +22,7 @@ public class DefaultFilteringTransformer : IQueryableFilteringTransformer private static readonly MethodInfo StartsWithMethod = typeof(string).GetMethod("StartsWith", new[] { typeof(string) }); private static readonly MethodInfo EndsWithMethod = typeof(string).GetMethod("EndsWith", new[] { typeof(string) }); private static readonly MethodInfo ToLowerMethod = typeof(string).GetMethod("ToLower", new Type[] {}); - + private static readonly MethodInfo GetPropertyExpressionMethod = typeof(DefaultFilteringTransformer).GetMethod("GetPropertyExpression", BindingFlags.NonPublic | BindingFlags.Static); /// @@ -127,266 +129,239 @@ private Expression GetPredicateBodyForField(ResourceTypeAttribute resourceTypeAt return GetPredicateBodyForProperty(resourceTypeAttribute.Property, queryValue, param); } - // ReSharper disable once FunctionComplexityOverflow - // TODO: should probably break this method up private Expression GetPredicateBodyForProperty(PropertyInfo prop, string queryValue, ParameterExpression param) { var propertyType = prop.PropertyType; - Expression expr; - if (propertyType == typeof(String)) + Expression expr = null; + if (propertyType == typeof(string)) { - if (String.IsNullOrWhiteSpace(queryValue)) + if (string.IsNullOrWhiteSpace(queryValue)) { Expression propertyExpr = Expression.Property(param, prop); expr = Expression.Equal(propertyExpr, Expression.Constant(null)); } else - { // inspired by http://stackoverflow.com/questions/5374481/like-operator-in-linq - if (queryValue.StartsWith("%") || queryValue.EndsWith("%")) + { + List parts = new List(); + if (queryValue.Contains("\"")) { - var startWith = queryValue.StartsWith("%"); - var endsWith = queryValue.EndsWith("%"); - - if (startWith) // remove % - queryValue = queryValue.Remove(0, 1); - - if (endsWith) // remove % - queryValue = queryValue.Remove(queryValue.Length - 1, 1); - - var constant = Expression.Constant(queryValue.ToLower()); - Expression propertyExpr = Expression.Property(param, prop); - - Expression nullCheckExpression = Expression.NotEqual(propertyExpr, Expression.Constant(null)); - - if (endsWith && startWith) + queryValue= queryValue.Replace("\"\"", "_#quote#_"); // escaped quotes + queryValue = Regex.Replace(queryValue, "\"([^\"]*)\"", delegate (Match match) + { + string v = match.ToString(); + v = v.Trim('"'); + v = v.Replace("_#quote#_", "\""); // restore quotes + parts.Add(v); + return string.Empty; + }); + } + + parts.AddRange(queryValue.Split(',')); + + foreach (var qpart in parts) + { + Expression innerExpression; + // inspired by http://stackoverflow.com/questions/5374481/like-operator-in-linq + if (qpart.StartsWith("%") || qpart.EndsWith("%")) { - expr = Expression.AndAlso(nullCheckExpression, Expression.Call(Expression.Call(propertyExpr,ToLowerMethod), ContainsMethod, constant)); + var startWith = qpart.StartsWith("%"); + var endsWith = qpart.EndsWith("%"); + string innerPart = qpart; + + if (startWith) // remove % + innerPart = innerPart.Remove(0, 1); + + if (endsWith) // remove % + innerPart = innerPart.Remove(innerPart.Length - 1, 1); + + var constant = Expression.Constant(innerPart.ToLower()); + Expression propertyExpr = Expression.Property(param, prop); + + Expression nullCheckExpression = Expression.NotEqual(propertyExpr, Expression.Constant(null)); + + if (endsWith && startWith) + { + innerExpression = Expression.AndAlso(nullCheckExpression, Expression.Call(Expression.Call(propertyExpr, ToLowerMethod), ContainsMethod, constant)); + } + else if (startWith) + { + innerExpression = Expression.AndAlso(nullCheckExpression, Expression.Call(Expression.Call(propertyExpr, ToLowerMethod), EndsWithMethod, constant)); + } + else if (endsWith) + { + innerExpression = Expression.AndAlso(nullCheckExpression, Expression.Call(Expression.Call(propertyExpr, ToLowerMethod), StartsWithMethod, constant)); + } + else + { + innerExpression = Expression.Equal(propertyExpr, constant); + } } - else if (startWith) + else { - expr = Expression.AndAlso(nullCheckExpression, Expression.Call(Expression.Call(propertyExpr, ToLowerMethod), EndsWithMethod, constant)); + Expression propertyExpr = Expression.Property(param, prop); + innerExpression = Expression.Equal(propertyExpr, Expression.Constant(qpart)); } - else if (endsWith) + + if (expr == null) { - expr = Expression.AndAlso(nullCheckExpression, Expression.Call(Expression.Call(propertyExpr, ToLowerMethod), StartsWithMethod, constant)); + expr = innerExpression; } else { - expr = Expression.Equal(propertyExpr, constant); + expr = Expression.OrElse(expr, innerExpression); } } - else - { - Expression propertyExpr = Expression.Property(param, prop); - expr = Expression.Equal(propertyExpr, Expression.Constant(queryValue)); - } + } } - else if (propertyType == typeof(Boolean)) - { - bool value; - expr = bool.TryParse(queryValue, out value) - ? GetPropertyExpression(value, prop, param) - : Expression.Constant(false); - } - else if (propertyType == typeof(Boolean?)) - { - bool tmp; - var value = bool.TryParse(queryValue, out tmp) ? tmp : (bool?)null; - expr = GetPropertyExpression(value, prop, param); - } - else if (propertyType == typeof(SByte)) - { - SByte value; - expr = SByte.TryParse(queryValue, out value) - ? GetPropertyExpression(value, prop, param) - : Expression.Constant(false); - } - else if (propertyType == typeof(SByte?)) - { - SByte tmp; - var value = SByte.TryParse(queryValue, out tmp) ? tmp : (SByte?)null; - expr = GetPropertyExpression(value, prop, param); - } - else if (propertyType == typeof(Byte)) - { - Byte value; - expr = Byte.TryParse(queryValue, out value) - ? GetPropertyExpression(value, prop, param) - : Expression.Constant(false); - } - else if (propertyType == typeof(Byte?)) - { - Byte tmp; - var value = Byte.TryParse(queryValue, out tmp) ? tmp : (Byte?)null; - expr = GetPropertyExpression(value, prop, param); - } - else if (propertyType == typeof(Int16)) - { - Int16 value; - expr = Int16.TryParse(queryValue, out value) - ? GetPropertyExpression(value, prop, param) - : Expression.Constant(false); - } - else if (propertyType == typeof(Int16?)) - { - Int16 tmp; - var value = Int16.TryParse(queryValue, out tmp) ? tmp : (Int16?)null; - expr = GetPropertyExpression(value, prop, param); - } - else if (propertyType == typeof(UInt16)) - { - UInt16 value; - expr = UInt16.TryParse(queryValue, out value) - ? GetPropertyExpression(value, prop, param) - : Expression.Constant(false); - } - else if (propertyType == typeof(UInt16?)) - { - UInt16 tmp; - var value = UInt16.TryParse(queryValue, out tmp) ? tmp : (UInt16?)null; - expr = GetPropertyExpression(value, prop, param); - } - else if (propertyType == typeof(Int32)) - { - Int32 value; - expr = Int32.TryParse(queryValue, out value) - ? GetPropertyExpression(value, prop, param) - : Expression.Constant(false); - } - else if (propertyType == typeof(Int32?)) - { - Int32 tmp; - var value = Int32.TryParse(queryValue, out tmp) ? tmp : (Int32?)null; - expr = GetPropertyExpression(value, prop, param); - } - else if (propertyType == typeof(UInt32)) - { - UInt32 value; - expr = UInt32.TryParse(queryValue, out value) - ? GetPropertyExpression(value, prop, param) - : Expression.Constant(false); - } - else if (propertyType == typeof(UInt32?)) - { - UInt32 tmp; - var value = UInt32.TryParse(queryValue, out tmp) ? tmp : (UInt32?)null; - expr = GetPropertyExpression(value, prop, param); - } - else if (propertyType == typeof(Int64)) - { - Int64 value; - expr = Int64.TryParse(queryValue, out value) - ? GetPropertyExpression(value, prop, param) - : Expression.Constant(false); - } - else if (propertyType == typeof(Int64?)) - { - Int64 tmp; - var value = Int64.TryParse(queryValue, out tmp) ? tmp : (Int64?)null; - expr = GetPropertyExpression(value, prop, param); - } - else if (propertyType == typeof(UInt64)) - { - UInt64 value; - expr = UInt64.TryParse(queryValue, out value) - ? GetPropertyExpression(value, prop, param) - : Expression.Constant(false); - } - else if (propertyType == typeof(UInt64?)) - { - UInt64 tmp; - var value = UInt64.TryParse(queryValue, out tmp) ? tmp : (UInt64?)null; - expr = GetPropertyExpression(value, prop, param); - } - else if (propertyType == typeof(Single)) - { - Single value; - expr = Single.TryParse(queryValue, NumberStyles.Any, CultureInfo.InvariantCulture, out value) - ? GetPropertyExpression(value, prop, param) - : Expression.Constant(false); - } - else if (propertyType == typeof(Single?)) - { - Single tmp; - var value = Single.TryParse(queryValue, NumberStyles.Any, CultureInfo.InvariantCulture, out tmp) ? tmp : (Single?)null; - expr = GetPropertyExpression(value, prop, param); - } - else if (propertyType == typeof(Double)) - { - Double value; - expr = Double.TryParse(queryValue, NumberStyles.Any, CultureInfo.InvariantCulture, out value) - ? GetPropertyExpression(value, prop, param) - : Expression.Constant(false); - } - else if (propertyType == typeof(Double?)) - { - Double tmp; - var value = Double.TryParse(queryValue, NumberStyles.Any, CultureInfo.InvariantCulture, out tmp) ? tmp : (Double?)null; - expr = GetPropertyExpression(value, prop, param); - } - else if (propertyType == typeof(Decimal)) - { - Decimal value; - expr = Decimal.TryParse(queryValue, NumberStyles.Any, CultureInfo.InvariantCulture, out value) - ? GetPropertyExpression(value, prop, param) - : Expression.Constant(false); - } - else if (propertyType == typeof(Decimal?)) + else if (propertyType.IsEnum) { - Decimal tmp; - var value = Decimal.TryParse(queryValue, NumberStyles.Any, CultureInfo.InvariantCulture, out tmp) ? tmp : (Decimal?)null; - expr = GetPropertyExpression(value, prop, param); + if (string.IsNullOrWhiteSpace(queryValue)) // missing enum property + { + expr = Expression.Constant(false); + } + else + { + // try to split up for multiple values + var parts = queryValue.Split(','); + + foreach (var part in parts) + { + int value; + var partExpr = (int.TryParse(part, out value) && Enum.IsDefined(propertyType, value)) + ? GetEnumPropertyExpression(value, prop, param) + : Expression.Constant(false); + if (expr == null) + { + expr = partExpr; + } + else + { + expr = Expression.OrElse(expr, partExpr); + } + } + } } - else if (propertyType == typeof(DateTime)) + else if (propertyType.IsGenericType && propertyType.GetGenericTypeDefinition() == typeof(Nullable<>) && + propertyType.GenericTypeArguments[0].IsEnum) { - DateTime value; - expr = DateTime.TryParse(queryValue, out value) - ? GetPropertyExpression(value, prop, param) - : Expression.Constant(false); + if (string.IsNullOrWhiteSpace(queryValue)) + { + Expression propertyExpr = Expression.Property(param, prop); + expr = Expression.Equal(propertyExpr, Expression.Constant(null)); + } + else + { + // try to split up for multiple values + var parts = queryValue.Split(','); + + foreach (var part in parts) + { + int tmp; + var value = int.TryParse(part, out tmp) ? tmp : (int?)null; + var partExpr = GetEnumPropertyExpression(value, prop, param); + if (expr == null) + { + expr = partExpr; + } + else + { + expr = Expression.OrElse(expr, partExpr); + } + } + } } - else if (propertyType == typeof(DateTime?)) + else if (Nullable.GetUnderlyingType(propertyType) != null) // It's nullable { - DateTime tmp; - var value = DateTime.TryParse(queryValue, out tmp) ? tmp : (DateTime?)null; - expr = GetPropertyExpression(value, prop, param); + expr = GetExpressionNullable(queryValue, prop, propertyType, param); } - else if (propertyType == typeof(DateTimeOffset)) + else { - DateTimeOffset value; - expr = DateTimeOffset.TryParse(queryValue, out value) - ? GetPropertyExpression(value, prop, param) - : Expression.Constant(false); + expr = GetExpression(queryValue, prop, propertyType, param); + if (expr == null) + { + expr = Expression.Constant(true); + } } - else if (propertyType == typeof(DateTimeOffset?)) + + return expr; + } + + private Expression GetExpressionNullable(string queryValue, PropertyInfo prop, Type propertyType, ParameterExpression param) + { + Type underlayingType = Nullable.GetUnderlyingType(propertyType); + try { - DateTimeOffset tmp; - var value = DateTimeOffset.TryParse(queryValue, out tmp) ? tmp : (DateTimeOffset?)null; - expr = GetPropertyExpression(value, prop, param); + + var methodInfo = GetPropertyExpressionMethod.MakeGenericMethod(propertyType); + + if (queryValue == null) + { + return (Expression)methodInfo.Invoke(null, new object[] { null, prop, param }); + } + + // try to split up for multiple values + var parts = queryValue.Split(','); + + Expression expr = null; + foreach (var part in parts) + { + TypeConverter conv =TypeDescriptor.GetConverter(underlayingType); + var value = conv.ConvertFromInvariantString(part); + + if (expr == null) + { + expr = (Expression) methodInfo.Invoke(null, new[] { value, prop, param }); + } + else + { + expr = Expression.OrElse(expr, (Expression)methodInfo.Invoke(null, new[] { value, prop, param })); + } + } + return expr; } - else if (propertyType.IsEnum) + catch (NotSupportedException) { - int value; - expr = (int.TryParse(queryValue, out value) && Enum.IsDefined(propertyType, value)) - ? GetEnumPropertyExpression(value, prop, param) - : Expression.Constant(false); + return Expression.Constant(false); } - else if (propertyType.IsGenericType && propertyType.GetGenericTypeDefinition() == typeof (Nullable<>) && - propertyType.GenericTypeArguments[0].IsEnum) + + } + + + + + private Expression GetExpression(string queryValue, PropertyInfo prop, Type propertyType, ParameterExpression param) + { + try { - int tmp; - var value = int.TryParse(queryValue, out tmp) ? tmp : (int?) null; - expr = GetEnumPropertyExpression(value, prop, param); + if(queryValue == null) // missing property + return Expression.Constant(false); + + var parts = queryValue.Split(','); + Expression expr = null; + foreach (var part in parts) + { + dynamic value = TypeDescriptor.GetConverter(propertyType).ConvertFromInvariantString(part); + if (expr == null) + { + expr = GetPropertyExpression(value, prop, param); + } + else + { + expr = Expression.OrElse(expr, GetPropertyExpression(value, prop, param)); + } + } + return expr; } - else + catch (NotSupportedException) { - expr = Expression.Constant(true); + return Expression.Constant(false); } - - return expr; } + + private Expression GetPredicateBodyForRelationship(ResourceTypeRelationship resourceTypeProperty, string queryValue, ParameterExpression param) { var relatedType = resourceTypeProperty.RelatedType; From 37e2e453d7a1d61692b20ad12495bd245c2fcb69 Mon Sep 17 00:00:00 2001 From: Simon Hofer Date: Thu, 29 Sep 2016 16:53:52 +0200 Subject: [PATCH 06/10] added filtering for date ranges (year, month, day, hour, minute, second) --- .../DefaultFilteringTransformerTests.cs | 94 ++++++++++++++++++- .../DefaultFilteringTransformer.cs | 90 +++++++++++++++++- 2 files changed, 181 insertions(+), 3 deletions(-) diff --git a/JSONAPI.Tests/ActionFilters/DefaultFilteringTransformerTests.cs b/JSONAPI.Tests/ActionFilters/DefaultFilteringTransformerTests.cs index df49a463..6ab52b65 100644 --- a/JSONAPI.Tests/ActionFilters/DefaultFilteringTransformerTests.cs +++ b/JSONAPI.Tests/ActionFilters/DefaultFilteringTransformerTests.cs @@ -148,6 +148,27 @@ public void SetupFixtures() Id = "120", NullableDateTimeField = new DateTime(1961, 2, 18) }, + new Dummy + { + Id = "121", + NullableDateTimeField = new DateTime(1961, 5, 31) + }, + new Dummy + { + Id = "122", + NullableDateTimeField = new DateTime(1961, 5, 31, 18, 58, 0) + }, + new Dummy + { + Id = "123", + NullableDateTimeField = new DateTime(1961, 5, 31, 19, 01, 0) + }, + + new Dummy + { + Id = "124", + NullableDateTimeField = new DateTime(1962, 5, 31, 19, 01, 0) + }, #endregion @@ -807,12 +828,73 @@ public void Filters_by_matching_nullable_datetime_property() returnedArray[0].Id.Should().Be("120"); } + [TestMethod] + public void Filters_by_matching_nullable_datetime_property_month() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[nullable-date-time-field]=1961-02"); + returnedArray.Length.Should().Be(1); + returnedArray[0].Id.Should().Be("120"); + } + + [TestMethod] + public void Filters_by_multiple_matching_nullable_datetime_property() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[nullable-date-time-field]=1961-02-18,1961-05-31"); + returnedArray.Length.Should().Be(4); + returnedArray[0].Id.Should().Be("120"); + returnedArray[1].Id.Should().Be("121"); + returnedArray[2].Id.Should().Be("122"); + returnedArray[3].Id.Should().Be("123"); + } + + + [TestMethod] + public void Filters_by_multiple_matching_nullable_datetime_property_year() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[nullable-date-time-field]=1961"); + returnedArray.Length.Should().Be(4); + returnedArray[0].Id.Should().Be("120"); + returnedArray[1].Id.Should().Be("121"); + returnedArray[2].Id.Should().Be("122"); + returnedArray[3].Id.Should().Be("123"); + } + + [TestMethod] + public void Filters_by_multiple_matching_nullable_datetime_property_month() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[nullable-date-time-field]=1961-02"); + returnedArray.Length.Should().Be(1); + returnedArray[0].Id.Should().Be("120"); + } + + [TestMethod] + public void Filters_by_multiple_matching_nullable_datetime_property_hour() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[nullable-date-time-field]=1961-05-31 19"); + returnedArray.Length.Should().Be(1); + returnedArray[0].Id.Should().Be("123"); + } + + [TestMethod] + public void Filters_by_multiple_matching_nullable_datetime_property_hour_minute() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[nullable-date-time-field]=1961-05-31 19:01"); + returnedArray.Length.Should().Be(1); + returnedArray[0].Id.Should().Be("123"); + } + [TestMethod] + public void Filters_by_multiple_matching_nullable_datetime_property_time_missing() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[nullable-date-time-field]=1961-05-31 19:01:12"); + returnedArray.Length.Should().Be(0); + } + [TestMethod] public void Filters_by_missing_nullable_datetime_property() { var returnedArray = GetArray("http://api.example.com/dummies?filter[nullable-date-time-field]="); - returnedArray.Length.Should().Be(_fixtures.Count - 1); - returnedArray.Any(d => d.Id == "120").Should().BeFalse(); + returnedArray.Length.Should().Be(_fixtures.Count - 5); + returnedArray.Any(d => d.Id == "120" || d.Id == "121" || d.Id == "122" || d.Id == "123" || d.Id == "124").Should().BeFalse(); } #endregion @@ -842,6 +924,14 @@ public void Filters_by_matching_nullable_datetimeoffset_property() returnedArray[0].Id.Should().Be("140"); } + [TestMethod] + public void Filters_by_matching_nullable_datetimeoffset_property_month() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[nullable-date-time-offset-field]=2014-05"); + returnedArray.Length.Should().Be(1); + returnedArray[0].Id.Should().Be("140"); + } + [TestMethod] public void Filters_by_multiple_matching_nullable_datetimeoffset_property() { diff --git a/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs b/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs index c72a08dc..92196a50 100644 --- a/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs +++ b/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs @@ -23,6 +23,7 @@ public class DefaultFilteringTransformer : IQueryableFilteringTransformer private static readonly MethodInfo EndsWithMethod = typeof(string).GetMethod("EndsWith", new[] { typeof(string) }); private static readonly MethodInfo ToLowerMethod = typeof(string).GetMethod("ToLower", new Type[] {}); private static readonly MethodInfo GetPropertyExpressionMethod = typeof(DefaultFilteringTransformer).GetMethod("GetPropertyExpression", BindingFlags.NonPublic | BindingFlags.Static); + private static readonly MethodInfo GetPropertyExpressionBetweenMethod = typeof(DefaultFilteringTransformer).GetMethod("GetPropertyExpressionBetween", BindingFlags.NonPublic | BindingFlags.Static); /// @@ -304,6 +305,12 @@ private Expression GetExpressionNullable(string queryValue, PropertyInfo prop, T // try to split up for multiple values var parts = queryValue.Split(','); + if (underlayingType == typeof(DateTime) || underlayingType == typeof(DateTimeOffset)) + { + return GetDateReangeExpression(parts, prop, underlayingType, propertyType, param); + } + + Expression expr = null; foreach (var part in parts) { @@ -328,8 +335,78 @@ private Expression GetExpressionNullable(string queryValue, PropertyInfo prop, T } + private Expression GetDateReangeExpression(string[] parts, PropertyInfo prop, Type underlyingType, Type propertyType, ParameterExpression param) + { + Expression expr = null; + foreach (var part in parts) + { + var mode = ""; + if (!part.Contains("-")) + mode = "year"; + if (part.Contains("-")) + mode = "month"; + if (part.Count(x => x.Equals('-')) == 2) + { + mode = "day"; + if (part.Contains(" ")) // there is a time + { + mode = "hour"; + if (part.Contains(":")) + mode = "minute"; + if (part.Count(x => x.Equals(':')) == 2) + { + mode = "second"; + } + } + } + var partToParse = part; + + // make the datetime valid + if (mode == "year") + partToParse += "-01-01"; + if (mode == "hour") + partToParse += ":00"; + + TypeConverter conv = TypeDescriptor.GetConverter(underlyingType); + dynamic value = conv.ConvertFromInvariantString(partToParse); + var upper =value; + switch (mode) + { + case "year": + upper= upper.AddYears(1); + break; + case "month": + upper = upper.AddMonths(1); + break; + case "day": + upper = upper.AddDays(1); + break; + case "hour": + upper = upper.AddHours(1); + break; + case "minute": + upper = upper.AddMinutes(1); + break; + case "second": + upper = upper.AddSeconds(1); + break; + } + upper = upper.AddTicks(-1); + var methodInfo = GetPropertyExpressionBetweenMethod.MakeGenericMethod(propertyType); + Expression innerExpr = (Expression)methodInfo.Invoke(null, new object[] {value, upper, prop, param}); + if (expr == null) + { + expr = innerExpr; + } + else + { + expr = Expression.OrElse(expr, innerExpr); + } + + } + return expr; + } - private Expression GetExpression(string queryValue, PropertyInfo prop, Type propertyType, ParameterExpression param) { @@ -452,6 +529,17 @@ private static Expression GetPropertyExpression(T value, PropertyInfo propert return Expression.Equal(propertyExpr, castedConstantExpr); } + private static Expression GetPropertyExpressionBetween(T lowerValue, T upperValue, PropertyInfo property, + ParameterExpression param) + { + Expression propertyExpr = Expression.Property(param, property); + var lowerValueExpr = Expression.Constant(lowerValue); + var upperValueExpr = Expression.Constant(upperValue); + Expression lowerCastedConstantExpr = Expression.Convert(lowerValueExpr, typeof(T)); + Expression upperCastedConstantExpr = Expression.Convert(upperValueExpr, typeof(T)); + return Expression.AndAlso(Expression.GreaterThanOrEqual(propertyExpr, lowerCastedConstantExpr), Expression.LessThanOrEqual(propertyExpr, upperCastedConstantExpr)); + } + private static Expression GetEnumPropertyExpression(int? value, PropertyInfo property, ParameterExpression param) { From 9b97852ee126329a4c9cf22710f475a49cc66b82 Mon Sep 17 00:00:00 2001 From: Simon Hofer Date: Thu, 29 Sep 2016 18:22:47 +0200 Subject: [PATCH 07/10] always operate with utc dates --- .../DefaultFilteringTransformerTests.cs | 16 +++++++++++----- .../DefaultFilteringTransformer.cs | 3 +++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/JSONAPI.Tests/ActionFilters/DefaultFilteringTransformerTests.cs b/JSONAPI.Tests/ActionFilters/DefaultFilteringTransformerTests.cs index 6ab52b65..c13def5a 100644 --- a/JSONAPI.Tests/ActionFilters/DefaultFilteringTransformerTests.cs +++ b/JSONAPI.Tests/ActionFilters/DefaultFilteringTransformerTests.cs @@ -156,18 +156,18 @@ public void SetupFixtures() new Dummy { Id = "122", - NullableDateTimeField = new DateTime(1961, 5, 31, 18, 58, 0) + NullableDateTimeField = new DateTime(1961, 5, 31, 18, 58, 0, DateTimeKind.Utc) }, new Dummy { Id = "123", - NullableDateTimeField = new DateTime(1961, 5, 31, 19, 01, 0) + NullableDateTimeField = new DateTime(1961, 5, 31, 19, 01, 0, DateTimeKind.Utc) }, new Dummy { Id = "124", - NullableDateTimeField = new DateTime(1962, 5, 31, 19, 01, 0) + NullableDateTimeField = new DateTime(1962, 5, 31, 19, 01, 0, DateTimeKind.Utc) }, #endregion @@ -870,7 +870,10 @@ public void Filters_by_multiple_matching_nullable_datetime_property_month() [TestMethod] public void Filters_by_multiple_matching_nullable_datetime_property_hour() { - var returnedArray = GetArray("http://api.example.com/dummies?filter[nullable-date-time-field]=1961-05-31 19"); + var dt = new DateTime(1961,05,31,19,00,00, DateTimeKind.Utc); + var localdt = dt.ToLocalTime(); + + var returnedArray = GetArray("http://api.example.com/dummies?filter[nullable-date-time-field]=1961-05-31 " + string.Format("{0,2:D2}", localdt.Hour)); returnedArray.Length.Should().Be(1); returnedArray[0].Id.Should().Be("123"); } @@ -878,7 +881,10 @@ public void Filters_by_multiple_matching_nullable_datetime_property_hour() [TestMethod] public void Filters_by_multiple_matching_nullable_datetime_property_hour_minute() { - var returnedArray = GetArray("http://api.example.com/dummies?filter[nullable-date-time-field]=1961-05-31 19:01"); + var dt = new DateTime(1961, 05, 31, 19, 00, 00, DateTimeKind.Utc); + var localdt = dt.ToLocalTime(); + + var returnedArray = GetArray("http://api.example.com/dummies?filter[nullable-date-time-field]=1961-05-31 " + string.Format("{0,2:D2}", localdt.Hour) + ":01"); returnedArray.Length.Should().Be(1); returnedArray[0].Id.Should().Be("123"); } diff --git a/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs b/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs index 92196a50..a54bc2a8 100644 --- a/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs +++ b/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs @@ -392,6 +392,9 @@ private Expression GetDateReangeExpression(string[] parts, PropertyInfo prop, Ty break; } upper = upper.AddTicks(-1); + value = value.ToUniversalTime(); + upper = upper.ToUniversalTime(); + var methodInfo = GetPropertyExpressionBetweenMethod.MakeGenericMethod(propertyType); Expression innerExpr = (Expression)methodInfo.Invoke(null, new object[] {value, upper, prop, param}); if (expr == null) From 906dbc005afac036ff41b82f6a95e3d5f9b616d3 Mon Sep 17 00:00:00 2001 From: Simon Hofer Date: Sat, 1 Oct 2016 09:21:35 +0200 Subject: [PATCH 08/10] added some docs on filtering --- README.md | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/README.md b/README.md index 1fb411ca..68acfa8f 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,85 @@ One of the benefits of the JSON API spec is that it provides several ways to [se > :information_source: As a side note, the RelationAggregator class handles the work of pulling any included related objects into the document--which may be a recursive process: for example, if an object has a relationship of its own type and that relationship property is annotated to be included, then it will recursively include all related objects of the same type! However, those related objects will all be included in a flat array for that type, according to the spec. + +# Filtering + +JSONAPI defines the URL query parameter for filtering is `filter` and should be combined with the associations but there is not much more how the syntax should be. So the JSONAPI.Net framework provides the following filter syntax. + +## Basic +If you want to get a Resource by Id you provide the Id in the URL as part of the path e.g: + +``` URL +/posts/1 +``` +With this call you get the post with Id 1 or a status code 404 if no post with the Id 1 exists. + +If you would filter all related comments on post with the Id 1 you append the name of relationship e.g: + +``` URL +/posts/1/comments +``` + +In both above cases you can add the `filter` query parameter to filter the result on non Id properties. The property to filter on is specified in squared brackets like below. +The value(s) after the equal sing we would call filter value. + +``` URL +/posts/1/comments?filter[autor]=Bob +``` +This will only return objects where "Bob" is the value of the author property. + +``` URL +/posts/1/comments?filter[category]=3 +``` +This will only return objects where 3 is the value of the category property. + +## Multiple value filter + +If you want to filter by multiple values you can concatenate the values separated by comma. In case of strings you need to quote the strings to provide multiple values. + +``` URL +/posts?filter[title]=Post one, which is awesome + => this returns all posts with title "Post one, which is awesome" +``` +``` URL +/posts?filter[title]="Post one","awesome" + => this returns all posts with title "Post one" OR "which is awesome" +``` + +If the field is numeric or DateTime you can concatenate values with comma. +``` URL +/posts?filter[category]=1,2,3 + => this returns all posts with category 1 OR 2 OR 3 +``` +``` URL +/posts?filter[date-created]=2016-09-01,2016-09-02 + => this returns all posts with date-created 2016-09-01 OR 2016-09-02 +``` + +## Wildcard filters + +In case of string properties you can provide a percent sign (%) at the beginning or end of the filter value. This will advice to not compare with equal but with contains. + +``` URL +/posts?filter[title]="%one","%awesome%" + => this returns all posts with title ending on "one" OR containing the word "awesome" +``` + +> :information_source: HINT: the comparison with wildcards is made case **insensitive**. +> :information_source: HINT: If there is a comma inside of the quoted filter value the term gets not split. +> :information_source: HINT: The percent sign is used to start an encoded character in the URL so the filter values **must unconditionally be encoded** before put in an URL. The above example should look like this when sent to server:`filter%5Btitle%5D=%22%25one%22%2C%22%25awesome%25%22` + +## DateTime filters +DateTime filters with equal can be a pain. If you store DateTime with full resolution you must provide the full resolution to make the filter value equal the stored value. + + +## Range filters +There is no implementation on filtering number or date ranges. If you need things like this you can open an issue or even better provide a pull request. + +## Logical operation +There is no implementation on filtering with a tree of logical AND and OR operations. If you need things like this you can open an issue or even better provide a pull request. + + # Great! But what's all this other stuff? From 2d23dd338e36e976b82d70bb0762e87d5e11b655 Mon Sep 17 00:00:00 2001 From: Simon Hofer Date: Sat, 1 Oct 2016 09:32:39 +0200 Subject: [PATCH 09/10] added some docs on filtering --- README.md | 43 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 68acfa8f..5b5e23ca 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ If you want to filter by multiple values you can concatenate the values separate => this returns all posts with title "Post one, which is awesome" ``` ``` URL -/posts?filter[title]="Post one","awesome" +/posts?filter[title]="Post one","which is awesome" => this returns all posts with title "Post one" OR "which is awesome" ``` @@ -119,18 +119,53 @@ In case of string properties you can provide a percent sign (%) at the beginning ``` > :information_source: HINT: the comparison with wildcards is made case **insensitive**. + > :information_source: HINT: If there is a comma inside of the quoted filter value the term gets not split. + > :information_source: HINT: The percent sign is used to start an encoded character in the URL so the filter values **must unconditionally be encoded** before put in an URL. The above example should look like this when sent to server:`filter%5Btitle%5D=%22%25one%22%2C%22%25awesome%25%22` ## DateTime filters -DateTime filters with equal can be a pain. If you store DateTime with full resolution you must provide the full resolution to make the filter value equal the stored value. +DateTime filters with equal can be a pain. If you store DateTime with full resolution (milliseconds) you must provide the full resolution to make the filter value equal the stored value. + +To avoid this problem JSONAPI.Net is automatically filtering by a DateTime range. If you provide a "day" (YYYY-MM-DD) as filter value the filter will be this: `BETWEEN day 00:00:00.000 AND day 23:59:59.999`. + +Now if the property is DateTime or DateTimeOffset you can provide the following types of filter values: +| Part | Format | Filter | +|--------|---------------------|---------------------------------------------------| +| year | YYYY | YYYY-01-01 00:00:00.000 - YYYY-12-31 23:59:59.999 | +| month* | YYYY-MM | YYYY-MM-01 00:00:00.000 - YYYY-MM-31 23:59:59.999 | +| day | YYYY-MM-DD | YYYY-MM-DD 00:00:00.000 - YYYY-MM-DD 23:59:59.999 | +| hour | YYYY-MM-DD HH | YYYY-MM-DD HH:00:00.000 - YYYY-MM-DD HH:59:59.999 | +| minute | YYYY-MM-DD HH:mm | YYYY-MM-DD HH:mm:00.000 - YYYY-MM-DD HH:mm:59.999 | +| second | YYYY-MM-DD HH:mm:ss | YYYY-MM-DD HH:mm:ss.000 - YYYY-MM-DD HH:mm:ss.999 | + +*) assuming this month has 31 days. JSONAPI.Net automatically determines the last day of month by adding one month to the given date. This respects months with less than 31 days. + +If you want to filter all posts created in month May of year 2016 you must provide the format for month filled by your needed date: + + +``` URL +/posts?filter[date-created]=2016-05 + => this returns all posts with date-created in may 2016 +``` ## Range filters -There is no implementation on filtering number or date ranges. If you need things like this you can open an issue or even better provide a pull request. +There is no implementation on filtering number or date ranges. If you need things like that you can open an issue or even better provide a pull request. ## Logical operation -There is no implementation on filtering with a tree of logical AND and OR operations. If you need things like this you can open an issue or even better provide a pull request. +Filters can be combined for multiple fields. You can filter posts created in May 2016 and containing awesome in title: + +``` URL +/posts?filter[date-created]=2016-05&filter[title]="%awesome%" + => this returns all posts with date-created in may 2016 with title containing "awesome" +``` +So we have the following standard behavior to concatenate multiple filters together: +- multiple values on the same property are concatenated with OR +- multiple filters on multiple properties are always concatenated with AND + + +There is no implementation on filtering with a tree of logical AND and OR operations other than described above. If you need things like that you can open an issue or even better provide a pull request. # Great! But what's all this other stuff? From 933226af75eca99a821e7d08c92b9018fe767e96 Mon Sep 17 00:00:00 2001 From: Simon Hofer Date: Sun, 17 Oct 2021 06:34:40 +0200 Subject: [PATCH 10/10] adjust for new angular jsonapi module --- ...ryableResourceCollectionDocumentBuilder.cs | 111 +- .../Core/DateTimeAttributeValueConverter.cs | 96 +- .../DefaultFilteringTransformer.cs | 1085 +++++++++-------- .../DefaultPaginationTransformer.cs | 192 +-- .../DefaultSortingTransformer.cs | 234 ++-- 5 files changed, 861 insertions(+), 857 deletions(-) diff --git a/JSONAPI.EntityFramework/Documents/Builders/EntityFrameworkQueryableResourceCollectionDocumentBuilder.cs b/JSONAPI.EntityFramework/Documents/Builders/EntityFrameworkQueryableResourceCollectionDocumentBuilder.cs index 1bd9e2c8..bdfc98fb 100644 --- a/JSONAPI.EntityFramework/Documents/Builders/EntityFrameworkQueryableResourceCollectionDocumentBuilder.cs +++ b/JSONAPI.EntityFramework/Documents/Builders/EntityFrameworkQueryableResourceCollectionDocumentBuilder.cs @@ -1,55 +1,56 @@ -using System; -using System.Data.Entity; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using JSONAPI.Documents; -using JSONAPI.Documents.Builders; -using JSONAPI.Http; -using JSONAPI.QueryableTransformers; - -namespace JSONAPI.EntityFramework.Documents.Builders -{ - /// - /// Provides a entity framework implementation of an IQueryableResourceCollectionDocumentBuilder - /// - public class EntityFrameworkQueryableResourceCollectionDocumentBuilder: DefaultQueryableResourceCollectionDocumentBuilder - { - /// - /// Creates a new EntityFrameworkQueryableResourceCollectionDocumentBuilder - /// - public EntityFrameworkQueryableResourceCollectionDocumentBuilder( - IResourceCollectionDocumentBuilder resourceCollectionDocumentBuilder, - IQueryableEnumerationTransformer enumerationTransformer, - IQueryableFilteringTransformer filteringTransformer, - IQueryableSortingTransformer sortingTransformer, - IQueryablePaginationTransformer paginationTransformer, - IBaseUrlService baseUrlService) : - base(resourceCollectionDocumentBuilder, - enumerationTransformer, - filteringTransformer, - sortingTransformer, - paginationTransformer, - baseUrlService) - { - } - - /// - /// Returns the metadata that should be sent with this document. - /// - protected override async Task GetDocumentMetadata(IQueryable originalQuery, IQueryable filteredQuery, IOrderedQueryable sortedQuery, - IPaginationTransformResult paginationResult, CancellationToken cancellationToken) - { - var metadata = new Metadata(); - if (paginationResult.PaginationWasApplied) - { - var count = await filteredQuery.CountAsync(cancellationToken); - metadata.MetaObject.Add("total-pages", (int)Math.Ceiling((decimal) count / paginationResult.PageSize)); - metadata.MetaObject.Add("total-count", count); - } - if (metadata.MetaObject.HasValues) - return metadata; - return null; - } - } -} +using System; +using System.Data.Entity; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using JSONAPI.Documents; +using JSONAPI.Documents.Builders; +using JSONAPI.Http; +using JSONAPI.QueryableTransformers; + +namespace JSONAPI.EntityFramework.Documents.Builders +{ + /// + /// Provides a entity framework implementation of an IQueryableResourceCollectionDocumentBuilder + /// + public class EntityFrameworkQueryableResourceCollectionDocumentBuilder: DefaultQueryableResourceCollectionDocumentBuilder + { + /// + /// Creates a new EntityFrameworkQueryableResourceCollectionDocumentBuilder + /// + public EntityFrameworkQueryableResourceCollectionDocumentBuilder( + IResourceCollectionDocumentBuilder resourceCollectionDocumentBuilder, + IQueryableEnumerationTransformer enumerationTransformer, + IQueryableFilteringTransformer filteringTransformer, + IQueryableSortingTransformer sortingTransformer, + IQueryablePaginationTransformer paginationTransformer, + IBaseUrlService baseUrlService) : + base(resourceCollectionDocumentBuilder, + enumerationTransformer, + filteringTransformer, + sortingTransformer, + paginationTransformer, + baseUrlService) + { + } + + /// + /// Returns the metadata that should be sent with this document. + /// + protected override async Task GetDocumentMetadata(IQueryable originalQuery, IQueryable filteredQuery, IOrderedQueryable sortedQuery, + IPaginationTransformResult paginationResult, CancellationToken cancellationToken) + { + var metadata = new Metadata(); + if (paginationResult.PaginationWasApplied) + { + var count = await filteredQuery.CountAsync(cancellationToken); + metadata.MetaObject.Add("total-pages", (int)Math.Ceiling((decimal) count / paginationResult.PageSize)); + metadata.MetaObject.Add("total-count", count); + metadata.MetaObject.Add("total_resources", count); + } + if (metadata.MetaObject.HasValues) + return metadata; + return null; + } + } +} diff --git a/JSONAPI/Core/DateTimeAttributeValueConverter.cs b/JSONAPI/Core/DateTimeAttributeValueConverter.cs index 94550d07..50376898 100644 --- a/JSONAPI/Core/DateTimeAttributeValueConverter.cs +++ b/JSONAPI/Core/DateTimeAttributeValueConverter.cs @@ -1,49 +1,49 @@ -using System; -using System.Reflection; -using Newtonsoft.Json.Linq; - -namespace JSONAPI.Core -{ - /// - /// Implementation of suitable for - /// use converting between DateTime CLR properties and ISO8601 string values. - /// - public class DateTimeAttributeValueConverter : IAttributeValueConverter - { - private readonly PropertyInfo _property; - private readonly bool _isNullable; - - /// - /// Creates a new DateTimeAttributeValueConverter - /// - /// - /// - public DateTimeAttributeValueConverter(PropertyInfo property, bool isNullable) - { - _property = property; - _isNullable = isNullable; - } - - public JToken GetValue(object resource) - { - var value = _property.GetValue(resource); - if (value != null) return ((DateTime) value).ToString("s"); - if (_isNullable) return null; - return "0001-01-01"; - } - - public void SetValue(object resource, JToken value) - { - if (value == null || value.Type == JTokenType.Null) - { - _property.SetValue(resource, _isNullable ? (DateTime?)null : new DateTime()); - } - else - { - var dateTimeValue = value.Value(); - if (dateTimeValue.Kind == DateTimeKind.Local) dateTimeValue = dateTimeValue.ToUniversalTime(); - _property.SetValue(resource, dateTimeValue); - } - } - } +using System; +using System.Reflection; +using Newtonsoft.Json.Linq; + +namespace JSONAPI.Core +{ + /// + /// Implementation of suitable for + /// use converting between DateTime CLR properties and ISO8601 string values. + /// + public class DateTimeAttributeValueConverter : IAttributeValueConverter + { + private readonly PropertyInfo _property; + private readonly bool _isNullable; + + /// + /// Creates a new DateTimeAttributeValueConverter + /// + /// + /// + public DateTimeAttributeValueConverter(PropertyInfo property, bool isNullable) + { + _property = property; + _isNullable = isNullable; + } + + public JToken GetValue(object resource) + { + var value = _property.GetValue(resource); + if (value != null) return ((DateTime) value).ToString("u"); + if (_isNullable) return null; + return "0001-01-01"; + } + + public void SetValue(object resource, JToken value) + { + if (value == null || value.Type == JTokenType.Null) + { + _property.SetValue(resource, _isNullable ? (DateTime?)null : new DateTime()); + } + else + { + var dateTimeValue = value.Value(); + if (dateTimeValue.Kind == DateTimeKind.Local) dateTimeValue = dateTimeValue.ToUniversalTime(); + _property.SetValue(resource, dateTimeValue); + } + } + } } \ No newline at end of file diff --git a/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs b/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs index a54bc2a8..01a9b3f0 100644 --- a/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs +++ b/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs @@ -1,147 +1,150 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; -using System.Linq.Expressions; -using System.Net.Http; -using System.Reflection; -using System.Text.RegularExpressions; -using JSONAPI.Core; -using JSONAPI.Documents.Builders; - -namespace JSONAPI.QueryableTransformers -{ - /// - /// This transformer filters an IQueryable based on query-string values. - /// - public class DefaultFilteringTransformer : IQueryableFilteringTransformer - { - private readonly IResourceTypeRegistry _resourceTypeRegistry; - - private static readonly MethodInfo ContainsMethod = typeof(string).GetMethod("Contains"); - private static readonly MethodInfo StartsWithMethod = typeof(string).GetMethod("StartsWith", new[] { typeof(string) }); - private static readonly MethodInfo EndsWithMethod = typeof(string).GetMethod("EndsWith", new[] { typeof(string) }); - private static readonly MethodInfo ToLowerMethod = typeof(string).GetMethod("ToLower", new Type[] {}); - private static readonly MethodInfo GetPropertyExpressionMethod = typeof(DefaultFilteringTransformer).GetMethod("GetPropertyExpression", BindingFlags.NonPublic | BindingFlags.Static); - private static readonly MethodInfo GetPropertyExpressionBetweenMethod = typeof(DefaultFilteringTransformer).GetMethod("GetPropertyExpressionBetween", BindingFlags.NonPublic | BindingFlags.Static); - - - /// - /// Creates a new FilteringQueryableTransformer - /// - /// The registry used to look up registered type information. - public DefaultFilteringTransformer(IResourceTypeRegistry resourceTypeRegistry) - { - _resourceTypeRegistry = resourceTypeRegistry; - } - - public IQueryable Filter(IQueryable query, HttpRequestMessage request) - { - var parameter = Expression.Parameter(typeof(T)); - var bodyExpr = GetPredicateBody(request, parameter); - var lambdaExpr = Expression.Lambda>(bodyExpr, parameter); - return query.Where(lambdaExpr); - } - - // Borrowed from http://stackoverflow.com/questions/3631547/select-right-generic-method-with-reflection - // ReSharper disable once UnusedMember.Local - private readonly Lazy _whereMethod = new Lazy(() => - typeof(Queryable).GetMethods() - .Where(x => x.Name == "Where") - .Select(x => new { M = x, P = x.GetParameters() }) - .Where(x => x.P.Length == 2 - && x.P[0].ParameterType.IsGenericType - && x.P[0].ParameterType.GetGenericTypeDefinition() == typeof(IQueryable<>) - && x.P[1].ParameterType.IsGenericType - && x.P[1].ParameterType.GetGenericTypeDefinition() == typeof(Expression<>)) - .Select(x => new { x.M, A = x.P[1].ParameterType.GetGenericArguments() }) - .Where(x => x.A[0].IsGenericType - && x.A[0].GetGenericTypeDefinition() == typeof(Func<,>)) - .Select(x => new { x.M, A = x.A[0].GetGenericArguments() }) - .Where(x => x.A[0].IsGenericParameter - && x.A[1] == typeof(bool)) - .Select(x => x.M) - .SingleOrDefault() - ); - - private Expression GetPredicateBody(HttpRequestMessage request, ParameterExpression param) - { - Expression workingExpr = null; - - var type = param.Type; - var queryPairs = request.GetQueryNameValuePairs(); - foreach (var queryPair in queryPairs) - { - if (String.IsNullOrWhiteSpace(queryPair.Key)) - continue; - - if (!queryPair.Key.StartsWith("filter.")) - continue; - - var filterField = queryPair.Key.Substring(7); // Skip "filter." - - IResourceTypeRegistration registration; - try - { - registration = _resourceTypeRegistry.GetRegistrationForType(type); - } - catch (TypeRegistrationNotFoundException) - { - throw JsonApiException.CreateForBadRequest("No registration exists for the specified type"); - } - - var expr = GetPredicate(filterField, registration, param, queryPair.Value); - workingExpr = workingExpr == null ? expr : Expression.AndAlso(workingExpr, expr); - } - - return workingExpr ?? Expression.Constant(true); // No filters, so return everything - } - - private Expression GetPredicate(string filterField, IResourceTypeRegistration registration, ParameterExpression param, string queryValue) - { - if (filterField == "id") - return GetPredicateBodyForProperty(registration.IdProperty, queryValue, param); - - var resourceTypeField = registration.GetFieldByName(filterField); - if (resourceTypeField == null) - throw JsonApiException.CreateForBadRequest( - string.Format("No attribute {0} exists on the specified type.", filterField)); - - if (string.IsNullOrWhiteSpace(queryValue)) - queryValue = null; - - // See if it is a field property - var fieldModelProperty = resourceTypeField as ResourceTypeAttribute; - if (fieldModelProperty != null) - return GetPredicateBodyForField(fieldModelProperty, queryValue, param); - - // See if it is a relationship property - var relationshipModelProperty = resourceTypeField as ResourceTypeRelationship; - if (relationshipModelProperty != null) - return GetPredicateBodyForRelationship(relationshipModelProperty, queryValue, param); - - throw JsonApiException.CreateForBadRequest( - string.Format("The attribute {0} is unsupported for filtering.", filterField)); - } - - private Expression GetPredicateBodyForField(ResourceTypeAttribute resourceTypeAttribute, string queryValue, - ParameterExpression param) - { - return GetPredicateBodyForProperty(resourceTypeAttribute.Property, queryValue, param); - } - - private Expression GetPredicateBodyForProperty(PropertyInfo prop, string queryValue, ParameterExpression param) - { - var propertyType = prop.PropertyType; - - Expression expr = null; - if (propertyType == typeof(string)) - { - if (string.IsNullOrWhiteSpace(queryValue)) - { - Expression propertyExpr = Expression.Property(param, prop); - expr = Expression.Equal(propertyExpr, Expression.Constant(null)); - } +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Linq.Expressions; +using System.Net.Http; +using System.Reflection; +using System.Text.RegularExpressions; +using JSONAPI.Core; +using JSONAPI.Documents.Builders; + +namespace JSONAPI.QueryableTransformers +{ + /// + /// This transformer filters an IQueryable based on query-string values. + /// + public class DefaultFilteringTransformer : IQueryableFilteringTransformer + { + private readonly IResourceTypeRegistry _resourceTypeRegistry; + + private static readonly MethodInfo ContainsMethod = typeof(string).GetMethod("Contains"); + private static readonly MethodInfo StartsWithMethod = typeof(string).GetMethod("StartsWith", new[] { typeof(string) }); + private static readonly MethodInfo EndsWithMethod = typeof(string).GetMethod("EndsWith", new[] { typeof(string) }); + private static readonly MethodInfo ToLowerMethod = typeof(string).GetMethod("ToLower", new Type[] {}); + private static readonly MethodInfo GetPropertyExpressionMethod = typeof(DefaultFilteringTransformer).GetMethod("GetPropertyExpression", BindingFlags.NonPublic | BindingFlags.Static); + private static readonly MethodInfo GetPropertyExpressionBetweenMethod = typeof(DefaultFilteringTransformer).GetMethod("GetPropertyExpressionBetween", BindingFlags.NonPublic | BindingFlags.Static); + + + /// + /// Creates a new FilteringQueryableTransformer + /// + /// The registry used to look up registered type information. + public DefaultFilteringTransformer(IResourceTypeRegistry resourceTypeRegistry) + { + _resourceTypeRegistry = resourceTypeRegistry; + } + + public IQueryable Filter(IQueryable query, HttpRequestMessage request) + { + var parameter = Expression.Parameter(typeof(T)); + var bodyExpr = GetPredicateBody(request, parameter); + var lambdaExpr = Expression.Lambda>(bodyExpr, parameter); + return query.Where(lambdaExpr); + } + + // Borrowed from http://stackoverflow.com/questions/3631547/select-right-generic-method-with-reflection + // ReSharper disable once UnusedMember.Local + private readonly Lazy _whereMethod = new Lazy(() => + typeof(Queryable).GetMethods() + .Where(x => x.Name == "Where") + .Select(x => new { M = x, P = x.GetParameters() }) + .Where(x => x.P.Length == 2 + && x.P[0].ParameterType.IsGenericType + && x.P[0].ParameterType.GetGenericTypeDefinition() == typeof(IQueryable<>) + && x.P[1].ParameterType.IsGenericType + && x.P[1].ParameterType.GetGenericTypeDefinition() == typeof(Expression<>)) + .Select(x => new { x.M, A = x.P[1].ParameterType.GetGenericArguments() }) + .Where(x => x.A[0].IsGenericType + && x.A[0].GetGenericTypeDefinition() == typeof(Func<,>)) + .Select(x => new { x.M, A = x.A[0].GetGenericArguments() }) + .Where(x => x.A[0].IsGenericParameter + && x.A[1] == typeof(bool)) + .Select(x => x.M) + .SingleOrDefault() + ); + + private Expression GetPredicateBody(HttpRequestMessage request, ParameterExpression param) + { + Expression workingExpr = null; + + var type = param.Type; + var queryPairs = request.GetQueryNameValuePairs(); + foreach (var queryPair in queryPairs) + { + if (String.IsNullOrWhiteSpace(queryPair.Key)) + continue; + + if (!queryPair.Key.StartsWith("filter.")) + continue; + + if (queryPair.Key.StartsWith("filter.calculated.")) + continue; + + var filterField = queryPair.Key.Substring(7); // Skip "filter." + + IResourceTypeRegistration registration; + try + { + registration = _resourceTypeRegistry.GetRegistrationForType(type); + } + catch (TypeRegistrationNotFoundException) + { + throw JsonApiException.CreateForBadRequest("No registration exists for the specified type"); + } + + var expr = GetPredicate(filterField, registration, param, queryPair.Value); + workingExpr = workingExpr == null ? expr : Expression.AndAlso(workingExpr, expr); + } + + return workingExpr ?? Expression.Constant(true); // No filters, so return everything + } + + private Expression GetPredicate(string filterField, IResourceTypeRegistration registration, ParameterExpression param, string queryValue) + { + if (filterField == "id") + return GetPredicateBodyForProperty(registration.IdProperty, queryValue, param); + + var resourceTypeField = registration.GetFieldByName(filterField); + if (resourceTypeField == null) + throw JsonApiException.CreateForBadRequest( + string.Format("No attribute {0} exists on the specified type.", filterField)); + + if (string.IsNullOrWhiteSpace(queryValue)) + queryValue = null; + + // See if it is a field property + var fieldModelProperty = resourceTypeField as ResourceTypeAttribute; + if (fieldModelProperty != null) + return GetPredicateBodyForField(fieldModelProperty, queryValue, param); + + // See if it is a relationship property + var relationshipModelProperty = resourceTypeField as ResourceTypeRelationship; + if (relationshipModelProperty != null) + return GetPredicateBodyForRelationship(relationshipModelProperty, queryValue, param); + + throw JsonApiException.CreateForBadRequest( + string.Format("The attribute {0} is unsupported for filtering.", filterField)); + } + + private Expression GetPredicateBodyForField(ResourceTypeAttribute resourceTypeAttribute, string queryValue, + ParameterExpression param) + { + return GetPredicateBodyForProperty(resourceTypeAttribute.Property, queryValue, param); + } + + private Expression GetPredicateBodyForProperty(PropertyInfo prop, string queryValue, ParameterExpression param) + { + var propertyType = prop.PropertyType; + + Expression expr = null; + if (propertyType == typeof(string)) + { + if (string.IsNullOrWhiteSpace(queryValue)) + { + Expression propertyExpr = Expression.Property(param, prop); + expr = Expression.Equal(propertyExpr, Expression.Constant(null)); + } else { List parts = new List(); @@ -156,400 +159,400 @@ private Expression GetPredicateBodyForProperty(PropertyInfo prop, string queryVa parts.Add(v); return string.Empty; }); - } - - parts.AddRange(queryValue.Split(',')); - - foreach (var qpart in parts) - { - Expression innerExpression; - // inspired by http://stackoverflow.com/questions/5374481/like-operator-in-linq - if (qpart.StartsWith("%") || qpart.EndsWith("%")) - { - var startWith = qpart.StartsWith("%"); - var endsWith = qpart.EndsWith("%"); - string innerPart = qpart; - - if (startWith) // remove % - innerPart = innerPart.Remove(0, 1); - - if (endsWith) // remove % - innerPart = innerPart.Remove(innerPart.Length - 1, 1); - - var constant = Expression.Constant(innerPart.ToLower()); - Expression propertyExpr = Expression.Property(param, prop); - - Expression nullCheckExpression = Expression.NotEqual(propertyExpr, Expression.Constant(null)); - - if (endsWith && startWith) - { - innerExpression = Expression.AndAlso(nullCheckExpression, Expression.Call(Expression.Call(propertyExpr, ToLowerMethod), ContainsMethod, constant)); - } - else if (startWith) - { - innerExpression = Expression.AndAlso(nullCheckExpression, Expression.Call(Expression.Call(propertyExpr, ToLowerMethod), EndsWithMethod, constant)); - } - else if (endsWith) - { - innerExpression = Expression.AndAlso(nullCheckExpression, Expression.Call(Expression.Call(propertyExpr, ToLowerMethod), StartsWithMethod, constant)); - } - else - { - innerExpression = Expression.Equal(propertyExpr, constant); - } - } - else - { - Expression propertyExpr = Expression.Property(param, prop); - innerExpression = Expression.Equal(propertyExpr, Expression.Constant(qpart)); - } - - if (expr == null) - { - expr = innerExpression; - } - else - { - expr = Expression.OrElse(expr, innerExpression); - } - } - - } - } - else if (propertyType.IsEnum) - { - if (string.IsNullOrWhiteSpace(queryValue)) // missing enum property - { - expr = Expression.Constant(false); - } - else - { - // try to split up for multiple values - var parts = queryValue.Split(','); - - foreach (var part in parts) - { - int value; - var partExpr = (int.TryParse(part, out value) && Enum.IsDefined(propertyType, value)) - ? GetEnumPropertyExpression(value, prop, param) - : Expression.Constant(false); - if (expr == null) - { - expr = partExpr; - } - else - { - expr = Expression.OrElse(expr, partExpr); - } - } - } - } - else if (propertyType.IsGenericType && propertyType.GetGenericTypeDefinition() == typeof(Nullable<>) && - propertyType.GenericTypeArguments[0].IsEnum) - { - if (string.IsNullOrWhiteSpace(queryValue)) - { - Expression propertyExpr = Expression.Property(param, prop); - expr = Expression.Equal(propertyExpr, Expression.Constant(null)); - } - else - { - // try to split up for multiple values - var parts = queryValue.Split(','); - - foreach (var part in parts) - { - int tmp; - var value = int.TryParse(part, out tmp) ? tmp : (int?)null; - var partExpr = GetEnumPropertyExpression(value, prop, param); - if (expr == null) - { - expr = partExpr; - } - else - { - expr = Expression.OrElse(expr, partExpr); - } - } - } - } - else if (Nullable.GetUnderlyingType(propertyType) != null) // It's nullable - { - expr = GetExpressionNullable(queryValue, prop, propertyType, param); - } - else - { - expr = GetExpression(queryValue, prop, propertyType, param); - if (expr == null) - { - expr = Expression.Constant(true); - } - } - - return expr; - } - - private Expression GetExpressionNullable(string queryValue, PropertyInfo prop, Type propertyType, ParameterExpression param) - { - Type underlayingType = Nullable.GetUnderlyingType(propertyType); - try - { - - var methodInfo = GetPropertyExpressionMethod.MakeGenericMethod(propertyType); - - if (queryValue == null) - { - return (Expression)methodInfo.Invoke(null, new object[] { null, prop, param }); - } - - // try to split up for multiple values - var parts = queryValue.Split(','); - - if (underlayingType == typeof(DateTime) || underlayingType == typeof(DateTimeOffset)) - { - return GetDateReangeExpression(parts, prop, underlayingType, propertyType, param); - } - - - Expression expr = null; - foreach (var part in parts) - { - TypeConverter conv =TypeDescriptor.GetConverter(underlayingType); - var value = conv.ConvertFromInvariantString(part); - - if (expr == null) - { - expr = (Expression) methodInfo.Invoke(null, new[] { value, prop, param }); - } - else - { - expr = Expression.OrElse(expr, (Expression)methodInfo.Invoke(null, new[] { value, prop, param })); - } - } - return expr; - } - catch (NotSupportedException) - { - return Expression.Constant(false); - } - - } - - private Expression GetDateReangeExpression(string[] parts, PropertyInfo prop, Type underlyingType, Type propertyType, ParameterExpression param) - { - Expression expr = null; - foreach (var part in parts) - { - var mode = ""; - if (!part.Contains("-")) - mode = "year"; - if (part.Contains("-")) - mode = "month"; - if (part.Count(x => x.Equals('-')) == 2) - { - mode = "day"; - if (part.Contains(" ")) // there is a time - { - mode = "hour"; - if (part.Contains(":")) - mode = "minute"; - if (part.Count(x => x.Equals(':')) == 2) - { - mode = "second"; - } - } - } - var partToParse = part; - - // make the datetime valid - if (mode == "year") - partToParse += "-01-01"; - if (mode == "hour") - partToParse += ":00"; - - TypeConverter conv = TypeDescriptor.GetConverter(underlyingType); - dynamic value = conv.ConvertFromInvariantString(partToParse); - var upper =value; - switch (mode) - { - case "year": - upper= upper.AddYears(1); - break; - case "month": - upper = upper.AddMonths(1); - break; - case "day": - upper = upper.AddDays(1); - break; - case "hour": - upper = upper.AddHours(1); - break; - case "minute": - upper = upper.AddMinutes(1); - break; - case "second": - upper = upper.AddSeconds(1); - break; - } - upper = upper.AddTicks(-1); - value = value.ToUniversalTime(); - upper = upper.ToUniversalTime(); - - var methodInfo = GetPropertyExpressionBetweenMethod.MakeGenericMethod(propertyType); - Expression innerExpr = (Expression)methodInfo.Invoke(null, new object[] {value, upper, prop, param}); - if (expr == null) - { - expr = innerExpr; - } - else - { - expr = Expression.OrElse(expr, innerExpr); - } - - } - return expr; - } - - - private Expression GetExpression(string queryValue, PropertyInfo prop, Type propertyType, ParameterExpression param) - { - try - { - if(queryValue == null) // missing property - return Expression.Constant(false); - - var parts = queryValue.Split(','); - Expression expr = null; - foreach (var part in parts) - { - dynamic value = TypeDescriptor.GetConverter(propertyType).ConvertFromInvariantString(part); - if (expr == null) - { - expr = GetPropertyExpression(value, prop, param); - } - else - { - expr = Expression.OrElse(expr, GetPropertyExpression(value, prop, param)); - } - } - return expr; - } - catch (NotSupportedException) - { - return Expression.Constant(false); - } - } - - - - private Expression GetPredicateBodyForRelationship(ResourceTypeRelationship resourceTypeProperty, string queryValue, ParameterExpression param) - { - var relatedType = resourceTypeProperty.RelatedType; - PropertyInfo relatedIdProperty; - try - { - var registration = _resourceTypeRegistry.GetRegistrationForType(relatedType); - relatedIdProperty = registration.IdProperty; - } - catch (TypeRegistrationNotFoundException) - { - throw JsonApiException.CreateForBadRequest("No registration exists for the specified type"); - } - - var prop = resourceTypeProperty.Property; - - if (resourceTypeProperty.IsToMany) - { - var propertyExpr = Expression.Property(param, prop); - - if (string.IsNullOrWhiteSpace(queryValue)) - { - var leftExpr = Expression.Equal(propertyExpr, Expression.Constant(null)); - - var asQueryableCallExpr = Expression.Call( - typeof(Queryable), - "AsQueryable", - new[] { relatedType }, - propertyExpr); - var anyCallExpr = Expression.Call( - typeof(Queryable), - "Any", - new[] { relatedType }, - asQueryableCallExpr); - var rightExpr = Expression.Not(anyCallExpr); - - return Expression.OrElse(leftExpr, rightExpr); - } - else - { - var leftExpr = Expression.NotEqual(propertyExpr, Expression.Constant(null)); - - var idValue = queryValue.Trim(); - var idExpr = Expression.Constant(idValue); - var anyParam = Expression.Parameter(relatedType); - var relatedIdPropertyExpr = Expression.Property(anyParam, relatedIdProperty); - var relatedIdPropertyEqualsIdExpr = Expression.Equal(relatedIdPropertyExpr, idExpr); - var anyPredicateExpr = Expression.Lambda(relatedIdPropertyEqualsIdExpr, anyParam); - var asQueryableCallExpr = Expression.Call( - typeof(Queryable), - "AsQueryable", - new[] { relatedType }, - propertyExpr); - var rightExpr = Expression.Call( - typeof(Queryable), - "Any", - new[] { relatedType }, - asQueryableCallExpr, - anyPredicateExpr); - - return Expression.AndAlso(leftExpr, rightExpr); - } - } - else - { - var propertyExpr = Expression.Property(param, prop); - - if (string.IsNullOrWhiteSpace(queryValue)) - return Expression.Equal(propertyExpr, Expression.Constant(null)); - - var leftExpr = Expression.NotEqual(propertyExpr, Expression.Constant(null)); - - var idValue = queryValue.Trim(); - var idExpr = Expression.Constant(idValue); - var relatedIdPropertyExpr = Expression.Property(propertyExpr, relatedIdProperty); - var rightExpr = Expression.Equal(relatedIdPropertyExpr, idExpr); - - return Expression.AndAlso(leftExpr, rightExpr); - } - } - - private static Expression GetPropertyExpression(T value, PropertyInfo property, - ParameterExpression param) - { - Expression propertyExpr = Expression.Property(param, property); - var valueExpr = Expression.Constant(value); - Expression castedConstantExpr = Expression.Convert(valueExpr, typeof(T)); - return Expression.Equal(propertyExpr, castedConstantExpr); - } - - private static Expression GetPropertyExpressionBetween(T lowerValue, T upperValue, PropertyInfo property, - ParameterExpression param) - { - Expression propertyExpr = Expression.Property(param, property); - var lowerValueExpr = Expression.Constant(lowerValue); - var upperValueExpr = Expression.Constant(upperValue); - Expression lowerCastedConstantExpr = Expression.Convert(lowerValueExpr, typeof(T)); - Expression upperCastedConstantExpr = Expression.Convert(upperValueExpr, typeof(T)); - return Expression.AndAlso(Expression.GreaterThanOrEqual(propertyExpr, lowerCastedConstantExpr), Expression.LessThanOrEqual(propertyExpr, upperCastedConstantExpr)); - } - - private static Expression GetEnumPropertyExpression(int? value, PropertyInfo property, - ParameterExpression param) - { - Expression propertyExpr = Expression.Property(param, property); - var castedValueExpr = Expression.Convert(Expression.Constant(value), typeof(int?)); - var castedPropertyExpr = Expression.Convert(propertyExpr, typeof(int?)); - return Expression.Equal(castedPropertyExpr, castedValueExpr); - } - } -} + } + + parts.AddRange(queryValue.Split(',')); + + foreach (var qpart in parts) + { + Expression innerExpression; + // inspired by http://stackoverflow.com/questions/5374481/like-operator-in-linq + if (qpart.StartsWith("%") || qpart.EndsWith("%")) + { + var startWith = qpart.StartsWith("%"); + var endsWith = qpart.EndsWith("%"); + string innerPart = qpart; + + if (startWith) // remove % + innerPart = innerPart.Remove(0, 1); + + if (endsWith) // remove % + innerPart = innerPart.Remove(innerPart.Length - 1, 1); + + var constant = Expression.Constant(innerPart.ToLower()); + Expression propertyExpr = Expression.Property(param, prop); + + Expression nullCheckExpression = Expression.NotEqual(propertyExpr, Expression.Constant(null)); + + if (endsWith && startWith) + { + innerExpression = Expression.AndAlso(nullCheckExpression, Expression.Call(Expression.Call(propertyExpr, ToLowerMethod), ContainsMethod, constant)); + } + else if (startWith) + { + innerExpression = Expression.AndAlso(nullCheckExpression, Expression.Call(Expression.Call(propertyExpr, ToLowerMethod), EndsWithMethod, constant)); + } + else if (endsWith) + { + innerExpression = Expression.AndAlso(nullCheckExpression, Expression.Call(Expression.Call(propertyExpr, ToLowerMethod), StartsWithMethod, constant)); + } + else + { + innerExpression = Expression.Equal(propertyExpr, constant); + } + } + else + { + Expression propertyExpr = Expression.Property(param, prop); + innerExpression = Expression.Equal(propertyExpr, Expression.Constant(qpart)); + } + + if (expr == null) + { + expr = innerExpression; + } + else + { + expr = Expression.OrElse(expr, innerExpression); + } + } + + } + } + else if (propertyType.IsEnum) + { + if (string.IsNullOrWhiteSpace(queryValue)) // missing enum property + { + expr = Expression.Constant(false); + } + else + { + // try to split up for multiple values + var parts = queryValue.Split(','); + + foreach (var part in parts) + { + int value; + var partExpr = (int.TryParse(part, out value) && Enum.IsDefined(propertyType, value)) + ? GetEnumPropertyExpression(value, prop, param) + : Expression.Constant(false); + if (expr == null) + { + expr = partExpr; + } + else + { + expr = Expression.OrElse(expr, partExpr); + } + } + } + } + else if (propertyType.IsGenericType && propertyType.GetGenericTypeDefinition() == typeof(Nullable<>) && + propertyType.GenericTypeArguments[0].IsEnum) + { + if (string.IsNullOrWhiteSpace(queryValue)) + { + Expression propertyExpr = Expression.Property(param, prop); + expr = Expression.Equal(propertyExpr, Expression.Constant(null)); + } + else + { + // try to split up for multiple values + var parts = queryValue.Split(','); + + foreach (var part in parts) + { + int tmp; + var value = int.TryParse(part, out tmp) ? tmp : (int?)null; + var partExpr = GetEnumPropertyExpression(value, prop, param); + if (expr == null) + { + expr = partExpr; + } + else + { + expr = Expression.OrElse(expr, partExpr); + } + } + } + } + else if (Nullable.GetUnderlyingType(propertyType) != null) // It's nullable + { + expr = GetExpressionNullable(queryValue, prop, propertyType, param); + } + else + { + expr = GetExpression(queryValue, prop, propertyType, param); + if (expr == null) + { + expr = Expression.Constant(true); + } + } + + return expr; + } + + private Expression GetExpressionNullable(string queryValue, PropertyInfo prop, Type propertyType, ParameterExpression param) + { + Type underlayingType = Nullable.GetUnderlyingType(propertyType); + try + { + + var methodInfo = GetPropertyExpressionMethod.MakeGenericMethod(propertyType); + + if (queryValue == null) + { + return (Expression)methodInfo.Invoke(null, new object[] { null, prop, param }); + } + + // try to split up for multiple values + var parts = queryValue.Split(','); + + if (underlayingType == typeof(DateTime) || underlayingType == typeof(DateTimeOffset)) + { + return GetDateReangeExpression(parts, prop, underlayingType, propertyType, param); + } + + + Expression expr = null; + foreach (var part in parts) + { + TypeConverter conv =TypeDescriptor.GetConverter(underlayingType); + var value = conv.ConvertFromInvariantString(part); + + if (expr == null) + { + expr = (Expression) methodInfo.Invoke(null, new[] { value, prop, param }); + } + else + { + expr = Expression.OrElse(expr, (Expression)methodInfo.Invoke(null, new[] { value, prop, param })); + } + } + return expr; + } + catch (NotSupportedException) + { + return Expression.Constant(false); + } + + } + + private Expression GetDateReangeExpression(string[] parts, PropertyInfo prop, Type underlyingType, Type propertyType, ParameterExpression param) + { + Expression expr = null; + foreach (var part in parts) + { + var mode = ""; + if (!part.Contains("-")) + mode = "year"; + if (part.Contains("-")) + mode = "month"; + if (part.Count(x => x.Equals('-')) == 2) + { + mode = "day"; + if (part.Contains(" ")) // there is a time + { + mode = "hour"; + if (part.Contains(":")) + mode = "minute"; + if (part.Count(x => x.Equals(':')) == 2) + { + mode = "second"; + } + } + } + var partToParse = part; + + // make the datetime valid + if (mode == "year") + partToParse += "-01-01"; + if (mode == "hour") + partToParse += ":00"; + + TypeConverter conv = TypeDescriptor.GetConverter(underlyingType); + dynamic value = conv.ConvertFromInvariantString(partToParse); + var upper =value; + switch (mode) + { + case "year": + upper= upper.AddYears(1); + break; + case "month": + upper = upper.AddMonths(1); + break; + case "day": + upper = upper.AddDays(1); + break; + case "hour": + upper = upper.AddHours(1); + break; + case "minute": + upper = upper.AddMinutes(1); + break; + case "second": + upper = upper.AddSeconds(1); + break; + } + upper = upper.AddTicks(-1); + value = value.ToUniversalTime(); + upper = upper.ToUniversalTime(); + + var methodInfo = GetPropertyExpressionBetweenMethod.MakeGenericMethod(propertyType); + Expression innerExpr = (Expression)methodInfo.Invoke(null, new object[] {value, upper, prop, param}); + if (expr == null) + { + expr = innerExpr; + } + else + { + expr = Expression.OrElse(expr, innerExpr); + } + + } + return expr; + } + + + private Expression GetExpression(string queryValue, PropertyInfo prop, Type propertyType, ParameterExpression param) + { + try + { + if(queryValue == null) // missing property + return Expression.Constant(false); + + var parts = queryValue.Split(','); + Expression expr = null; + foreach (var part in parts) + { + dynamic value = TypeDescriptor.GetConverter(propertyType).ConvertFromInvariantString(part); + if (expr == null) + { + expr = GetPropertyExpression(value, prop, param); + } + else + { + expr = Expression.OrElse(expr, GetPropertyExpression(value, prop, param)); + } + } + return expr; + } + catch (NotSupportedException) + { + return Expression.Constant(false); + } + } + + + + private Expression GetPredicateBodyForRelationship(ResourceTypeRelationship resourceTypeProperty, string queryValue, ParameterExpression param) + { + var relatedType = resourceTypeProperty.RelatedType; + PropertyInfo relatedIdProperty; + try + { + var registration = _resourceTypeRegistry.GetRegistrationForType(relatedType); + relatedIdProperty = registration.IdProperty; + } + catch (TypeRegistrationNotFoundException) + { + throw JsonApiException.CreateForBadRequest("No registration exists for the specified type"); + } + + var prop = resourceTypeProperty.Property; + + if (resourceTypeProperty.IsToMany) + { + var propertyExpr = Expression.Property(param, prop); + + if (string.IsNullOrWhiteSpace(queryValue)) + { + var leftExpr = Expression.Equal(propertyExpr, Expression.Constant(null)); + + var asQueryableCallExpr = Expression.Call( + typeof(Queryable), + "AsQueryable", + new[] { relatedType }, + propertyExpr); + var anyCallExpr = Expression.Call( + typeof(Queryable), + "Any", + new[] { relatedType }, + asQueryableCallExpr); + var rightExpr = Expression.Not(anyCallExpr); + + return Expression.OrElse(leftExpr, rightExpr); + } + else + { + var leftExpr = Expression.NotEqual(propertyExpr, Expression.Constant(null)); + + var idValue = queryValue.Trim(); + var idExpr = Expression.Constant(idValue); + var anyParam = Expression.Parameter(relatedType); + var relatedIdPropertyExpr = Expression.Property(anyParam, relatedIdProperty); + var relatedIdPropertyEqualsIdExpr = Expression.Equal(relatedIdPropertyExpr, idExpr); + var anyPredicateExpr = Expression.Lambda(relatedIdPropertyEqualsIdExpr, anyParam); + var asQueryableCallExpr = Expression.Call( + typeof(Queryable), + "AsQueryable", + new[] { relatedType }, + propertyExpr); + var rightExpr = Expression.Call( + typeof(Queryable), + "Any", + new[] { relatedType }, + asQueryableCallExpr, + anyPredicateExpr); + + return Expression.AndAlso(leftExpr, rightExpr); + } + } + else + { + var propertyExpr = Expression.Property(param, prop); + + if (string.IsNullOrWhiteSpace(queryValue)) + return Expression.Equal(propertyExpr, Expression.Constant(null)); + + var leftExpr = Expression.NotEqual(propertyExpr, Expression.Constant(null)); + + var idValue = queryValue.Trim(); + var idExpr = Expression.Constant(idValue); + var relatedIdPropertyExpr = Expression.Property(propertyExpr, relatedIdProperty); + var rightExpr = Expression.Equal(relatedIdPropertyExpr, idExpr); + + return Expression.AndAlso(leftExpr, rightExpr); + } + } + + private static Expression GetPropertyExpression(T value, PropertyInfo property, + ParameterExpression param) + { + Expression propertyExpr = Expression.Property(param, property); + var valueExpr = Expression.Constant(value); + Expression castedConstantExpr = Expression.Convert(valueExpr, typeof(T)); + return Expression.Equal(propertyExpr, castedConstantExpr); + } + + private static Expression GetPropertyExpressionBetween(T lowerValue, T upperValue, PropertyInfo property, + ParameterExpression param) + { + Expression propertyExpr = Expression.Property(param, property); + var lowerValueExpr = Expression.Constant(lowerValue); + var upperValueExpr = Expression.Constant(upperValue); + Expression lowerCastedConstantExpr = Expression.Convert(lowerValueExpr, typeof(T)); + Expression upperCastedConstantExpr = Expression.Convert(upperValueExpr, typeof(T)); + return Expression.AndAlso(Expression.GreaterThanOrEqual(propertyExpr, lowerCastedConstantExpr), Expression.LessThanOrEqual(propertyExpr, upperCastedConstantExpr)); + } + + private static Expression GetEnumPropertyExpression(int? value, PropertyInfo property, + ParameterExpression param) + { + Expression propertyExpr = Expression.Property(param, property); + var castedValueExpr = Expression.Convert(Expression.Constant(value), typeof(int?)); + var castedPropertyExpr = Expression.Convert(propertyExpr, typeof(int?)); + return Expression.Equal(castedPropertyExpr, castedValueExpr); + } + } +} diff --git a/JSONAPI/QueryableTransformers/DefaultPaginationTransformer.cs b/JSONAPI/QueryableTransformers/DefaultPaginationTransformer.cs index 21c87754..2bcff9f8 100644 --- a/JSONAPI/QueryableTransformers/DefaultPaginationTransformer.cs +++ b/JSONAPI/QueryableTransformers/DefaultPaginationTransformer.cs @@ -1,96 +1,96 @@ -using System; -using System.Linq; -using System.Net.Http; -using JSONAPI.ActionFilters; -using JSONAPI.Documents.Builders; - -namespace JSONAPI.QueryableTransformers -{ - /// - /// Performs pagination - /// - public class DefaultPaginationTransformer : IQueryablePaginationTransformer - { - private const int DefaultPageSize = 100; - private const string PageNumberQueryParam = "page.number"; - private const string PageSizeQueryParam = "page.size"; - - private readonly int? _maxPageSize; - - /// - /// Creates a DefaultPaginationTransformer - /// - /// The maximum page size to allow clients to request. Leave null for no restriction. - public DefaultPaginationTransformer(int? maxPageSize = null) - { - if (maxPageSize <= 0) throw new ArgumentOutOfRangeException("maxPageSize", "The maximum page size must be 1 or greater."); - - _maxPageSize = maxPageSize; - } - - public IPaginationTransformResult ApplyPagination(IQueryable query, HttpRequestMessage request) - { - var hasPageNumberParam = false; - var hasPageSizeParam = false; - var pageNumber = 0; - var pageSize = _maxPageSize ?? DefaultPageSize; - foreach (var kvp in request.GetQueryNameValuePairs()) - { - if (kvp.Key == PageNumberQueryParam) - { - hasPageNumberParam = true; - if (!int.TryParse(kvp.Value, out pageNumber)) - throw JsonApiException.CreateForParameterError("Invalid page number", - "Page number must be a positive integer.", PageNumberQueryParam); - - } - else if (kvp.Key == PageSizeQueryParam) - { - hasPageSizeParam = true; - if (!int.TryParse(kvp.Value, out pageSize)) - throw JsonApiException.CreateForParameterError("Invalid page size", - "Page size must be a positive integer.", PageSizeQueryParam); - } - } - - if (!hasPageNumberParam && !hasPageSizeParam) - { - return new DefaultPaginationTransformResult - { - PagedQuery = query, - PaginationWasApplied = false - }; - } - - if ((hasPageNumberParam && !hasPageSizeParam)) - throw JsonApiException.CreateForParameterError("Page size missing", - string.Format("In order for paging to work properly, if either {0} or {1} is set, both must be.", - PageNumberQueryParam, PageSizeQueryParam), PageNumberQueryParam); - - if ((!hasPageNumberParam && hasPageSizeParam)) - throw JsonApiException.CreateForParameterError("Page number missing", - string.Format("In order for paging to work properly, if either {0} or {1} is set, both must be.", - PageNumberQueryParam, PageSizeQueryParam), PageSizeQueryParam); - - if (pageNumber < 0) - throw JsonApiException.CreateForParameterError("Page number out of bounds", - "Page number must not be negative.", PageNumberQueryParam); - - if (pageSize <= 0) - throw JsonApiException.CreateForParameterError("Page size out of bounds", - "Page size must be greater than or equal to 1.", PageSizeQueryParam); - - if (_maxPageSize != null && pageSize > _maxPageSize.Value) - pageSize = _maxPageSize.Value; - - var skip = pageNumber * pageSize; - return new DefaultPaginationTransformResult - { - PageNumber = pageNumber, - PageSize = pageSize, - PagedQuery = query.Skip(skip).Take(pageSize), - PaginationWasApplied = true - }; - } - } -} +using System; +using System.Linq; +using System.Net.Http; +using JSONAPI.ActionFilters; +using JSONAPI.Documents.Builders; + +namespace JSONAPI.QueryableTransformers +{ + /// + /// Performs pagination + /// + public class DefaultPaginationTransformer : IQueryablePaginationTransformer + { + private const int DefaultPageSize = 100; + private const string PageNumberQueryParam = "page.number"; + private const string PageSizeQueryParam = "page.size"; + + private readonly int? _maxPageSize; + + /// + /// Creates a DefaultPaginationTransformer + /// + /// The maximum page size to allow clients to request. Leave null for no restriction. + public DefaultPaginationTransformer(int? maxPageSize = null) + { + if (maxPageSize <= 0) throw new ArgumentOutOfRangeException("maxPageSize", "The maximum page size must be 1 or greater."); + + _maxPageSize = maxPageSize; + } + + public IPaginationTransformResult ApplyPagination(IQueryable query, HttpRequestMessage request) + { + var hasPageNumberParam = false; + var hasPageSizeParam = false; + var pageNumber = 0; + var pageSize = _maxPageSize ?? DefaultPageSize; + foreach (var kvp in request.GetQueryNameValuePairs()) + { + if (kvp.Key == PageNumberQueryParam) + { + hasPageNumberParam = true; + if (!int.TryParse(kvp.Value, out pageNumber)) + throw JsonApiException.CreateForParameterError("Invalid page number", + "Page number must be a positive integer.", PageNumberQueryParam); + + } + else if (kvp.Key == PageSizeQueryParam) + { + hasPageSizeParam = true; + if (!int.TryParse(kvp.Value, out pageSize)) + throw JsonApiException.CreateForParameterError("Invalid page size", + "Page size must be a positive integer.", PageSizeQueryParam); + } + } + + if (!hasPageNumberParam && !hasPageSizeParam) + { + return new DefaultPaginationTransformResult + { + PagedQuery = query, + PaginationWasApplied = false + }; + } + + if ((hasPageNumberParam && !hasPageSizeParam)) + throw JsonApiException.CreateForParameterError("Page size missing", + string.Format("In order for paging to work properly, if either {0} or {1} is set, both must be.", + PageNumberQueryParam, PageSizeQueryParam), PageNumberQueryParam); + + if (pageNumber < 0) + throw JsonApiException.CreateForParameterError("Page number out of bounds", + "Page number must not be negative.", PageNumberQueryParam); + + if (pageSize <= 0) + throw JsonApiException.CreateForParameterError("Page size out of bounds", + "Page size must be greater than or equal to 1.", PageSizeQueryParam); + + if (_maxPageSize != null && pageSize > _maxPageSize.Value) + pageSize = _maxPageSize.Value; + + if (pageNumber > 0) + { + pageNumber -= 1; // pagination is 1 based in frontend but zero based in backend! + } + + var skip = pageNumber * pageSize; + return new DefaultPaginationTransformResult + { + PageNumber = pageNumber, + PageSize = pageSize, + PagedQuery = query.Skip(skip).Take(pageSize), + PaginationWasApplied = true + }; + } + } +} diff --git a/JSONAPI/QueryableTransformers/DefaultSortingTransformer.cs b/JSONAPI/QueryableTransformers/DefaultSortingTransformer.cs index 2898a58b..5a9f60c7 100644 --- a/JSONAPI/QueryableTransformers/DefaultSortingTransformer.cs +++ b/JSONAPI/QueryableTransformers/DefaultSortingTransformer.cs @@ -1,69 +1,69 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using System.Reflection; -using JSONAPI.Core; -using JSONAPI.Documents.Builders; - -namespace JSONAPI.QueryableTransformers -{ - /// - /// This transform sorts an IQueryable according to query parameters. - /// - public class DefaultSortingTransformer : IQueryableSortingTransformer - { - private readonly IResourceTypeRegistry _resourceTypeRegistry; - - /// - /// Creates a new SortingQueryableTransformer - /// - /// The registry used to look up registered type information. - public DefaultSortingTransformer(IResourceTypeRegistry resourceTypeRegistry) - { - _resourceTypeRegistry = resourceTypeRegistry; - } - - public IOrderedQueryable Sort(IQueryable query, string[] sortExpressions) - { - if (sortExpressions == null || sortExpressions.Length == 0) - sortExpressions = new [] { "id" }; - - var selectors = new List>(); - var usedProperties = new Dictionary(); - - var registration = _resourceTypeRegistry.GetRegistrationForType(typeof (T)); - - foreach (var sortExpression in sortExpressions) - { - if (string.IsNullOrEmpty(sortExpression)) - throw JsonApiException.CreateForParameterError("Empty sort expression", "One of the sort expressions is empty.", "sort"); - - bool ascending; - string fieldName; - if (sortExpression[0] == '-') - { - ascending = false; - fieldName = sortExpression.Substring(1); - } - else - { - ascending = true; - fieldName = sortExpression; - } - - if (string.IsNullOrWhiteSpace(fieldName)) - throw JsonApiException.CreateForParameterError("Empty sort expression", "One of the sort expressions is empty.", "sort"); - - var paramExpr = Expression.Parameter(typeof(T)); - Expression sortValueExpression; - - if (fieldName == "id") - { - sortValueExpression = registration.GetSortByIdExpression(paramExpr); - } - else - { +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using JSONAPI.Core; +using JSONAPI.Documents.Builders; + +namespace JSONAPI.QueryableTransformers +{ + /// + /// This transform sorts an IQueryable according to query parameters. + /// + public class DefaultSortingTransformer : IQueryableSortingTransformer + { + private readonly IResourceTypeRegistry _resourceTypeRegistry; + + /// + /// Creates a new SortingQueryableTransformer + /// + /// The registry used to look up registered type information. + public DefaultSortingTransformer(IResourceTypeRegistry resourceTypeRegistry) + { + _resourceTypeRegistry = resourceTypeRegistry; + } + + public IOrderedQueryable Sort(IQueryable query, string[] sortExpressions) + { + if (sortExpressions == null || sortExpressions.Length == 0) + sortExpressions = new [] { "id" }; + + var selectors = new List>(); + var usedProperties = new Dictionary(); + + var registration = _resourceTypeRegistry.GetRegistrationForType(typeof (T)); + + foreach (var sortExpression in sortExpressions) + { + if (string.IsNullOrEmpty(sortExpression)) + throw JsonApiException.CreateForParameterError("Empty sort expression", "One of the sort expressions is empty.", "sort"); + + bool ascending; + string fieldName; + if (sortExpression[0] == '-') + { + ascending = false; + fieldName = sortExpression.Substring(1); + } + else + { + ascending = true; + fieldName = sortExpression; + } + + if (string.IsNullOrWhiteSpace(fieldName)) + throw JsonApiException.CreateForParameterError("Empty sort expression", "One of the sort expressions is empty.", "sort"); + + var paramExpr = Expression.Parameter(typeof(T)); + Expression sortValueExpression; + + if (fieldName == "id") + { + sortValueExpression = registration.GetSortByIdExpression(paramExpr); + } + else + { var modelProperty = registration.GetFieldByName(fieldName); if (modelProperty == null) throw JsonApiException.CreateForParameterError("Attribute not found", @@ -78,54 +78,54 @@ public IOrderedQueryable Sort(IQueryable query, string[] sortExpression usedProperties[property] = null; sortValueExpression = Expression.Property(paramExpr, property); - } - - var selector = GetSelector(paramExpr, sortValueExpression, !ascending); - selectors.Add(selector); - } - - var firstSelector = selectors.First(); - - IOrderedQueryable workingQuery = firstSelector.ApplyInitially(query); - return selectors.Skip(1).Aggregate(workingQuery, (current, selector) => selector.ApplySubsequently(current)); - } - - private ISelector GetSelector(ParameterExpression paramExpr, Expression sortValueExpression, bool isDescending) - { - var lambda = Expression.Lambda(sortValueExpression, paramExpr); - var selectorType = typeof (Selector<,>).MakeGenericType(typeof (T), sortValueExpression.Type); - var selector = Activator.CreateInstance(selectorType, isDescending, lambda); - return (ISelector)selector; - } - } - - internal interface ISelector - { - IOrderedQueryable ApplyInitially(IQueryable unsortedQuery); - IOrderedQueryable ApplySubsequently(IOrderedQueryable currentQuery); - } - - internal class Selector : ISelector - { - private readonly bool _isDescending; - private readonly Expression> _propertyAccessorExpression; - - public Selector(bool isDescending, Expression> propertyAccessorExpression) - { - _isDescending = isDescending; - _propertyAccessorExpression = propertyAccessorExpression; - } - - public IOrderedQueryable ApplyInitially(IQueryable unsortedQuery) - { - if (_isDescending) return unsortedQuery.OrderByDescending(_propertyAccessorExpression); - return unsortedQuery.OrderBy(_propertyAccessorExpression); - } - - public IOrderedQueryable ApplySubsequently(IOrderedQueryable currentQuery) - { - if (_isDescending) return currentQuery.ThenByDescending(_propertyAccessorExpression); - return currentQuery.ThenBy(_propertyAccessorExpression); - } - } -} + } + + var selector = GetSelector(paramExpr, sortValueExpression, !ascending); + selectors.Add(selector); + } + + var firstSelector = selectors.First(); + + IOrderedQueryable workingQuery = firstSelector.ApplyInitially(query); + return selectors.Skip(1).Aggregate(workingQuery, (current, selector) => selector.ApplySubsequently(current)); + } + + private ISelector GetSelector(ParameterExpression paramExpr, Expression sortValueExpression, bool isDescending) + { + var lambda = Expression.Lambda(sortValueExpression, paramExpr); + var selectorType = typeof (Selector<,>).MakeGenericType(typeof (T), sortValueExpression.Type); + var selector = Activator.CreateInstance(selectorType, isDescending, lambda); + return (ISelector)selector; + } + } + + internal interface ISelector + { + IOrderedQueryable ApplyInitially(IQueryable unsortedQuery); + IOrderedQueryable ApplySubsequently(IOrderedQueryable currentQuery); + } + + internal class Selector : ISelector + { + private readonly bool _isDescending; + private readonly Expression> _propertyAccessorExpression; + + public Selector(bool isDescending, Expression> propertyAccessorExpression) + { + _isDescending = isDescending; + _propertyAccessorExpression = propertyAccessorExpression; + } + + public IOrderedQueryable ApplyInitially(IQueryable unsortedQuery) + { + if (_isDescending) return unsortedQuery.OrderByDescending(_propertyAccessorExpression); + return unsortedQuery.OrderBy(_propertyAccessorExpression); + } + + public IOrderedQueryable ApplySubsequently(IOrderedQueryable currentQuery) + { + if (_isDescending) return currentQuery.ThenByDescending(_propertyAccessorExpression); + return currentQuery.ThenBy(_propertyAccessorExpression); + } + } +}