Skip to content

Commit f7d8778

Browse files
authored
FIELDENG-681 adding api for running raw queries (#523)
adding api for running raw queries
1 parent 2748227 commit f7d8778

File tree

9 files changed

+643
-3
lines changed

9 files changed

+643
-3
lines changed

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,44 @@ customers.Where(x => x.LastName == "Bond" && x.FirstName == "James");
315315
customers.Where(x=>x.NickNames.Contains("Jim"));
316316
```
317317

318+
#### Using Raw Queries
319+
320+
For advanced scenarios where you need direct control over the filter syntax, Redis OM provides a Raw extension method that lets you write the query filter using the Redis Search query syntax directly:
321+
322+
```csharp
323+
// Use raw query to find customers named Bond with exact tag matching
324+
var results = customers.Raw("@LastName:{Bond}").ToList();
325+
```
326+
327+
**Important**: The Raw method ONLY sets the filter portion of the query. For all other query parameters such as sorting, pagination, etc., you should continue to use the LINQ methods:
328+
329+
```csharp
330+
// Raw for filter + OrderBy for sorting
331+
var results = customers.Raw("@LastName:{Bond}")
332+
.OrderBy(c => c.Age)
333+
.ToList();
334+
335+
// Raw for filter + Skip/Take for pagination
336+
var results = customers.Raw("@Age:[30 70]")
337+
.Skip(10)
338+
.Take(5)
339+
.ToList();
340+
```
341+
342+
This is also true for aggregations, where Raw only sets the initial filter, and you should use the full aggregation API for the rest of the pipeline:
343+
344+
```csharp
345+
var aggSet = customers.AggregationSet();
346+
347+
// Raw sets ONLY the filter, then use the aggregation API for operations
348+
var results = aggSet.Raw("@Age:[30 70]")
349+
.GroupBy(x => x.RecordShell.LastName)
350+
.Average(x => x.RecordShell.Age)
351+
.ToList();
352+
```
353+
354+
For more details on the Redis Search query syntax for filters, refer to the [RediSearch Query Syntax Documentation](https://redis.io/docs/stack/search/reference/query_syntax/).
355+
318356
### Vectors
319357

320358
Redis OM .NET also supports storing and querying Vectors stored in Redis.
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
using Redis.OM;
2+
using Redis.OM.Modeling;
3+
using Redis.OM.Searching;
4+
using System;
5+
6+
namespace Redis.OM.RawQueryExample
7+
{
8+
[Document(StorageType = StorageType.Json, IndexName = "person-idx")]
9+
public class Person
10+
{
11+
[RedisIdField]
12+
[Indexed]
13+
public string Id { get; set; } = Ulid.NewUlid().ToString();
14+
15+
[Indexed]
16+
public string FirstName { get; set; }
17+
18+
[Indexed]
19+
public string LastName { get; set; }
20+
21+
[Searchable]
22+
public string FullText { get; set; }
23+
24+
[Indexed(Sortable = true)]
25+
public int Age { get; set; }
26+
27+
[Indexed]
28+
public string[] Skills { get; set; }
29+
}
30+
31+
class Program
32+
{
33+
static void Main(string[] args)
34+
{
35+
var provider = new RedisConnectionProvider("redis://localhost:6379");
36+
var connection = provider.Connection;
37+
38+
// Create index if it doesn't exist
39+
connection.CreateIndex(typeof(Person));
40+
41+
// Add some sample data
42+
var collection = provider.RedisCollection<Person>();
43+
44+
if (!collection.Any())
45+
{
46+
collection.Insert(new Person { FirstName = "John", LastName = "Doe", Age = 30, Skills = new[] { "C#", "Redis" }, FullText = "Hey Now Brown Cow"});
47+
collection.Insert(new Person { FirstName = "Jane", LastName = "Smith", Age = 28, Skills = new[] { "Java", "MongoDB", "Redis" } });
48+
collection.Insert(new Person { FirstName = "Bob", LastName = "Johnson", Age = 35, Skills = new[] { "Python", "Redis", "AWS" } });
49+
collection.Insert(new Person { FirstName = "Alice", LastName = "Williams", Age = 32, Skills = new[] { "JavaScript", "Node.js" } });
50+
}
51+
52+
Console.WriteLine("=== Using standard LINQ expression ===");
53+
var linqResult = collection.Where(p => p.Age > 30).ToList();
54+
PrintResults(linqResult);
55+
56+
Console.WriteLine("\n=== Using Raw query expression ===");
57+
var rawResult = collection.Raw("@Age:[31 +inf]").ToList();
58+
PrintResults(rawResult);
59+
60+
Console.WriteLine("\n=== Using Raw query for tag search ===");
61+
var skillsResult = collection.Raw("@Skills:{Redis}").ToList();
62+
PrintResults(skillsResult);
63+
64+
Console.WriteLine("\n=== Using Raw query with complex conditions ===");
65+
var complexResult = collection.Raw("@Age:[30 35] @Skills:{Redis}").ToList();
66+
PrintResults(complexResult);
67+
68+
// Using Raw with AggregationSet properly
69+
Console.WriteLine("\n=== Using Raw with AggregationSet ===");
70+
71+
// The proper way - Raw only sets the filter part, then use regular aggregation API
72+
var aggSet = provider.AggregationSet<Person>();
73+
var aggregationResult = aggSet.Raw("*")
74+
.GroupBy(x => x.RecordShell.Skills)
75+
.CountGroupMembers()
76+
.ToList();
77+
78+
Console.WriteLine("Skills grouped by count:");
79+
foreach (var result in aggregationResult)
80+
{
81+
Console.WriteLine($"Skill: {result["Skills"]}, Count: {result["COUNT"]}");
82+
}
83+
}
84+
85+
static void PrintResults(System.Collections.Generic.IEnumerable<Person> results)
86+
{
87+
foreach (var person in results)
88+
{
89+
Console.WriteLine($"{person.FirstName} {person.LastName}, Age: {person.Age}, Skills: {string.Join(", ", person.Skills)}");
90+
}
91+
Console.WriteLine($"Total results: {results.Count()}");
92+
}
93+
}
94+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFramework>net6.0</TargetFramework>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<Nullable>enable</Nullable>
8+
</PropertyGroup>
9+
10+
<ItemGroup>
11+
<ProjectReference Include="..\..\src\Redis.OM\Redis.OM.csproj" />
12+
<PackageReference Include="Ulid" Version="1.2.6" />
13+
</ItemGroup>
14+
15+
</Project>

src/Redis.OM/Aggregation/RedisAggregation.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ public RedisAggregation(string indexName)
4343
/// </summary>
4444
public Stack<IAggregationPredicate> Predicates { get; } = new ();
4545

46+
/// <summary>
47+
/// Gets or sets the raw query string for direct execution.
48+
/// </summary>
49+
public string? RawQuery { get; set; }
50+
4651
/// <summary>
4752
/// serializes the aggregation into an array of arguments for redis.
4853
/// </summary>
@@ -51,7 +56,11 @@ public string[] Serialize()
5156
{
5257
var queries = new List<string>();
5358
var ret = new List<string>() { IndexName };
54-
if (Queries.Any())
59+
if (!string.IsNullOrEmpty(RawQuery))
60+
{
61+
ret.Add(RawQuery!);
62+
}
63+
else if (Queries.Any())
5564
{
5665
foreach (var query in Queries)
5766
{

src/Redis.OM/Common/ExpressionTranslator.cs

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,10 @@ public static RedisAggregation BuildAggregationFromExpression(Expression express
161161
case "LoadAll":
162162
aggregation.Predicates.Push(new LoadAll());
163163
break;
164+
case "Raw":
165+
var rawQuery = ((ConstantExpression)exp.Arguments[1]).Value.ToString();
166+
aggregation.RawQuery = rawQuery;
167+
break;
164168
}
165169
}
166170

@@ -188,6 +192,7 @@ internal static RedisQuery BuildQueryFromExpression(Expression expression, Type
188192
var indexName = string.IsNullOrEmpty(attr.IndexName) ? $"{type.Name.ToLower()}-idx" : attr.IndexName;
189193
var query = new RedisQuery(indexName!) { QueryText = "*" };
190194
var dialect = 1;
195+
string? rawQuery = null; // Store the raw query string if one is encountered
191196
switch (expression)
192197
{
193198
case MethodCallExpression methodExpression:
@@ -230,12 +235,35 @@ internal static RedisQuery BuildQueryFromExpression(Expression expression, Type
230235
query.GeoFilter = ExpressionParserUtilities.TranslateGeoFilter(exp);
231236
break;
232237
case "Where":
233-
query.QueryText = query.QueryText == "*" ? TranslateWhereMethod(exp, parameters, ref dialect) : $"({TranslateWhereMethod(exp, parameters, ref dialect)} {query.QueryText})";
238+
// Combine Where clause with existing query
239+
var whereClause = TranslateWhereMethod(exp, parameters, ref dialect);
240+
query.QueryText = query.QueryText == "*" ?
241+
whereClause :
242+
$"({whereClause} {query.QueryText})";
234243
query.Dialect = dialect;
235244
break;
236245
case "NearestNeighbors":
237246
query.NearestNeighbors = ParseNearestNeighborsFromExpression(exp);
238247
break;
248+
case "Raw":
249+
// Get the current raw query
250+
var currentRawQuery = ((ConstantExpression)exp.Arguments[1]).Value.ToString();
251+
252+
// If we've seen a raw query before, combine them
253+
if (rawQuery != null)
254+
{
255+
rawQuery = $"({rawQuery} {currentRawQuery})";
256+
}
257+
else
258+
{
259+
rawQuery = currentRawQuery;
260+
}
261+
262+
// Set the query text appropriately
263+
query.QueryText = query.QueryText == "*" ?
264+
rawQuery :
265+
$"({query.QueryText} {rawQuery})";
266+
break;
239267
}
240268
}
241269

@@ -251,7 +279,20 @@ internal static RedisQuery BuildQueryFromExpression(Expression expression, Type
251279
if (mainBooleanExpression != null)
252280
{
253281
parameters = new List<object>();
254-
query.QueryText = BuildQueryFromExpression(((LambdaExpression)mainBooleanExpression).Body, parameters, ref dialect);
282+
var booleanExpressionQuery = BuildQueryFromExpression(((LambdaExpression)mainBooleanExpression).Body, parameters, ref dialect);
283+
284+
// If we have a raw query, make sure to preserve it
285+
if (rawQuery != null)
286+
{
287+
// Combine the boolean expression with the raw query
288+
query.QueryText = $"({booleanExpressionQuery} {rawQuery})";
289+
}
290+
else
291+
{
292+
// No raw query, just use the boolean expression
293+
query.QueryText = booleanExpressionQuery;
294+
}
295+
255296
query.Dialect = dialect;
256297
}
257298

src/Redis.OM/SearchExtensions.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,40 @@ namespace Redis.OM
1414
/// </summary>
1515
public static class SearchExtensions
1616
{
17+
/// <summary>
18+
/// Executes a raw search query string directly against Redis.
19+
/// </summary>
20+
/// <param name="source">The source collection.</param>
21+
/// <param name="rawQuery">Raw query string to be used directly in Redis search.</param>
22+
/// <typeparam name="T">The indexed type.</typeparam>
23+
/// <returns>A Redis Collection with the raw query expression applied.</returns>
24+
public static IRedisCollection<T> Raw<T>(this IRedisCollection<T> source, string rawQuery)
25+
where T : notnull
26+
{
27+
var collection = (RedisCollection<T>)source;
28+
var exp = Expression.Call(
29+
null,
30+
GetMethodInfo(Raw, source, rawQuery),
31+
new[] { source.Expression, Expression.Constant(rawQuery) });
32+
return new RedisCollection<T>((RedisQueryProvider)source.Provider, exp, source.StateManager, collection.BooleanExpression, source.SaveState, source.ChunkSize);
33+
}
34+
35+
/// <summary>
36+
/// Executes a raw aggregation query string directly against Redis.
37+
/// </summary>
38+
/// <param name="source">The source aggregation set.</param>
39+
/// <param name="rawQuery">Raw query string to be used directly in Redis aggregation.</param>
40+
/// <typeparam name="T">The indexed type.</typeparam>
41+
/// <returns>A Redis AggregationSet with the raw query expression applied.</returns>
42+
public static RedisAggregationSet<T> Raw<T>(this RedisAggregationSet<T> source, string rawQuery)
43+
{
44+
var exp = Expression.Call(
45+
null,
46+
GetMethodInfo(Raw, source, rawQuery),
47+
new[] { source.Expression, Expression.Constant(rawQuery) });
48+
return new RedisAggregationSet<T>(source, exp);
49+
}
50+
1751
/// <summary>
1852
/// Apply the provided expression to data in Redis.
1953
/// </summary>

test/Redis.OM.Unit.Tests/RediSearchTests/AggregationFunctionalTests.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Globalization;
34
using Redis.OM.Aggregation;
45
using Redis.OM.Contracts;
56
using System.Threading.Tasks;
67
using System.Linq;
8+
using Redis.OM.Searching;
79
using Xunit;
810

911
namespace Redis.OM.Unit.Tests.RediSearchTests
@@ -320,5 +322,42 @@ public async Task TestListNotContains()
320322
Assert.Contains(people, x => x.Hydrate().Name == "Statler");
321323
Assert.DoesNotContain(people, x => x.Hydrate().Name == "Beaker");
322324
}
325+
326+
[Fact]
327+
public void TestRawAggregationQuery()
328+
{
329+
Setup();
330+
var aggSet = new RedisAggregationSet<Person>(_connection);
331+
332+
333+
// Test raw query with aggregation operations
334+
// Raw sets only the filter part (*), then we use proper aggregation API
335+
var result = aggSet.Raw("@DepartmentNumber:[1 2]")
336+
.GroupBy(x => x.RecordShell.DepartmentNumber)
337+
.Average(x => x.RecordShell.Age)
338+
.ToList();
339+
340+
// Verify results
341+
Assert.Equal(2, result.Count);
342+
343+
var dept1 = result.FirstOrDefault(r => r["DepartmentNumber"].ToString() == "1");
344+
var dept2 = result.FirstOrDefault(r => r["DepartmentNumber"].ToString() == "2");
345+
346+
Assert.NotNull(dept1);
347+
Assert.NotNull(dept2);
348+
349+
// Check average ages (converting to double for comparison)
350+
Assert.True(Math.Abs(52.0 - double.Parse(dept1["Age_AVG"].ToString(CultureInfo.InvariantCulture))) < 0.01);
351+
Assert.True(Math.Abs(45.0 - double.Parse(dept2["Age_AVG"].ToString(CultureInfo.InvariantCulture))) < 0.01);
352+
353+
// Test complex filter with aggregation operations
354+
// Raw sets only the filter part (@Age > 30), then we use proper aggregation API
355+
var complexResult = aggSet.Raw("@Age:[30 inf]")
356+
.GroupBy(x => x.RecordShell.DepartmentNumber)
357+
.CountGroupMembers()
358+
.ToList();
359+
360+
Assert.Equal(4, complexResult.Count);
361+
}
323362
}
324363
}

0 commit comments

Comments
 (0)