diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs index 0a1e3b5a0d..a4697287ab 100644 --- a/src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs +++ b/src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs @@ -678,13 +678,13 @@ private CountNode GetCountClause(CountExpression expression, TableAccessorNode o public override SqlTreeNode VisitMatchText(MatchTextExpression expression, TableAccessorNode tableAccessor) { - var column = (ColumnNode)Visit(expression.TargetAttribute, tableAccessor); + var column = (ColumnNode)Visit(expression.MatchTarget, tableAccessor); return new LikeNode(column, expression.MatchKind, (string)expression.TextValue.TypedValue); } public override SqlTreeNode VisitAny(AnyExpression expression, TableAccessorNode tableAccessor) { - var column = (ColumnNode)Visit(expression.TargetAttribute, tableAccessor); + var column = (ColumnNode)Visit(expression.MatchTarget, tableAccessor); ReadOnlyCollection parameters = VisitSequence(expression.Constants.OrderBy(constant => constant.TypedValue), tableAccessor); diff --git a/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs index 799232b155..3d88cd1b5d 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs @@ -17,21 +17,21 @@ namespace JsonApiDotNetCore.Queries.Expressions; public class AnyExpression : FilterExpression { /// - /// The attribute whose value to compare. Chain format: an optional list of to-one relationships, followed by an attribute. + /// The function or attribute whose value to compare. Attribute chain format: an optional list of to-one relationships, followed by an attribute. /// - public ResourceFieldChainExpression TargetAttribute { get; } + public QueryExpression MatchTarget { get; } /// /// One or more constants to compare the attribute's value against. /// public IImmutableSet Constants { get; } - public AnyExpression(ResourceFieldChainExpression targetAttribute, IImmutableSet constants) + public AnyExpression(QueryExpression matchTarget, IImmutableSet constants) { - ArgumentNullException.ThrowIfNull(targetAttribute); + ArgumentNullException.ThrowIfNull(matchTarget); ArgumentGuard.NotNullNorEmpty(constants); - TargetAttribute = targetAttribute; + MatchTarget = matchTarget; Constants = constants; } @@ -56,7 +56,7 @@ private string InnerToString(bool toFullString) builder.Append(Keywords.Any); builder.Append('('); - builder.Append(toFullString ? TargetAttribute.ToFullString() : TargetAttribute.ToString()); + builder.Append(toFullString ? MatchTarget.ToFullString() : MatchTarget.ToString()); builder.Append(','); builder.Append(string.Join(',', Constants.Select(constant => toFullString ? constant.ToFullString() : constant.ToString()).Order())); builder.Append(')'); @@ -78,13 +78,13 @@ public override bool Equals(object? obj) var other = (AnyExpression)obj; - return TargetAttribute.Equals(other.TargetAttribute) && Constants.SetEquals(other.Constants); + return MatchTarget.Equals(other.MatchTarget) && Constants.SetEquals(other.Constants); } public override int GetHashCode() { var hashCode = new HashCode(); - hashCode.Add(TargetAttribute); + hashCode.Add(MatchTarget); foreach (LiteralConstantExpression constant in Constants) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs index 96568b7a67..fe4ae35f86 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs @@ -24,12 +24,12 @@ namespace JsonApiDotNetCore.Queries.Expressions; public class MatchTextExpression : FilterExpression { /// - /// The attribute whose value to match. Chain format: an optional list of to-one relationships, followed by an attribute. + /// The function or attribute whose value to match. Attribute chain format: an optional list of to-one relationships, followed by an attribute. /// - public ResourceFieldChainExpression TargetAttribute { get; } + public QueryExpression MatchTarget { get; } /// - /// The text to match the attribute's value against. + /// The text to match against. /// public LiteralConstantExpression TextValue { get; } @@ -38,12 +38,12 @@ public class MatchTextExpression : FilterExpression /// public TextMatchKind MatchKind { get; } - public MatchTextExpression(ResourceFieldChainExpression targetAttribute, LiteralConstantExpression textValue, TextMatchKind matchKind) + public MatchTextExpression(QueryExpression matchTarget, LiteralConstantExpression textValue, TextMatchKind matchKind) { - ArgumentNullException.ThrowIfNull(targetAttribute); + ArgumentNullException.ThrowIfNull(matchTarget); ArgumentNullException.ThrowIfNull(textValue); - TargetAttribute = targetAttribute; + MatchTarget = matchTarget; TextValue = textValue; MatchKind = matchKind; } @@ -71,8 +71,8 @@ private string InnerToString(bool toFullString) builder.Append('('); builder.Append(toFullString - ? string.Join(',', TargetAttribute.ToFullString(), TextValue.ToFullString()) - : string.Join(',', TargetAttribute.ToString(), TextValue.ToString())); + ? string.Join(',', MatchTarget.ToFullString(), TextValue.ToFullString()) + : string.Join(',', MatchTarget.ToString(), TextValue.ToString())); builder.Append(')'); @@ -93,11 +93,11 @@ public override bool Equals(object? obj) var other = (MatchTextExpression)obj; - return TargetAttribute.Equals(other.TargetAttribute) && TextValue.Equals(other.TextValue) && MatchKind == other.MatchKind; + return MatchTarget.Equals(other.MatchTarget) && TextValue.Equals(other.TextValue) && MatchKind == other.MatchKind; } public override int GetHashCode() { - return HashCode.Combine(TargetAttribute, TextValue, MatchKind); + return HashCode.Combine(MatchTarget, TextValue, MatchKind); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs index 071491b8a7..2da39bb2fc 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs @@ -149,12 +149,12 @@ public override QueryExpression VisitPagination(PaginationExpression expression, public override QueryExpression? VisitMatchText(MatchTextExpression expression, TArgument argument) { - var newTargetAttribute = Visit(expression.TargetAttribute, argument) as ResourceFieldChainExpression; + var newMatchTarget = Visit(expression.MatchTarget, argument) as ResourceFieldChainExpression; var newTextValue = Visit(expression.TextValue, argument) as LiteralConstantExpression; - if (newTargetAttribute != null && newTextValue != null) + if (newMatchTarget != null && newTextValue != null) { - var newExpression = new MatchTextExpression(newTargetAttribute, newTextValue, expression.MatchKind); + var newExpression = new MatchTextExpression(newMatchTarget, newTextValue, expression.MatchKind); return newExpression.Equals(expression) ? expression : newExpression; } @@ -163,12 +163,12 @@ public override QueryExpression VisitPagination(PaginationExpression expression, public override QueryExpression? VisitAny(AnyExpression expression, TArgument argument) { - var newTargetAttribute = Visit(expression.TargetAttribute, argument) as ResourceFieldChainExpression; + var newMatchTarget = Visit(expression.MatchTarget, argument) as ResourceFieldChainExpression; IImmutableSet newConstants = VisitSet(expression.Constants, argument); - if (newTargetAttribute != null) + if (newMatchTarget != null) { - var newExpression = new AnyExpression(newTargetAttribute, newConstants); + var newExpression = new AnyExpression(newMatchTarget, newConstants); return newExpression.Equals(expression) ? expression : newExpression; } diff --git a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs index 34f88f1c77..3001c19296 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs @@ -320,10 +320,37 @@ protected virtual MatchTextExpression ParseTextMatch(string operatorName) EatText(operatorName); EatSingleCharacterToken(TokenKind.OpenParen); + QueryExpression matchTarget = ParseTextMatchLeftTerm(); + + EatSingleCharacterToken(TokenKind.Comma); + + ConstantValueConverter constantValueConverter = GetConstantValueConverterForType(typeof(string)); + LiteralConstantExpression constant = ParseConstant(constantValueConverter); + + EatSingleCharacterToken(TokenKind.CloseParen); + + var matchKind = Enum.Parse(operatorName.Pascalize()); + return new MatchTextExpression(matchTarget, constant, matchKind); + } + + private QueryExpression ParseTextMatchLeftTerm() + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text } && IsFunction(nextToken.Value!)) + { + FunctionExpression targetFunction = ParseFunction(); + + if (targetFunction.ReturnType != typeof(string)) + { + throw new QueryParseException("Function that returns type 'String' expected.", nextToken.Position); + } + + return targetFunction; + } + int chainStartPosition = GetNextTokenPositionOrEnd(); - ResourceFieldChainExpression targetAttributeChain = - ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, + ResourceTypeInScope, null); var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1]; @@ -333,15 +360,7 @@ protected virtual MatchTextExpression ParseTextMatch(string operatorName) throw new QueryParseException("Attribute of type 'String' expected.", position); } - EatSingleCharacterToken(TokenKind.Comma); - - ConstantValueConverter constantValueConverter = GetConstantValueConverterForAttribute(targetAttribute); - LiteralConstantExpression constant = ParseConstant(constantValueConverter); - - EatSingleCharacterToken(TokenKind.CloseParen); - - var matchKind = Enum.Parse(operatorName.Pascalize()); - return new MatchTextExpression(targetAttributeChain, constant, matchKind); + return targetAttributeChain; } protected virtual AnyExpression ParseAny() @@ -349,16 +368,13 @@ protected virtual AnyExpression ParseAny() EatText(Keywords.Any); EatSingleCharacterToken(TokenKind.OpenParen); - ResourceFieldChainExpression targetAttributeChain = - ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); - - var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1]; + (QueryExpression matchTarget, Func constantValueConverterFactory) = ParseAnyLeftTerm(); EatSingleCharacterToken(TokenKind.Comma); ImmutableHashSet.Builder constantsBuilder = ImmutableHashSet.CreateBuilder(); - ConstantValueConverter constantValueConverter = GetConstantValueConverterForAttribute(targetAttribute); + ConstantValueConverter constantValueConverter = constantValueConverterFactory(); LiteralConstantExpression constant = ParseConstant(constantValueConverter); constantsBuilder.Add(constant); @@ -374,7 +390,26 @@ protected virtual AnyExpression ParseAny() IImmutableSet constantSet = constantsBuilder.ToImmutable(); - return new AnyExpression(targetAttributeChain, constantSet); + return new AnyExpression(matchTarget, constantSet); + } + + private (QueryExpression matchTarget, Func constantValueConverterFactory) ParseAnyLeftTerm() + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text } && IsFunction(nextToken.Value!)) + { + FunctionExpression targetFunction = ParseFunction(); + + Func functionConverterFactory = () => GetConstantValueConverterForType(targetFunction.ReturnType); + return (targetFunction, functionConverterFactory); + } + + ResourceFieldChainExpression targetAttributeChain = + ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + + var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1]; + + Func attributeConverterFactory = () => GetConstantValueConverterForAttribute(targetAttribute); + return (targetAttributeChain, attributeConverterFactory); } protected virtual HasExpression ParseHas() diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs index 772b8dd18d..0000456cc5 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs @@ -90,7 +90,7 @@ public override Expression VisitIsType(IsTypeExpression expression, QueryClauseB public override Expression VisitMatchText(MatchTextExpression expression, QueryClauseBuilderContext context) { - Expression property = Visit(expression.TargetAttribute, context); + Expression property = Visit(expression.MatchTarget, context); if (property.Type != typeof(string)) { @@ -109,7 +109,7 @@ public override Expression VisitMatchText(MatchTextExpression expression, QueryC public override Expression VisitAny(AnyExpression expression, QueryClauseBuilderContext context) { - Expression property = Visit(expression.TargetAttribute, context); + Expression property = Visit(expression.MatchTarget, context); var valueList = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(property.Type))!; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs index 1e4234c259..c071c4e4d4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs @@ -50,12 +50,15 @@ public CarExpressionRewriter(IResourceGraph resourceGraph) public override QueryExpression? VisitAny(AnyExpression expression, object? argument) { - PropertyInfo property = expression.TargetAttribute.Fields[^1].Property; - - if (IsCarId(property)) + if (expression.MatchTarget is ResourceFieldChainExpression targetAttributeChain) { - string[] carStringIds = expression.Constants.Select(constant => (string)constant.TypedValue).ToArray(); - return RewriteFilterOnCarStringIds(expression.TargetAttribute, carStringIds); + PropertyInfo property = targetAttributeChain.Fields[^1].Property; + + if (IsCarId(property)) + { + string[] carStringIds = expression.Constants.Select(constant => (string)constant.TypedValue).ToArray(); + return RewriteFilterOnCarStringIds(targetAttributeChain, carStringIds); + } } return base.VisitAny(expression, argument); @@ -63,11 +66,14 @@ public CarExpressionRewriter(IResourceGraph resourceGraph) public override QueryExpression? VisitMatchText(MatchTextExpression expression, object? argument) { - PropertyInfo property = expression.TargetAttribute.Fields[^1].Property; - - if (IsCarId(property)) + if (expression.MatchTarget is ResourceFieldChainExpression targetAttributeChain) { - throw new NotSupportedException("Partial text matching on Car IDs is not possible."); + PropertyInfo property = targetAttributeChain.Fields[^1].Property; + + if (IsCarId(property)) + { + throw new NotSupportedException("Partial text matching on Car IDs is not possible."); + } } return base.VisitMatchText(expression, argument); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DatabaseFunctionStub.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DatabaseFunctionStub.cs new file mode 100644 index 0000000000..35fbb050f6 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DatabaseFunctionStub.cs @@ -0,0 +1,16 @@ +using System.Reflection; + +#pragma warning disable AV1008 // Class should not be static + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.Decrypt; + +internal static class DatabaseFunctionStub +{ + public static readonly MethodInfo DecryptMethod = typeof(DatabaseFunctionStub).GetMethod(nameof(Decrypt), [typeof(string)])!; + + public static string Decrypt(string text) + { + _ = text; + throw new InvalidOperationException($"The '{nameof(Decrypt)}' user-defined SQL function cannot be called client-side."); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptDbContext.cs new file mode 100644 index 0000000000..76b330c69b --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptDbContext.cs @@ -0,0 +1,34 @@ +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; + +// @formatter:wrap_chained_method_calls chop_always + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.Decrypt; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class DecryptDbContext(DbContextOptions options) + : TestableDbContext(options) +{ + public DbSet Blogs => Set(); + + protected override void OnModelCreating(ModelBuilder builder) + { + QueryStringDbContext.ConfigureModel(builder); + + builder.HasDbFunction(DatabaseFunctionStub.DecryptMethod) + .HasName("decrypt_column_value"); + + base.OnModelCreating(builder); + } + + internal async Task DeclareDecryptFunctionAsync() + { + // Just for demo purposes, decryption is defined as: base64-decode the incoming value. + await Database.ExecuteSqlRawAsync(""" + CREATE OR REPLACE FUNCTION decrypt_column_value(value text) + RETURNS text + RETURN encode(decode(value, 'base64'), 'escape'); + """); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptExpression.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptExpression.cs new file mode 100644 index 0000000000..0dddf0317f --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptExpression.cs @@ -0,0 +1,80 @@ +using System.Text; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.Decrypt; + +/// +/// This expression allows to call the user-defined "decrypt_column_value" database function. It represents the "decrypt" function, resulting from text +/// such as: +/// +/// decrypt(title) +/// +/// , or: +/// +/// decrypt(owner.lastName) +/// +/// . +/// +internal sealed class DecryptExpression(ResourceFieldChainExpression targetAttribute) : FunctionExpression +{ + public const string Keyword = "decrypt"; + + /// + /// The CLR type this function returns, which is always . + /// + public override Type ReturnType { get; } = typeof(string); + + /// + /// The string attribute to decrypt. Chain format: an optional list of to-one relationships, followed by an attribute. + /// + public ResourceFieldChainExpression TargetAttribute { get; } = targetAttribute; + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.DefaultVisit(this, argument); + } + + public override string ToString() + { + return InnerToString(false); + } + + public override string ToFullString() + { + return InnerToString(true); + } + + private string InnerToString(bool toFullString) + { + var builder = new StringBuilder(); + + builder.Append(Keyword); + builder.Append('('); + builder.Append(toFullString ? TargetAttribute.ToFullString() : TargetAttribute.ToString()); + builder.Append(')'); + + return builder.ToString(); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj is null || GetType() != obj.GetType()) + { + return false; + } + + var other = (DecryptExpression)obj; + + return TargetAttribute.Equals(other.TargetAttribute); + } + + public override int GetHashCode() + { + return TargetAttribute.GetHashCode(); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParseTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParseTests.cs new file mode 100644 index 0000000000..06fb1b5068 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParseTests.cs @@ -0,0 +1,86 @@ +using System.ComponentModel.Design; +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreTests.UnitTests.QueryStringParameters; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.Decrypt; + +public sealed class DecryptFilterParseTests : BaseParseTests +{ + private readonly FilterQueryStringParameterReader _reader; + + public DecryptFilterParseTests() + { + using var serviceProvider = new ServiceContainer(); + var resourceFactory = new ResourceFactory(serviceProvider); + var scopeParser = new QueryStringParameterScopeParser(); + var valueParser = new DecryptFilterParser(resourceFactory); + + _reader = new FilterQueryStringParameterReader(scopeParser, valueParser, Request, ResourceGraph, Options); + } + + [Theory] + [InlineData("filter", "equals(decrypt^", "( expected.")] + [InlineData("filter", "equals(decrypt(^", "Field name expected.")] + [InlineData("filter", "equals(decrypt(^ ", "Unexpected whitespace.")] + [InlineData("filter", "equals(decrypt(^)", "Field name expected.")] + [InlineData("filter", "equals(decrypt(^'a')", "Field name expected.")] + [InlineData("filter", "equals(decrypt(^some)", "Field 'some' does not exist on resource type 'blogs'.")] + [InlineData("filter", "equals(decrypt(^caption)", "Field 'caption' does not exist on resource type 'blogs'.")] + [InlineData("filter", "equals(decrypt(^null)", "Field name expected.")] + [InlineData("filter", "equals(decrypt(title)^)", ", expected.")] + [InlineData("filter", "equals(decrypt(owner.preferences.^useDarkTheme)", "Attribute of type 'String' expected.")] + public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage) + { + // Arrange + var parameterValueSource = new MarkedText(parameterValue, '^'); + + // Act + Action action = () => _reader.Read(parameterName, parameterValueSource.Text); + + // Assert + InvalidQueryStringParameterException exception = action.Should().ThrowExactly().And; + + exception.ParameterName.Should().Be(parameterName); + exception.Errors.Should().HaveCount(1); + + ErrorObject error = exception.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"{errorMessage} {parameterValueSource}"); + error.Source.Should().NotBeNull(); + error.Source.Parameter.Should().Be(parameterName); + } + + [Theory] + [InlineData("filter", "equals(decrypt(title),'secret')", null)] + [InlineData("filter", "startsWith(decrypt(title),'secret')", null)] + [InlineData("filter", "endsWith(decrypt(title),'secret')", null)] + [InlineData("filter", "any(decrypt(title),'x','y')", null)] + [InlineData("filter", "contains(decrypt(owner.userName),'secret')", null)] + [InlineData("filter", "or(equals(decrypt(title),'one'),equals(decrypt(platformName),'two'))", null)] + [InlineData("filter[posts]", "equals(decrypt(author.userName),'secret')", "posts")] + public void Reader_Read_Succeeds(string parameterName, string parameterValue, string? scopeExpected) + { + // Act + _reader.Read(parameterName, parameterValue); + + IReadOnlyCollection constraints = _reader.GetConstraints(); + + // Assert + ResourceFieldChainExpression? scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); + scope?.ToString().Should().Be(scopeExpected); + + QueryExpression value = constraints.Select(expressionInScope => expressionInScope.Expression).Single(); + value.ToString().Should().Be(parameterValue); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParser.cs new file mode 100644 index 0000000000..7fbc3ed77b --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterParser.cs @@ -0,0 +1,54 @@ +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings.FieldChains; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.Decrypt; + +internal sealed class DecryptFilterParser(IResourceFactory resourceFactory) + : FilterParser(resourceFactory) +{ + protected override bool IsFunction(string name) + { + if (name == DecryptExpression.Keyword) + { + return true; + } + + return base.IsFunction(name); + } + + protected override FunctionExpression ParseFunction() + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text, Value: DecryptExpression.Keyword }) + { + return ParseDecrypt(); + } + + return base.ParseFunction(); + } + + private DecryptExpression ParseDecrypt() + { + EatText(DecryptExpression.Keyword); + EatSingleCharacterToken(TokenKind.OpenParen); + + int chainStartPosition = GetNextTokenPositionOrEnd(); + + ResourceFieldChainExpression targetAttributeChain = + ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + + ResourceFieldAttribute attribute = targetAttributeChain.Fields[^1]; + + if (attribute.Property.PropertyType != typeof(string)) + { + int position = chainStartPosition + GetRelativePositionOfLastFieldInChain(targetAttributeChain); + throw new QueryParseException("Attribute of type 'String' expected.", position); + } + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new DecryptExpression(targetAttributeChain); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterTests.cs new file mode 100644 index 0000000000..a641526fcd --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptFilterTests.cs @@ -0,0 +1,135 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.Queries.QueryableBuilding; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.Decrypt; + +public sealed class DecryptFilterTests : IClassFixture, DecryptDbContext>> +{ + private readonly IntegrationTestContext, DecryptDbContext> _testContext; + private readonly QueryStringFakers _fakers = new(); + + public DecryptFilterTests(IntegrationTestContext, DecryptDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + + testContext.ConfigureServices(services => + { + services.AddTransient(); + services.AddTransient(); + }); + } + + [Fact] + public async Task Can_filter_on_encrypted_column_at_primary_endpoint() + { + // Arrange + List blogs = _fakers.Blog.GenerateList(2); + + blogs[0].Title = Convert.ToBase64String("something-else"u8); + blogs[1].Title = Convert.ToBase64String("two"u8); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.DeclareDecryptFunctionAsync(); + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?filter=any(decrypt(title),'one','two','three')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); + } + + [Fact] + public async Task Can_filter_on_encrypted_column_in_compound_expression_at_secondary_endpoint() + { + // Arrange + Blog blog = _fakers.Blog.GenerateOne(); + blog.Posts = _fakers.BlogPost.GenerateList(4); + + blog.Posts[0].Caption = Convert.ToBase64String("the-needle-in-the-haystack"u8); + blog.Posts[0].Url = Convert.ToBase64String("https://www.domain.org"u8); + + blog.Posts[1].Caption = Convert.ToBase64String("the-needle-in-the-haystack"u8); + blog.Posts[1].Url = Convert.ToBase64String("https://www.domain.com"u8); + + blog.Posts[2].Caption = Convert.ToBase64String("something-else"u8); + blog.Posts[2].Url = Convert.ToBase64String("https://www.domain.org"u8); + + blog.Posts[3].Caption = Convert.ToBase64String("something-else"u8); + blog.Posts[3].Url = Convert.ToBase64String("https://www.domain.com"u8); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.DeclareDecryptFunctionAsync(); + dbContext.Blogs.Add(blog); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/blogs/{blog.StringId}/posts?filter=and(contains(decrypt(caption),'needle'),not(endsWith(decrypt(url),'.org')))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogPosts"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blog.Posts[1].StringId); + } + + [Fact] + public async Task Can_filter_on_encrypted_column_in_included_resources() + { + // Arrange + List blogs = _fakers.Blog.GenerateList(2); + blogs[0].Title = Convert.ToBase64String("one"u8); + blogs[1].Title = Convert.ToBase64String("two"u8); + + blogs[1].Posts = _fakers.BlogPost.GenerateList(2); + blogs[1].Posts[0].Caption = Convert.ToBase64String("first-value"u8); + blogs[1].Posts[1].Caption = Convert.ToBase64String("second-value"u8); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.DeclareDecryptFunctionAsync(); + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?filter=equals(decrypt(title),'two')&include=posts&filter[posts]=startsWith(decrypt(caption),'second')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("blogPosts"); + responseDocument.Included[0].Id.Should().Be(blogs[1].Posts[1].StringId); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptWhereClauseBuilder.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptWhereClauseBuilder.cs new file mode 100644 index 0000000000..87a08c47de --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Decrypt/DecryptWhereClauseBuilder.cs @@ -0,0 +1,24 @@ +using System.Linq.Expressions; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.QueryableBuilding; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.Decrypt; + +internal sealed class DecryptWhereClauseBuilder : WhereClauseBuilder +{ + public override Expression DefaultVisit(QueryExpression expression, QueryClauseBuilderContext context) + { + if (expression is DecryptExpression decryptExpression) + { + return VisitDecrypt(decryptExpression, context); + } + + return base.DefaultVisit(expression, context); + } + + private MethodCallExpression VisitDecrypt(DecryptExpression expression, QueryClauseBuilderContext context) + { + Expression propertyAccess = Visit(expression.TargetAttribute, context); + return Expression.Call(null, DatabaseFunctionStub.DecryptMethod, propertyAccess); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseExpression.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseExpression.cs index 7a4bfa0788..a4df84c888 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseExpression.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseExpression.cs @@ -52,7 +52,7 @@ private string InnerToString(bool toFullString) builder.Append(Keyword); builder.Append('('); - builder.Append(toFullString ? TargetAttribute.ToFullString() : TargetAttribute); + builder.Append(toFullString ? TargetAttribute.ToFullString() : TargetAttribute.ToString()); builder.Append(')'); return builder.ToString(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterTests.cs index fa991e7544..3b2f7e6314 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterTests.cs @@ -61,7 +61,7 @@ public async Task Can_filter_casing_in_compound_expression_at_secondary_endpoint { // Arrange Blog blog = _fakers.Blog.GenerateOne(); - blog.Posts = _fakers.BlogPost.GenerateList(3); + blog.Posts = _fakers.BlogPost.GenerateList(4); blog.Posts[0].Caption = blog.Posts[0].Caption.ToUpperInvariant(); blog.Posts[0].Url = blog.Posts[0].Url.ToUpperInvariant(); @@ -69,8 +69,11 @@ public async Task Can_filter_casing_in_compound_expression_at_secondary_endpoint blog.Posts[1].Caption = blog.Posts[1].Caption.ToUpperInvariant(); blog.Posts[1].Url = blog.Posts[1].Url.ToLowerInvariant(); - blog.Posts[2].Caption = blog.Posts[2].Caption.ToLowerInvariant(); - blog.Posts[2].Url = blog.Posts[2].Url.ToLowerInvariant(); + blog.Posts[2].Caption = blog.Posts[1].Caption.ToLowerInvariant(); + blog.Posts[2].Url = blog.Posts[1].Url.ToUpperInvariant(); + + blog.Posts[3].Caption = blog.Posts[2].Caption.ToLowerInvariant(); + blog.Posts[3].Url = blog.Posts[2].Url.ToLowerInvariant(); await _testContext.RunOnDatabaseAsync(async dbContext => { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthExpression.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthExpression.cs index 1fcf8b45dd..c3849f0133 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthExpression.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthExpression.cs @@ -56,7 +56,7 @@ private string InnerToString(bool toFullString) builder.Append(Keyword); builder.Append('('); - builder.Append(toFullString ? TargetAttribute.ToFullString() : TargetAttribute); + builder.Append(toFullString ? TargetAttribute.ToFullString() : TargetAttribute.ToString()); builder.Append(')'); return builder.ToString(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumExpression.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumExpression.cs index f353c1552b..da18dbd4b6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumExpression.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumExpression.cs @@ -65,9 +65,9 @@ private string InnerToString(bool toFullString) builder.Append(Keyword); builder.Append('('); - builder.Append(toFullString ? TargetToManyRelationship.ToFullString() : TargetToManyRelationship); + builder.Append(toFullString ? TargetToManyRelationship.ToFullString() : TargetToManyRelationship.ToString()); builder.Append(','); - builder.Append(toFullString ? Selector.ToFullString() : Selector); + builder.Append(toFullString ? Selector.ToFullString() : Selector.ToString()); builder.Append(')'); return builder.ToString(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs index 93a5ec5e65..98fe2c999a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs @@ -25,6 +25,12 @@ public sealed class QueryStringDbContext(DbContextOptions public DbSet Reminders => Set(); protected override void OnModelCreating(ModelBuilder builder) + { + ConfigureModel(builder); + base.OnModelCreating(builder); + } + + internal static void ConfigureModel(ModelBuilder builder) { builder.Entity() .HasMany(webAccount => webAccount.Posts) @@ -38,7 +44,5 @@ protected override void OnModelCreating(ModelBuilder builder) builder.Entity() .HasMany(calendar => calendar.Appointments) .WithOne(appointment => appointment.Calendar); - - base.OnModelCreating(builder); } } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs index fbfd7aaecb..d3ea46286f 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs @@ -121,6 +121,7 @@ public void Reader_Read_ParameterName_Fails(string parameterName, string errorMe [InlineData("filter", "has(posts,^", "Filter function expected.")] [InlineData("filter", "contains^)", "( expected.")] [InlineData("filter", "contains(title,'a'^,'b')", ") expected.")] + [InlineData("filter", "contains(^equals(title,'x'),'a')", "Function that returns type 'String' expected.")] [InlineData("filter", "contains(title,^null)", "Value between quotes expected.")] [InlineData("filter[posts]", "contains(author^,null)", "Field chain on resource type 'blogPosts' failed to match the pattern: zero or more to-one relationships, followed by an attribute. " + @@ -128,6 +129,7 @@ public void Reader_Read_ParameterName_Fails(string parameterName, string errorMe [InlineData("filter", "any(^null,'a','b')", "Field name expected.")] [InlineData("filter", "any(^'a','b','c')", "Field name expected.")] [InlineData("filter", "any(title,'b','c',^)", "Value between quotes expected.")] + [InlineData("filter", "any(equals(title,'x'),^'b')", "Failed to convert 'b' of type 'String' to type 'Boolean'.")] [InlineData("filter", "any(title^)", ", expected.")] [InlineData("filter[posts]", "any(author^,'a','b')", "Field chain on resource type 'blogPosts' failed to match the pattern: zero or more to-one relationships, followed by an attribute. " + @@ -201,6 +203,7 @@ public void Reader_Read_ParameterValue_Fails(string parameterName, string parame [InlineData("filter", "endsWith(title,'this')", null)] [InlineData("filter", "any(title,'this')", null)] [InlineData("filter", "any(title,'that','there','this')", null)] + [InlineData("filter", "any(equals(title,'x'),'true')", null)] [InlineData("filter", "and(contains(title,'sales'),contains(title,'marketing'),contains(title,'advertising'))", null)] [InlineData("filter[posts]", "or(and(not(equals(author.userName,null)),not(equals(author.displayName,null))),not(has(comments,startsWith(text,'A'))))", "posts")]