Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/querykit-integration-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ jobs:
run: dotnet build --configuration Release --no-restore
- name: Test
working-directory: QueryKit.IntegrationTests
run: dotnet test --no-restore --verbosity minimal
run: dotnet test --no-restore --configuration Release --verbosity minimal
2 changes: 1 addition & 1 deletion .github/workflows/querykit-unit-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ jobs:
run: dotnet build --configuration Release --no-restore
- name: Test
working-directory: QueryKit.UnitTests
run: dotnet test --no-restore --verbosity minimal
run: dotnet test --no-restore --configuration Release --verbosity minimal
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ dotnet test --collect:"XPlat Code Coverage"

### Packaging
```bash
# Pack NuGet package (already configured for multi-targeting: net6.0, net7.0, net8.0, net9.0)
# Pack NuGet package (already configured for multi-targeting: net6.0, net7.0, net8.0, net9.0, net10.0)
dotnet pack --configuration Release
```

Expand All @@ -58,7 +58,7 @@ dotnet pack --configuration Release

## Development Notes

- The library supports multiple .NET versions (net6.0 through net9.0)
- The library supports multiple .NET versions (net6.0 through net10.0)
- Integration tests use PostgreSQL via Testcontainers for realistic database scenarios
- Filter syntax supports complex expressions with parentheses, logical operators (&&, ||), and extensive comparison operators
- Property mappings allow aliasing entity properties to different query names
Expand Down
34 changes: 0 additions & 34 deletions QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -842,40 +842,6 @@
people.Count.Should().Be(0);
}

// var people = testingServiceScope.DbContext().People
// .Where(x => x.Email == fakePersonOne.Email)
// .OrderBy(x => x.Email)
// .ToList();
// TODO needs to have `Email` not `Email.Value` if using `HasConversion`
[Fact(Skip = "Will need something like this if i want to support HasConversion in efcore.")]
public async Task can_filter_with_child_props()
{
// Arrange
var testingServiceScope = new TestingServiceScope();
var faker = new Faker();
var fakePersonOne = new FakeTestingPersonBuilder()
.WithEmail(faker.Internet.Email())
.Build();
var fakePersonTwo = new FakeTestingPersonBuilder()
.Build();
await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo);

var input = $"""email == "{fakePersonOne.Email.Value}" """;

// Act
var queryablePeople = testingServiceScope.DbContext().People;
var config = new QueryKitConfiguration(config =>
{
config.Property<TestingPerson>(x => x.Email!.Value!).HasQueryName("email");
});
var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input, config);
var people = await appliedQueryable.ToListAsync();

// Assert
people.Count.Should().Be(1);
people[0].Id.Should().Be(fakePersonOne.Id);
}

[Fact]
public async Task can_filter_with_alias_and_in_operator()
{
Expand Down Expand Up @@ -3943,7 +3909,7 @@
var config = new QueryKitConfiguration(settings =>
{
settings.CaseInsensitiveComparison = CaseInsensitiveMode.Lower;
settings.Property<TestingPerson>(x => x.Title).HasCaseInsensitiveMode(CaseInsensitiveMode.Upper);

Check warning on line 3912 in QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference return.

Check warning on line 3912 in QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference return.

Check warning on line 3912 in QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference return.
});

// Act
Expand Down Expand Up @@ -4194,7 +4160,7 @@
var config = new QueryKitConfiguration(settings =>
{
settings.CaseInsensitiveComparison = CaseInsensitiveMode.Upper;
settings.Property<TestingPerson>(x => x.Title).HasCaseInsensitiveMode(CaseInsensitiveMode.Lower);

Check warning on line 4163 in QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference return.

Check warning on line 4163 in QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference return.
});

// Act
Expand Down Expand Up @@ -4226,7 +4192,7 @@
var config = new QueryKitConfiguration(settings =>
{
settings.CaseInsensitiveComparison = CaseInsensitiveMode.Lower;
settings.Property<TestingPerson>(x => x.Title).HasCaseInsensitiveMode(CaseInsensitiveMode.Upper);

Check warning on line 4195 in QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference return.

Check warning on line 4195 in QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference return.
});

// Act
Expand Down
25 changes: 0 additions & 25 deletions QueryKit.UnitTests/CustomFilterPropertyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,31 +35,6 @@ public void can_have_custom_child_prop_name_ownsone()
filterExpression.ToDisplayString().Should().Be($"""x => (x.PhysicalAddress.State == "{value}")""");
}

