Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 0 additions & 25 deletions .run/Aviationexam.MoneyErp.Tests.run.xml

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using Aviationexam.MoneyErp.Common.Filters;
using System;
using Xunit;

namespace Aviationexam.MoneyErp.Common.Tests;

public partial class FilterForTests
{
/// <summary>
/// Reproduces a real-world failure where a street address value contains '#',
/// which is also the AND operator in the filter DSL.
///
/// Original (anonymized) request had "Street 80A # 17-85" as FaktUlice value.
/// Without escaping, the '#' was misinterpreted as an AND operator.
/// </summary>
[Fact]
public void AndWithValueContainingHashIsEscaped()
{
var filter = FilterFor<ApiModel>.And(
x => x.StartWith(m => m.AProperty, "ABC123"),
x => x.Equal(m => m.BProperty, "John Doe"),
x => x.Equal(m => m.CProperty, "Street 80A # 17-85"),
x => x.Equal(m => m.DProperty, "Prague"),
x => x.Equal(m => m.EProperty, "11000"),
x => x.Equal(m => m.BoolProperty, false),
x => x.Equal(m => m.AProperty, "b679009e-4d3c-40a0-9031-bc00b24ec9d6"),
x => x.Empty(m => m.AProperty)
);

Assert.Equal(
@"AProperty~sw~ABC123#BProperty~eq~John Doe#CProperty~eq~Street 80A \# 17-85#DProperty~eq~Prague#EProperty~eq~11000#BoolProperty~eq~false#AProperty~eq~b679009e-4d3c-40a0-9031-bc00b24ec9d6#AProperty~eq~",
filter
);
}

[Fact]
public void EqualWithValueContainingHash()
{
var filter = FilterFor<ApiModel>.Equal(x => x.AProperty, "Street 80A # 17-85");

Assert.Equal(@"AProperty~eq~Street 80A \# 17-85", filter);
}

[Fact]
public void EqualWithValueContainingTilde()
{
var filter = FilterFor<ApiModel>.Equal(x => x.AProperty, "value~with~tildes");

Assert.Equal(@"AProperty~eq~value\~with\~tildes", filter);
}

[Fact]
public void EqualWithValueContainingPipe()
{
var filter = FilterFor<ApiModel>.Equal(x => x.AProperty, "option A|option B");

Assert.Equal(@"AProperty~eq~option A\|option B", filter);
}

[Fact]
public void EqualWithValueContainingBackslash()
{
var filter = FilterFor<ApiModel>.Equal(x => x.AProperty, @"path\to\file");

Assert.Equal(@"AProperty~eq~path\\to\\file", filter);
}

[Fact]
public void EqualWithValueContainingMultipleSpecialCharacters()
{
var filter = FilterFor<ApiModel>.Equal(x => x.AProperty, "a#b|c~d");

Assert.Equal(@"AProperty~eq~a\#b\|c\~d", filter);
}

[Fact]
public void EqualWithValueWithoutSpecialCharactersIsUnchanged()
{
var filter = FilterFor<ApiModel>.Equal(x => x.AProperty, "normal value 123");

Assert.Equal("AProperty~eq~normal value 123", filter);
}

[Fact]
public void EscapeFilterValueReturnsInputWhenNoSpecialChars()
{
ReadOnlySpan<char> input = "hello world";
var result = FilterFor<ApiModel>.EscapeFilterValue(input);

Assert.Equal("hello world", result);
}

[Fact]
public void EscapeFilterValueEscapesAllOperatorCharacters()
{
ReadOnlySpan<char> input = @"a#b|c~d\e";
var result = FilterFor<ApiModel>.EscapeFilterValue(input);

Assert.Equal(@"a\#b\|c\~d\\e", result);
}
}
12 changes: 12 additions & 0 deletions src/Aviationexam.MoneyErp.Common.Tests/FilterForTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ private sealed class ApiModel
{
public required string AProperty { get; set; }

public string? BProperty { get; set; }

public string? CProperty { get; set; }

public string? DProperty { get; set; }

public string? EProperty { get; set; }

public int IntProperty { get; set; }

public int? NullableIntProperty { get; set; }
Expand All @@ -43,5 +51,9 @@ private sealed class ApiModel
public DateOnly DateOnlyProperty { get; set; }

public DateOnly? NullableDateOnlyProperty { get; set; }

public Guid GuidProperty { get; set; }

public Guid? NullableGuidProperty { get; set; }
}
}
84 changes: 72 additions & 12 deletions src/Aviationexam.MoneyErp.Common/Filters/FilterFor.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
using System.Numerics;
using System.Reflection;
Expand All @@ -11,6 +12,16 @@ public partial class FilterFor<T> where T : class
{
public const char AndOperator = '#';
public const char OrOperator = '|';
private const char OperatorDelimiter = '~';

[SuppressMessage("ReSharper", "StaticMemberInGenericType")]
private static readonly IReadOnlyCollection<(char Character, string Replacement)> EscapeRules =
[
('\\', "\\\\"),
(AndOperator, $"\\{AndOperator}"),
(OrOperator, $"\\{OrOperator}"),
(OperatorDelimiter, $"\\{OperatorDelimiter}"),
];

private static ReadOnlySpan<char> CombineExpressions(
char joiningCharacter,
Expand Down Expand Up @@ -94,19 +105,68 @@ public static ReadOnlySpan<char> GetFilterClause(
EFilterOperator filterOperator,
ReadOnlySpan<char> property,
ReadOnlySpan<char> value
) => filterOperator switch
)
{
EFilterOperator.Equal => $"{property}~eq~{value}",
EFilterOperator.NotEqual => $"{property}~ne~{value}",
EFilterOperator.LessThan => $"{property}~lt~{value}",
EFilterOperator.LessThanOrEqual => $"{property}~lte~{value}",
EFilterOperator.GreaterThan => $"{property}~gt~{value}",
EFilterOperator.GreaterThanOrEqual => $"{property}~gte~{value}",
EFilterOperator.StartWith => $"{property}~sw~{value}",
EFilterOperator.Contains => $"{property}~ct~{value}",
EFilterOperator.EndWith => $"{property}~ew~{value}",
_ => throw new ArgumentOutOfRangeException(nameof(filterOperator), filterOperator, null),
};
var escapedValue = EscapeFilterValue(value);

return filterOperator switch
{
EFilterOperator.Equal => $"{property}~eq~{escapedValue}",
EFilterOperator.NotEqual => $"{property}~ne~{escapedValue}",
EFilterOperator.LessThan => $"{property}~lt~{escapedValue}",
EFilterOperator.LessThanOrEqual => $"{property}~lte~{escapedValue}",
EFilterOperator.GreaterThan => $"{property}~gt~{escapedValue}",
EFilterOperator.GreaterThanOrEqual => $"{property}~gte~{escapedValue}",
EFilterOperator.StartWith => $"{property}~sw~{escapedValue}",
EFilterOperator.Contains => $"{property}~ct~{escapedValue}",
EFilterOperator.EndWith => $"{property}~ew~{escapedValue}",
_ => throw new ArgumentOutOfRangeException(nameof(filterOperator), filterOperator, null),
};
}

internal static ReadOnlySpan<char> EscapeFilterValue(ReadOnlySpan<char> value)
{
if (!ContainsAnyEscapableCharacter(value))
{
return value;
}

var sb = new StringBuilder(value.Length + 4);

foreach (var ch in value)
{
var replaced = false;
foreach (var (character, replacement) in EscapeRules)
{
if (ch == character)
{
sb.Append(replacement);
replaced = true;
break;
}
}

if (!replaced)
{
sb.Append(ch);
}
}

return sb.ToString();
}

private static bool ContainsAnyEscapableCharacter(ReadOnlySpan<char> value)
{
foreach (var (character, _) in EscapeRules)
{
if (value.Contains(character))
{
return true;
}
}

return false;
}

public static ReadOnlySpan<char> GetFilterClause<TP>(
EFilterOperator filterOperator,
Expand Down
Loading