Skip to content

Commit 5af8e8d

Browse files
committed
Add support for keyed filters in EntityFilter
1 parent 52a4708 commit 5af8e8d

11 files changed

Lines changed: 361 additions & 27 deletions

File tree

docs/guide/queries/filters.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,9 @@ public class EntityFilter
143143
[JsonPropertyName("name")]
144144
public string? Name { get; set; }
145145

146+
[JsonPropertyName("key")]
147+
public string? Key { get; set; }
148+
146149
[JsonPropertyName("value")]
147150
public object? Value { get; set; }
148151

@@ -165,6 +168,10 @@ public class EntityFilter
165168

166169
The name of the field or property to filter on. This should match the property name of the entity being queried.
167170

171+
#### Key
172+
173+
An optional key used to select a value from a dictionary-like or keyed field. When specified, the filter targets the value for the key within the field instead of the field itself.
174+
168175
#### Operator
169176

170177
The operator to use for the filter. Use the `FilterOperators` enum for type safety:
@@ -227,6 +234,18 @@ var filter = new EntityFilter
227234
};
228235
```
229236

237+
#### Dictionary Key Filter
238+
239+
```csharp
240+
var filter = new EntityFilter
241+
{
242+
Name = "Attributes",
243+
Key = "Status",
244+
Operator = FilterOperators.Equal,
245+
Value = "Active"
246+
};
247+
```
248+
230249
#### String Operations
231250

232251
```csharp

