Skip to content

Commit 67deb70

Browse files
authored
Embedded objects queries & Array Indexing/Querying. (#76)
1 parent 9e12605 commit 67deb70

File tree

14 files changed

+737
-46
lines changed

14 files changed

+737
-46
lines changed

README.md

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -84,13 +84,14 @@ docker run -p 6379:6379 redislabs/redismod:preview
8484
With Redis OM, you can model your data and declare indexes with minimal code. For example, here's how we might model a customer object:
8585

8686
```csharp
87-
[Document]
87+
[Document(StorageType = StorageType.Json)]
8888
public class Customer
8989
{
90-
[Indexed(Sortable = true)] public string FirstName { get; set; }
91-
[Indexed(Aggregatable = true)] public string LastName { get; set; }
90+
[Indexed] public string FirstName { get; set; }
91+
[Indexed] public string LastName { get; set; }
9292
public string Email { get; set; }
9393
[Indexed(Sortable = true)] public int Age { get; set; }
94+
[Indexed] public string[] NickNames {get; set;}
9495
}
9596
```
9697

@@ -104,6 +105,63 @@ var provider = new RedisConnectionProvider("redis://localhost:6379");
104105
provider.Connection.CreateIndex(typeof(Customer));
105106
```
106107

108+
### Indexing Embedded Documents
109+
110+
There are two methods for indexing embedded documents with Redis.OM, an embedded document is a complex object, e.g. if our `Customer` model had an `Address` property with the following model:
111+
112+
```csharp
113+
[Document(IndexName = "address-idx", StorageType = StorageType.Json)]
114+
public partial class Address
115+
{
116+
public string StreetName { get; set; }
117+
public string ZipCode { get; set; }
118+
[Indexed] public string City { get; set; }
119+
[Indexed] public string State { get; set; }
120+
[Indexed(CascadeDepth = 1)] public Address ForwardingAddress { get; set; }
121+
[Indexed] public GeoLoc Location { get; set; }
122+
[Indexed] public int HouseNumber { get; set; }
123+
}
124+
```
125+
126+
#### Index By JSON Path
127+
128+
You can index fields by JSON path, in the top level model, in this case `Customer` you can decorate the `Address` property with an `Indexed` and/or `Searchable` attribute, specifying the JSON path to the desired field:
129+
130+
```csharp
131+
[Document(StorageType = StorageType.Json)]
132+
public class Customer
133+
{
134+
[Indexed] public string FirstName { get; set; }
135+
[Indexed] public string LastName { get; set; }
136+
public string Email { get; set; }
137+
[Indexed(Sortable = true)] public int Age { get; set; }
138+
[Indexed] public string[] NickNames {get; set;}
139+
[Indexed(JsonPath = "$.ZipCode")]
140+
[Searchable(JsonPath = "$.StreetAddress")]
141+
public Address Address {get; set;}
142+
}
143+
```
144+
145+
#### Cascading Index
146+
147+
Alternatively, you can also embedded models by cascading indexes. In this instance you'd simply decorate the property with `Indexed` and set the `CascadeDepth` to whatever to however may levels you want the model to cascade for. The default is 0, so if `CascadeDepth` is not set, indexing an object will be a no-op:
148+
149+
```csharp
150+
[Document(StorageType = StorageType.Json)]
151+
public class Customer
152+
{
153+
[Indexed] public string FirstName { get; set; }
154+
[Indexed] public string LastName { get; set; }
155+
public string Email { get; set; }
156+
[Indexed(Sortable = true)] public int Age { get; set; }
157+
[Indexed] public string[] NickNames {get; set;}
158+
[Indexed(CascadeDepth = 2)]
159+
public Address Address {get; set;}
160+
}
161+
```
162+
163+
In the above case, all indexed/searchable fields in Address will be indexed down 2 levels, so the `ForwardingAddress` field in `Address` will also be indexed.
164+
107165
Once the index is created, we can:
108166

109167
* Insert Customer objects into Redis
@@ -120,14 +178,15 @@ Let's see how!
120178
Ids are unique per object, and are used as part of key generation for the primary index in Redis. The natively supported Id type in Redis OM is the [ULID][ulid-url]. You can bind ids to your model, by explicitly decorating your Id field with the `RedisIdField` attribute:
121179

122180
```csharp
123-
[Document]
181+
[Document(StorageType = StorageType.Json)]
124182
public class Customer
125183
{
126184
[RedisIdField] public Ulid Id { get; set; }
127-
[Indexed(Sortable = true)] public string FirstName { get; set; }
128-
[Indexed(Aggregatable = true)] public string LastName { get; set; }
185+
[Indexed] public string FirstName { get; set; }
186+
[Indexed] public string LastName { get; set; }
129187
public string Email { get; set; }
130188
[Indexed(Sortable = true)] public int Age { get; set; }
189+
[Indexed] public string[] NickNames { get; set; }
131190
}
132191
```
133192

@@ -203,6 +262,9 @@ customers.Where(x => x.LastName == "Bond" || x.Age > 65);
203262

204263
// Find all customers whose last name is Bond AND whose first name is James
205264
customers.Where(x => x.LastName == "Bond" && x.FirstName == "James");
265+
266+
// Find all customers with the nickname of Jim
267+
customer.Where(x=>x.NickNames.Contains("Jim"));
206268
```
207269

208270
### 🖩 Aggregations

src/Redis.OM/Common/ExpressionParserUtilities.cs

Lines changed: 117 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Reflection;
66
using System.Text;
77
using System.Text.RegularExpressions;
8+
using Redis.OM.Aggregation;
89
using Redis.OM.Aggregation.AggregationPredicates;
910
using Redis.OM.Modeling;
1011
using Redis.OM.Searching.Query;
@@ -155,21 +156,117 @@ internal static RedisGeoFilter TranslateGeoFilter(MethodCallExpression exp)
155156
return new RedisGeoFilter(memberOperand, longitude, latitude, radius, unit);
156157
}
157158

