Skip to content

Commit e871a43

Browse files
committed
feat: support max depth
fixes #99
1 parent cf1af16 commit e871a43

File tree

9 files changed

+370
-1
lines changed

9 files changed

+370
-1
lines changed
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
namespace QueryKit.UnitTests;
2+
3+
using QueryKit.Configuration;
4+
using QueryKit.Exceptions;
5+
using FluentAssertions;
6+
using WebApiTestProject.Entities;
7+
8+
public class PropertyDepthTests
9+
{
10+
// Filter tests for global MaxPropertyDepth
11+
12+
[Fact]
13+
public void filter_with_depth_1_allowed_when_max_depth_is_1()
14+
{
15+
var input = """Email.Value == "test@example.com" """;
16+
var config = new QueryKitConfiguration(settings =>
17+
{
18+
settings.MaxPropertyDepth = 1;
19+
});
20+
21+
var filterExpression = FilterParser.ParseFilter<TestingPerson>(input, config);
22+
filterExpression.Should().NotBeNull();
23+
}
24+
25+
[Fact]
26+
public void filter_with_depth_2_throws_when_max_depth_is_1()
27+
{
28+
var input = """PhysicalAddress.PostalCode.Value == "12345" """;
29+
var config = new QueryKitConfiguration(settings =>
30+
{
31+
settings.MaxPropertyDepth = 1;
32+
});
33+
34+
var act = () => FilterParser.ParseFilter<TestingPerson>(input, config);
35+
act.Should().Throw<QueryKitPropertyDepthExceededException>()
36+
.WithMessage("*PhysicalAddress.PostalCode.Value*depth of 2*maximum allowed depth of 1*");
37+
}
38+
39+
[Fact]
40+
public void filter_with_depth_2_allowed_when_max_depth_is_2()
41+
{
42+
var input = """PhysicalAddress.PostalCode.Value == "12345" """;
43+
var config = new QueryKitConfiguration(settings =>
44+
{
45+
settings.MaxPropertyDepth = 2;
46+
});
47+
48+
var filterExpression = FilterParser.ParseFilter<TestingPerson>(input, config);
49+
filterExpression.Should().NotBeNull();
50+
}
51+
52+
[Fact]
53+
public void filter_with_depth_0_allowed_when_max_depth_is_1()
54+
{
55+
var input = """Title == "Test" """;
56+
var config = new QueryKitConfiguration(settings =>
57+
{
58+
settings.MaxPropertyDepth = 1;
59+
});
60+
61+
var filterExpression = FilterParser.ParseFilter<TestingPerson>(input, config);
62+
filterExpression.Should().NotBeNull();
63+
}
64+
65+
[Fact]
66+
public void filter_with_no_max_depth_allows_any_depth()
67+
{
68+
var input = """PhysicalAddress.PostalCode.Value == "12345" """;
69+
70+
var filterExpression = FilterParser.ParseFilter<TestingPerson>(input);
71+
filterExpression.Should().NotBeNull();
72+
}
73+
74+
// Filter tests for per-property MaxDepth override
75+
76+
[Fact]
77+
public void filter_per_property_max_depth_overrides_global()
78+
{
79+
var input = """PhysicalAddress.PostalCode.Value == "12345" """;
80+
var config = new QueryKitConfiguration(settings =>
81+
{
82+
settings.MaxPropertyDepth = 1; // Global limit of 1
83+
settings.Property<TestingPerson>(x => x.PhysicalAddress).HasMaxDepth(2); // Override for PhysicalAddress
84+
});
85+
86+
var filterExpression = FilterParser.ParseFilter<TestingPerson>(input, config);
87+
filterExpression.Should().NotBeNull();
88+
}
89+
90+
[Fact]
91+
public void filter_per_property_max_depth_can_be_more_restrictive()
92+
{
93+
var input = """PhysicalAddress.PostalCode.Value == "12345" """;
94+
var config = new QueryKitConfiguration(settings =>
95+
{
96+
settings.MaxPropertyDepth = 5; // Global limit of 5
97+
settings.Property<TestingPerson>(x => x.PhysicalAddress).HasMaxDepth(1); // Restrict PhysicalAddress to 1
98+
});
99+
100+
var act = () => FilterParser.ParseFilter<TestingPerson>(input, config);
101+
act.Should().Throw<QueryKitPropertyDepthExceededException>()
102+
.WithMessage("*PhysicalAddress.PostalCode.Value*depth of 2*maximum allowed depth of 1*");
103+
}
104+
105+
[Fact]
106+
public void filter_other_properties_still_use_global_max_depth()
107+
{
108+
var input = """Email.Value == "test@example.com" """;
109+
var config = new QueryKitConfiguration(settings =>
110+
{
111+
settings.MaxPropertyDepth = 1;
112+
settings.Property<TestingPerson>(x => x.PhysicalAddress).HasMaxDepth(2);
113+
});
114+
115+
// Email.Value has depth 1, which is within global limit
116+
var filterExpression = FilterParser.ParseFilter<TestingPerson>(input, config);
117+
filterExpression.Should().NotBeNull();
118+
}
119+
120+
// Sort tests for global MaxPropertyDepth
121+
122+
[Fact]
123+
public void sort_with_depth_1_allowed_when_max_depth_is_1()
124+
{
125+
var input = "Email.Value";
126+
var config = new QueryKitConfiguration(settings =>
127+
{
128+
settings.MaxPropertyDepth = 1;
129+
});
130+
131+
var act = () => SortParser.ParseSort<TestingPerson>(input, config);
132+
act.Should().NotThrow();
133+
}
134+
135+
[Fact]
136+
public void sort_with_depth_2_throws_when_max_depth_is_1()
137+
{
138+
var input = "PhysicalAddress.PostalCode.Value";
139+
var config = new QueryKitConfiguration(settings =>
140+
{
141+
settings.MaxPropertyDepth = 1;
142+
});
143+
144+
var act = () => SortParser.ParseSort<TestingPerson>(input, config);
145+
act.Should().Throw<QueryKitPropertyDepthExceededException>()
146+
.WithMessage("*PhysicalAddress.PostalCode.Value*depth of 2*maximum allowed depth of 1*");
147+
}
148+
149+
[Fact]
150+
public void sort_with_depth_2_allowed_when_max_depth_is_2()
151+
{
152+
var input = "PhysicalAddress.PostalCode.Value";
153+
var config = new QueryKitConfiguration(settings =>
154+
{
155+
settings.MaxPropertyDepth = 2;
156+
});
157+
158+
var act = () => SortParser.ParseSort<TestingPerson>(input, config);
159+
act.Should().NotThrow();
160+
}
161+
162+
[Fact]
163+
public void sort_with_no_max_depth_allows_any_depth()
164+
{
165+
var input = "PhysicalAddress.PostalCode.Value";
166+
167+
var act = () => SortParser.ParseSort<TestingPerson>(input, null);
168+
act.Should().NotThrow();
169+
}
170+
171+
// Sort tests for per-property MaxDepth override
172+
173+
[Fact]
174+
public void sort_per_property_max_depth_overrides_global()
175+
{
176+
var input = "PhysicalAddress.PostalCode.Value";
177+
var config = new QueryKitConfiguration(settings =>
178+
{
179+
settings.MaxPropertyDepth = 1; // Global limit of 1
180+
settings.Property<TestingPerson>(x => x.PhysicalAddress).HasMaxDepth(2); // Override for PhysicalAddress
181+
});
182+
183+
var act = () => SortParser.ParseSort<TestingPerson>(input, config);
184+
act.Should().NotThrow();
185+
}
186+
187+
[Fact]
188+
public void sort_per_property_max_depth_can_be_more_restrictive()
189+
{
190+
var input = "PhysicalAddress.PostalCode.Value";
191+
var config = new QueryKitConfiguration(settings =>
192+
{
193+
settings.MaxPropertyDepth = 5; // Global limit of 5
194+
settings.Property<TestingPerson>(x => x.PhysicalAddress).HasMaxDepth(1); // Restrict PhysicalAddress to 1
195+
});
196+
197+
var act = () => SortParser.ParseSort<TestingPerson>(input, config);
198+
act.Should().Throw<QueryKitPropertyDepthExceededException>()
199+
.WithMessage("*PhysicalAddress.PostalCode.Value*depth of 2*maximum allowed depth of 1*");
200+
}
201+
202+
// Property list grouping tests
203+
204+
[Fact]
205+
public void filter_property_list_respects_max_depth()
206+
{
207+
var input = """(Title, PhysicalAddress.PostalCode.Value) @=* "test" """;
208+
var config = new QueryKitConfiguration(settings =>
209+
{
210+
settings.MaxPropertyDepth = 1;
211+
});
212+
213+
var act = () => FilterParser.ParseFilter<TestingPerson>(input, config);
214+
act.Should().Throw<QueryKitPropertyDepthExceededException>();
215+
}
216+
217+
[Fact]
218+
public void filter_property_list_allows_valid_depth()
219+
{
220+
var input = """(Title, Email.Value) @=* "test" """;
221+
var config = new QueryKitConfiguration(settings =>
222+
{
223+
settings.MaxPropertyDepth = 1;
224+
});
225+
226+
var filterExpression = FilterParser.ParseFilter<TestingPerson>(input, config);
227+
filterExpression.Should().NotBeNull();
228+
}
229+
230+
// Edge cases
231+
232+
[Fact]
233+
public void filter_max_depth_of_0_only_allows_root_properties()
234+
{
235+
var input = """Email.Value == "test@example.com" """;
236+
var config = new QueryKitConfiguration(settings =>
237+
{
238+
settings.MaxPropertyDepth = 0;
239+
});
240+
241+
var act = () => FilterParser.ParseFilter<TestingPerson>(input, config);
242+
act.Should().Throw<QueryKitPropertyDepthExceededException>()
243+
.WithMessage("*Email.Value*depth of 1*maximum allowed depth of 0*");
244+
}
245+
246+
[Fact]
247+
public void filter_root_property_allowed_when_max_depth_is_0()
248+
{
249+
var input = """Title == "Test" """;
250+
var config = new QueryKitConfiguration(settings =>
251+
{
252+
settings.MaxPropertyDepth = 0;
253+
});
254+
255+
var filterExpression = FilterParser.ParseFilter<TestingPerson>(input, config);
256+
filterExpression.Should().NotBeNull();
257+
}
258+
}

