From e7279d2ff0ecaf931be925109c7f9f87a37bdb08 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Tue, 5 Dec 2023 11:01:25 +0100 Subject: [PATCH] Add range filter options to the Delivery API (#15353) * Add range filter options to the Delivery API * Add range filter examples to Swagger docs --- .../SwaggerContentDocumentationFilter.cs | 12 ++- .../Querying/Filters/ContainsFilterBase.cs | 78 +++++++++++++++++++ .../Querying/Filters/CreateDateFilter.cs | 14 ++++ .../Querying/Filters/UpdateDateFilter.cs | 14 ++++ .../ApiContentQueryFilterBuilder.cs | 67 ++++++++++++++++ .../DeliveryApi/FilterOperation.cs | 6 +- 6 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Delivery/Querying/Filters/ContainsFilterBase.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Querying/Filters/CreateDateFilter.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Querying/Filters/UpdateDateFilter.cs diff --git a/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerContentDocumentationFilter.cs b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerContentDocumentationFilter.cs index 9d938cef41..0d2d35fb23 100644 --- a/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerContentDocumentationFilter.cs +++ b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerContentDocumentationFilter.cs @@ -113,12 +113,20 @@ internal sealed class SwaggerContentDocumentationFilter : SwaggerDocumentationFi { { "Default filter", new OpenApiExample { Value = new OpenApiString(string.Empty) } }, { - "Filter by content type", + "Filter by content type (equals)", new OpenApiExample { Value = new OpenApiArray { new OpenApiString("contentType:alias1") } } }, { - "Filter by name", + "Filter by name (contains)", new OpenApiExample { Value = new OpenApiArray { new OpenApiString("name:nodeName") } } + }, + { + "Filter by creation date (less than)", + new OpenApiExample { Value = new OpenApiArray { new OpenApiString("createDate<2024-01-01") } } + }, + { + "Filter by update date (greater than or equal)", + new OpenApiExample { Value = new OpenApiArray { new OpenApiString("updateDate>:2023-01-01") } } } }; diff --git a/src/Umbraco.Cms.Api.Delivery/Querying/Filters/ContainsFilterBase.cs b/src/Umbraco.Cms.Api.Delivery/Querying/Filters/ContainsFilterBase.cs new file mode 100644 index 0000000000..1770bfd4b9 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Querying/Filters/ContainsFilterBase.cs @@ -0,0 +1,78 @@ +using System.Text.RegularExpressions; +using Umbraco.Cms.Core.DeliveryApi; + +namespace Umbraco.Cms.Api.Delivery.Querying.Filters; + +public abstract class ContainsFilterBase : IFilterHandler +{ + /// + /// The regex to parse the filter query. Must supply two match groups named "operator" and "value", where "operator" + /// contains the filter operator (i.e. ">") and "value" contains the value to filter on. + /// + /// + /// Supported operators: + /// + /// ":" = Is (the filter value equals the index field value) + /// ":!" = IsNot (the filter value does not equal the index field value) + /// ">" = GreaterThan (the filter value is greater than the index field value) + /// ">:" = GreaterThanOrEqual (the filter value is greater than or equal to the index field value) + /// "<" = LessThan (the filter value is less than the index field value) + /// "<:" = LessThanOrEqual (the filter value is less than or equal to the index field value) + /// + /// Range operators (greater than, less than) only work with numeric and date type filters. + /// + protected abstract Regex QueryParserRegex { get; } + + /// + /// The index field name to filter on. + /// + protected abstract string FieldName { get; } + + /// + public bool CanHandle(string query) + => QueryParserRegex.IsMatch(query); + + /// + public FilterOption BuildFilterOption(string filter) + { + GroupCollection groups = QueryParserRegex.Match(filter).Groups; + + if (groups.Count != 3 || groups.ContainsKey("operator") is false || groups.ContainsKey("value") is false) + { + return DefaultFilterOption(); + } + + FilterOperation? filterOperation = ParseFilterOperation(groups["operator"].Value); + if (filterOperation.HasValue is false) + { + return DefaultFilterOption(); + } + + return new FilterOption + { + FieldName = FieldName, + Values = new[] { groups["value"].Value }, + Operator = filterOperation.Value + }; + + FilterOption DefaultFilterOption() + => new FilterOption + { + FieldName = FieldName, + Values = new[] { Guid.NewGuid().ToString() }, + Operator = FilterOperation.Is + }; + } + + private FilterOperation? ParseFilterOperation(string filterOperation) + => filterOperation switch + { + ":" => FilterOperation.Is, + ":!" => FilterOperation.IsNot, + ">" => FilterOperation.GreaterThan, + ">:" => FilterOperation.GreaterThanOrEqual, + "<" => FilterOperation.LessThan, + "<:" => FilterOperation.LessThanOrEqual, + _ => null + }; +} diff --git a/src/Umbraco.Cms.Api.Delivery/Querying/Filters/CreateDateFilter.cs b/src/Umbraco.Cms.Api.Delivery/Querying/Filters/CreateDateFilter.cs new file mode 100644 index 0000000000..725d7d8f89 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Querying/Filters/CreateDateFilter.cs @@ -0,0 +1,14 @@ +using System.Text.RegularExpressions; +using Umbraco.Cms.Api.Delivery.Indexing.Sorts; + +namespace Umbraco.Cms.Api.Delivery.Querying.Filters; + +public sealed partial class CreateDateFilter : ContainsFilterBase +{ + protected override string FieldName => CreateDateSortIndexer.FieldName; + + protected override Regex QueryParserRegex => CreateDateRegex(); + + [GeneratedRegex("createDate(?[><:]{1,2})(?.*)", RegexOptions.IgnoreCase)] + public static partial Regex CreateDateRegex(); +} diff --git a/src/Umbraco.Cms.Api.Delivery/Querying/Filters/UpdateDateFilter.cs b/src/Umbraco.Cms.Api.Delivery/Querying/Filters/UpdateDateFilter.cs new file mode 100644 index 0000000000..9ceb0ca5a1 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Querying/Filters/UpdateDateFilter.cs @@ -0,0 +1,14 @@ +using System.Text.RegularExpressions; +using Umbraco.Cms.Api.Delivery.Indexing.Sorts; + +namespace Umbraco.Cms.Api.Delivery.Querying.Filters; + +public sealed partial class UpdateDateFilter : ContainsFilterBase +{ + protected override string FieldName => UpdateDateSortIndexer.FieldName; + + protected override Regex QueryParserRegex => UpdateDateRegex(); + + [GeneratedRegex("updateDate(?[><:]{1,2})(?.*)", RegexOptions.IgnoreCase)] + public static partial Regex UpdateDateRegex(); +} diff --git a/src/Umbraco.Cms.Api.Delivery/Services/QueryBuilders/ApiContentQueryFilterBuilder.cs b/src/Umbraco.Cms.Api.Delivery/Services/QueryBuilders/ApiContentQueryFilterBuilder.cs index a54965f116..819ee3aa72 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/QueryBuilders/ApiContentQueryFilterBuilder.cs +++ b/src/Umbraco.Cms.Api.Delivery/Services/QueryBuilders/ApiContentQueryFilterBuilder.cs @@ -50,6 +50,12 @@ internal sealed class ApiContentQueryFilterBuilder case FilterOperation.DoesNotContain: ApplyContainsFilter(queryOperation.Not(), filterOption.FieldName, values); break; + case FilterOperation.LessThan: + case FilterOperation.LessThanOrEqual: + case FilterOperation.GreaterThan: + case FilterOperation.GreaterThanOrEqual: + ApplyRangeFilter(queryOperation.And(), filterOption.FieldName, values, fieldType, filterOption.Operator); + break; default: continue; } @@ -143,6 +149,67 @@ internal sealed class ApiContentQueryFilterBuilder } } + private void ApplyRangeFilter(IQuery query, string fieldName, string[] values, FieldType fieldType, FilterOperation filterOperation) + { + switch (fieldType) + { + case FieldType.Number: + ApplyRangeNumberFilter(query, fieldName, values, filterOperation); + break; + case FieldType.Date: + ApplyRangeDateFilter(query, fieldName, values, filterOperation); + break; + default: + _logger.LogWarning("Range filtering cannot be used with String fields. Only Number and Date fields support range filtering."); + break; + } + } + + private void ApplyRangeNumberFilter(IQuery query, string fieldName, string[] values, FilterOperation filterOperation) + { + if (TryParseIntFilterValue(values.First(), out int intValue) is false) + { + return; + } + + AddRangeFilter(query, fieldName, intValue, filterOperation); + } + + private void ApplyRangeDateFilter(IQuery query, string fieldName, string[] values, FilterOperation filterOperation) + { + if (TryParseDateTimeFilterValue(values.First(), out DateTime dateValue) is false) + { + return; + } + + AddRangeFilter(query, fieldName, dateValue, filterOperation); + } + + private void AddRangeFilter(IQuery query, string fieldName, T value, FilterOperation filterOperation) + where T : struct + { + T? min = null, max = null; + bool minInclusive = false, maxInclusive = false; + + switch (filterOperation) + { + case FilterOperation.GreaterThan: + case FilterOperation.GreaterThanOrEqual: + min = value; + minInclusive = filterOperation is FilterOperation.GreaterThanOrEqual; + break; + case FilterOperation.LessThan: + case FilterOperation.LessThanOrEqual: + max = value; + maxInclusive = filterOperation is FilterOperation.LessThanOrEqual; + break; + default: + throw new ArgumentOutOfRangeException(nameof(filterOperation)); + } + + query.RangeQuery(new[] { fieldName }, min, max, minInclusive, maxInclusive); + } + private void AddGroupedOrFilter(IQuery query, string fieldName, params T[] values) where T : struct { diff --git a/src/Umbraco.Core/DeliveryApi/FilterOperation.cs b/src/Umbraco.Core/DeliveryApi/FilterOperation.cs index 1e69f68c7f..6b897fd27d 100644 --- a/src/Umbraco.Core/DeliveryApi/FilterOperation.cs +++ b/src/Umbraco.Core/DeliveryApi/FilterOperation.cs @@ -5,5 +5,9 @@ public enum FilterOperation Is, IsNot, Contains, - DoesNotContain + DoesNotContain, + LessThan, + LessThanOrEqual, + GreaterThan, + GreaterThanOrEqual }