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/QueryableTransformers/DefaultFilteringTransformer.cs b/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs index f449d1bb..a54bc2a8 100644 --- a/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs +++ b/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs @@ -1,9 +1,11 @@ using System; -using System.Globalization; +using System.Collections.Generic; +using System.ComponentModel; using System.Linq; using System.Linq.Expressions; using System.Net.Http; using System.Reflection; +using System.Text.RegularExpressions; using JSONAPI.Core; using JSONAPI.Documents.Builders; @@ -16,6 +18,14 @@ 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 /// @@ -120,230 +130,318 @@ private Expression GetPredicateBodyForField(ResourceTypeAttribute resourceTypeAt return GetPredicateBodyForProperty(resourceTypeAttribute.Property, queryValue, param); } - // ReSharper disable once FunctionComplexityOverflow - // TODO: should probably break this method up private Expression GetPredicateBodyForProperty(PropertyInfo prop, string queryValue, ParameterExpression param) { var propertyType = prop.PropertyType; - Expression expr; - if (propertyType == typeof(String)) + Expression expr = null; + if (propertyType == typeof(string)) { - if (String.IsNullOrWhiteSpace(queryValue)) + if (string.IsNullOrWhiteSpace(queryValue)) { Expression propertyExpr = Expression.Property(param, prop); expr = Expression.Equal(propertyExpr, Expression.Constant(null)); } else { - Expression propertyExpr = Expression.Property(param, prop); - expr = Expression.Equal(propertyExpr, Expression.Constant(queryValue)); + 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 == 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)) + else if (propertyType.IsEnum) { - Decimal value; - expr = Decimal.TryParse(queryValue, NumberStyles.Any, CultureInfo.InvariantCulture, out value) - ? GetPropertyExpression(value, prop, param) - : Expression.Constant(false); + if (string.IsNullOrWhiteSpace(queryValue)) // missing enum property + { + expr = Expression.Constant(false); + } + else + { + // try to split up for multiple values + var parts = queryValue.Split(','); + + foreach (var part in parts) + { + int value; + var partExpr = (int.TryParse(part, out value) && Enum.IsDefined(propertyType, value)) + ? GetEnumPropertyExpression(value, prop, param) + : Expression.Constant(false); + if (expr == null) + { + expr = partExpr; + } + else + { + expr = Expression.OrElse(expr, partExpr); + } + } + } } - else if (propertyType == typeof(Decimal?)) + else if (propertyType.IsGenericType && propertyType.GetGenericTypeDefinition() == typeof(Nullable<>) && + propertyType.GenericTypeArguments[0].IsEnum) { - Decimal tmp; - var value = Decimal.TryParse(queryValue, NumberStyles.Any, CultureInfo.InvariantCulture, out tmp) ? tmp : (Decimal?)null; - expr = GetPropertyExpression(value, prop, param); + if (string.IsNullOrWhiteSpace(queryValue)) + { + Expression propertyExpr = Expression.Property(param, prop); + expr = Expression.Equal(propertyExpr, Expression.Constant(null)); + } + else + { + // try to split up for multiple values + var parts = queryValue.Split(','); + + foreach (var part in parts) + { + int tmp; + var value = int.TryParse(part, out tmp) ? tmp : (int?)null; + var partExpr = GetEnumPropertyExpression(value, prop, param); + if (expr == null) + { + expr = partExpr; + } + else + { + expr = Expression.OrElse(expr, partExpr); + } + } + } } - else if (propertyType == typeof(DateTime)) + else if (Nullable.GetUnderlyingType(propertyType) != null) // It's nullable { - DateTime value; - expr = DateTime.TryParse(queryValue, out value) - ? GetPropertyExpression(value, prop, param) - : Expression.Constant(false); + expr = GetExpressionNullable(queryValue, prop, propertyType, param); } - else if (propertyType == typeof(DateTime?)) + else { - DateTime tmp; - var value = DateTime.TryParse(queryValue, out tmp) ? tmp : (DateTime?)null; - expr = GetPropertyExpression(value, prop, param); + expr = GetExpression(queryValue, prop, propertyType, param); + if (expr == null) + { + expr = Expression.Constant(true); + } } - else if (propertyType == typeof(DateTimeOffset)) + + return expr; + } + + private Expression GetExpressionNullable(string queryValue, PropertyInfo prop, Type propertyType, ParameterExpression param) + { + Type underlayingType = Nullable.GetUnderlyingType(propertyType); + try { - DateTimeOffset value; - expr = DateTimeOffset.TryParse(queryValue, out value) - ? GetPropertyExpression(value, prop, param) - : Expression.Constant(false); + + 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; } - else if (propertyType == typeof(DateTimeOffset?)) + catch (NotSupportedException) { - DateTimeOffset tmp; - var value = DateTimeOffset.TryParse(queryValue, out tmp) ? tmp : (DateTimeOffset?)null; - expr = GetPropertyExpression(value, prop, param); + return Expression.Constant(false); } - else if (propertyType.IsEnum) - { - int value; - expr = (int.TryParse(queryValue, out value) && Enum.IsDefined(propertyType, value)) - ? GetEnumPropertyExpression(value, prop, param) - : 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); + } + } - else if (propertyType.IsGenericType && propertyType.GetGenericTypeDefinition() == typeof (Nullable<>) && - propertyType.GenericTypeArguments[0].IsEnum) + return expr; + } + + + private Expression GetExpression(string queryValue, PropertyInfo prop, Type propertyType, ParameterExpression param) + { + try { - int tmp; - var value = int.TryParse(queryValue, out tmp) ? tmp : (int?) null; - expr = GetEnumPropertyExpression(value, prop, param); + if(queryValue == null) // missing property + return Expression.Constant(false); + + var parts = queryValue.Split(','); + Expression expr = null; + foreach (var part in parts) + { + dynamic value = TypeDescriptor.GetConverter(propertyType).ConvertFromInvariantString(part); + if (expr == null) + { + expr = GetPropertyExpression(value, prop, param); + } + else + { + expr = Expression.OrElse(expr, GetPropertyExpression(value, prop, param)); + } + } + return expr; } - else + catch (NotSupportedException) { - expr = Expression.Constant(true); + return Expression.Constant(false); } - - return expr; } + + private Expression GetPredicateBodyForRelationship(ResourceTypeRelationship resourceTypeProperty, string queryValue, ParameterExpression param) { var relatedType = resourceTypeProperty.RelatedType; @@ -434,6 +532,17 @@ private static Expression GetPropertyExpression(T value, PropertyInfo propert return Expression.Equal(propertyExpr, castedConstantExpr); } + private static Expression GetPropertyExpressionBetween(T lowerValue, T upperValue, PropertyInfo property, + ParameterExpression param) + { + Expression propertyExpr = Expression.Property(param, property); + var lowerValueExpr = Expression.Constant(lowerValue); + var upperValueExpr = Expression.Constant(upperValue); + Expression lowerCastedConstantExpr = Expression.Convert(lowerValueExpr, typeof(T)); + Expression upperCastedConstantExpr = Expression.Convert(upperValueExpr, typeof(T)); + return Expression.AndAlso(Expression.GreaterThanOrEqual(propertyExpr, lowerCastedConstantExpr), Expression.LessThanOrEqual(propertyExpr, upperCastedConstantExpr)); + } + private static Expression GetEnumPropertyExpression(int? value, PropertyInfo property, ParameterExpression param) { diff --git a/README.md b/README.md index 1fb411ca..5b5e23ca 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,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?