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..89e9b4f0
--- /dev/null
+++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/FetchingResourcesQueryResolverTests.cs
@@ -0,0 +1,69 @@
+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);
+ }
+ }
+
+
+ [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/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/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 35d82430..e1cc107f 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,11 +283,15 @@
+
+
+
+
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/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..39893c40 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,8 @@
+
+
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..f444f287 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.RegisterResourceType().ResolveCollectionWith();
return configuration;
}
diff --git a/JSONAPI.Autofac/JsonApiAutofacModule.cs b/JSONAPI.Autofac/JsonApiAutofacModule.cs
index dd248aa8..9e3982f7 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;
@@ -8,6 +9,7 @@
using JSONAPI.Documents.Builders;
using JSONAPI.Http;
using JSONAPI.Json;
+using JSONAPI.QueryableResolvers;
using JSONAPI.QueryableTransformers;
namespace JSONAPI.Autofac
@@ -38,6 +40,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 +72,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 +91,15 @@ 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)};
+
+ // 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);
@@ -101,11 +118,21 @@ 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)
};
+
+ // 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;
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.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs b/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs
index 114f7877..a1996e36 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 readonly 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.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.Tests/ActionFilters/DefaultFilteringTransformerTests.cs b/JSONAPI.Tests/ActionFilters/DefaultFilteringTransformerTests.cs
index 6b458e2a..c13def5a 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
@@ -126,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, DateTimeKind.Utc)
+ },
+ new Dummy
+ {
+ Id = "123",
+ NullableDateTimeField = new DateTime(1961, 5, 31, 19, 01, 0, DateTimeKind.Utc)
+ },
+
+ new Dummy
+ {
+ Id = "124",
+ NullableDateTimeField = new DateTime(1962, 5, 31, 19, 01, 0, DateTimeKind.Utc)
+ },
#endregion
@@ -151,6 +194,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 +214,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 +259,11 @@ public void SetupFixtures()
Id = "160",
NullableEnumField = SomeEnum.EnumValue3
},
+ new Dummy
+ {
+ Id = "161",
+ NullableEnumField = SomeEnum.EnumValue6
+ },
#endregion
@@ -191,6 +279,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,12 +680,125 @@ 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()
+ {
+ 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_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()
+ {
+ 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()
+ {
+ 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()
+ {
+ 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_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
@@ -617,12 +828,79 @@ 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 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");
+ }
+
+ [TestMethod]
+ public void Filters_by_multiple_matching_nullable_datetime_property_hour_minute()
+ {
+ 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");
+ }
+ [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
@@ -652,12 +930,29 @@ 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()
+ {
+ 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
@@ -672,6 +967,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()
{
@@ -687,12 +1002,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
@@ -707,6 +1031,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/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/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/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)
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);
+ }
+}
diff --git a/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs b/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs
index f449d1bb..01a9b3f0 100644
--- a/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs
+++ b/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs
@@ -1,446 +1,558 @@
-using System;
-using System.Globalization;
-using System.Linq;
-using System.Linq.Expressions;
-using System.Net.Http;
-using System.Reflection;
-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;
-
- ///
- /// 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);
- }
-
- // 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))
- {
- if (String.IsNullOrWhiteSpace(queryValue))
- {
- Expression propertyExpr = Expression.Property(param, prop);
- expr = Expression.Equal(propertyExpr, Expression.Constant(null));
- }
- 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?))
- {
- Decimal tmp;
- var value = Decimal.TryParse(queryValue, NumberStyles.Any, CultureInfo.InvariantCulture, out tmp) ? tmp : (Decimal?)null;
- expr = GetPropertyExpression(value, prop, param);
- }
- else if (propertyType == typeof(DateTime))
- {
- DateTime value;
- expr = DateTime.TryParse(queryValue, out value)
- ? GetPropertyExpression(value, prop, param)
- : Expression.Constant(false);
- }
- else if (propertyType == typeof(DateTime?))
- {
- DateTime tmp;
- var value = DateTime.TryParse(queryValue, out tmp) ? tmp : (DateTime?)null;
- expr = GetPropertyExpression(value, prop, param);
- }
- else if (propertyType == typeof(DateTimeOffset))
- {
- DateTimeOffset value;
- expr = DateTimeOffset.TryParse(queryValue, out value)
- ? GetPropertyExpression(value, prop, param)
- : Expression.Constant(false);
- }
- else if (propertyType == typeof(DateTimeOffset?))
- {
- DateTimeOffset tmp;
- var value = DateTimeOffset.TryParse(queryValue, out tmp) ? tmp : (DateTimeOffset?)null;
- expr = GetPropertyExpression(value, prop, param);
- }
- else if (propertyType.IsEnum)
- {
- int value;
- expr = (int.TryParse(queryValue, out value) && Enum.IsDefined(propertyType, value))
- ? GetEnumPropertyExpression(value, prop, param)
- : Expression.Constant(false);
- }
- else if (propertyType.IsGenericType && propertyType.GetGenericTypeDefinition() == typeof (Nullable<>) &&
- propertyType.GenericTypeArguments[0].IsEnum)
- {
- int tmp;
- var value = int.TryParse(queryValue, out tmp) ? tmp : (int?) null;
- expr = GetEnumPropertyExpression(value, prop, param);
- }
- else
- {
- expr = Expression.Constant(true);
- }
-
- return expr;
- }
-
- 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);
- }
+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
{
- 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 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);
- }
- }
-}
+ List parts = new List();
+ if (queryValue.Contains("\""))
+ {
+ 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("%"))
+ {
+ 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);
+ }
+ }
+}
diff --git a/README.md b/README.md
index ee1dc2e9..a2869236 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,120 @@ 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","which is 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 (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 that you can open an issue or even better provide a pull request.
+
+## Logical operation
+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?