Skip to content

Commit b4acb9c

Browse files
Context expressions - Add support for Scan and Query using LINQ (#3872)
* implement query with expressions - wip * wip * refactoring * unit tests * unit tests on context internal * add changeLog Messages * small refactoring * clenup * increase test coverage and address PR feedback * add unit test for QueryFilter usage on Scan methods * fix QueryFilter usage from scanConfig * fix native aot for dictionary path * fix native AOT worning on GetConstant * remove commented out code * Apply suggestions from code review Co-authored-by: Copilot <[email protected]> * uodate unit tests * apply review suggestions --------- Co-authored-by: Copilot <[email protected]>
1 parent a0e9d9a commit b4acb9c

File tree

17 files changed

+2404
-91
lines changed

17 files changed

+2404
-91
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"services": [
3+
{
4+
"serviceName": "DynamoDBv2",
5+
"type": "minor",
6+
"changeLogMessages": [
7+
"Add native support for LINQ expression trees in the IDynamoDBContext API for ScanAsync<T>() and QueryAsync<T>()"
8+
]
9+
}
10+
]
11+
}

sdk/src/Services/DynamoDBv2/Custom/DataModel/Configs.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,14 @@ public class DynamoDBOperationConfig
269269
/// </remarks>
270270
public List<ScanCondition> QueryFilter { get; set; }
271271

272+
/// <summary>
273+
/// Represents a filter expression that can be used to filter results in DynamoDB operations.
274+
/// </summary>
275+
/// <remarks>
276+
/// Note: Conditions must be against non-key properties.
277+
/// </remarks>
278+
public ContextExpression Expression { get; set; }
279+
272280
/// <summary>
273281
/// Default constructor
274282
/// </summary>
@@ -281,6 +289,14 @@ public DynamoDBOperationConfig()
281289
/// Checks if the IndexName is set on the config
282290
/// </summary>
283291
internal bool IsIndexOperation { get { return !string.IsNullOrEmpty(IndexName); } }
292+
293+
internal void ValidateFilter()
294+
{
295+
if (QueryFilter is { Count: > 0 } && Expression is { Filter: not null } )
296+
{
297+
throw new InvalidOperationException("Cannot specify both QueryFilter and ExpressionFilter in the same operation configuration. Please use one or the other.");
298+
}
299+
}
284300
}
285301

286302
/// <summary>

sdk/src/Services/DynamoDBv2/Custom/DataModel/Context.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ public partial class DynamoDBContext : IDynamoDBContext
5656
#endregion
5757