159+
/// <summary>
160+
/// Gets the search field name from member expression. Will climb back up to the parent node and build the alias.
161+
/// Which will be all the names in the path to the expression seperated by an underscore. e.g. Address_City.
162+
/// </summary>
163+
/// <param name="member">The member expression to pull the serach field name from.</param>
164+
/// <returns>The alias to search for.</returns>
165+
internal static string GetSearchFieldNameFromMember(MemberExpression member)
166+
{
167+
var stack = GetMemberChain(member);
168+
var topMember = stack.Peek();
169+
var memberPath = stack.Select(x => x.Name).ToArray();
170+
171+
if (topMember == member.Member)
172+
{
173+
var searchField = member.Member.GetCustomAttributes().Where(x => x is SearchFieldAttribute).Cast<SearchFieldAttribute>().FirstOrDefault();
174+
if (searchField != null && !string.IsNullOrEmpty(searchField.PropertyName))
175+
{
176+
return searchField.PropertyName;
177+
}
178+
}
179+
180+
return string.Join("_", memberPath);
181+
}
182+
183+
/// <summary>
184+
/// Gets the chain of members down to the currently accessed member.
185+
/// </summary>
186+
/// <param name="memberExpression">The member expression being accessed.</param>
187+
/// <returns>The chain of members down to the currently accessed member, e.g. if a Person's
188+
/// Address.City was being accessed a stack with Address at the top and City at the bottom would be returned.</returns>
189+
internal static Stack<MemberInfo> GetMemberChain(MemberExpression memberExpression)
190+
{
191+
var memberStack = new Stack<MemberInfo>();
192+
memberStack.Push(memberExpression.Member);
193+
194+
var parentExpression = memberExpression.Expression;
195+
while (parentExpression is MemberExpression parentMember)
196+
{
197+
if (parentMember.Member.Name == nameof(AggregationResult<object>.RecordShell))
198+
{
199+
break;
200+
}
201+
202+
memberStack.Push(parentMember.Member);
203+
parentExpression = parentMember.Expression;
204+
}
205+
206+
return memberStack;
207+
}
208+
209+
/// <summary>
210+
/// Gets the Search Field type for the member.
211+
/// </summary>
212+
/// <param name="memberExpression">the member expression.</param>
213+
/// <returns>the <see cref="SearchFieldAttribute"/>.</returns>
214+
internal static SearchFieldAttribute? DetermineSearchAttribute(MemberExpression memberExpression)
215+
{
216+
var memberChain = GetMemberChain(memberExpression);
217+
SearchFieldAttribute? attr;
218+
do
219+
{
220+
var memberInfo = memberChain.Pop();
221+
attr = memberInfo
222+
.GetCustomAttributes()
223+
.Where(x => x is SearchFieldAttribute)
224+
.Cast<SearchFieldAttribute>()
225+
.FirstOrDefault(x => x.JsonPath?.Split('.').Last() == memberExpression.Member.Name);
226+
}
227+
while (attr == null && memberChain.Any());
228+
229+
if (attr == null)
230+
{
231+
attr = memberExpression.Member.GetCustomAttributes().Where(x => x is SearchFieldAttribute).Cast<SearchFieldAttribute>().FirstOrDefault();
232+
}
233+
234+
return attr;
235+
}
236+
158237
private static string GetOperandStringForMember(MemberExpression member)
159238
{
160-
var searchField = member.Member.GetCustomAttribute<SearchFieldAttribute>();
239+
var memberPath = new List<string>();
240+
var parentExpression = member.Expression;
241+
while (parentExpression is MemberExpression parentMember)
242+
{
243+
memberPath.Add(parentMember.Member.Name);
244+
parentExpression = parentMember.Expression;
245+
}
246+
247+
memberPath.Add(member.Member.Name);
248+
249+
var searchField = member.Member.GetCustomAttributes().Where(x => x is SearchFieldAttribute).Cast<SearchFieldAttribute>().FirstOrDefault();
161250
if (searchField == null)
162251
{
163252
if (member.Expression is not ConstantExpression c)
164253
{
165-
return Expression.Lambda(member).Compile().DynamicInvoke().ToString();
254+
try
255+
{
256+
return Expression.Lambda(member).Compile().DynamicInvoke().ToString();
257+
}
258+
catch (Exception ex)
259+
{
260+
throw new InvalidOperationException(
261+
$"Could not retrieve value from {member.Member.Name}, most likely, it is not properly decorated in the model defining the index.", ex);
262+
}
166263
}
167264

168265
var val = GetValue(member.Member, c.Value);
169266
return val.ToString();
170267
}
171268

172-
var propertyName = string.IsNullOrEmpty(searchField.PropertyName) ? member.Member.Name : searchField.PropertyName;
269+
var propertyName = GetSearchFieldNameFromMember(member);
173270
return $"@{propertyName}";
174271
}
175272