[Fact(Skip = "Will need something like this if i want to support HasConversion in efcore.")]
public void can_have_child_prop_name_for_efcore_HasConversion()
{
var faker = new Faker();
var value = faker.Lorem.Word();
var input = $"""Email.Value == "{value}" """;
var filterExpression = FilterParser.ParseFilter<TestingPerson>(input);
filterExpression.ToDisplayString().Should().Be($"""x => (x.Email == "{value}")""");
}

[Fact(Skip = "Will need something like this if i want to support HasConversion in efcore.")]
public void can_have_custom_child_prop_name_for_efcore_HasConversion()
{
var faker = new Faker();
var value = faker.Lorem.Word();
var input = $"""email == "{value}" """;

var config = new QueryKitConfiguration(config =>
{
config.Property<TestingPerson>(x => x.Email).HasQueryName("email");
});
var filterExpression = FilterParser.ParseFilter<TestingPerson>(input, config);
filterExpression.ToDisplayString().Should().Be($"""x => (x.Email == "{value}")""");
}

[Fact]
public void can_have_custom_prop_name_for_string()
{
Expand Down
18 changes: 11 additions & 7 deletions QueryKit.UnitTests/FilterParserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -482,8 +482,7 @@
[Fact]
public void can_throw_error_when_property_not_recognized()
{
var faker = new Faker();
var propertyName = faker.Lorem.Word();
var propertyName = "NotARealProperty";
var input = $"""{propertyName} == 25""";
var act = () => FilterParser.ParseFilter<TestingPerson>(input);
act.Should().Throw<UnknownFilterPropertyException>()
Expand Down Expand Up @@ -754,6 +753,15 @@
.Be(""""x => x.Tags.Any(z => (z.ToLower() != "winner".ToLower()))"""");
}

[Fact]
public void has_type_throws_correct_message_on_non_collection_property()
{
var input = """Title ^$ "winner" """;
var act = () => FilterParser.ParseFilter<Recipe>(input);
act.Should().Throw<QueryKitParsingException>()
.WithMessage("HasType is only supported for collections");
}

[Fact]
public void can_throw_exception_when_invalid_enum_value()
{
Expand Down Expand Up @@ -785,12 +793,8 @@
// The expression should be created successfully (not throw an exception)
filterExpression.Should().NotBeNull();

// Let's see what the actual expression looks like
var expressionString = filterExpression.ToDisplayString();

// Debug output - this should show us the actual expression
Console.WriteLine($"Generated expression: {expressionString}");


// The expression should be created and contain the key elements
expressionString.Should().NotBeNullOrEmpty();
expressionString.Should().Contain("x.Email");
Expand Down Expand Up @@ -837,7 +841,7 @@
var config = new QueryKitConfiguration(settings =>
{
settings.CaseInsensitiveComparison = CaseInsensitiveMode.Lower;
settings.Property<TestingPerson>(x => x.Title).HasCaseInsensitiveMode(CaseInsensitiveMode.Upper);

Check warning on line 844 in QueryKit.UnitTests/FilterParserTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference return.

Check warning on line 844 in QueryKit.UnitTests/FilterParserTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference return.
});

// Act
Expand Down Expand Up @@ -982,7 +986,7 @@
var config = new QueryKitConfiguration(settings =>
{
settings.CaseInsensitiveComparison = CaseInsensitiveMode.Upper;
settings.Property<TestingPerson>(x => x.Title).HasCaseInsensitiveMode(CaseInsensitiveMode.Lower);

Check warning on line 989 in QueryKit.UnitTests/FilterParserTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference return.

Check warning on line 989 in QueryKit.UnitTests/FilterParserTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference return.
});

var filterExpression = FilterParser.ParseFilter<TestingPerson>(input, config);
Expand Down
104 changes: 104 additions & 0 deletions QueryKit.UnitTests/ParseLimitsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
namespace QueryKit.UnitTests;

using QueryKit.Configuration;
using QueryKit.Exceptions;
using FluentAssertions;
using WebApiTestProject.Entities;

public class ParseLimitsTests
{
[Fact]
public void filter_within_default_nesting_depth_parses()
{
var input = new string('(', 5) + """Title == "salt" """ + new string(')', 5);

var filterExpression = FilterParser.ParseFilter<TestingPerson>(input);
filterExpression.Should().NotBeNull();
}

[Fact]
public void filter_over_default_nesting_depth_throws()
{
var input = new string('(', QueryKitSettings.DefaultMaxNestingDepth + 1)
+ """Title == "salt" """
+ new string(')', QueryKitSettings.DefaultMaxNestingDepth + 1);

var act = () => FilterParser.ParseFilter<TestingPerson>(input);
act.Should().Throw<QueryKitNestingDepthExceededException>()
.WithMessage($"*depth of {QueryKitSettings.DefaultMaxNestingDepth + 1}*maximum allowed depth of {QueryKitSettings.DefaultMaxNestingDepth}*");
}

[Fact]
public void filter_over_configured_nesting_depth_throws()
{
var input = new string('(', 3) + """Title == "salt" """ + new string(')', 3);
var config = new QueryKitConfiguration(settings =>
{
settings.MaxNestingDepth = 2;
});

var act = () => FilterParser.ParseFilter<TestingPerson>(input, config);
act.Should().Throw<QueryKitNestingDepthExceededException>()
.WithMessage("*depth of 3*maximum allowed depth of 2*");
}

[Fact]
public void filter_within_configured_nesting_depth_parses()
{
var input = new string('(', 3) + """Title == "salt" """ + new string(')', 3);
var config = new QueryKitConfiguration(settings =>
{
settings.MaxNestingDepth = 3;
});

var filterExpression = FilterParser.ParseFilter<TestingPerson>(input, config);
filterExpression.Should().NotBeNull();
}

[Fact]
public void filter_within_default_input_length_parses()
{
var input = """Title == "salt" """;

var filterExpression = FilterParser.ParseFilter<TestingPerson>(input);
filterExpression.Should().NotBeNull();
}

[Fact]
public void filter_over_default_input_length_throws()
{
var padding = new string('a', QueryKitSettings.DefaultMaxInputLength);
var input = $"""Title == "{padding}" """;

var act = () => FilterParser.ParseFilter<TestingPerson>(input);
act.Should().Throw<QueryKitInputLengthExceededException>()
.WithMessage($"*length of {input.Length}*maximum allowed length of {QueryKitSettings.DefaultMaxInputLength}*");
}

[Fact]
public void filter_over_configured_input_length_throws()
{
var input = """Title == "salt and pepper" """;
var config = new QueryKitConfiguration(settings =>
{
settings.MaxInputLength = 10;
});

var act = () => FilterParser.ParseFilter<TestingPerson>(input, config);
act.Should().Throw<QueryKitInputLengthExceededException>()
.WithMessage($"*length of {input.Length}*maximum allowed length of 10*");
}

[Fact]
public void filter_within_configured_input_length_parses()
{
var input = """Title == "salt" """;
var config = new QueryKitConfiguration(settings =>
{
settings.MaxInputLength = input.Length;
});

var filterExpression = FilterParser.ParseFilter<TestingPerson>(input, config);
filterExpression.Should().NotBeNull();
}
}
4 changes: 1 addition & 3 deletions QueryKit.UnitTests/SortParserTests.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
namespace QueryKit.UnitTests;

using System.Linq.Expressions;
using Bogus;
using Configuration;
using Exceptions;
using FluentAssertions;
Expand Down Expand Up @@ -265,8 +264,7 @@ public void can_prevent_sort()
[Fact]
public void can_throw_error_when_property_not_recognized()
{
var faker = new Faker();
var propertyName = faker.Lorem.Word();
var propertyName = "NotARealProperty";
var input = $"""Title, {propertyName}, Age desc""";
var act = () => SortParser.ParseSort<TestingPerson>(input);
act.Should().Throw<SortParsingException>()
Expand Down
6 changes: 6 additions & 0 deletions QueryKit/Configuration/QueryKitConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ public interface IQueryKitConfiguration
public string HasOperator { get; set; }
public string DoesNotHaveOperator { get; set; }
public int? MaxPropertyDepth { get; set; }
public int MaxNestingDepth { get; set; }
public int MaxInputLength { get; set; }
public CaseInsensitiveMode CaseInsensitiveComparison { get; set; }
}

Expand Down Expand Up @@ -69,6 +71,8 @@ public class QueryKitConfiguration : IQueryKitConfiguration
public bool AllowUnknownProperties { get; set; } = false;
public Type? DbContextType { get; set; }
public int? MaxPropertyDepth { get; set; }
public int MaxNestingDepth { get; set; }
public int MaxInputLength { get; set; }
public CaseInsensitiveMode CaseInsensitiveComparison { get; set; }

public QueryKitConfiguration(Action<QueryKitSettings> configureSettings)
Expand Down Expand Up @@ -108,6 +112,8 @@ public QueryKitConfiguration(Action<QueryKitSettings> configureSettings)
HasOperator = settings.HasOperator;
DoesNotHaveOperator = settings.DoesNotHaveOperator;
MaxPropertyDepth = settings.MaxPropertyDepth;
MaxNestingDepth = settings.MaxNestingDepth;
MaxInputLength = settings.MaxInputLength;
CaseInsensitiveComparison = settings.CaseInsensitiveComparison;
}
}
5 changes: 5 additions & 0 deletions QueryKit/Configuration/QueryKitSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ namespace QueryKit.Configuration;

public class QueryKitSettings
{
public const int DefaultMaxNestingDepth = 32;
public const int DefaultMaxInputLength = 5000;

public QueryKitPropertyMappings PropertyMappings { get; set; } = new QueryKitPropertyMappings();
public string EqualsOperator { get; set; } = ComparisonOperator.EqualsOperator().Operator();
public string NotEqualsOperator { get; set; } = ComparisonOperator.NotEqualsOperator().Operator();
Expand Down Expand Up @@ -36,6 +39,8 @@ public class QueryKitSettings
public bool AllowUnknownProperties { get; set; }
public Type? DbContextType { get; set; }
public int? MaxPropertyDepth { get; set; }
public int MaxNestingDepth { get; set; } = DefaultMaxNestingDepth;
public int MaxInputLength { get; set; } = DefaultMaxInputLength;
public CaseInsensitiveMode CaseInsensitiveComparison { get; set; } = CaseInsensitiveMode.Lower;

public QueryKitPropertyMapping<TModel> Property<TModel>(Expression<Func<TModel, object>>? propertySelector)
Expand Down
9 changes: 9 additions & 0 deletions QueryKit/Exceptions/QueryKitInputLengthExceededException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace QueryKit.Exceptions;

public sealed class QueryKitInputLengthExceededException : QueryKitException
{
public QueryKitInputLengthExceededException(int length, int maxLength)
: base($"The filter has a length of {length}, which exceeds the maximum allowed length of {maxLength}.")
{
}
}
9 changes: 9 additions & 0 deletions QueryKit/Exceptions/QueryKitNestingDepthExceededException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace QueryKit.Exceptions;

public sealed class QueryKitNestingDepthExceededException : QueryKitException
{
public QueryKitNestingDepthExceededException(int depth, int maxDepth)
: base($"The filter has a nesting depth of {depth}, which exceeds the maximum allowed depth of {maxDepth}.")
{
}
}
35 changes: 35 additions & 0 deletions QueryKit/FilterParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ public static class FilterParser
/// <returns>Returns a Func delegate that represents a lambda expression that applies the filter defined by the input parameter.</returns>
public static Expression<Func<T, bool>> ParseFilter<T>(string input, IQueryKitConfiguration? config = null)
{
EnsureWithinParseLimits(input, config);

input = config?.ReplaceLogicalAliases(input) ?? input;
input = config?.ReplaceComparisonAliases(input) ?? input;
input = config?.PropertyMappings?.ReplaceAliasesWithPropertyPaths(input) ?? input;
Expand Down Expand Up @@ -53,6 +55,39 @@ private static Expression ReplaceDerivedProperties(Expression expr, IQueryKitCon
return new ParameterReplacer(parameter).Visit(expr);
}

// Runs before the grammar sees the input, so a hostile filter (deeply nested parentheses,
// or an oversized `in` list) is rejected with a QueryKitException instead of overflowing the
// call stack or exhausting CPU and memory during parsing.
private static void EnsureWithinParseLimits(string input, IQueryKitConfiguration? config)
{
var maxLength = config?.MaxInputLength ?? QueryKitSettings.DefaultMaxInputLength;
if (input.Length > maxLength)
{
throw new QueryKitInputLengthExceededException(input.Length, maxLength);
}

// Counts every '(' and ')', including ones inside quoted values. QueryKit supports several
// quoting styles (plain and raw-string style with 3+ quote marks), so a scanner that tries
// to skip "quoted" spans could misjudge one of them and undercount real nesting. Counting
// everything can only reject too much, never too little.
var maxDepth = config?.MaxNestingDepth ?? QueryKitSettings.DefaultMaxNestingDepth;
var depth = 0;
foreach (var c in input)
{
if (c == '(')
{
depth++;
if (depth > maxDepth)
{
throw new QueryKitNestingDepthExceededException(depth, maxDepth);
}
}
else if (c == ')')
{
depth--;
}
}
}

private static readonly Parser<string> Identifier =
from first in Parse.Letter.Once()
Expand Down
10 changes: 0 additions & 10 deletions QueryKit/Operators/ArithmeticOperator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,6 @@ protected ArithmeticOperator(string symbol, int precedence)
public static ArithmeticOperator Multiply => new MultiplyOperator();
public static ArithmeticOperator Divide => new DivideOperator();
public static ArithmeticOperator Modulo => new ModuloOperator();

public static ArithmeticOperator? FromSymbol(string symbol) => symbol switch
{
"+" => Add,
"-" => Subtract,
"*" => Multiply,
"/" => Divide,
"%" => Modulo,
_ => null
};
}

internal class AddOperator : ArithmeticOperator
Expand Down
Loading
Loading