5858
#region Public methods
59+
5960
/// <inheritdoc/>
6061
public void RegisterTableDefinition(Table table)
6162
{
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
using Amazon.DynamoDBv2.DocumentModel;
2+
using System;
3+
using System.Collections.Generic;
4+
using System.Linq.Expressions;
5+
using ThirdParty.RuntimeBackports;
6+
using System.Diagnostics.CodeAnalysis;
7+
using System.Linq;
8+
using System.Reflection;
9+
using Expression = System.Linq.Expressions.Expression;
10+
11+
namespace Amazon.DynamoDBv2.DataModel
12+
{
13+
/// <summary>
14+
/// Represents a context expression for DynamoDB operations in the object-persistence programming model.
15+
/// Used to encapsulate filter expressions for query and scan operations.
16+
/// </summary>
17+
public class ContextExpression
18+
{
19+
/// <summary>
20+
/// Gets the filter expression used to filter results in DynamoDB operations.
21+
/// This expression is typically constructed from a LINQ expression tree.
22+
/// </summary>
23+
public Expression Filter { get; private set; }
24+
25+
/// <summary>
26+
/// Sets the filter expression for DynamoDB operations.
27+
/// Converts the provided LINQ expression into an internal expression tree for use in DynamoDB queries or scans.
28+
/// </summary>
29+
/// <typeparam name="T">The type of the object being filtered.</typeparam>
30+
/// <param name="filterExpression">A LINQ expression representing the filter condition.</param>
31+
/// <exception cref="ArgumentNullException">Thrown if <paramref name="filterExpression"/> is null.</exception>
32+
public void SetFilter<[DynamicallyAccessedMembers(InternalConstants.DataModelModeledType)] T>(Expression<Func<T, bool>> filterExpression)
33+
{
34+
if (filterExpression == null)
35+
{
36+
throw new ArgumentNullException(nameof(filterExpression), "Filter expression cannot be null.");
37+
}
38+
Filter = filterExpression.Body;
39+
}
40+
41+
/// <summary>
42+
/// Indicates that the value should be compared to see if it falls inclusively between the specified lower and upper bounds.
43+
/// Intended for use in LINQ expressions to generate DynamoDB BETWEEN conditions.
44+
/// This method is only used inside expression trees and should not be called at runtime.
45+
/// </summary>
46+
/// <typeparam name="T">The type of the value being compared.</typeparam>
47+
/// <param name="value">The value to test.</param>
48+
/// <param name="lower">The inclusive lower bound.</param>
49+
/// <param name="upper">The inclusive upper bound.</param>
50+
/// <returns>This method is intended to be used only within expression definitions (such as LINQ expression trees) and should not be called at runtime.</returns>
51+
public static bool Between<[DynamicallyAccessedMembers(InternalConstants.DataModelModeledType)] T>(T value, T lower, T upper)
52+
{
53+
throw new NotSupportedException("The method 'Between' is intended for use only in LINQ expression trees and should not be called at runtime.");
54+
}
55+
56+
/// <summary>
57+
/// Indicates that the attribute exists in the DynamoDB item.
58+
/// Intended for use in LINQ expressions to generate DynamoDB attribute_exists conditions.
59+
/// This method is only used inside expression trees and should not be called at runtime.
60+
/// </summary>
61+
/// <param name="_">The object representing the attribute to check.</param>
62+
/// <returns>True if the attribute exists; otherwise, false.</returns>
63+
public static bool AttributeExists(object _) => throw new NotSupportedException("This method is only intended for use in LINQ expression trees and should not be called at runtime.");
64+
65+
/// <summary>
66+
/// Indicates that the attribute does not exist in the DynamoDB item.
67+
/// Intended for use in LINQ expressions to generate DynamoDB attribute_not_exists conditions.
68+
/// This method is only used inside expression trees and should not be called at runtime.
69+
/// </summary>
70+
/// <param name="_">The object representing the attribute to check.</param>
71+
/// <returns>This method is intended to be used only within expression definitions (such as LINQ expression trees) and should not be called at runtime.</returns>
72+
public static bool AttributeNotExists(object _) => throw new NotSupportedException("This method is intended to be used only within expression definitions (such as LINQ expression trees) and should not be called at runtime.");
73+
74+
/// <summary>
75+
/// Indicates that the attribute is of the specified DynamoDB type.
76+
/// Intended for use in LINQ expressions to generate DynamoDB attribute_type conditions.
77+
/// This method is only used inside expression trees and should not be called at runtime.
78+
/// </summary>
79+
/// <param name="_">The object representing the attribute to check.</param>
80+
/// <param name="dynamoDbType">The DynamoDB attribute type to compare against.</param>
81+
/// <returns>This method is intended to be used only within expression definitions (such as LINQ expression trees) and should not be called at runtime.</returns>
82+
public static bool AttributeType(object _, string dynamoDbType) => throw new NotSupportedException("The method AttributeType is intended for use only in expression trees and should not be called at runtime.");
83+
}
84+
85+
86+
87+
internal static class ContextExpressionsUtils
88+
{
89+
internal static string GetRangeKeyConditionExpression(string rangeKeyAlias, QueryOperator op)
90+
{
91+
return op switch
92+
{
93+
QueryOperator.Equal => $" AND {rangeKeyAlias} = :rangeKey0",
94+
QueryOperator.LessThan => $" AND {rangeKeyAlias} < :rangeKey0",
95+
QueryOperator.LessThanOrEqual => $" AND {rangeKeyAlias} <= :rangeKey0",
96+
QueryOperator.GreaterThan => $" AND {rangeKeyAlias} > :rangeKey0",
97+
QueryOperator.GreaterThanOrEqual => $" AND {rangeKeyAlias} >= :rangeKey0",
98+
QueryOperator.Between => $" AND {rangeKeyAlias} BETWEEN :rangeKey0 AND :rangeKey0",
99+
QueryOperator.BeginsWith => $" AND begins_with({rangeKeyAlias}, :rangeKey0)",
100+
_ => throw new NotSupportedException($"QueryOperator '{op}' is not supported for key conditions.")
101+
};
102+
}
103+
104+
internal static bool IsMember(Expression expr)
105+
{
106+
return expr switch
107+
{
108+
MemberExpression memberExpr => true,
109+
UnaryExpression ue => IsMember(ue.Operand),
110+
_ => false
111+
};
112+
}
113+
114+
internal static object GetConstant(Expression expr)
115+
{
116+
return EvaluateExpression(expr);
117+
}
118+
119+
private static object EvaluateExpression(Expression expr)
120+
{
121+
switch (expr)
122+
{
123+
case ConstantExpression c:
124+
return c.Value;
125+
126+
case MemberExpression m:
127+
var instance = m.Expression != null ? EvaluateExpression(m.Expression) : null;
128+
if (m.Member is FieldInfo fi)
129+
return fi.GetValue(instance);
130+
if (m.Member is PropertyInfo pi)
131+
return pi.GetValue(instance);
132+
break;
133+
134+
case MethodCallExpression call:
135+
var method = call.Method;
136+
137+
if (method.Name == "get_Item")
138+
{
139+
var target = EvaluateExpression(call.Object);
140+
var indexArgs = call.Arguments.Select(EvaluateExpression).ToArray();
141+
return method.Invoke(target, indexArgs);
142+
}
143+
144+
if (method.Name == "Contains")
145+
{
146+
throw new NotSupportedException("The 'Contains' method is not supported for constant extraction in expression trees. Use supported property or indexer access instead.");
147+
}
148+
149+
var targetObj = call.Object != null ? EvaluateExpression(call.Object) : null;
150+
var arguments = call.Arguments.Select(EvaluateExpression).ToArray();
151+
return method.Invoke(targetObj, arguments);
152+
153+
case UnaryExpression u when u.NodeType == ExpressionType.Convert:
154+
var operand = EvaluateExpression(u.Operand);
155+
return Convert.ChangeType(operand, u.Type);
156+
157+
case NewExpression n:
158+
var args = n.Arguments.Select(EvaluateExpression).ToArray();
159+
return n.Constructor.Invoke(args);
160+
}
161+
162+
throw new NotSupportedException($"Expression type '{expr.NodeType}' not supported.");
163+
}
164+
165+
internal static bool IsComparison(ExpressionType type)
166+
{
167+
return type is ExpressionType.Equal or ExpressionType.NotEqual or
168+
ExpressionType.GreaterThan or ExpressionType.GreaterThanOrEqual or
169+
ExpressionType.LessThan or ExpressionType.LessThanOrEqual;
170+
}
171+
172+
internal static MemberExpression GetMember(Expression expr)
173+
{
174+
switch (expr)
175+
{
176+
case MemberExpression memberExpr:
177+
return memberExpr;
178+
case UnaryExpression ue:
179+
return GetMember(ue.Operand);
180+
// Handle indexer access (get_Item) for lists/arrays/dictionaries
181+
case MethodCallExpression methodCall:
182+
switch (methodCall.Method.Name)
183+
{
184+
case "get_Item":
185+
return GetMember(methodCall.Object);
186+
case "First":
187+
case "FirstOrDefault":
188+
if (methodCall.Arguments.Count > 0)
189+
return GetMember(methodCall.Arguments[0]);
190+
break;
191+
}
192+
193+
break;
194+
}
195+
196+
return null;
197+
}
198+
199+
internal static List<PathNode> ExtractPathNodes(Expression expr)
200+
{
201+
var pathNodes = new List<PathNode>();
202+
int indexDepth = 0;
203+
string indexed = string.Empty;
204+
205+
while (expr != null)
206+
{
207+
switch (expr)
208+
{
209+
case MemberExpression memberExpr:
210+
pathNodes.Insert(0,
211+
new PathNode(memberExpr.Member.Name, indexDepth, false, $"#n{indexed}"));
212+
indexed = string.Empty;
213+
indexDepth = 0;
214+
expr = memberExpr.Expression;
215+
break;
216+
case MethodCallExpression { Method: { Name: "First" or "FirstOrDefault" } } methodCall:
217+
expr = methodCall.Arguments.Count > 0 ? methodCall.Arguments[0] : methodCall.Object;
218+
indexDepth++;
219+
indexed += "[0]";
220+
break;
221+
case MethodCallExpression { Method: { Name: "get_Item" } } methodCall:
222+
{
223+
var arg = methodCall.Arguments[0];
224+
if (arg is ConstantExpression constArg)
225+
{
226+
var indexValue = constArg.Value;
227+
switch (indexValue)
228+
{
229+
case int intValue:
230+
indexDepth++;
231+
indexed += $"[{intValue}]";
232+
break;
233+
case string stringValue:
234+
pathNodes.Insert(0, new PathNode(stringValue, indexDepth, true, $"#n{indexed}"));
235+
indexDepth = 0;
236+
indexed = string.Empty;
237+
break;
238+
default:
239+
throw new NotSupportedException(
240+
$"Indexer argument must be an integer or string, got {indexValue.GetType().Name}.");
241+
}
242+
}
243+
else
244+
{
245+
throw new NotSupportedException(
246+
$"Method {methodCall.Method.Name} is not supported in property path.");
247+
}
248+
249+
expr = methodCall.Object;
250+
break;
251+
}
252+
case MethodCallExpression methodCall:
253+
throw new NotSupportedException(
254+
$"Method {methodCall.Method.Name} is not supported in property path.");
255+
case UnaryExpression
256+
{
257+
NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked
258+
} unaryExpr:
259+
// Handle conversion expressions (e.g., (int)someEnum)
260+
expr = unaryExpr.Operand;
261+
break;
262+
263+
default:
264+
expr = null;
265+
break;
266+
}
267+
}
268+
269+
return pathNodes;
270+
}
271+
}
272+
273+
/// <summary>
274+
/// Represents a node in a path expression for DynamoDB operations.
275+
/// </summary>
276+
internal class PathNode
277+
{
278+
public string Path { get; }
279+
280+
public string FormattedPath { get; }
281+
282+
public int IndexDepth { get; }
283+
284+
public bool IsMap { get; }
285+
286+
public PathNode(string path, int indexDepth, bool isMap, string formattedPath)
287+
{
288+
Path = path;
289+
IndexDepth = indexDepth;
290+
IsMap = isMap;
291+
FormattedPath = formattedPath;
292+
}
293+
}
294+
295+
internal class PropertyNode
296+
{
297+
[DynamicallyAccessedMembers(InternalConstants.DataModelModeledType)]
298+
public Type PropertyType { get; set; }
299+
}
300+
}

0 commit comments

Comments
 (0)