src/Arbiter.CommandQuery/Converters/EntityFilterConverter.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ namespace Arbiter.CommandQuery.Converters;
1111
public sealed class EntityFilterConverter : JsonConverter<EntityFilter>
1212
{
1313
private static readonly JsonEncodedText Name = JsonEncodedText.Encode("name");
14+
private static readonly JsonEncodedText Key = JsonEncodedText.Encode("key");
1415
private static readonly JsonEncodedText Value = JsonEncodedText.Encode("value");
1516
private static readonly JsonEncodedText Operator = JsonEncodedText.Encode("operator");
1617
private static readonly JsonEncodedText Logic = JsonEncodedText.Encode("logic");
@@ -44,6 +45,10 @@ private static void ReadValue(ref Utf8JsonReader reader, EntityFilter value, Jso
4445
{
4546
value.Name = propertyValue;
4647
}
48+
else if (TryReadStringProperty(ref reader, Key, out propertyValue))
49+
{
50+
value.Key = propertyValue;
51+
}
4752
else if (TryReadObjectProperty(ref reader, Value, out var objectValue))
4853
{
4954
value.Value = objectValue;
@@ -214,6 +219,9 @@ private static void WriteEntityFilter(Utf8JsonWriter writer, EntityFilter value,
214219
if (!string.IsNullOrEmpty(value.Name))
215220
writer.WriteString(Name, value.Name);
216221

222+
if (!string.IsNullOrEmpty(value.Key))
223+
writer.WriteString(Key, value.Key);
224+
217225
if (value.Operator.HasValue)
218226
writer.WriteString(Operator, value.Operator.Value.ToString());
219227

src/Arbiter.CommandQuery/Queries/EntityFilter.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,15 @@ public partial class EntityFilter
5454
[JsonPropertyName("name")]
5555
public string? Name { get; set; }
5656

57+
/// <summary>
58+
/// Gets or sets the optional key used when filtering a keyed value within the field or property.
59+
/// </summary>
60+
/// <value>
61+
/// The optional key used to select a value from a keyed or dictionary-like field.
62+
/// </value>
63+
[JsonPropertyName("key")]
64+
public string? Key { get; set; }
65+
5766
/// <summary>
5867
/// Gets or sets the value to filter on.
5968
/// </summary>

src/Arbiter.CommandQuery/Queries/EntityFilterBuilder.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,19 @@ public static EntitySort CreateSort(string field, SortDirections? direction = nu
142142
public static EntityFilter CreateFilter(string field, object? value, FilterOperators? @operator = null)
143143
=> new() { Name = field, Value = value, Operator = @operator };
144144

145+
/// <summary>
146+
/// Creates a filter for the specified keyed field, value, and operator.
147+
/// </summary>
148+
/// <param name="field">The name of the field or property to filter on. Cannot be <see langword="null"/> or empty.</param>
149+
/// <param name="key">The key used to select a value from the field or property. Cannot be <see langword="null"/> or empty.</param>
150+
/// <param name="value">The value to filter against. Can be <see langword="null"/> depending on the operator used.</param>
151+
/// <param name="operator">The comparison operator to use for filtering. If <see langword="null"/>, uses the default operator (Equal).</param>
152+
/// <returns>
153+
/// An <see cref="EntityFilter"/> instance configured with the specified field, key, value, and operator.
154+
/// </returns>
155+
public static EntityFilter CreateFilter(string field, string key, object? value, FilterOperators? @operator = null)
156+
=> new() { Name = field, Key = key, Value = value, Operator = @operator };
157+
145158
/// <summary>
146159
/// Creates a filter group for the specified filters using the AND logic operator.
147160
/// </summary>

src/Arbiter.CommandQuery/Queries/LinqExpressionBuilder.cs

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -185,10 +185,9 @@ private static void WriteStringFilter(StringBuilder builder, List<object?> param
185185
if (string.IsNullOrWhiteSpace(filter.Name))
186186
return;
187187

188-
int index = parameters.Count;
189-
190-
var field = filter.Name;
188+
var field = GetFieldExpression(parameters, filter);
191189
var value = filter.Value;
190+
int index = parameters.Count;
192191

193192
var method = filter.Operator switch
194193
{
@@ -239,10 +238,9 @@ private static void WriteStandardFilter(StringBuilder builder, List<object?> par
239238
if (string.IsNullOrWhiteSpace(filter.Name))
240239
return;
241240

242-
int index = parameters.Count;
243-
244-
var field = filter.Name;
241+
var field = GetFieldExpression(parameters, filter);
245242
var value = filter.Value;
243+
int index = parameters.Count;
246244

247245
var comparison = filter.Operator switch
248246
{
@@ -277,10 +275,7 @@ private static void WriteNullFilter(StringBuilder builder, List<object?> paramet
277275
if (string.IsNullOrWhiteSpace(filter.Name))
278276
return;
279277

280-
int index = parameters.Count;
281-
282-
var field = filter.Name;
283-
var value = filter.Value;
278+
var field = GetFieldExpression(parameters, filter);
284279

285280
var comparison = filter.Operator switch
286281
{
@@ -321,21 +316,32 @@ private static void WriteInFilter(StringBuilder builder, List<object?> parameter
321316
if (string.IsNullOrWhiteSpace(filter.Name))
322317
return;
323318

324-
int index = parameters.Count;
325-
326-
var field = filter.Name;
319+
var field = GetFieldExpression(parameters, filter, qualifyWithIt: true);
327320
var value = filter.Value;
321+
int index = parameters.Count;
328322

329323
var negation = filter.Operator == FilterOperators.NotIn;
330324
if (negation)
331325
builder.Append('!');
332326

333327
builder
334-
.Append("it.")
335328
.Append(field)
336329
.Append(" in @")
337330
.Append(index);
338331

339332
parameters.Add(value);
340333
}
334+
335+
private static string GetFieldExpression(List<object?> parameters, EntityFilter filter, bool qualifyWithIt = false)
336+
{
337+
var field = qualifyWithIt ? $"it.{filter.Name}" : filter.Name!;
338+
339+
if (string.IsNullOrWhiteSpace(filter.Key))
340+
return field;
341+
342+
int index = parameters.Count;
343+
parameters.Add(filter.Key);
344+
345+
return $"{field}[@{index}]";
346+
}
341347
}

test/Arbiter.CommandQuery.Tests/Extensions/QueryExtensionsTests.cs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,4 +439,58 @@ public void FilterInvalidName()
439439
act.Should().Throw<ParseException>();
440440
}
441441

442+
[Test]
443+
public void FilterKeyNormal()
444+
{
445+
var fruits = Fruit.Data();
446+
fruits.Should().NotBeEmpty();
447+
448+
var list = fruits
449+
.AsQueryable()
450+
.Filter(new EntityFilter { Name = "Tags", Key = "color", Value = "red" })
451+
.ToList();
452+
453+
list.Should().NotBeEmpty();
454+
list.Count.Should().Be(3);
455+
list.Should().AllSatisfy(f => f.Tags!["color"].Should().Be("red"));
456+
}
457+
458+
[Test]
459+
public void FilterKeyContains()
460+
{
461+
var fruits = Fruit.Data();
462+
fruits.Should().NotBeEmpty();
463+
464+
var list = fruits
465+
.AsQueryable()
466+
.Filter(new EntityFilter { Name = "Tags", Key = "season", Operator = FilterOperators.Contains, Value = "sum" })
467+
.ToList();
468+
469+
list.Should().NotBeEmpty();
470+
list.Count.Should().Be(4);
471+
}
472+
473+
[Test]
474+
public void FilterKeyLogicalOr()
475+
{
476+
var fruits = Fruit.Data();
477+
fruits.Should().NotBeEmpty();
478+
479+
var list = fruits
480+
.AsQueryable()
481+
.Filter(new EntityFilter
482+
{
483+
Logic = FilterLogic.Or,
484+
Filters = new List<EntityFilter>
485+
{
486+
new EntityFilter { Name = "Tags", Key = "color", Value = "blue" },
487+
new EntityFilter { Name = "Tags", Key = "color", Value = "purple" }
488+
}
489+
})
490+
.ToList();
491+
492+
list.Should().NotBeEmpty();
493+
list.Count.Should().Be(2);
494+
}
495+
442496
}

test/Arbiter.CommandQuery.Tests/MessagePackSerializationTests.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,30 @@ public void EntityFilterRoundTripSerialization()
2929
deserialized.Value.Should().Be(filter.Value);
3030
}
3131

32+
[Test]
33+
public void EntityFilterWithKeyRoundTripSerialization()
34+
{
35+
// Arrange - create a filter for a keyed value
36+
var filter = new EntityFilter
37+
{
38+
Name = "Attributes",
39+
Key = "Status",
40+
Operator = FilterOperators.Equal,
41+
Value = "Active"
42+
};
43+
44+
// Act - serialize and deserialize
45+
var bytes = MessagePackSerializer.Serialize(filter, MessagePackDefaults.DefaultSerializerOptions);
46+
var deserialized = MessagePackSerializer.Deserialize<EntityFilter>(bytes, MessagePackDefaults.DefaultSerializerOptions);
47+
48+
// Assert
49+
deserialized.Should().NotBeNull();
50+
deserialized.Name.Should().Be(filter.Name);
51+
deserialized.Key.Should().Be(filter.Key);
52+
deserialized.Operator.Should().Be(filter.Operator);
53+
deserialized.Value.Should().Be(filter.Value);
54+
}
55+
3256
[Test]
3357
public void EntityFilterGroupRoundTripSerialization()
3458
{

test/Arbiter.CommandQuery.Tests/Models/Fruit.cs

Lines changed: 75 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,87 @@ public class Fruit
88

99
public int Rank { get; set; }
1010

11+
public Dictionary<string, string>? Tags { get; set; }
12+
1113
public override string ToString()
1214
{
1315
return $"{nameof(Name)}: {Name}, {nameof(Rank)}: {Rank}";
1416
}
1517

1618
public static List<Fruit> Data()
1719
{
18-
return new List<Fruit>
19-
{
20-
new Fruit{ Id = new Guid("3a1ec4ee-239c-41e5-b934-fbe4ce8113df"), Name = "Pear", Rank = 1 },
21-
new Fruit{ Id = new Guid("1109c1a7-65e3-4006-9611-c359f3d1f086"), Name = "Pineapple", Rank = 4 },
22-
new Fruit{ Id = new Guid("0d830fec-e023-438f-bcf6-1b3cba1245e6"), Name = "Peach", Rank = 2 },
23-
new Fruit{ Id = new Guid("bb7aa825-bdbb-4cda-9c12-131c13e02bea"), Name = "Apple", Rank = 3 },
24-
new Fruit{ Id = new Guid("5fef330b-6f30-461d-95e2-d526b4669e76"), Name = "Grape", Rank = 5 },
25-
new Fruit{ Id = new Guid("50611bc9-dac8-4552-b556-f252b1cff0d3"), Name = "Orange", Rank = 6},
26-
new Fruit{ Id = new Guid("5f467286-e321-44df-8a27-2c52a5ceed64"), Name = "Strawberry", Rank = 7 },
27-
new Fruit{ Id = new Guid("ef628e60-500a-4663-8b06-548b0f5857de"), Name = "Blueberry", Rank = 7 },
28-
new Fruit{ Id = new Guid("dc0a7dc7-40de-430d-a8fc-e17370c2a773"), Name = "Banana", Rank = 8 },
29-
new Fruit{ Id = new Guid("98620233-75c5-4213-966c-9c6bfcf9e8d5"), Name = "Raspberry", Rank = 7 }
30-
};
20+
return
21+
[
22+
new Fruit
23+
{
24+
Id = new("3a1ec4ee-239c-41e5-b934-fbe4ce8113df"),
25+
Name = "Pear",
26+
Rank = 1,
27+
Tags = new() { ["color"] = "green", ["season"] = "fall" }
28+
},
29+
new Fruit
30+
{
31+
Id = new("1109c1a7-65e3-4006-9611-c359f3d1f086"),
32+
Name = "Pineapple",
33+
Rank = 4,
34+
Tags = new() { ["color"] = "yellow", ["season"] = "summer" }
35+
},
36+
new Fruit
37+
{
38+
Id = new("0d830fec-e023-438f-bcf6-1b3cba1245e6"),
39+
Name = "Peach",
40+
Rank = 2,
41+
Tags = new() { ["color"] = "orange", ["season"] = "summer" }
42+
},
43+
new Fruit
44+
{
45+
Id = new("bb7aa825-bdbb-4cda-9c12-131c13e02bea"),
46+
Name = "Apple",
47+
Rank = 3,
48+
Tags = new() { ["color"] = "red", ["season"] = "fall" }
49+
},
50+
new Fruit
51+
{
52+
Id = new("5fef330b-6f30-461d-95e2-d526b4669e76"),
53+
Name = "Grape",
54+
Rank = 5,
55+
Tags = new() { ["color"] = "purple", ["season"] = "fall" }
56+
},
57+
new Fruit
58+
{
59+
Id = new("50611bc9-dac8-4552-b556-f252b1cff0d3"),
60+
Name = "Orange",
61+
Rank = 6,
62+
Tags = new() { ["color"] = "orange", ["season"] = "winter" }
63+
},
64+
new Fruit
65+
{
66+
Id = new("5f467286-e321-44df-8a27-2c52a5ceed64"),
67+
Name = "Strawberry",
68+
Rank = 7,
69+
Tags = new() { ["color"] = "red", ["season"] = "spring" }
70+
},
71+
new Fruit
72+
{
73+
Id = new("ef628e60-500a-4663-8b06-548b0f5857de"),
74+
Name = "Blueberry",
75+
Rank = 7,
76+
Tags = new() { ["color"] = "blue", ["season"] = "summer" }
77+
},
78+
new Fruit
79+
{
80+
Id = new("dc0a7dc7-40de-430d-a8fc-e17370c2a773"),
81+
Name = "Banana",
82+
Rank = 8,
83+
Tags = new() { ["color"] = "yellow", ["season"] = "all" }
84+
},
85+
new Fruit
86+
{
87+
Id = new("98620233-75c5-4213-966c-9c6bfcf9e8d5"),
88+
Name = "Raspberry",
89+
Rank = 7,
90+
Tags = new() { ["color"] = "red", ["season"] = "summer" }
91+
}
92+
];
3193
}
3294
}

0 commit comments

Comments
 (0)