@@ -400,14 +497,26 @@ private static IEnumerable<BinaryExpression> SplitBinaryExpression(BinaryExpress
400497

401498
private static string TranslateContainsStandardQuerySyntax(MethodCallExpression exp)
402499
{
403-
if (exp.Object is not MemberExpression member)
500+
MemberExpression? expression = null;
501+
if (exp.Object is MemberExpression)
404502
{
405-
throw new ArgumentException("String that Contains is called on must be a member of an indexed class");
503+
expression = exp.Object as MemberExpression;
406504
}
505+
else if (exp.Arguments.FirstOrDefault() is MemberExpression)
506+
{
507+
expression = exp.Arguments.FirstOrDefault() as MemberExpression;
508+
}
509+
510+
if (expression == null)
511+
{
512+
throw new InvalidOperationException($"Could not parse query for Contains");
513+
}
514+
515+
var type = Nullable.GetUnderlyingType(expression.Type) ?? expression.Type;
407516

408-
var memberName = GetOperandStringForMember(member);
409-
var literal = GetOperandStringForQueryArgs(exp.Arguments[0]);
410-
return $"{memberName}:{literal}";
517+
var memberName = GetOperandStringForMember(expression);
518+
var literal = GetOperandStringForQueryArgs(exp.Arguments.Last());
519+
return (type == typeof(string)) ? $"{memberName}:{literal}" : $"{memberName}:{{{literal}}}";
411520
}
412521
}
413522
}