QueryKit/Configuration/QueryKitConfiguration.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ public interface IQueryKitConfiguration
3232
public string HasCountLessThanOrEqualOperator { get; set; }
3333
public string HasOperator { get; set; }
3434
public string DoesNotHaveOperator { get; set; }
35+
public int? MaxPropertyDepth { get; set; }
3536
}
3637

3738
public class QueryKitConfiguration : IQueryKitConfiguration
@@ -66,6 +67,7 @@ public class QueryKitConfiguration : IQueryKitConfiguration
6667
public string OrOperator { get; set; }
6768
public bool AllowUnknownProperties { get; set; } = false;
6869
public Type? DbContextType { get; set; }
70+
public int? MaxPropertyDepth { get; set; }
6971

7072
public QueryKitConfiguration(Action<QueryKitSettings> configureSettings)
7173
{
@@ -103,5 +105,6 @@ public QueryKitConfiguration(Action<QueryKitSettings> configureSettings)
103105
HasCountLessThanOrEqualOperator = settings.HasCountLessThanOrEqualOperator;
104106
HasOperator = settings.HasOperator;
105107
DoesNotHaveOperator = settings.DoesNotHaveOperator;
108+
MaxPropertyDepth = settings.MaxPropertyDepth;
106109
}
107110
}

QueryKit/Configuration/QueryKitConfigurationExtensions.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
namespace QueryKit.Configuration;
22

