diff --git a/CHANGELOG.md b/CHANGELOG.md index d44f018e..d31ccf64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ Represents the **NuGet** versions. +## v3.25.1 +- *Fixed:* Extend `QueryFilterFieldConfigBase` to include `AsNullable()` to specifiy whether the field supports `null`. +- *Fixed:* Extend `QueryFilterFieldConfigBase` to include `WithResultWriter()` to specify a function to override the corresponding LINQ statement result writing. +- *Fixed:* Adjusted the fluent-style method-chaining interface to improve usability (and consistency). + ## v3.25.0 - *Enhancement:* Added new `CoreEx.Data` project/package to encapsulate all generic data-related capabilities, specifically the new `QueryFilterParser` and `QueryOrderByParser` classes. These enable a limited, explicitly supported, dynamic capability to `$filter` and `$orderby` an underlying query _similar_ to _OData_. This is **not** intended to be a replacement for the full capabilities of OData, GraphQL, etc. but to offer basic dynamic flexibility where needed. - Added `IQueryable.Where()` and `IQueryable.OrderBy` extension method that will use the aforementioned parsers configured within the new `QueryArgsConfig` and `QueryArgs` and apply leveraging `System.Linq.Dynamic.Core`. diff --git a/Common.targets b/Common.targets index e1a1de1d..6ba7b822 100644 --- a/Common.targets +++ b/Common.targets @@ -1,6 +1,6 @@  - 3.25.0 + 3.25.1 preview Avanade Avanade diff --git a/samples/My.Hr/My.Hr.Business/Services/EmployeeService.cs b/samples/My.Hr/My.Hr.Business/Services/EmployeeService.cs index d740a5cf..c375d848 100644 --- a/samples/My.Hr/My.Hr.Business/Services/EmployeeService.cs +++ b/samples/My.Hr/My.Hr.Business/Services/EmployeeService.cs @@ -6,8 +6,8 @@ public class EmployeeService : IEmployeeService { private static readonly QueryArgsConfig _queryConfig = QueryArgsConfig.Create() .WithFilter(filter => filter - .AddField("LastName", c => c.Operators(QueryFilterTokenKind.AllStringOperators).UseUpperCase()) - .AddField("FirstName", c => c.Operators(QueryFilterTokenKind.AllStringOperators).UseUpperCase()) + .AddField("LastName", c => c.WithOperators(QueryFilterOperator.AllStringOperators).WithUpperCase()) + .AddField("FirstName", c => c.WithOperators(QueryFilterOperator.AllStringOperators).WithUpperCase()) .AddField("StartDate") .AddField("TerminationDate") .AddField(nameof(Employee.Gender), c => c.WithValue(v => diff --git a/src/CoreEx.Data/Querying/Expressions/IQueryFilterFieldStatementExpression.cs b/src/CoreEx.Data/Querying/Expressions/IQueryFilterFieldStatementExpression.cs new file mode 100644 index 00000000..64edf7e5 --- /dev/null +++ b/src/CoreEx.Data/Querying/Expressions/IQueryFilterFieldStatementExpression.cs @@ -0,0 +1,20 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +namespace CoreEx.Data.Querying.Expressions +{ + /// + /// Identifies a query filter statement expression. + /// + public interface IQueryFilterFieldStatementExpression + { + /// + /// Gets the field . + /// + IQueryFilterFieldConfig FieldConfig { get; } + + /// + /// Gets the field . + /// + QueryFilterToken Field { get; } + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/Expressions/QueryFilterCloseParenthesisExpression.cs b/src/CoreEx.Data/Querying/Expressions/QueryFilterCloseParenthesisExpression.cs index ca0c2dec..017a51eb 100644 --- a/src/CoreEx.Data/Querying/Expressions/QueryFilterCloseParenthesisExpression.cs +++ b/src/CoreEx.Data/Querying/Expressions/QueryFilterCloseParenthesisExpression.cs @@ -17,8 +17,5 @@ public sealed class QueryFilterCloseParenthesisExpression(QueryFilterParser pars /// public override void WriteToResult(QueryFilterParserResult result) => result.FilterBuilder.Append(_syntax.ToLinq(Filter)); - - /// - protected override IQueryFilterFieldConfig? GetFieldConfig() => null; } } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/Expressions/QueryFilterExpressionBase.cs b/src/CoreEx.Data/Querying/Expressions/QueryFilterExpressionBase.cs index 8aad0792..8e9d2f53 100644 --- a/src/CoreEx.Data/Querying/Expressions/QueryFilterExpressionBase.cs +++ b/src/CoreEx.Data/Querying/Expressions/QueryFilterExpressionBase.cs @@ -65,12 +65,6 @@ public void AddToken(QueryFilterToken token) /// The . protected abstract void AddToken(int index, QueryFilterToken token); - /// - /// Gets the underlying used in the expression. - /// - /// The field where applicable; otherwise, . - protected abstract IQueryFilterFieldConfig? GetFieldConfig(); - /// /// Converts the query filter expression into the corresponding dynamic LINQ appending to the . /// diff --git a/src/CoreEx.Data/Querying/Expressions/QueryFilterLogicalExpression.cs b/src/CoreEx.Data/Querying/Expressions/QueryFilterLogicalExpression.cs index 3b26c90d..7a0849f3 100644 --- a/src/CoreEx.Data/Querying/Expressions/QueryFilterLogicalExpression.cs +++ b/src/CoreEx.Data/Querying/Expressions/QueryFilterLogicalExpression.cs @@ -50,8 +50,5 @@ public override void WriteToResult(QueryFilterParserResult result) if (_not.Kind != QueryFilterTokenKind.Unspecified) result.Append(_not.ToLinq(Filter)); } - - /// - protected override IQueryFilterFieldConfig? GetFieldConfig() => null; } } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/Expressions/QueryFilterOpenParenthesisExpression.cs b/src/CoreEx.Data/Querying/Expressions/QueryFilterOpenParenthesisExpression.cs index 99c0beb4..9658d929 100644 --- a/src/CoreEx.Data/Querying/Expressions/QueryFilterOpenParenthesisExpression.cs +++ b/src/CoreEx.Data/Querying/Expressions/QueryFilterOpenParenthesisExpression.cs @@ -17,8 +17,5 @@ public sealed class QueryFilterOpenParenthesisExpression(QueryFilterParser parse /// public override void WriteToResult(QueryFilterParserResult result) => result.Append(_syntax.ToLinq(Filter)); - - /// - protected override IQueryFilterFieldConfig? GetFieldConfig() => null; } } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/Expressions/QueryFilterOperatorExpression.cs b/src/CoreEx.Data/Querying/Expressions/QueryFilterOperatorExpression.cs index 058276fd..4af0f2f5 100644 --- a/src/CoreEx.Data/Querying/Expressions/QueryFilterOperatorExpression.cs +++ b/src/CoreEx.Data/Querying/Expressions/QueryFilterOperatorExpression.cs @@ -1,23 +1,25 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +using System; using System.Collections.Generic; namespace CoreEx.Data.Querying.Expressions { /// - /// Represents a query filter expression. + /// Represents a query filter expression. /// /// The . /// The originating query filter. /// The field . - public sealed class QueryFilterOperatorExpression(QueryFilterParser parser, string filter, QueryFilterToken field) : QueryFilterExpressionBase(parser, filter, field) + public sealed class QueryFilterOperatorExpression(QueryFilterParser parser, string filter, QueryFilterToken field) : QueryFilterExpressionBase(parser, filter, field), IQueryFilterFieldStatementExpression { + private IQueryFilterFieldConfig? _fieldConfig; private bool _isComplete; /// /// Gets the field . /// - public IQueryFilterFieldConfig? FieldConfig { get; private set; } + public IQueryFilterFieldConfig FieldConfig => _fieldConfig ?? throw new InvalidOperationException($"{nameof(FieldConfig)} must be set before it can be accessed."); /// /// Gets the field . @@ -38,7 +40,7 @@ public sealed class QueryFilterOperatorExpression(QueryFilterParser parser, stri public override bool IsComplete => _isComplete; /// - public override bool CanAddToken(QueryFilterToken token) => !_isComplete || TokenCount == 1 && QueryFilterTokenKind.Operator.HasFlag(token.Kind); + public override bool CanAddToken(QueryFilterToken token) => !_isComplete || TokenCount == 1 && QueryFilterTokenKind.ComparisonOperators.HasFlag(token.Kind); /// protected override void AddToken(int index, QueryFilterToken token) @@ -47,7 +49,7 @@ protected override void AddToken(int index, QueryFilterToken token) { case 0: Field = token; - FieldConfig = Parser.GetFieldConfig(Field, Filter); + _fieldConfig = Parser.GetFieldConfig(Field, Filter); _isComplete = FieldConfig.IsTypeBoolean; break; @@ -55,7 +57,8 @@ protected override void AddToken(int index, QueryFilterToken token) if (!QueryFilterTokenKind.AllStringOperators.HasFlag(token.Kind)) throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter).ToString()}' does not support '{token.GetRawToken(Filter).ToString()}' as an operator."); - if (!FieldConfig!.SupportedKinds.HasFlag(token.Kind)) + var op = (QueryFilterOperator)(int)token.Kind; + if (!FieldConfig.Operators.HasFlag(op)) throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter).ToString()}' does not support the '{token.GetRawToken(Filter).ToString()}' operator."); _isComplete = false; @@ -71,10 +74,10 @@ protected override void AddToken(int index, QueryFilterToken token) break; } - if (token.Kind == QueryFilterTokenKind.Null && !QueryFilterTokenKind.EqualityOperator.HasFlag(Operator.Kind)) + if (token.Kind == QueryFilterTokenKind.Null && !QueryFilterTokenKind.EqualityOperators.HasFlag(Operator.Kind)) throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter).ToString()}' constant must not be null for an '{Operator.GetRawToken(Filter).ToString()}' operator."); - FieldConfig!.ValidateConstant(Field, token, Filter); + FieldConfig.ValidateConstant(Field, token, Filter); Constants.Add(token); _isComplete = true; break; @@ -91,7 +94,7 @@ protected override void AddToken(int index, QueryFilterToken token) if (token.Kind == QueryFilterTokenKind.Null) throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter).ToString()}' constant must not be null for an '{Operator.GetRawToken(Filter).ToString()}' operator."); - FieldConfig!.ValidateConstant(Field, token, Filter); + FieldConfig.ValidateConstant(Field, token, Filter); Constants.Add(token); } else @@ -113,19 +116,24 @@ protected override void AddToken(int index, QueryFilterToken token) } } + /// + /// Gets the converted value using the specified . + /// + /// The index. + /// The converted value. + public object GetConstantValue(int index) => Constants[index].GetConvertedValue(Operator, Field, FieldConfig, Filter); + /// public override void WriteToResult(QueryFilterParserResult result) { - result.Fields.Add(FieldConfig!.Field); - - if (Operator.Kind != QueryFilterTokenKind.In && (Constants.Count == 0 || Constants[0].Kind != QueryFilterTokenKind.Null) && FieldConfig!.IsCheckForNotNull) + if (Operator.Kind != QueryFilterTokenKind.In && (Constants.Count == 0 || Constants[0].Kind != QueryFilterTokenKind.Null) && FieldConfig.IsCheckForNotNull) { result.Append("("); result.FilterBuilder.Append(FieldConfig.Model); result.FilterBuilder.Append(" != null && "); } - result.Append(FieldConfig!.Model); + result.Append(FieldConfig.Model); if (Constants.Count > 0) { @@ -144,7 +152,7 @@ public override void WriteToResult(QueryFilterParserResult result) if (i > 0) result.FilterBuilder.Append(", "); - result.AppendValue(Constants[i].GetConvertedValue(Operator, Field, FieldConfig, Filter)); + result.AppendValue(GetConstantValue(i)); } result.FilterBuilder.Append(')'); @@ -152,17 +160,14 @@ public override void WriteToResult(QueryFilterParserResult result) else { if (Constants[0].Kind == QueryFilterTokenKind.Value || Constants[0].Kind == QueryFilterTokenKind.Literal) - result.AppendValue(Constants[0].GetConvertedValue(Operator, Field, FieldConfig, Filter)); + result.AppendValue(GetConstantValue(0)); else result.FilterBuilder.Append(Constants[0].ToLinq(Filter)); } } - if (Operator.Kind != QueryFilterTokenKind.In && (Constants.Count == 0 || Constants[0].Kind != QueryFilterTokenKind.Null) && FieldConfig!.IsCheckForNotNull) + if (Operator.Kind != QueryFilterTokenKind.In && (Constants.Count == 0 || Constants[0].Kind != QueryFilterTokenKind.Null) && FieldConfig.IsCheckForNotNull) result.FilterBuilder.Append(')'); } - - /// - protected override IQueryFilterFieldConfig? GetFieldConfig() => FieldConfig; } } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/Expressions/QueryFilterStringFunctionExpression.cs b/src/CoreEx.Data/Querying/Expressions/QueryFilterStringFunctionExpression.cs index b7109d39..4510bedb 100644 --- a/src/CoreEx.Data/Querying/Expressions/QueryFilterStringFunctionExpression.cs +++ b/src/CoreEx.Data/Querying/Expressions/QueryFilterStringFunctionExpression.cs @@ -1,15 +1,18 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +using System; + namespace CoreEx.Data.Querying.Expressions { /// - /// Represents a query filter expression. + /// Represents a query filter expression. /// /// The . /// The originating query filter. /// The function - public sealed class QueryFilterStringFunctionExpression(QueryFilterParser parser, string filter, QueryFilterToken function) : QueryFilterExpressionBase(parser, filter, function) + public sealed class QueryFilterStringFunctionExpression(QueryFilterParser parser, string filter, QueryFilterToken function) : QueryFilterExpressionBase(parser, filter, function), IQueryFilterFieldStatementExpression { + private IQueryFilterFieldConfig? _fieldConfig; private bool _isComplete; /// @@ -20,7 +23,7 @@ public sealed class QueryFilterStringFunctionExpression(QueryFilterParser parser /// /// Gets the . /// - public IQueryFilterFieldConfig? FieldConfig { get; private set; } + public IQueryFilterFieldConfig FieldConfig => _fieldConfig ?? throw new InvalidOperationException($"{nameof(FieldConfig)} must be set before it can be accessed."); /// /// Gets the field . @@ -55,9 +58,10 @@ protected override void AddToken(int index, QueryFilterToken token) case 2: Field = token; - FieldConfig = Parser.GetFieldConfig(Field, Filter); + _fieldConfig = Parser.GetFieldConfig(Field, Filter); - if (!FieldConfig!.SupportedKinds.HasFlag(Function.Kind)) + var op = (QueryFilterOperator)(int)Function.Kind; + if (!FieldConfig.Operators.HasFlag(op)) throw new QueryFilterParserException($"Field '{Field.GetRawToken(Filter).ToString()}' does not support the '{Function.GetRawToken(Filter).ToString()}' function."); break; @@ -72,7 +76,7 @@ protected override void AddToken(int index, QueryFilterToken token) if (token.Kind == QueryFilterTokenKind.Null) throw new QueryFilterParserException($"A '{Function.GetRawToken(Filter).ToString()}' function references a null constant which is not supported."); - FieldConfig!.ValidateConstant(Field, token, Filter); + FieldConfig.ValidateConstant(Field, token, Filter); Constant = token; break; @@ -85,12 +89,16 @@ protected override void AddToken(int index, QueryFilterToken token) } } + /// + /// Gets the converted value. + /// + /// The converted value. + public object GetConstantValue() => Constant!.GetConvertedValue(Function, Field, FieldConfig, Filter); + /// public override void WriteToResult(QueryFilterParserResult result) { - result.Fields.Add(FieldConfig!.Field); - - if (FieldConfig!.IsCheckForNotNull) + if (FieldConfig.IsCheckForNotNull) { result.Append('('); result.FilterBuilder.Append(FieldConfig.Model); @@ -107,11 +115,8 @@ public override void WriteToResult(QueryFilterParserResult result) result.AppendValue(Constant.GetConvertedValue(Function, Field, FieldConfig, Filter)); result.FilterBuilder.Append(')'); - if (FieldConfig!.IsCheckForNotNull) + if (FieldConfig.IsCheckForNotNull) result.FilterBuilder.Append(')'); } - - /// - protected override IQueryFilterFieldConfig? GetFieldConfig() => FieldConfig; } } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterToken.cs b/src/CoreEx.Data/Querying/Expressions/QueryFilterToken.cs similarity index 99% rename from src/CoreEx.Data/Querying/QueryFilterToken.cs rename to src/CoreEx.Data/Querying/Expressions/QueryFilterToken.cs index 5403efe2..1b4b03f4 100644 --- a/src/CoreEx.Data/Querying/QueryFilterToken.cs +++ b/src/CoreEx.Data/Querying/Expressions/QueryFilterToken.cs @@ -2,7 +2,7 @@ using System; -namespace CoreEx.Data.Querying +namespace CoreEx.Data.Querying.Expressions { /// /// Represents a token. diff --git a/src/CoreEx.Data/Querying/QueryFilterTokenKind.cs b/src/CoreEx.Data/Querying/Expressions/QueryFilterTokenKind.cs similarity index 91% rename from src/CoreEx.Data/Querying/QueryFilterTokenKind.cs rename to src/CoreEx.Data/Querying/Expressions/QueryFilterTokenKind.cs index 60ea6b2c..d0852bbf 100644 --- a/src/CoreEx.Data/Querying/QueryFilterTokenKind.cs +++ b/src/CoreEx.Data/Querying/Expressions/QueryFilterTokenKind.cs @@ -2,7 +2,7 @@ using System; -namespace CoreEx.Data.Querying +namespace CoreEx.Data.Querying.Expressions { /// /// Provides the kind. @@ -128,12 +128,12 @@ public enum QueryFilterTokenKind /// /// An expression operator token. /// - Operator = Equal | NotEqual | GreaterThan | GreaterThanOrEqual | LessThan | LessThanOrEqual | In, + ComparisonOperators = Equal | NotEqual | GreaterThan | GreaterThanOrEqual | LessThan | LessThanOrEqual | In, /// /// An expression equality operator token. /// - EqualityOperator = Equal | NotEqual | In, + EqualityOperators = Equal | NotEqual | In, /// /// An expression constant token. @@ -153,11 +153,11 @@ public enum QueryFilterTokenKind /// /// A string oriented function-based operator. /// - StringFunction = StartsWith | EndsWith | Contains, + StringFunctions = StartsWith | EndsWith | Contains, /// /// All string oriented operators. /// - AllStringOperators = Operator | StringFunction + AllStringOperators = ComparisonOperators | StringFunctions } } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/IQueryFilterFieldConfig.cs b/src/CoreEx.Data/Querying/IQueryFilterFieldConfig.cs index fc56cdb2..4a15dfd9 100644 --- a/src/CoreEx.Data/Querying/IQueryFilterFieldConfig.cs +++ b/src/CoreEx.Data/Querying/IQueryFilterFieldConfig.cs @@ -1,7 +1,9 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +using CoreEx.Data.Querying.Expressions; using CoreEx.Mapping.Converters; using System; +using System.Text; namespace CoreEx.Data.Querying { @@ -44,8 +46,8 @@ public interface IQueryFilterFieldConfig /// /// Gets the supported kinds. /// - /// Where defaults to both and only; otherwise, defaults to . - QueryFilterTokenKind SupportedKinds { get; } + /// Where defaults to both and only; otherwise, defaults to . + QueryFilterOperator Operators { get; } /// /// Indicates whether the comparison should ignore case or not; will use when selected for comparisons. @@ -53,6 +55,11 @@ public interface IQueryFilterFieldConfig /// This is only applicable where the . bool IsToUpper { get; } + /// + /// Indicates whether the field can be or not. + /// + bool IsNullable { get; } + /// /// Indicates whether a not- check should also be performed before the comparion occurs. /// @@ -63,6 +70,11 @@ public interface IQueryFilterFieldConfig /// QueryStatement? DefaultStatement { get; } + /// + /// Gets the additional help text. + /// + string? HelpText { get; } + /// /// Converts to the destination type using the configurations where specified. /// @@ -80,5 +92,17 @@ public interface IQueryFilterFieldConfig /// The constant . /// The query filter. void ValidateConstant(QueryFilterToken field, QueryFilterToken constant, string filter); + + /// + /// Gets the . + /// + QueryFilterFieldResultWriter? ResultWriter { get; } + + /// + /// Appends the field configuration to the . + /// + /// The . + /// The . + StringBuilder AppendToString(StringBuilder stringBuilder); } } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryArgsConfig.cs b/src/CoreEx.Data/Querying/QueryArgsConfig.cs index 70cac640..c412fa00 100644 --- a/src/CoreEx.Data/Querying/QueryArgsConfig.cs +++ b/src/CoreEx.Data/Querying/QueryArgsConfig.cs @@ -22,7 +22,7 @@ public class QueryArgsConfig /// /// Gets the . /// - public QueryFilterParser FilterParser => _filterParser ??= new QueryFilterParser(); + public QueryFilterParser FilterParser => _filterParser ??= new QueryFilterParser(this); /// /// Indicates whether there is a . @@ -32,7 +32,7 @@ public class QueryArgsConfig /// /// Gets the . /// - public QueryOrderByParser OrderByParser => _orderByParser ??= new QueryOrderByParser(); + public QueryOrderByParser OrderByParser => _orderByParser ??= new QueryOrderByParser(this); /// /// Indicates whether there is an . diff --git a/src/CoreEx.Data/Querying/QueryFilterExtensions.cs b/src/CoreEx.Data/Querying/QueryFilterExtensions.cs index 401c47c6..ffdd8083 100644 --- a/src/CoreEx.Data/Querying/QueryFilterExtensions.cs +++ b/src/CoreEx.Data/Querying/QueryFilterExtensions.cs @@ -75,7 +75,7 @@ public static IQueryable OrderBy(this IQueryable query, QueryArgsConfig { queryConfig.ThrowIfNull(nameof(queryConfig)); if (!queryConfig.HasOrderByParser) - throw new QueryOrderByParserException("Capability is not currently supported."); + throw new QueryOrderByParserException("OrderBy statement is not currently supported."); var linq = queryConfig.OrderByParser.Parse(orderby.ThrowIfNullOrEmpty(nameof(orderby))); return query.OrderBy(linq); diff --git a/src/CoreEx.Data/Querying/QueryFilterFieldConfigBase.cs b/src/CoreEx.Data/Querying/QueryFilterFieldConfigBase.cs index 56414362..df828830 100644 --- a/src/CoreEx.Data/Querying/QueryFilterFieldConfigBase.cs +++ b/src/CoreEx.Data/Querying/QueryFilterFieldConfigBase.cs @@ -1,7 +1,9 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +using CoreEx.Data.Querying.Expressions; using CoreEx.Mapping.Converters; using System; +using System.Text; namespace CoreEx.Data.Querying { @@ -33,22 +35,37 @@ public QueryFilterFieldConfigBase(QueryFilterParser parser, Type type, string fi IsTypeBoolean = type == typeof(bool); if (IsTypeBoolean) - SupportedKinds = QueryFilterTokenKind.Equal | QueryFilterTokenKind.NotEqual; + Operators = QueryFilterOperator.Equal | QueryFilterOperator.NotEqual; else - SupportedKinds = QueryFilterTokenKind.Operator; + Operators = QueryFilterOperator.ComparisonOperators; } /// QueryFilterParser IQueryFilterFieldConfig.Parser => _parser; /// - Type IQueryFilterFieldConfig.Type => _type; + Type IQueryFilterFieldConfig.Type => Type; + + /// + /// Gets the field type. + /// + protected Type Type => _type; /// - string IQueryFilterFieldConfig.Field => _field; + string IQueryFilterFieldConfig.Field => Field; + + /// + /// Gets the field name. + /// + protected string Field => _field; /// - string? IQueryFilterFieldConfig.Model => _model ?? _field; + string? IQueryFilterFieldConfig.Model => Model; + + /// + /// Gets the model name to be used for the dynamic LINQ expression. + /// + protected string? Model => _model ?? _field; /// bool IQueryFilterFieldConfig.IsTypeString => IsTypeString; @@ -69,13 +86,13 @@ public QueryFilterFieldConfigBase(QueryFilterParser parser, Type type, string fi protected bool IsTypeBoolean { get; set; } /// - QueryFilterTokenKind IQueryFilterFieldConfig.SupportedKinds => SupportedKinds; + QueryFilterOperator IQueryFilterFieldConfig.Operators => Operators; /// - /// Gets the supported kinds. + /// Gets the supported (s). /// - /// Where defaults to both and ; otherwise, defaults to . - protected QueryFilterTokenKind SupportedKinds { get; set; } + /// Where defaults to both and ; otherwise, defaults to . + protected QueryFilterOperator Operators { get; set; } /// bool IQueryFilterFieldConfig.IsToUpper => IsToUpper; @@ -86,6 +103,14 @@ public QueryFilterFieldConfigBase(QueryFilterParser parser, Type type, string fi /// This is only applicable where the . protected bool IsToUpper { get; set; } = false; + /// + bool IQueryFilterFieldConfig.IsNullable => IsNullable; + + /// + /// Indicates whether the field can be or not. + /// + protected bool IsNullable { get; set; } = false; + /// bool IQueryFilterFieldConfig.IsCheckForNotNull => IsCheckForNotNull; @@ -98,10 +123,26 @@ public QueryFilterFieldConfigBase(QueryFilterParser parser, Type type, string fi QueryStatement? IQueryFilterFieldConfig.DefaultStatement => DefaultStatement; /// - /// Gets the default LINQ to be used where no filtering is specified. + /// Gets or sets the default LINQ to be used where no filtering is specified. /// protected QueryStatement? DefaultStatement { get; set; } + /// + QueryFilterFieldResultWriter? IQueryFilterFieldConfig.ResultWriter => ResultWriter; + + /// + /// Gets or sets the . + /// + protected QueryFilterFieldResultWriter? ResultWriter { get; set; } + + /// + string? IQueryFilterFieldConfig.HelpText => HelpText; + + /// + /// Gets or sets the additional help text. + /// + protected string? HelpText { get; set; } + /// object? IQueryFilterFieldConfig.ConvertToValue(QueryFilterToken operation, QueryFilterToken field, string filter) => ConvertToValue(operation, field, filter); @@ -126,6 +167,9 @@ void IQueryFilterFieldConfig.ValidateConstant(QueryFilterToken field, QueryFilte if (!QueryFilterTokenKind.Constant.HasFlag(constant.Kind)) throw new QueryFilterParserException($"Field '{field.GetRawToken(filter).ToString()}' constant '{constant.GetValueToken(filter)}' is not considered valid."); + if (constant.Kind == QueryFilterTokenKind.Null && !IsNullable) + throw new QueryFilterParserException($"Field '{field.GetRawToken(filter).ToString()}' constant '{constant.GetValueToken(filter)}' is not supported."); + if (IsTypeString) { if (!(constant.Kind == QueryFilterTokenKind.Literal || constant.Kind == QueryFilterTokenKind.Null)) @@ -142,5 +186,76 @@ void IQueryFilterFieldConfig.ValidateConstant(QueryFilterToken field, QueryFilte throw new QueryFilterParserException($"Field '{field.GetRawToken(filter).ToString()}' constant '{constant.GetValueToken(filter)}' must not be specified as a {QueryFilterTokenKind.Literal} where the underlying type is not a string."); } } + + /// + public override string ToString() => AppendToString(new StringBuilder()).ToString(); + + /// + /// Appends the field configuration to the . + /// + /// The . + /// The . + public virtual StringBuilder AppendToString(StringBuilder stringBuilder) + { + stringBuilder.Append(_field); + stringBuilder.Append(" (Type: ").Append(_type.Name); + stringBuilder.Append(", Null: ").Append(IsNullable ? "true" : "false"); + stringBuilder.Append(", Operators: "); + + AppendOperatorsToString(stringBuilder); + + stringBuilder.Append(')'); + if (!string.IsNullOrEmpty(HelpText)) + stringBuilder.Append(" - ").Append(HelpText); + + return stringBuilder; + } + + /// + /// Appends the to the . + /// + /// The . + /// The . + protected StringBuilder AppendOperatorsToString(StringBuilder stringBuilder) + { + var first = true; + foreach (var e in Enum.GetValues(typeof(QueryFilterOperator))) + { + if (Operators.HasFlag((QueryFilterOperator)e)) + { + var op = GetODataOperator((QueryFilterOperator)e); + if (op is not null) + { + if (first) + first = false; + else + stringBuilder.Append(", "); + + stringBuilder.Append(op); + } + } + } + + return stringBuilder; + } + + /// + /// Gets the ODATA operator for the specified + /// + /// The . + protected static string? GetODataOperator(QueryFilterOperator @operator) => @operator switch + { + QueryFilterOperator.Equal => "EQ", + QueryFilterOperator.NotEqual => "NE", + QueryFilterOperator.GreaterThan => "GT", + QueryFilterOperator.GreaterThanOrEqual => "GE", + QueryFilterOperator.LessThan => "LT", + QueryFilterOperator.LessThanOrEqual => "LE", + QueryFilterOperator.In => "IN", + QueryFilterOperator.StartsWith => nameof(QueryFilterOperator.StartsWith), + QueryFilterOperator.EndsWith => nameof(QueryFilterOperator.EndsWith), + QueryFilterOperator.Contains => nameof(QueryFilterOperator.Contains), + _ => null + }; } } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterFieldConfigBaseT.cs b/src/CoreEx.Data/Querying/QueryFilterFieldConfigBaseT.cs index e038b271..a0067b76 100644 --- a/src/CoreEx.Data/Querying/QueryFilterFieldConfigBaseT.cs +++ b/src/CoreEx.Data/Querying/QueryFilterFieldConfigBaseT.cs @@ -1,5 +1,6 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +using CoreEx.Data.Querying.Expressions; using System; namespace CoreEx.Data.Querying @@ -15,14 +16,26 @@ namespace CoreEx.Data.Querying public abstract class QueryFilterFieldConfigBase(QueryFilterParser parser, Type type, string field, string? model) : QueryFilterFieldConfigBase(parser, type, field, model) where TSelf : QueryFilterFieldConfigBase { + /// + /// Indicates that the field can be . + /// + /// The to support fluent-style method-chaining. + /// Sets the to . + public TSelf AsNullable() + { + IsNullable = true; + return (TSelf)this; + } + /// /// Indicates that a not- check should also be performed before a comparion occurs. /// /// The to support fluent-style method-chaining. - /// Sets the to . + /// Sets the and to . public TSelf AlsoCheckNotNull() { IsCheckForNotNull = true; + IsNullable = true; return (TSelf)this; } @@ -30,13 +43,35 @@ public TSelf AlsoCheckNotNull() /// Sets (overrides) the default default LINQ statement to be used where no filtering is specified. /// /// The LINQ . - /// + /// The to support fluent-style method-chaining. /// To avoid unnecessary parsing this should be specified as a valid dynamic LINQ statement. /// This must be the required expression only. It will be appended as an and to the final LINQ statement. - public TSelf Default(QueryStatement? statement) + public TSelf WithDefault(QueryStatement? statement) { DefaultStatement = statement; return (TSelf)this; } + + /// + /// Sets (overrides) the function that will be used to write the LINQ statement to the . + /// + /// The . + /// The to support fluent-style method-chaining. + public TSelf WithResultWriter(QueryFilterFieldResultWriter? resultWriter) + { + ResultWriter = resultWriter; + return (TSelf)this; + } + + /// + /// Sets (overrides) the additional help text. + /// + /// The additional help text. + /// The to support fluent-style method-chaining. + public TSelf WithHelpText(string text) + { + HelpText = text.ThrowIfNullOrEmpty(nameof(text)); + return (TSelf)this; + } } } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterFieldConfigT.cs b/src/CoreEx.Data/Querying/QueryFilterFieldConfigT.cs index 544a2be7..431e6ff7 100644 --- a/src/CoreEx.Data/Querying/QueryFilterFieldConfigT.cs +++ b/src/CoreEx.Data/Querying/QueryFilterFieldConfigT.cs @@ -1,5 +1,6 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +using CoreEx.Data.Querying.Expressions; using CoreEx.Mapping.Converters; using System; @@ -18,17 +19,17 @@ public class QueryFilterFieldConfig(QueryFilterParser parser, string field, s private Func? _valueFunc; /// - /// Sets (overrides) the operator . + /// Sets (overrides) the operator . /// - /// The supported flags. + /// The supported (s). /// The to support fluent-style method-chaining. - /// The default is . - public QueryFilterFieldConfig Operators(QueryFilterTokenKind kinds) + /// The default is . + public QueryFilterFieldConfig WithOperators(QueryFilterOperator operators) { if (((IQueryFilterFieldConfig)this).IsTypeBoolean) - throw new NotSupportedException($"{nameof(Operators)} is not supported where {nameof(IQueryFilterFieldConfig.IsTypeBoolean)}."); + throw new NotSupportedException($"{nameof(WithOperators)} is not supported where {nameof(IQueryFilterFieldConfig.IsTypeBoolean)}."); - SupportedKinds = kinds; + Operators = operators; return this; } @@ -37,10 +38,10 @@ public QueryFilterFieldConfig Operators(QueryFilterTokenKind kinds) /// /// The to support fluent-style method-chaining. /// Sets the to . - public QueryFilterFieldConfig UseUpperCase() + public QueryFilterFieldConfig WithUpperCase() { if (!((IQueryFilterFieldConfig)this).IsTypeString) - throw new ArgumentException($"A {nameof(UseUpperCase)} can only be specified where the field type is a string."); + throw new ArgumentException($"A {nameof(WithUpperCase)} can only be specified where the field type is a string."); IsToUpper = true; return this; diff --git a/src/CoreEx.Data/Querying/QueryFilterFieldResultWriter.cs b/src/CoreEx.Data/Querying/QueryFilterFieldResultWriter.cs new file mode 100644 index 00000000..6a004e58 --- /dev/null +++ b/src/CoreEx.Data/Querying/QueryFilterFieldResultWriter.cs @@ -0,0 +1,15 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx.Data.Querying.Expressions; + +namespace CoreEx.Data.Querying +{ + /// + /// The writing function that will be used to write the to the . + /// + /// The expression. + /// The . + /// indicates that a LINQ statement has been written to the and that the standard writer should not be invoked; + /// otherwise, indicates that the standard writer is to be invoked. + public delegate bool QueryFilterFieldResultWriter(IQueryFilterFieldStatementExpression expression, QueryFilterParserResult result); +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterNullFieldConfig.cs b/src/CoreEx.Data/Querying/QueryFilterNullFieldConfig.cs index 357b1e40..2eb6d81c 100644 --- a/src/CoreEx.Data/Querying/QueryFilterNullFieldConfig.cs +++ b/src/CoreEx.Data/Querying/QueryFilterNullFieldConfig.cs @@ -1,6 +1,9 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx using System; +using System.Data; +using System.Text; +using CoreEx.Data.Querying.Expressions; namespace CoreEx.Data.Querying { @@ -15,10 +18,35 @@ public class QueryFilterNullFieldConfig : QueryFilterFieldConfigBaseThe owning . /// The field name. /// The model name (defaults to . - public QueryFilterNullFieldConfig(QueryFilterParser parser, string field, string? model) : base(parser, typeof(object), field, model) => SupportedKinds = QueryFilterTokenKind.Equal | QueryFilterTokenKind.NotEqual; + public QueryFilterNullFieldConfig(QueryFilterParser parser, string field, string? model) : base(parser, typeof(object), field, model) + { + Operators = QueryFilterOperator.Equal | QueryFilterOperator.NotEqual; + IsNullable = true; + } /// protected override object ConvertToValue(QueryFilterToken operation, QueryFilterToken field, string filter) => throw new FormatException("Only null comparisons are supported."); + + /// + /// Appends the field configuration to the . + /// + /// The . + /// The . + public override StringBuilder AppendToString(StringBuilder stringBuilder) + { + stringBuilder.Append(Field); + stringBuilder.Append(" (Type: "); + stringBuilder.Append(", Null: ").Append(IsNullable ? "true" : "false"); + stringBuilder.Append(", Operators: "); + + AppendOperatorsToString(stringBuilder); + + stringBuilder.Append(')'); + if (!string.IsNullOrEmpty(HelpText)) + stringBuilder.Append(" - ").Append(HelpText); + + return stringBuilder; + } } } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterOperator.cs b/src/CoreEx.Data/Querying/QueryFilterOperator.cs new file mode 100644 index 00000000..9b8f3deb --- /dev/null +++ b/src/CoreEx.Data/Querying/QueryFilterOperator.cs @@ -0,0 +1,83 @@ +using System; +using CoreEx.Data.Querying.Expressions; + +namespace CoreEx.Data.Querying +{ + /// + /// Enables the . + /// + /// Values are the same as the to simplify usage. + [Flags] + public enum QueryFilterOperator + { + /// + /// The equal operator. + /// + Equal = 4, + + /// + /// The not equal operator. + /// + NotEqual = 8, + + /// + /// The less than operator. + /// + LessThan = 16, + + /// + /// The less than or equal operator. + /// + LessThanOrEqual = 32, + + /// + /// The greater than or equal operator. + /// + GreaterThanOrEqual = 64, + + /// + /// The greater than operator. + /// + GreaterThan = 128, + + /// + /// The logical IN operator. + /// + In = 256, + + /// + /// The starts with function. + /// + StartsWith = 524288, + + /// + /// The contains function. + /// + Contains = 1048576, + + /// + /// The ends with function. + /// + EndsWith = 2097152, + + /// + /// The equality operators. + /// + EqualityOperators = Equal | NotEqual | In, + + /// + /// The comparison operators. + /// + ComparisonOperators = EqualityOperators | GreaterThan | GreaterThanOrEqual | LessThan | LessThanOrEqual, + + /// + /// The string oriented function-based operators. + /// + StringFunctions = StartsWith | EndsWith | Contains, + + /// + /// All string oriented operators and functions. + /// + AllStringOperators = ComparisonOperators | StringFunctions + } +} \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterParser.cs b/src/CoreEx.Data/Querying/QueryFilterParser.cs index 7d21acd7..ddb56563 100644 --- a/src/CoreEx.Data/Querying/QueryFilterParser.cs +++ b/src/CoreEx.Data/Querying/QueryFilterParser.cs @@ -26,11 +26,23 @@ namespace CoreEx.Data.Querying /// .AddField<DateTime>(nameof(Employee.StartDate)) /// .AddNullField(nameof(Employee.Termination), nameof(EfModel.Employee.TerminationDate), c => c.Default(new QueryStatement($"{nameof(EfModel.Employee.TerminationDate)} == null")))); /// - public class QueryFilterParser() + /// The owning . + public sealed class QueryFilterParser(QueryArgsConfig owner) { private readonly Dictionary _fields = new(StringComparer.OrdinalIgnoreCase); private QueryStatement? _defaultStatement; private Action? _onQuery; + private string? _helpText; + + /// + /// Gets the owning . + /// + public QueryArgsConfig Owner => owner.ThrowIfNull(nameof(owner)); + + /// + /// Indicates that at least a single field has been configured. + /// + public bool HasFields => _fields.Count > 0; /// /// Adds a to the parser for the specified as-is. @@ -124,9 +136,15 @@ public QueryFilterParser OnQuery(Action? onQuery) } /// - /// Indicates that at least a single field has been configured. + /// Sets (override) the additional help text. /// - public bool HasFields => _fields.Count > 0; + /// The additional help text. + /// The to support fluent-style method-chaining. + public QueryFilterParser WithHelpText(string text) + { + _helpText = text; + return this; + } /// /// Trys and gets the specified . @@ -169,7 +187,14 @@ public QueryFilterParserResult Parse(string? filter) // Append all the expressions to the resulting LINQ whilst parsing. foreach (var expression in GetExpressions(filter)) { - WriteToResult(expression, result); + if (expression is IQueryFilterFieldStatementExpression fse) + { + result.Fields.Add(fse.FieldConfig.Field); + if (fse.FieldConfig.ResultWriter is not null && fse.FieldConfig.ResultWriter.Invoke(fse, result)) + continue; + } + + expression.WriteToResult(result); } // Append any default statements where no fields are in the filter. @@ -228,7 +253,7 @@ public IEnumerable GetExpressions(string? filter) canOpenParen = false; canLogical = true; } - else if (QueryFilterTokenKind.StringFunction.HasFlag(t.Kind)) + else if (QueryFilterTokenKind.StringFunctions.HasFlag(t.Kind)) { current = new QueryFilterStringFunctionExpression(this, filter, t); canOpenParen = false; @@ -420,65 +445,23 @@ private static int FindEndOfLiteral(ref ReadOnlySpan filter) return inQuote ? -1 : i; } - /// - /// Converts the query filter into the corresponding dynamic LINQ appending to the . - /// - /// The . - /// The . - /// Override this method to provide a custom dynamic LINQ conversion. - protected virtual void WriteToResult(QueryFilterExpressionBase expression, QueryFilterParserResult result) => expression.WriteToResult(result); - /// public override string ToString() { if (!HasFields) return "Filter statement is not currently supported."; - var sb = new StringBuilder("Supported field(s) are as follows:"); + var sb = new StringBuilder("Filter field(s) are as follows:"); foreach (var field in _fields) { - sb.AppendLine().Append(field.Key).Append(" (Type: ").Append(field.Value.Type.Name).Append(", Operations: "); - - var first = true; - foreach (var e in Enum.GetValues(typeof(QueryFilterTokenKind))) - { - if (field.Value.SupportedKinds.HasFlag((QueryFilterTokenKind)e)) - { - var op = GetODataOperator((QueryFilterTokenKind)e); - if (op is not null) - { - if (first) - first = false; - else - sb.Append(", "); - - sb.Append(op); - } - } - } - - sb.Append(')'); + sb.AppendLine(); + field.Value.AppendToString(sb); } + if (!string.IsNullOrEmpty(_helpText)) + sb.AppendLine().Append(_helpText); + return sb.ToString(); } - - /// - /// Gets the ODATA operator. - /// - private static string? GetODataOperator(QueryFilterTokenKind kind) => kind switch - { - QueryFilterTokenKind.Equal => "EQ", - QueryFilterTokenKind.NotEqual => "NE", - QueryFilterTokenKind.GreaterThan => "GT", - QueryFilterTokenKind.GreaterThanOrEqual => "GE", - QueryFilterTokenKind.LessThan => "LT", - QueryFilterTokenKind.LessThanOrEqual => "LE", - QueryFilterTokenKind.In => "IN", - QueryFilterTokenKind.StartsWith => nameof(QueryFilterTokenKind.StartsWith), - QueryFilterTokenKind.EndsWith => nameof(QueryFilterTokenKind.EndsWith), - QueryFilterTokenKind.Contains => nameof(QueryFilterTokenKind.Contains), - _ => null - }; } } \ No newline at end of file diff --git a/src/CoreEx.Data/Querying/QueryFilterReferenceDataFieldConfig.cs b/src/CoreEx.Data/Querying/QueryFilterReferenceDataFieldConfig.cs index 57ade843..9ca6f9f0 100644 --- a/src/CoreEx.Data/Querying/QueryFilterReferenceDataFieldConfig.cs +++ b/src/CoreEx.Data/Querying/QueryFilterReferenceDataFieldConfig.cs @@ -1,5 +1,6 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx +using CoreEx.Data.Querying.Expressions; using CoreEx.Entities; using CoreEx.RefData; using System; @@ -10,7 +11,7 @@ namespace CoreEx.Data.Querying /// Provides the field configuration. /// /// The . - /// This will automatically set the to be only. + /// Defaults the to only. public class QueryFilterReferenceDataFieldConfig : QueryFilterFieldConfigBase> where TRef : IReferenceData, new() { private bool _useIdentifier; @@ -25,7 +26,7 @@ namespace CoreEx.Data.Querying /// The model name (defaults to . public QueryFilterReferenceDataFieldConfig(QueryFilterParser parser, string field, string? model) : base(parser, typeof(TRef), field, model) { - SupportedKinds = QueryFilterTokenKind.EqualityOperator; + Operators = QueryFilterOperator.EqualityOperators; IsTypeString = true; } @@ -33,7 +34,7 @@ public QueryFilterReferenceDataFieldConfig(QueryFilterParser parser, string fiel /// Indicates that the is to be used as the value for the query (versus the originating filter value being the ). /// /// The to support fluent-style method-chaining. - /// This will automatically set the to be only as other operators are nonsensical in this context. + /// This will automatically set the to be only as other operators are nonsensical in this context. public QueryFilterReferenceDataFieldConfig UseIdentifier() { _useIdentifier = true; diff --git a/src/CoreEx.Data/Querying/QueryOrderByFieldConfig.cs b/src/CoreEx.Data/Querying/QueryOrderByFieldConfig.cs index 31759ae0..fda009fb 100644 --- a/src/CoreEx.Data/Querying/QueryOrderByFieldConfig.cs +++ b/src/CoreEx.Data/Querying/QueryOrderByFieldConfig.cs @@ -8,7 +8,7 @@ namespace CoreEx.Data.Querying /// The owning . /// The field name. /// The model name (defaults to . - public class QueryOrderByFieldConfig(QueryOrderByParser parser, string field, string? model) + public sealed class QueryOrderByFieldConfig(QueryOrderByParser parser, string field, string? model) { private readonly string? _model = model; @@ -32,17 +32,33 @@ public class QueryOrderByFieldConfig(QueryOrderByParser parser, string field, st /// Gets the supported . /// /// Defaults to . - public QueryOrderByDirection SupportedDirection { get; private set; } = QueryOrderByDirection.Both; + public QueryOrderByDirection Direction { get; private set; } = QueryOrderByDirection.Both; /// - /// Sets (overrides) the . + /// Gets the additional help text. + /// + public string? HelpText { get; private set; } + + /// + /// Sets (overrides) the . /// /// The . /// The to support fluent-style method-chaining. /// The default is . - public QueryOrderByFieldConfig Supports(QueryOrderByDirection supportedDirection) + public QueryOrderByFieldConfig WithDirection(QueryOrderByDirection supportedDirection) + { + Direction = supportedDirection; + return this; + } + + /// + /// Sets (overrides) the additional help text. + /// + /// The additional help text. + /// The to support fluent-style method-chaining. + public QueryOrderByFieldConfig WithHelpText(string text) { - SupportedDirection = supportedDirection; + HelpText = text.ThrowIfNullOrEmpty(nameof(text)); return this; } } diff --git a/src/CoreEx.Data/Querying/QueryOrderByParser.cs b/src/CoreEx.Data/Querying/QueryOrderByParser.cs index 66391876..ee14c4cc 100644 --- a/src/CoreEx.Data/Querying/QueryOrderByParser.cs +++ b/src/CoreEx.Data/Querying/QueryOrderByParser.cs @@ -11,15 +11,22 @@ namespace CoreEx.Data.Querying /// Represents a basic query sort order by parser with explicitly defined field support. /// /// This is not intended to be a replacement for OData, GraphQL, etc. but to provide a limited, explicitly supported, dynamic capability to sort an underlying query. - public sealed class QueryOrderByParser + /// The owning . + public sealed class QueryOrderByParser(QueryArgsConfig owner) { private readonly Dictionary _fields = new(StringComparer.OrdinalIgnoreCase); private Action? _validator; + private string? _helpText; + + /// + /// Gets the owning . + /// + public QueryArgsConfig Owner => owner.ThrowIfNull(nameof(owner)); /// /// Gets the default order-by dynamic LINQ statement. /// - /// To avoid unnecessary parsing this should have been specified as a valid dynamic LINQ statement. + /// To avoid unnecessary parsing this should be specified as a valid dynamic LINQ statement. public string? DefaultOrderBy { get; private set; } /// @@ -64,13 +71,24 @@ public QueryOrderByParser WithDefault(string? defaultOrderBy) } /// - /// Adds (overrides) a that can be used to further validate the fields specified in the order by. + /// Sets (override) the additional help text. + /// + /// The additional help text. + /// The to support fluent-style method-chaining. + public QueryOrderByParser WithHelpText(string text) + { + _helpText = text; + return this; + } + + /// + /// Sets (overrides) a that can be used to further validate the fields specified in the order by. /// /// The validator action. /// The to support fluent-style method-chaining. /// Throw a to have the validation message formatted correctly and consistently. /// The string[] passed into the validator will contain the parsed fields (names) in the order in which they were specified. - public QueryOrderByParser Validate(Action? validator) + public QueryOrderByParser WithValidator(Action? validator) { _validator = validator; return this; @@ -101,7 +119,7 @@ public string Parse(string? orderBy) if (parts.Length == 0) continue; else if (parts.Length > 2) - throw new QueryOrderByParserException("Invalid syntax."); + throw new QueryOrderByParserException("Statement is syntactically incorrect."); #if NET6_0_OR_GREATER var field = parts[0]; @@ -118,12 +136,17 @@ public string Parse(string? orderBy) var dir = parts.Length == 2 ? parts[1].Trim() : null; if (dir is not null) { - if (dir.Length > 2 && nameof(QueryOrderByDirection.Ascending).StartsWith(dir, StringComparison.OrdinalIgnoreCase)) - sb.Append(" asc"); - else if (dir.Length > 3 && nameof(QueryOrderByDirection.Descending).StartsWith(dir, StringComparison.OrdinalIgnoreCase)) + var direction = QueryOrderByDirection.Ascending; + if (dir.Length > 3 && nameof(QueryOrderByDirection.Descending).StartsWith(dir, StringComparison.OrdinalIgnoreCase)) + { sb.Append(" desc"); - else - throw new QueryOrderByParserException($"Direction '{dir}' must be either 'asc' (ascending) or 'desc' (descending)."); + direction = QueryOrderByDirection.Descending; + } + else if (!(dir.Length > 2 && nameof(QueryOrderByDirection.Ascending).StartsWith(dir, StringComparison.OrdinalIgnoreCase))) + throw new QueryOrderByParserException($"Field '{field}' direction '{dir}' is invalid; must be either 'asc' (ascending) or 'desc' (descending)."); + + if (!config.Direction.HasFlag(direction)) + throw new QueryOrderByParserException($"Field '{field}' direction '{dir}' is invalid; not supported."); } if (fields.Contains(config.Field)) @@ -138,6 +161,21 @@ public string Parse(string? orderBy) } /// - public override string ToString() => _fields.Count == 0 ? "OrderBy statement is not currently supported." : $"Supported field(s) are as follows: {string.Join(", ", _fields.Values.Select(x => x.Field))}."; + public override string ToString() + { + if (!HasFields) + return "Order-By statement is not currently supported."; + + var sb = new StringBuilder("Order-by field(s) are as follows:"); + foreach (var field in _fields) + { + sb.AppendLine().Append(field.Key).Append(" (Direction: ").Append(field.Value.Direction).Append(')'); + } + + if (!string.IsNullOrEmpty(_helpText)) + sb.AppendLine().Append(_helpText); + + return sb.ToString(); + } } } \ No newline at end of file diff --git a/src/CoreEx.Data/README.md b/src/CoreEx.Data/README.md index 7041f773..87a9e2be 100644 --- a/src/CoreEx.Data/README.md +++ b/src/CoreEx.Data/README.md @@ -18,6 +18,8 @@ However, the desire to provide a similar experience to the client remains. The ` _Note:_ This is **not** intended to be a replacement for [OData](https://learn.microsoft.com/en-us/odata/webapi-8/overview), [GraphQL](https://github.com/graphql-dotnet/graphql-dotnet), etc. but to provide a limited, explicitly supported, dynamic capability to filter an underlying query. +Where this capability is different is that the separation from the API contract and the underlying data source schema is maintained. This is achieved by using configuration to explicitly define the fields that can be filtered and ordered, whilst also defining their relationship to the data source. This is in contrast to OData and GraphQL where the data source schema is largely exposed to the client. +
### Features @@ -75,13 +77,13 @@ This contains the following key capabilities: Each of these properties have the ability to _explicitly_ add fields and their corresponding configuration. An example is as follows: ``` csharp -private static readonly QueryArgsConfig _queryConfig = QueryArgsConfig.Create() +private static readonly QueryArgsConfig _config = QueryArgsConfig.Create() .WithFilter(filter => filter - .AddField(nameof(Employee.LastName), c => c.Operators(QueryFilterTokenKind.AllStringOperators).UseUpperCase()) - .AddField(nameof(Employee.FirstName), c => c.Operators(QueryFilterTokenKind.AllStringOperators).UseUpperCase()) + .AddField(nameof(Employee.LastName), c => c.WithOperators(QueryFilterOperator.AllStringOperators).WithUpperCase()) + .AddField(nameof(Employee.FirstName), c => c.WithOperators(QueryFilterOperator.AllStringOperators).WithUpperCase()) .AddReferenceDataField(nameof(Employee.Gender), nameof(EfModel.Employee.GenderCode)) .AddField(nameof(Employee.StartDate)) - .AddNullField(nameof(Employee.Termination), nameof(EfModel.Employee.TerminationDate), c => c.Default(new QueryStatement($"{nameof(EfModel.Employee.TerminationDate)} == null")))) + .AddNullField(nameof(Employee.Termination), nameof(EfModel.Employee.TerminationDate), c => c.WithDefault(new QueryStatement($"{nameof(EfModel.Employee.TerminationDate)} == null")))) .WithOrderBy(orderby => orderby .AddField(nameof(Employee.LastName)) .AddField(nameof(Employee.FirstName)) @@ -97,8 +99,8 @@ The configuration is then used to parse and apply the filter and/or order-by to ``` csharp var query = new QueryArgs { - Filter = "LastName eq 'Doe' and startswith(firstname, 'a')", - OrderBy = "LastName desc, FirstName" + Filter = "LastName eq 'Doe' and startswith(firstname, 'a')", + OrderBy = "LastName desc, FirstName" }; return _dbContext.Employees.Where(_queryConfig, query).OrderBy(_queryConfig, query).ToCollectionResultAsync(paging); diff --git a/tests/CoreEx.Cosmos.Test/CosmosDbQueryTestcs.cs b/tests/CoreEx.Cosmos.Test/CosmosDbQueryTestcs.cs index 1c4a43e8..89f5d0b1 100644 --- a/tests/CoreEx.Cosmos.Test/CosmosDbQueryTestcs.cs +++ b/tests/CoreEx.Cosmos.Test/CosmosDbQueryTestcs.cs @@ -1,4 +1,5 @@ using CoreEx.Data.Querying; +using CoreEx.Data.Querying.Expressions; using CoreEx.Entities; namespace CoreEx.Cosmos.Test @@ -217,7 +218,7 @@ public async Task ModelQuery_WithFilter() { var qac = QueryArgsConfig.Create() .WithFilter(f => f - .AddField("Name", "Value.Name", c => c.Operators(QueryFilterTokenKind.AllStringOperators).UseUpperCase()) + .AddField("Name", "Value.Name", c => c.WithOperators(QueryFilterOperator.AllStringOperators).WithUpperCase()) .AddField("Birthday", "Value.Birthday")); var v = await _db.Persons3.ModelContainer.Query(q => q.Where(qac, QueryArgs.Create("endswith(name, 'Y')")).OrderBy(x => x.Id)).ToArrayAsync(); diff --git a/tests/CoreEx.Test/Framework/Data/QueryFilterParserTest.cs b/tests/CoreEx.Test/Framework/Data/QueryArgsConfigTest.cs similarity index 68% rename from tests/CoreEx.Test/Framework/Data/QueryFilterParserTest.cs rename to tests/CoreEx.Test/Framework/Data/QueryArgsConfigTest.cs index df47ff68..11359d63 100644 --- a/tests/CoreEx.Test/Framework/Data/QueryFilterParserTest.cs +++ b/tests/CoreEx.Test/Framework/Data/QueryArgsConfigTest.cs @@ -1,4 +1,5 @@ using CoreEx.Data.Querying; +using CoreEx.Data.Querying.Expressions; using NUnit.Framework; using System; using System.Linq; @@ -6,18 +7,25 @@ namespace CoreEx.Test.Framework.Data { [TestFixture] - public class QueryFilterParserTest + public class QueryArgsConfigTest { private static readonly QueryArgsConfig _queryConfig = QueryArgsConfig.Create() .WithFilter(filter => filter - .AddField("LastName", c => c.Operators(QueryFilterTokenKind.AllStringOperators).AlsoCheckNotNull()) - .AddField("FirstName", c => c.Operators(QueryFilterTokenKind.AllStringOperators).UseUpperCase()) + .AddField("LastName", c => c.WithOperators(QueryFilterOperator.AllStringOperators).AlsoCheckNotNull()) + .AddField("FirstName", c => c.WithOperators(QueryFilterOperator.AllStringOperators).WithUpperCase()) .AddField("Code") .AddField("Birthday", "BirthDate") - .AddField("Age") + .AddField("Age", c => c.WithHelpText("Age is but a number.")) .AddField("Salary") - .AddField("IsOld")) - .WithOrderBy(order => order.WithDefault("LastName, FirstName")); + .AddField("IsOld", c => c.AsNullable()) + .AddNullField("Terminated", "TerminatedDate") + .WithHelpText($"---{Environment.NewLine}Note: The OData-like filtering is awesome!")) + .WithOrderBy(order => order + .AddField("FirstName") + .AddField("LastName") + .AddField("Birthday", "BirthDate", c => c.WithDirection(QueryOrderByDirection.Descending)) + .WithDefault("LastName, FirstName") + .WithHelpText($"---{Environment.NewLine}Note: The OData-like ordering is awesome!")); private static void AssertFilter(string filter, string expected, params object[] expectedArgs) => AssertFilter(_queryConfig, filter, expected, expectedArgs); @@ -46,7 +54,7 @@ private static void AssertException(QueryArgsConfig config, string? filter, stri } [Test] - public void Parse_SimpleValid() + public void FilterParser_SimpleValid() { AssertFilter("lastname eq 'Smith'", "(LastName != null && LastName == @0)", "Smith"); AssertFilter("lastname eq null", "LastName == null"); @@ -66,7 +74,7 @@ public void Parse_SimpleValid() } [Test] - public void Parse_In() + public void FilterParser_In() { AssertFilter("code in ('abc', 'def')", "Code in (@0, @1)", "abc", "def"); AssertFilter("age in (20, 30, 40)", "Age in (@0, @1, @2)", 20, 30, 40); @@ -82,7 +90,7 @@ public void Parse_In() } [Test] - public void Parse_ComplexValid() + public void FilterParser_ComplexValid() { AssertFilter("(age eq 1 or age eq 2) and isold eq true", "(Age == @0 || Age == @1) && IsOld == true", 1, 2); AssertFilter("(age eq 1 or age eq 2 ) and isold ", "(Age == @0 || Age == @1) && IsOld", 1, 2); @@ -91,7 +99,7 @@ public void Parse_ComplexValid() } [Test] - public void Parse_Invalid() + public void FilterParser_Invalid() { AssertException("banana", "Field 'banana' is not supported."); AssertException("banana eq", "Field 'banana' is not supported."); @@ -117,10 +125,11 @@ public void Parse_Invalid() AssertException("age ( 1", "Field 'age' does not support '(' as an operator."); AssertException("age eq (", "Field 'age' constant '(' is not considered valid."); AssertException("age eq )", "Field 'age' constant ')' is not considered valid."); + AssertException("age eq null", "Field 'age' constant 'null' is not supported."); } [Test] - public void Parse_Literals() + public void FilterParser_Literals() { AssertException("code eq '", "A Literal has not been terminated."); AssertException("code eq '''", "A Literal has not been terminated."); @@ -140,7 +149,7 @@ public void Parse_Literals() } [Test] - public void Parse_StringFunction() + public void FilterParser_StringFunction() { AssertFilter("startswith(firstName, 'abc')", "FirstName.ToUpper().StartsWith(@0)", "ABC"); AssertFilter("endswith(firstName, 'abc')", "FirstName.ToUpper().EndsWith(@0)", "ABC"); @@ -155,7 +164,7 @@ public void Parse_StringFunction() } [Test] - public void Parse_Not() + public void FilterParser_Not() { AssertFilter("not (age eq 1)", "!(Age == @0)", 1); AssertFilter("age eq 1 and not (age eq 2)", "Age == @0 && !(Age == @1)", 1, 2); @@ -165,11 +174,11 @@ public void Parse_Not() } [Test] - public void Parse_Field_Default() + public void FilterParser_Field_Default() { var config = QueryArgsConfig.Create() .WithFilter(filter => filter - .AddField("LastName", c => c.Default(new QueryStatement("LastName == @0", "Brown"))) + .AddField("LastName", c => c.WithDefault(new QueryStatement("LastName == @0", "Brown"))) .AddField("FirstName") .Default(new QueryStatement("FirstName == @0", "Zoe"))); @@ -179,7 +188,7 @@ public void Parse_Field_Default() } [Test] - public void Parse_Default() + public void FilterParser_Default() { var config = QueryArgsConfig.Create() .WithFilter(filter => filter @@ -193,7 +202,7 @@ public void Parse_Default() } [Test] - public void Parse_Field_OnQuery() + public void FilterParser_Field_OnQuery() { var config = QueryArgsConfig.Create() .WithFilter(filter => filter @@ -216,7 +225,7 @@ public void Parse_Field_OnQuery() } [Test] - public void Parse_Null() + public void FilterParser_Null() { var config = QueryArgsConfig.Create() .WithFilter(filter => filter @@ -230,18 +239,89 @@ public void Parse_Null() } [Test] - public void ToStringHelp() + public void FilterParser_StatementWriter() + { + static bool LastNameWriter(IQueryFilterFieldStatementExpression expression, QueryFilterParserResult result) + { + if (expression is QueryFilterOperatorExpression oex && oex.Operator.Kind == QueryFilterTokenKind.Equal) + { + result.AppendStatement(new QueryStatement("LastName EQUALS @0", oex.GetConstantValue(0))); + return true; + } + + return false; + } + + var config = QueryArgsConfig.Create() + .WithFilter(filter => filter + .AddField("LastName", c => c.WithResultWriter(LastNameWriter)) + .AddField("FirstName")); + + AssertFilter(config, "lastname ne 'abc'", "LastName != @0", "abc"); + AssertFilter(config, "lastname eq 'abc'", "LastName EQUALS @0", "abc"); + } + + [Test] + public void FilterParser_ToString() { var s = _queryConfig.FilterParser.ToString(); Console.WriteLine(s); - Assert.That(s, Is.EqualTo(@"Supported field(s) are as follows: -LastName (Type: String, Operations: EQ, NE, LT, LE, GE, GT, IN, StartsWith, Contains, EndsWith) -FirstName (Type: String, Operations: EQ, NE, LT, LE, GE, GT, IN, StartsWith, Contains, EndsWith) -Code (Type: String, Operations: EQ, NE, LT, LE, GE, GT, IN) -Birthday (Type: DateTime, Operations: EQ, NE, LT, LE, GE, GT, IN) -Age (Type: Int32, Operations: EQ, NE, LT, LE, GE, GT, IN) -Salary (Type: Decimal, Operations: EQ, NE, LT, LE, GE, GT, IN) -IsOld (Type: Boolean, Operations: EQ, NE)")); + Assert.That(s, Is.EqualTo(@"Filter field(s) are as follows: +LastName (Type: String, Null: true, Operators: EQ, NE, LT, LE, GE, GT, IN, StartsWith, Contains, EndsWith) +FirstName (Type: String, Null: false, Operators: EQ, NE, LT, LE, GE, GT, IN, StartsWith, Contains, EndsWith) +Code (Type: String, Null: false, Operators: EQ, NE, LT, LE, GE, GT, IN) +Birthday (Type: DateTime, Null: false, Operators: EQ, NE, LT, LE, GE, GT, IN) +Age (Type: Int32, Null: false, Operators: EQ, NE, LT, LE, GE, GT, IN) - Age is but a number. +Salary (Type: Decimal, Null: false, Operators: EQ, NE, LT, LE, GE, GT, IN) +IsOld (Type: Boolean, Null: true, Operators: EQ, NE) +Terminated (Type: , Null: true, Operators: EQ, NE) +--- +Note: The OData-like filtering is awesome!")); + } + + [Test] + public void OrderByParser_Valid() + { + Assert.Multiple(() => + { + Assert.That(_queryConfig.OrderByParser.Parse("firstname, birthday desc"), Is.EqualTo("FirstName, BirthDate desc")); + Assert.That(_queryConfig.OrderByParser.Parse("lastname asc, birthday desc"), Is.EqualTo("LastName, BirthDate desc")); + }); + } + + [Test] + public void OrderByParser_Invalid() + { + void AssertException(string? orderBy, string expected) + { + var ex = Assert.Throws(() => _queryConfig.OrderByParser.Parse(orderBy)); + Assert.That(ex.Messages, Is.Not.Null); + Assert.That(ex.Messages, Has.Count.EqualTo(1)); + Assert.Multiple(() => + { + Assert.That(ex.Messages.First().Property, Is.EqualTo("$orderby")); + Assert.That(ex.Messages.First().Text, Does.StartWith(expected)); + }); + } + + AssertException("firstname, middlename", "Field 'middlename' is not supported."); + AssertException("firstname, birthday asc", "Field 'birthday' direction 'asc' is invalid; not supported."); + AssertException("firstname, birthday both", "Field 'birthday' direction 'both' is invalid; must be either 'asc' (ascending) or 'desc' (descending)."); + AssertException("firstname asc, firstname desc", "Field 'firstname' must not be specified more than once."); + AssertException("firstname asc desc", "Statement is syntactically incorrect."); + } + + [Test] + public void OrderByParser_ToString() + { + var s = _queryConfig.OrderByParser.ToString(); + Console.WriteLine(s); + Assert.That(s, Is.EqualTo(@"Order-by field(s) are as follows: +FirstName (Direction: Both) +LastName (Direction: Both) +Birthday (Direction: Descending) +--- +Note: The OData-like ordering is awesome!")); } } } \ No newline at end of file