src/Redis.OM/Common/ExpressionTranslator.cs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -530,7 +530,7 @@ private static string TranslateBinaryExpression(BinaryExpression binExpression)
530530

531531
if (binExpression.Left is MemberExpression member)
532532
{
533-
var predicate = BuildQueryPredicate(binExpression.NodeType, leftContent, rightContent, member.Member);
533+
var predicate = BuildQueryPredicate(binExpression.NodeType, leftContent, rightContent, member);
534534
sb.Append("(");
535535
sb.Append(predicate);
536536
sb.Append(")");
@@ -558,25 +558,25 @@ private static string TranslateWhereMethod(MethodCallExpression expression)
558558
return BuildQueryFromExpression(lambda.Body);
559559
}
560560

561-
private static string BuildQueryPredicate(ExpressionType expType, string left, string right, MemberInfo member)
561+
private static string BuildQueryPredicate(ExpressionType expType, string left, string right, MemberExpression memberExpression)
562562
{
563563
var queryPredicate = expType switch
564564
{
565565
ExpressionType.GreaterThan => $"{left}:[({right} inf]",
566566
ExpressionType.LessThan => $"{left}:[-inf ({right}]",
567567
ExpressionType.GreaterThanOrEqual => $"{left}:[{right} inf]",
568568
ExpressionType.LessThanOrEqual => $"{left}:[-inf {right}]",
569-
ExpressionType.Equal => BuildEqualityPredicate(member, right),
570-
ExpressionType.NotEqual => BuildEqualityPredicate(member, right, true),
569+
ExpressionType.Equal => BuildEqualityPredicate(memberExpression, right),
570+
ExpressionType.NotEqual => BuildEqualityPredicate(memberExpression, right, true),
571571
_ => string.Empty
572572
};
573573
return queryPredicate;
574574
}
575575

576-
private static string BuildEqualityPredicate(MemberInfo member, string right, bool negated = false)
576+
private static string BuildEqualityPredicate(MemberExpression member, string right, bool negated = false)
577577
{
578578
var sb = new StringBuilder();
579-
var fieldAttribute = member.GetCustomAttribute<SearchFieldAttribute>();
579+
var fieldAttribute = ExpressionParserUtilities.DetermineSearchAttribute(member);
580580
if (fieldAttribute == null)
581581
{
582582
throw new InvalidOperationException("Searches can only be performed on fields marked with a " +
@@ -588,10 +588,10 @@ private static string BuildEqualityPredicate(MemberInfo member, string right, bo
588588
sb.Append("-");
589589
}
590590

591-
sb.Append($"@{member.Name}:");
591+
sb.Append($"@{ExpressionParserUtilities.GetSearchFieldNameFromMember(member)}:");
592592
var searchFieldType = fieldAttribute.SearchFieldType != SearchFieldType.INDEXED
593593
? fieldAttribute.SearchFieldType
594-
: DetermineIndexFieldsType(member);
594+
: DetermineIndexFieldsType(member.Member);
595595
switch (searchFieldType)
596596
{
597597
case SearchFieldType.TAG:
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using System;
2+
3+
namespace Redis.OM.Modeling
4+
{
5+
/// <summary>
6+
/// An exception thrown when trying to index classes in Redis.
7+
/// </summary>
8+
public class RedisIndexingException : Exception
9+
{
10+
/// <summary>
11+
/// Initializes a new instance of the <see cref="RedisIndexingException"/> class.
12+
/// </summary>
13+
/// <param name="message">the message.</param>
14+
public RedisIndexingException(string message)
15+
: base(message)
16+
{
17+
}
18+
}
19+
}

0 commit comments

Comments
 (0)