33
using System.Text.RegularExpressions;
4+
using QueryKit.Exceptions;
45
using QueryKit.Operators;
56

67
internal static class QueryKitConfigurationExtensions
@@ -40,4 +41,23 @@ internal static bool IsPropertySortable(this IQueryKitConfiguration configuratio
4041
{
4142
return configuration.PropertyMappings.GetPropertyInfo(propertyName)?.CanSort ?? true;
4243
}
44+
45+
internal static void ValidatePropertyDepth(this IQueryKitConfiguration? configuration, string? propertyPath)
46+
{
47+
if (configuration == null || string.IsNullOrEmpty(propertyPath))
48+
return;
49+
50+
var depth = propertyPath.Count(c => c == '.');
51+
if (depth == 0)
52+
return;
53+
54+
// Check for per-property override first
55+
var propertyMaxDepth = configuration.PropertyMappings?.GetMaxDepthForProperty(propertyPath);
56+
var effectiveMaxDepth = propertyMaxDepth ?? configuration.MaxPropertyDepth;
57+
58+
if (effectiveMaxDepth.HasValue && depth > effectiveMaxDepth.Value)
59+
{
60+
throw new QueryKitPropertyDepthExceededException(propertyPath, depth, effectiveMaxDepth.Value);
61+
}
62+
}
4363
}

