diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs index fdb2f1f174..eb032cc5fd 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs @@ -19,6 +19,9 @@ public class Person : Identifiable, IHasMeta [Attr("last-name")] public string LastName { get; set; } + [Attr("age")] + public int Age { get; set; } + [HasMany("todo-items")] public virtual List TodoItems { get; set; } diff --git a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs index b94a39bb37..b92667af95 100644 --- a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs +++ b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs @@ -90,10 +90,10 @@ public virtual IQueryable Get() /// public virtual IQueryable Filter(IQueryable entities, FilterQuery filterQuery) { - if(_resourceDefinition != null) + if (_resourceDefinition != null) { var defaultQueryFilters = _resourceDefinition.GetQueryFilters(); - if(defaultQueryFilters != null && defaultQueryFilters.TryGetValue(filterQuery.Attribute, out var defaultQueryFilter) == true) + if (defaultQueryFilters != null && defaultQueryFilters.TryGetValue(filterQuery.Attribute, out var defaultQueryFilter) == true) { return defaultQueryFilter(entities, filterQuery.Value); } @@ -106,17 +106,17 @@ public virtual IQueryable Filter(IQueryable entities, FilterQu public virtual IQueryable Sort(IQueryable entities, List sortQueries) { if (sortQueries != null && sortQueries.Count > 0) - return entities.Sort(sortQueries); - - if(_resourceDefinition != null) + return entities.Sort(_jsonApiContext, sortQueries); + + if (_resourceDefinition != null) { var defaultSortOrder = _resourceDefinition.DefaultSort(); - if(defaultSortOrder != null && defaultSortOrder.Count > 0) + if (defaultSortOrder != null && defaultSortOrder.Count > 0) { - foreach(var sortProp in defaultSortOrder) - { + foreach (var sortProp in defaultSortOrder) + { // this is dumb...add an overload, don't allocate for no reason - entities.Sort(new SortQuery(sortProp.Item2, sortProp.Item1)); + entities.Sort(_jsonApiContext, new SortQuery(sortProp.Item2, sortProp.Item1.PublicAttributeName)); } } } @@ -189,10 +189,10 @@ private void AttachHasManyPointers(TEntity entity) var relationships = _jsonApiContext.HasManyRelationshipPointers.Get(); foreach (var relationship in relationships) { - if(relationship.Key is HasManyThroughAttribute hasManyThrough) + if (relationship.Key is HasManyThroughAttribute hasManyThrough) AttachHasManyThrough(entity, hasManyThrough, relationship.Value); else - AttachHasMany(relationship.Key as HasManyAttribute, relationship.Value); + AttachHasMany(relationship.Key as HasManyAttribute, relationship.Value); } } @@ -289,7 +289,7 @@ public virtual async Task DeleteAsync(TId id) /// public virtual IQueryable Include(IQueryable entities, string relationshipName) { - if(string.IsNullOrWhiteSpace(relationshipName)) throw new JsonApiException(400, "Include parameter must not be empty if provided"); + if (string.IsNullOrWhiteSpace(relationshipName)) throw new JsonApiException(400, "Include parameter must not be empty if provided"); var relationshipChain = relationshipName.Split('.'); @@ -297,7 +297,7 @@ public virtual IQueryable Include(IQueryable entities, string // TODO: make recursive method string internalRelationshipPath = null; var entity = _jsonApiContext.RequestEntity; - for(var i = 0; i < relationshipChain.Length; i++) + for (var i = 0; i < relationshipChain.Length; i++) { var requestedRelationship = relationshipChain[i]; var relationship = entity.Relationships.FirstOrDefault(r => r.PublicRelationshipName == requestedRelationship); @@ -315,7 +315,7 @@ public virtual IQueryable Include(IQueryable entities, string internalRelationshipPath = (internalRelationshipPath == null) ? relationship.RelationshipPath : $"{internalRelationshipPath}.{relationship.RelationshipPath}"; - + if(i < relationshipChain.Length) entity = _jsonApiContext.ResourceGraph.GetContextEntity(relationship.Type); } diff --git a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs index 8afab4d45a..fc0386da57 100644 --- a/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IQueryableExtensions.cs @@ -28,68 +28,93 @@ private static MethodInfo ContainsMethod } } + [Obsolete("Use overload Sort(IJsonApiContext, List) instead.", error: true)] + public static IQueryable Sort(this IQueryable source, List sortQueries) => null; - public static IQueryable Sort(this IQueryable source, List sortQueries) + [Obsolete("Use overload Sort(IJsonApiContext, SortQuery) instead.", error: true)] + public static IOrderedQueryable Sort(this IQueryable source, SortQuery sortQuery) => null; + + [Obsolete("Use overload Sort(IJsonApiContext, SortQuery) instead.", error: true)] + public static IOrderedQueryable Sort(this IOrderedQueryable source, SortQuery sortQuery) => null; + + public static IQueryable Sort(this IQueryable source, IJsonApiContext jsonApiContext, List sortQueries) { if (sortQueries == null || sortQueries.Count == 0) return source; - var orderedEntities = source.Sort(sortQueries[0]); + var orderedEntities = source.Sort(jsonApiContext, sortQueries[0]); - if (sortQueries.Count <= 1) return orderedEntities; + if (sortQueries.Count <= 1) + return orderedEntities; for (var i = 1; i < sortQueries.Count; i++) - orderedEntities = orderedEntities.Sort(sortQueries[i]); + orderedEntities = orderedEntities.Sort(jsonApiContext, sortQueries[i]); return orderedEntities; } - public static IOrderedQueryable Sort(this IQueryable source, SortQuery sortQuery) + public static IOrderedQueryable Sort(this IQueryable source, IJsonApiContext jsonApiContext, SortQuery sortQuery) { + BaseAttrQuery attr; + if (sortQuery.IsAttributeOfRelationship) + attr = new RelatedAttrSortQuery(jsonApiContext, sortQuery); + else + attr = new AttrSortQuery(jsonApiContext, sortQuery); + return sortQuery.Direction == SortDirection.Descending - ? source.OrderByDescending(sortQuery.SortedAttribute.InternalAttributeName) - : source.OrderBy(sortQuery.SortedAttribute.InternalAttributeName); + ? source.OrderByDescending(attr.GetPropertyPath()) + : source.OrderBy(attr.GetPropertyPath()); } - public static IOrderedQueryable Sort(this IOrderedQueryable source, SortQuery sortQuery) + public static IOrderedQueryable Sort(this IOrderedQueryable source, IJsonApiContext jsonApiContext, SortQuery sortQuery) { + BaseAttrQuery attr; + if (sortQuery.IsAttributeOfRelationship) + attr = new RelatedAttrSortQuery(jsonApiContext, sortQuery); + else + attr = new AttrSortQuery(jsonApiContext, sortQuery); + return sortQuery.Direction == SortDirection.Descending - ? source.ThenByDescending(sortQuery.SortedAttribute.InternalAttributeName) - : source.ThenBy(sortQuery.SortedAttribute.InternalAttributeName); + ? source.ThenByDescending(attr.GetPropertyPath()) + : source.ThenBy(attr.GetPropertyPath()); } public static IOrderedQueryable OrderBy(this IQueryable source, string propertyName) - { - return CallGenericOrderMethod(source, propertyName, "OrderBy"); - } + => CallGenericOrderMethod(source, propertyName, "OrderBy"); public static IOrderedQueryable OrderByDescending(this IQueryable source, string propertyName) - { - return CallGenericOrderMethod(source, propertyName, "OrderByDescending"); - } + => CallGenericOrderMethod(source, propertyName, "OrderByDescending"); public static IOrderedQueryable ThenBy(this IOrderedQueryable source, string propertyName) - { - return CallGenericOrderMethod(source, propertyName, "ThenBy"); - } + => CallGenericOrderMethod(source, propertyName, "ThenBy"); public static IOrderedQueryable ThenByDescending(this IOrderedQueryable source, string propertyName) - { - return CallGenericOrderMethod(source, propertyName, "ThenByDescending"); - } + => CallGenericOrderMethod(source, propertyName, "ThenByDescending"); private static IOrderedQueryable CallGenericOrderMethod(IQueryable source, string propertyName, string method) { // {x} var parameter = Expression.Parameter(typeof(TSource), "x"); - // {x.propertyName} - var property = Expression.Property(parameter, propertyName); - // {x=>x.propertyName} - var lambda = Expression.Lambda(property, parameter); + MemberExpression member; + + var values = propertyName.Split('.'); + if (values.Length > 1) + { + var relation = Expression.PropertyOrField(parameter, values[0]); + // {x.relationship.propertyName} + member = Expression.Property(relation, values[1]); + } + else + { + // {x.propertyName} + member = Expression.Property(parameter, values[0]); + } + // {x=>x.propertyName} or {x=>x.relationship.propertyName} + var lambda = Expression.Lambda(member, parameter); // REFLECTION: source.OrderBy(x => x.Property) var orderByMethod = typeof(Queryable).GetMethods().First(x => x.Name == method && x.GetParameters().Length == 2); - var orderByGeneric = orderByMethod.MakeGenericMethod(typeof(TSource), property.Type); + var orderByGeneric = orderByMethod.MakeGenericMethod(typeof(TSource), member.Type); var result = orderByGeneric.Invoke(null, new object[] { source, lambda }); return (IOrderedQueryable)result; @@ -100,126 +125,24 @@ public static IQueryable Filter(this IQueryable sourc if (filterQuery == null) return source; + // Relationship.Attribute if (filterQuery.IsAttributeOfRelationship) return source.Filter(new RelatedAttrFilterQuery(jsonApiContext, filterQuery)); return source.Filter(new AttrFilterQuery(jsonApiContext, filterQuery)); } - public static IQueryable Filter(this IQueryable source, AttrFilterQuery filterQuery) + public static IQueryable Filter(this IQueryable source, BaseFilterQuery filterQuery) { if (filterQuery == null) return source; - var concreteType = typeof(TSource); - var property = concreteType.GetProperty(filterQuery.FilteredAttribute.InternalAttributeName); - var op = filterQuery.FilterOperation; - - if (property == null) - throw new ArgumentException($"'{filterQuery.FilteredAttribute.InternalAttributeName}' is not a valid property of '{concreteType}'"); - - try - { - if (op == FilterOperations.@in || op == FilterOperations.nin) - { - string[] propertyValues = filterQuery.PropertyValue.Split(','); - var lambdaIn = ArrayContainsPredicate(propertyValues, property.Name, op); - - return source.Where(lambdaIn); - } - else if (op == FilterOperations.isnotnull || op == FilterOperations.isnull) { - // {model} - var parameter = Expression.Parameter(concreteType, "model"); - // {model.Id} - var left = Expression.PropertyOrField(parameter, property.Name); - var right = Expression.Constant(null); - - var body = GetFilterExpressionLambda(left, right, op); - var lambda = Expression.Lambda>(body, parameter); - - return source.Where(lambda); - } - else - { // convert the incoming value to the target value type - // "1" -> 1 - var convertedValue = TypeHelper.ConvertType(filterQuery.PropertyValue, property.PropertyType); - // {model} - var parameter = Expression.Parameter(concreteType, "model"); - // {model.Id} - var left = Expression.PropertyOrField(parameter, property.Name); - // {1} - var right = Expression.Constant(convertedValue, property.PropertyType); - - var body = GetFilterExpressionLambda(left, right, op); - - var lambda = Expression.Lambda>(body, parameter); - - return source.Where(lambda); - } - } - catch (FormatException) - { - throw new JsonApiException(400, $"Could not cast {filterQuery.PropertyValue} to {property.PropertyType.Name}"); - } - } - - public static IQueryable Filter(this IQueryable source, RelatedAttrFilterQuery filterQuery) - { - if (filterQuery == null) - return source; - - var concreteType = typeof(TSource); - var relation = concreteType.GetProperty(filterQuery.FilteredRelationship.InternalRelationshipName); - if (relation == null) - throw new ArgumentException($"'{filterQuery.FilteredRelationship.InternalRelationshipName}' is not a valid relationship of '{concreteType}'"); - - var relatedType = filterQuery.FilteredRelationship.Type; - var relatedAttr = relatedType.GetProperty(filterQuery.FilteredAttribute.InternalAttributeName); - if (relatedAttr == null) - throw new ArgumentException($"'{filterQuery.FilteredAttribute.InternalAttributeName}' is not a valid attribute of '{filterQuery.FilteredRelationship.InternalRelationshipName}'"); - - try - { - if (filterQuery.FilterOperation == FilterOperations.@in || filterQuery.FilterOperation == FilterOperations.nin) - { - string[] propertyValues = filterQuery.PropertyValue.Split(','); - var lambdaIn = ArrayContainsPredicate(propertyValues, relatedAttr.Name, filterQuery.FilterOperation, relation.Name); - - return source.Where(lambdaIn); - } - else - { - // convert the incoming value to the target value type - // "1" -> 1 - var convertedValue = TypeHelper.ConvertType(filterQuery.PropertyValue, relatedAttr.PropertyType); - // {model} - var parameter = Expression.Parameter(concreteType, "model"); - - // {model.Relationship} - var leftRelationship = Expression.PropertyOrField(parameter, relation.Name); - - // {model.Relationship.Attr} - var left = Expression.PropertyOrField(leftRelationship, relatedAttr.Name); - - // {1} - var right = Expression.Constant(convertedValue, relatedAttr.PropertyType); - - var body = GetFilterExpressionLambda(left, right, filterQuery.FilterOperation); - - var lambda = Expression.Lambda>(body, parameter); - - return source.Where(lambda); - } - } - catch (FormatException) - { - throw new JsonApiException(400, $"Could not cast {filterQuery.PropertyValue} to {relatedAttr.PropertyType.Name}"); - } + if (filterQuery.FilterOperation == FilterOperations.@in || filterQuery.FilterOperation == FilterOperations.nin) + return CallGenericWhereContainsMethod(source, filterQuery); + else + return CallGenericWhereMethod(source, filterQuery); } - private static bool IsNullable(Type type) => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>); - - private static Expression GetFilterExpressionLambda(Expression left, Expression right, FilterOperations operation) { Expression body; @@ -248,7 +171,7 @@ private static Expression GetFilterExpressionLambda(Expression left, Expression case FilterOperations.like: body = Expression.Call(left, "Contains", null, right); break; - // {model.Id != 1} + // {model.Id != 1} case FilterOperations.ne: body = Expression.NotEqual(left, right); break; @@ -267,32 +190,109 @@ private static Expression GetFilterExpressionLambda(Expression left, Expression return body; } - private static Expression> ArrayContainsPredicate(string[] propertyValues, string fieldname, FilterOperations op, string relationName = null) + private static IQueryable CallGenericWhereContainsMethod(IQueryable source, BaseFilterQuery filter) { - ParameterExpression entity = Expression.Parameter(typeof(TSource), "entity"); - MemberExpression member; - if (!string.IsNullOrEmpty(relationName)) + var concreteType = typeof(TSource); + var property = concreteType.GetProperty(filter.Attribute.InternalAttributeName); + + try { - var relation = Expression.PropertyOrField(entity, relationName); - member = Expression.Property(relation, fieldname); + var propertyValues = filter.PropertyValue.Split(QueryConstants.COMMA); + ParameterExpression entity = Expression.Parameter(concreteType, "entity"); + MemberExpression member; + if (filter.IsAttributeOfRelationship) + { + var relation = Expression.PropertyOrField(entity, filter.Relationship.InternalRelationshipName); + member = Expression.Property(relation, filter.Attribute.InternalAttributeName); + } + else + member = Expression.Property(entity, filter.Attribute.InternalAttributeName); + + var method = ContainsMethod.MakeGenericMethod(member.Type); + var obj = TypeHelper.ConvertListType(propertyValues, member.Type); + + if (filter.FilterOperation == FilterOperations.@in) + { + // Where(i => arr.Contains(i.column)) + var contains = Expression.Call(method, new Expression[] { Expression.Constant(obj), member }); + var lambda = Expression.Lambda>(contains, entity); + + return source.Where(lambda); + } + else + { + // Where(i => !arr.Contains(i.column)) + var notContains = Expression.Not(Expression.Call(method, new Expression[] { Expression.Constant(obj), member })); + var lambda = Expression.Lambda>(notContains, entity); + + return source.Where(lambda); + } } - else - member = Expression.Property(entity, fieldname); + catch (FormatException) + { + throw new JsonApiException(400, $"Could not cast {filter.PropertyValue} to {property.PropertyType.Name}"); + } + } - var method = ContainsMethod.MakeGenericMethod(member.Type); - var obj = TypeHelper.ConvertListType(propertyValues, member.Type); + private static IQueryable CallGenericWhereMethod(IQueryable source, BaseFilterQuery filter) + { + var op = filter.FilterOperation; + var concreteType = typeof(TSource); + PropertyInfo relationProperty = null; + PropertyInfo property = null; + MemberExpression left; + ConstantExpression right; - if (op == FilterOperations.@in) + // {model} + var parameter = Expression.Parameter(concreteType, "model"); + // Is relationship attribute + if (filter.IsAttributeOfRelationship) { - // Where(i => arr.Contains(i.column)) - var contains = Expression.Call(method, new Expression[] { Expression.Constant(obj), member }); - return Expression.Lambda>(contains, entity); + relationProperty = concreteType.GetProperty(filter.Relationship.InternalRelationshipName); + if (relationProperty == null) + throw new ArgumentException($"'{filter.Relationship.InternalRelationshipName}' is not a valid relationship of '{concreteType}'"); + + var relatedType = filter.Relationship.Type; + property = relatedType.GetProperty(filter.Attribute.InternalAttributeName); + if (property == null) + throw new ArgumentException($"'{filter.Attribute.InternalAttributeName}' is not a valid attribute of '{filter.Relationship.InternalRelationshipName}'"); + + var leftRelationship = Expression.PropertyOrField(parameter, filter.Relationship.InternalRelationshipName); + // {model.Relationship} + left = Expression.PropertyOrField(leftRelationship, property.Name); } + // Is standalone attribute else { - // Where(i => !arr.Contains(i.column)) - var notContains = Expression.Not(Expression.Call(method, new Expression[] { Expression.Constant(obj), member })); - return Expression.Lambda>(notContains, entity); + property = concreteType.GetProperty(filter.Attribute.InternalAttributeName); + if (property == null) + throw new ArgumentException($"'{filter.Attribute.InternalAttributeName}' is not a valid property of '{concreteType}'"); + + // {model.Id} + left = Expression.PropertyOrField(parameter, property.Name); + } + + try + { + if (op == FilterOperations.isnotnull || op == FilterOperations.isnull) + right = Expression.Constant(null); + else + { + // convert the incoming value to the target value type + // "1" -> 1 + var convertedValue = TypeHelper.ConvertType(filter.PropertyValue, property.PropertyType); + // {1} + right = Expression.Constant(convertedValue, property.PropertyType); + } + + var body = GetFilterExpressionLambda(left, right, filter.FilterOperation); + var lambda = Expression.Lambda>(body, parameter); + + return source.Where(lambda); + } + catch (FormatException) + { + throw new JsonApiException(400, $"Could not cast {filter.PropertyValue} to {property.PropertyType.Name}"); } } @@ -337,5 +337,6 @@ public static IQueryable PageForward(this IQueryable source, int pageSi return source; } + } } diff --git a/src/JsonApiDotNetCore/Internal/Query/AttrFilterQuery.cs b/src/JsonApiDotNetCore/Internal/Query/AttrFilterQuery.cs index a914fea3a7..f415ad9434 100644 --- a/src/JsonApiDotNetCore/Internal/Query/AttrFilterQuery.cs +++ b/src/JsonApiDotNetCore/Internal/Query/AttrFilterQuery.cs @@ -1,4 +1,4 @@ -using System.Linq; +using System; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Services; @@ -6,32 +6,21 @@ namespace JsonApiDotNetCore.Internal.Query { public class AttrFilterQuery : BaseFilterQuery { - private readonly IJsonApiContext _jsonApiContext; - public AttrFilterQuery( IJsonApiContext jsonApiContext, FilterQuery filterQuery) + : base(jsonApiContext, filterQuery) { - _jsonApiContext = jsonApiContext; - - var attribute = GetAttribute(filterQuery.Attribute); - - if (attribute == null) + if (Attribute == null) throw new JsonApiException(400, $"'{filterQuery.Attribute}' is not a valid attribute."); - if (attribute.IsFilterable == false) - throw new JsonApiException(400, $"Filter is not allowed for attribute '{attribute.PublicAttributeName}'."); + if (Attribute.IsFilterable == false) + throw new JsonApiException(400, $"Filter is not allowed for attribute '{Attribute.PublicAttributeName}'."); - FilteredAttribute = attribute; - PropertyValue = filterQuery.Value; - FilterOperation = GetFilterOperation(filterQuery.Operation); + FilteredAttribute = Attribute; } - public AttrAttribute FilteredAttribute { get; } - public string PropertyValue { get; } - public FilterOperations FilterOperation { get; } - - private AttrAttribute GetAttribute(string attribute) => - _jsonApiContext.RequestEntity.Attributes.FirstOrDefault(attr => attr.Is(attribute)); + [Obsolete("Use " + nameof(BaseAttrQuery.Attribute) + " insetad.")] + public AttrAttribute FilteredAttribute { get; set; } } } diff --git a/src/JsonApiDotNetCore/Internal/Query/AttrSortQuery.cs b/src/JsonApiDotNetCore/Internal/Query/AttrSortQuery.cs new file mode 100644 index 0000000000..341b7e15c0 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Query/AttrSortQuery.cs @@ -0,0 +1,23 @@ +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCore.Internal.Query +{ + public class AttrSortQuery : BaseAttrQuery + { + public AttrSortQuery( + IJsonApiContext jsonApiContext, + SortQuery sortQuery) + :base(jsonApiContext, sortQuery) + { + if (Attribute == null) + throw new JsonApiException(400, $"'{sortQuery.Attribute}' is not a valid attribute."); + + if (Attribute.IsSortable == false) + throw new JsonApiException(400, $"Sort is not allowed for attribute '{Attribute.PublicAttributeName}'."); + + Direction = sortQuery.Direction; + } + + public SortDirection Direction { get; } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Query/BaseAttrQuery.cs b/src/JsonApiDotNetCore/Internal/Query/BaseAttrQuery.cs new file mode 100644 index 0000000000..7520f2ebc9 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Query/BaseAttrQuery.cs @@ -0,0 +1,56 @@ +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Services; +using System.Linq; + +namespace JsonApiDotNetCore.Internal.Query +{ + /// + /// Abstract class to make available shared properties of all query implementations + /// It elimines boilerplate of providing specified type(AttrQuery or RelatedAttrQuery) + /// while filter and sort operations and eliminates plenty of methods to keep DRY principles + /// + public abstract class BaseAttrQuery + { + private readonly IJsonApiContext _jsonApiContext; + + public BaseAttrQuery(IJsonApiContext jsonApiContext, BaseQuery baseQuery) + { + _jsonApiContext = jsonApiContext; + if (baseQuery.IsAttributeOfRelationship) + { + Relationship = GetRelationship(baseQuery.Relationship); + Attribute = GetAttribute(Relationship, baseQuery.Attribute); + } + else + { + Attribute = GetAttribute(baseQuery.Attribute); + } + + } + + public AttrAttribute Attribute { get; } + public RelationshipAttribute Relationship { get; } + public bool IsAttributeOfRelationship => Relationship != null; + + public string GetPropertyPath() + { + if (IsAttributeOfRelationship) + return string.Format("{0}.{1}", Relationship.InternalRelationshipName, Attribute.InternalAttributeName); + else + return Attribute.InternalAttributeName; + } + + private AttrAttribute GetAttribute(string attribute) + => _jsonApiContext.RequestEntity.Attributes.FirstOrDefault(attr => attr.Is(attribute)); + + private RelationshipAttribute GetRelationship(string propertyName) + => _jsonApiContext.RequestEntity.Relationships.FirstOrDefault(r => r.Is(propertyName)); + + private AttrAttribute GetAttribute(RelationshipAttribute relationship, string attribute) + { + var relatedContextExntity = _jsonApiContext.ResourceGraph.GetContextEntity(relationship.Type); + return relatedContextExntity.Attributes + .FirstOrDefault(a => a.Is(attribute)); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Query/BaseFilterQuery.cs b/src/JsonApiDotNetCore/Internal/Query/BaseFilterQuery.cs index 1c43d84254..f7e308369e 100644 --- a/src/JsonApiDotNetCore/Internal/Query/BaseFilterQuery.cs +++ b/src/JsonApiDotNetCore/Internal/Query/BaseFilterQuery.cs @@ -1,10 +1,23 @@ +using JsonApiDotNetCore.Services; using System; namespace JsonApiDotNetCore.Internal.Query { - public class BaseFilterQuery + public class BaseFilterQuery : BaseAttrQuery { - protected FilterOperations GetFilterOperation(string prefix) + public BaseFilterQuery( + IJsonApiContext jsonApiContext, + FilterQuery filterQuery) + : base(jsonApiContext, filterQuery) + { + PropertyValue = filterQuery.Value; + FilterOperation = GetFilterOperation(filterQuery.Operation); + } + + public string PropertyValue { get; } + public FilterOperations FilterOperation { get; } + + private FilterOperations GetFilterOperation(string prefix) { if (prefix.Length == 0) return FilterOperations.eq; @@ -13,5 +26,6 @@ protected FilterOperations GetFilterOperation(string prefix) return opertion; } + } } diff --git a/src/JsonApiDotNetCore/Internal/Query/BaseQuery.cs b/src/JsonApiDotNetCore/Internal/Query/BaseQuery.cs new file mode 100644 index 0000000000..90830196c4 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Query/BaseQuery.cs @@ -0,0 +1,26 @@ +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Services; +using System; +using System.Linq; + +namespace JsonApiDotNetCore.Internal.Query +{ + public abstract class BaseQuery + { + public BaseQuery(string attribute) + { + var properties = attribute.Split(QueryConstants.DOT); + if(properties.Length > 1) + { + Relationship = properties[0]; + Attribute = properties[1]; + } + else + Attribute = properties[0]; + } + + public string Attribute { get; } + public string Relationship { get; } + public bool IsAttributeOfRelationship => Relationship != null; + } +} diff --git a/src/JsonApiDotNetCore/Internal/Query/FilterQuery.cs b/src/JsonApiDotNetCore/Internal/Query/FilterQuery.cs index dd72e827ff..220f35b506 100644 --- a/src/JsonApiDotNetCore/Internal/Query/FilterQuery.cs +++ b/src/JsonApiDotNetCore/Internal/Query/FilterQuery.cs @@ -1,23 +1,23 @@ using System; using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCore.Models; namespace JsonApiDotNetCore.Internal.Query { - public class FilterQuery + public class FilterQuery : BaseQuery { public FilterQuery(string attribute, string value, string operation) + : base(attribute) { - Attribute = attribute; Key = attribute.ToProperCase(); Value = value; Operation = operation; } - + [Obsolete("Key has been replaced by '" + nameof(Attribute) + "'. Members should be located by their public name, not by coercing the provided value to the internal name.")] public string Key { get; set; } - public string Attribute { get; } public string Value { get; set; } public string Operation { get; set; } - public bool IsAttributeOfRelationship => Attribute.Contains("."); + } } diff --git a/src/JsonApiDotNetCore/Internal/Query/QueryConstants.cs b/src/JsonApiDotNetCore/Internal/Query/QueryConstants.cs index 3117ff7cb3..25913ab3e6 100644 --- a/src/JsonApiDotNetCore/Internal/Query/QueryConstants.cs +++ b/src/JsonApiDotNetCore/Internal/Query/QueryConstants.cs @@ -10,6 +10,7 @@ public static class QueryConstants { public const char COMMA = ','; public const char COLON = ':'; public const string COLON_STR = ":"; + public const char DOT = '.'; } -} \ No newline at end of file +} diff --git a/src/JsonApiDotNetCore/Internal/Query/QuerySet.cs b/src/JsonApiDotNetCore/Internal/Query/QuerySet.cs index 88aac1e67b..1a60879667 100644 --- a/src/JsonApiDotNetCore/Internal/Query/QuerySet.cs +++ b/src/JsonApiDotNetCore/Internal/Query/QuerySet.cs @@ -10,4 +10,4 @@ public class QuerySet public List IncludedRelationships { get; set; } = new List(); public List Fields { get; set; } = new List(); } -} \ No newline at end of file +} diff --git a/src/JsonApiDotNetCore/Internal/Query/RelatedAttrFilterQuery.cs b/src/JsonApiDotNetCore/Internal/Query/RelatedAttrFilterQuery.cs index f01865adf4..96d33d0e72 100644 --- a/src/JsonApiDotNetCore/Internal/Query/RelatedAttrFilterQuery.cs +++ b/src/JsonApiDotNetCore/Internal/Query/RelatedAttrFilterQuery.cs @@ -1,4 +1,4 @@ -using System.Linq; +using System; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Services; @@ -6,45 +6,28 @@ namespace JsonApiDotNetCore.Internal.Query { public class RelatedAttrFilterQuery : BaseFilterQuery { - private readonly IJsonApiContext _jsonApiContext; - public RelatedAttrFilterQuery( IJsonApiContext jsonApiContext, FilterQuery filterQuery) + :base(jsonApiContext, filterQuery) { - _jsonApiContext = jsonApiContext; + if (Relationship == null) + throw new JsonApiException(400, $"{filterQuery.Relationship} is not a valid relationship on {jsonApiContext.RequestEntity.EntityName}."); - var relationshipArray = filterQuery.Attribute.Split('.'); - var relationship = GetRelationship(relationshipArray[0]); - if (relationship == null) - throw new JsonApiException(400, $"{relationshipArray[1]} is not a valid relationship on {relationshipArray[0]}."); - - var attribute = GetAttribute(relationship, relationshipArray[1]); - if (attribute == null) + if (Attribute == null) throw new JsonApiException(400, $"'{filterQuery.Attribute}' is not a valid attribute."); - if (attribute.IsFilterable == false) - throw new JsonApiException(400, $"Filter is not allowed for attribute '{attribute.PublicAttributeName}'."); + if (Attribute.IsFilterable == false) + throw new JsonApiException(400, $"Filter is not allowed for attribute '{Attribute.PublicAttributeName}'."); - FilteredRelationship = relationship; - FilteredAttribute = attribute; - PropertyValue = filterQuery.Value; - FilterOperation = GetFilterOperation(filterQuery.Operation); + FilteredRelationship = Relationship; + FilteredAttribute = Attribute; } + [Obsolete("Use " + nameof(Attribute) + " instead.")] public AttrAttribute FilteredAttribute { get; set; } - public string PropertyValue { get; set; } - public FilterOperations FilterOperation { get; set; } - public RelationshipAttribute FilteredRelationship { get; } - - private RelationshipAttribute GetRelationship(string propertyName) - => _jsonApiContext.RequestEntity.Relationships.FirstOrDefault(r => r.Is(propertyName)); - private AttrAttribute GetAttribute(RelationshipAttribute relationship, string attribute) - { - var relatedContextExntity = _jsonApiContext.ResourceGraph.GetContextEntity(relationship.Type); - return relatedContextExntity.Attributes - .FirstOrDefault(a => a.Is(attribute)); - } + [Obsolete("Use " + nameof(Relationship) + " instead.")] + public RelationshipAttribute FilteredRelationship { get; set; } } } diff --git a/src/JsonApiDotNetCore/Internal/Query/RelatedAttrSortQuery.cs b/src/JsonApiDotNetCore/Internal/Query/RelatedAttrSortQuery.cs new file mode 100644 index 0000000000..4215382c80 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Query/RelatedAttrSortQuery.cs @@ -0,0 +1,26 @@ +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCore.Internal.Query +{ + public class RelatedAttrSortQuery : BaseAttrQuery + { + public RelatedAttrSortQuery( + IJsonApiContext jsonApiContext, + SortQuery sortQuery) + :base(jsonApiContext, sortQuery) + { + if (Relationship == null) + throw new JsonApiException(400, $"{sortQuery.Relationship} is not a valid relationship on {jsonApiContext.RequestEntity.EntityName}."); + + if (Attribute == null) + throw new JsonApiException(400, $"'{sortQuery.Attribute}' is not a valid attribute."); + + if (Attribute.IsSortable == false) + throw new JsonApiException(400, $"Sort is not allowed for attribute '{Attribute.PublicAttributeName}'."); + + Direction = sortQuery.Direction; + } + + public SortDirection Direction { get; } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Query/SortQuery.cs b/src/JsonApiDotNetCore/Internal/Query/SortQuery.cs index 7ef6682cc6..034b7bf7fd 100644 --- a/src/JsonApiDotNetCore/Internal/Query/SortQuery.cs +++ b/src/JsonApiDotNetCore/Internal/Query/SortQuery.cs @@ -1,15 +1,29 @@ using JsonApiDotNetCore.Models; +using System; namespace JsonApiDotNetCore.Internal.Query { - public class SortQuery + /// + /// An internal representation of the raw sort query. + /// + public class SortQuery : BaseQuery { + [Obsolete("Use constructor overload (SortDirection, string) instead.", error: true)] public SortQuery(SortDirection direction, AttrAttribute sortedAttribute) + : base(sortedAttribute.PublicAttributeName) { } + + public SortQuery(SortDirection direction, string attribute) + : base(attribute) { Direction = direction; - SortedAttribute = sortedAttribute; } + + /// + /// Direction the sort should be applied + /// public SortDirection Direction { get; set; } + + [Obsolete("Use string based Attribute instead.", error: true)] public AttrAttribute SortedAttribute { get; set; } } -} \ No newline at end of file +} diff --git a/src/JsonApiDotNetCore/Services/QueryParser.cs b/src/JsonApiDotNetCore/Services/QueryParser.cs index b42d616a0c..f559679f33 100644 --- a/src/JsonApiDotNetCore/Services/QueryParser.cs +++ b/src/JsonApiDotNetCore/Services/QueryParser.cs @@ -156,7 +156,6 @@ protected virtual List ParseSortParameters(string value) foreach (var sortSegment in sortSegments) { - var propertyName = sortSegment; var direction = SortDirection.Ascending; @@ -166,12 +165,7 @@ protected virtual List ParseSortParameters(string value) propertyName = propertyName.Substring(1); } - var attribute = GetAttribute(propertyName); - - if (attribute.IsSortable == false) - throw new JsonApiException(400, $"Sort is not allowed for attribute '{attribute.PublicAttributeName}'."); - - sortParameters.Add(new SortQuery(direction, attribute)); + sortParameters.Add(new SortQuery(direction, propertyName)); }; return sortParameters; @@ -241,11 +235,5 @@ private string GetFilterOperation(string value) return operation; } - - private FilterQuery BuildFilterQuery(ReadOnlySpan query, string propertyName) - { - var (operation, filterValue) = ParseFilterOperation(query.ToString()); - return new FilterQuery(propertyName, filterValue, operation); - } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs index 7286a94fc5..01ee8bd4d6 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; @@ -35,7 +35,8 @@ public async Task Can_Select_Sparse_Fieldsets() { // arrange var fields = new List { "Id", "Description", "CreatedDate", "AchievedDate" }; - var todoItem = new TodoItem { + var todoItem = new TodoItem + { Description = "description", Ordinal = 1, CreatedDate = DateTime.Now, @@ -50,7 +51,7 @@ public async Task Can_Select_Sparse_Fieldsets() // act var query = _dbContext .TodoItems - .Where(t=>t.Id == todoItem.Id) + .Where(t => t.Id == todoItem.Id) .Select(fields); var resultSql = StringExtensions.Normalize(query.ToSql()); @@ -68,14 +69,15 @@ public async Task Can_Select_Sparse_Fieldsets() public async Task Fields_Query_Selects_Sparse_Field_Sets() { // arrange - var todoItem = new TodoItem { + var todoItem = new TodoItem + { Description = "description", - Ordinal = 1, + Ordinal = 1, CreatedDate = DateTime.Now }; _dbContext.TodoItems.Add(todoItem); await _dbContext.SaveChangesAsync(); - + var builder = new WebHostBuilder() .UseStartup(); var httpMethod = new HttpMethod("GET"); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs index c53e81738c..618db7dd10 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs @@ -25,6 +25,7 @@ public class TodoItemControllerTests private AppDbContext _context; private IJsonApiContext _jsonApiContext; private Faker _todoItemFaker; + private Faker _personFaker; public TodoItemControllerTests(TestFixture fixture) { @@ -35,6 +36,11 @@ public TodoItemControllerTests(TestFixture fixture) .RuleFor(t => t.Description, f => f.Lorem.Sentence()) .RuleFor(t => t.Ordinal, f => f.Random.Number()) .RuleFor(t => t.CreatedDate, f => f.Date.Past()); + + _personFaker = new Faker() + .RuleFor(t => t.FirstName, f => f.Name.FirstName()) + .RuleFor(t => t.LastName, f => f.Name.LastName()) + .RuleFor(t => t.Age, f => f.Random.Int(1, 99)); } [Fact] @@ -217,6 +223,82 @@ public async Task Can_Sort_TodoItems_By_Ordinal_Ascending() } } + [Fact] + public async Task Can_Sort_TodoItems_By_Nested_Attribute_Ascending() + { + // Arrange + _context.TodoItems.RemoveRange(_context.TodoItems); + + const int numberOfItems = 10; + + for (var i = 1; i <= numberOfItems; i++) + { + var todoItem = _todoItemFaker.Generate(); + todoItem.Ordinal = i; + todoItem.Owner = _personFaker.Generate(); + _context.TodoItems.Add(todoItem); + } + _context.SaveChanges(); + + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/todo-items?page[size]={numberOfItems}&include=owner&sort=owner.age"; + var request = new HttpRequestMessage(httpMethod, route); + + // Act + var response = await _fixture.Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var deserializedBody = _fixture.GetService().DeserializeList(body); + Assert.NotEmpty(deserializedBody); + + long lastAge = 0; + foreach (var todoItemResult in deserializedBody) + { + Assert.True(todoItemResult.Owner.Age >= lastAge); + lastAge = todoItemResult.Owner.Age; + } + } + + [Fact] + public async Task Can_Sort_TodoItems_By_Nested_Attribute_Descending() + { + // Arrange + _context.TodoItems.RemoveRange(_context.TodoItems); + + const int numberOfItems = 10; + + for (var i = 1; i <= numberOfItems; i++) + { + var todoItem = _todoItemFaker.Generate(); + todoItem.Ordinal = i; + todoItem.Owner = _personFaker.Generate(); + _context.TodoItems.Add(todoItem); + } + _context.SaveChanges(); + + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/todo-items?page[size]={numberOfItems}&include=owner&sort=-owner.age"; + var request = new HttpRequestMessage(httpMethod, route); + + // Act + var response = await _fixture.Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var deserializedBody = _fixture.GetService().DeserializeList(body); + Assert.NotEmpty(deserializedBody); + + int maxAge = deserializedBody.Max(i => i.Owner.Age) + 1; + foreach (var todoItemResult in deserializedBody) + { + Assert.True(todoItemResult.Owner.Age <= maxAge); + maxAge = todoItemResult.Owner.Age; + } + } + [Fact] public async Task Can_Sort_TodoItems_By_Ordinal_Descending() { diff --git a/test/UnitTests/Services/QueryComposerTests.cs b/test/UnitTests/Services/QueryComposerTests.cs index 330083820c..efa600f2f3 100644 --- a/test/UnitTests/Services/QueryComposerTests.cs +++ b/test/UnitTests/Services/QueryComposerTests.cs @@ -23,7 +23,7 @@ public void Can_ComposeEqual_FilterStringForUrl() var querySet = new QuerySet(); List filters = new List(); filters.Add(filter); - querySet.Filters=filters; + querySet.Filters = filters; _jsonApiContext .Setup(m => m.QuerySet) @@ -73,7 +73,7 @@ public void NoFilter_Compose_EmptyStringReturned() // act var filterString = queryComposer.Compose(_jsonApiContext.Object); // assert - Assert.Equal("", filterString); + Assert.Equal("", filterString); } } } diff --git a/test/UnitTests/Services/QueryParser_Tests.cs b/test/UnitTests/Services/QueryParser_Tests.cs index 64c9830f2b..baaee8f81c 100644 --- a/test/UnitTests/Services/QueryParser_Tests.cs +++ b/test/UnitTests/Services/QueryParser_Tests.cs @@ -246,7 +246,7 @@ public void Can_Parse_Fields_Query() .Returns(new ContextEntity { EntityName = type, - Attributes = new List + Attributes = new List { new AttrAttribute(attrName) { @@ -285,7 +285,7 @@ public void Throws_JsonApiException_If_Field_DoesNotExist() .Returns(new ContextEntity { EntityName = type, - Attributes = new List() + Attributes = new List() }); var queryParser = new QueryParser(_controllerContextMock.Object, new JsonApiOptions()); @@ -355,4 +355,4 @@ public void Can_Parse_Page_Number_Query(string value, int expectedValue, bool sh } } } -} \ No newline at end of file +}