QueryKit/Configuration/QueryKitSettings.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ public class QueryKitSettings
3535
public string CaseInsensitiveAppendix { get; set; } = ComparisonOperator.CaseSensitiveAppendix.ToString();
3636
public bool AllowUnknownProperties { get; set; }
3737
public Type? DbContextType { get; set; }
38+
public int? MaxPropertyDepth { get; set; }
3839

3940
public QueryKitPropertyMapping<TModel> Property<TModel>(Expression<Func<TModel, object>>? propertySelector)
4041
{
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
namespace QueryKit.Exceptions;
2+
3+
public sealed class QueryKitPropertyDepthExceededException : QueryKitException
4+
{
5+
public QueryKitPropertyDepthExceededException(string propertyPath, int depth, int maxDepth)
6+
: base($"The property path '{propertyPath}' has a depth of {depth}, which exceeds the maximum allowed depth of {maxDepth}.")
7+
{
8+
}
9+
}

QueryKit/FilterParser.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -790,8 +790,10 @@ private static Parser<Expression> ComparisonExprParser<T>(ParameterExpression pa
790790
return leftIdentifierParser?.Select(left =>
791791
{
792792
var leftList = left.ToList();
793+
var fullPropPath = string.Join(".", leftList);
793794

794-
var fullPropPath = leftList?.First();
795+
// Validate property depth before processing
796+
config?.ValidatePropertyDepth(fullPropPath);
795797
var propertyExpression = leftList?.Aggregate((Expression)parameter, (expr, propName) =>
796798
{
797799
if (expr is MemberExpression member)
@@ -955,6 +957,9 @@ private static Expression CreatePropertyExpressionFromPath<T>(
955957
{
956958
var fullPropPath = string.Join(".", propertyPath);
957959

960+
// Validate property depth before processing
961+
config?.ValidatePropertyDepth(fullPropPath);
962+
958963
return propertyPath.Aggregate((Expression)parameter, (expr, propName) =>
959964
{
960965
if (expr is MemberExpression member)
@@ -1330,6 +1335,9 @@ private static bool IsPropertyPath(string value, Type entityType)
13301335
{
13311336
try
13321337
{
1338+
// Validate property depth before processing
1339+
config?.ValidatePropertyDepth(propertyPath);
1340+
13331341
var propertyNames = propertyPath.Split('.');
13341342
return propertyNames.Aggregate((Expression)parameter, (expr, propName) =>
13351343
{

0 commit comments

Comments
 (0)