-
Notifications
You must be signed in to change notification settings - Fork 867
Context expressions - Add support for Scan and Query using LINQ #3872
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
irina-herciu
wants to merge
14
commits into
aws:development
Choose a base branch
from
irina-herciu:features/LinqExpressions
base: development
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
030aba4
implement query with expressions - wip
irina-herciu f2418a0
wip
irina-herciu 26753ae
refactoring
irina-herciu c59a0a9
unit tests
irina-herciu d416dd8
merge from development
irina-herciu 66bd10a
unit tests on context internal
irina-herciu 9e07b49
add changeLog Messages
irina-herciu 3865c1a
small refactoring
irina-herciu 4f93f28
clenup
irina-herciu d7293c9
increase test coverage and address PR feedback
irina-herciu 11c48e8
add unit test for QueryFilter usage on Scan methods
irina-herciu 0955526
fix QueryFilter usage from scanConfig
irina-herciu 94a65b5
fix native aot for dictionary path
irina-herciu bfe8642
fix native AOT worning on GetConstant
irina-herciu File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
11 changes: 11 additions & 0 deletions
11
generator/.DevConfigs/3d369d65-77a9-4b2d-a0b0-ac6cf5ff384c.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
{ | ||
"services": [ | ||
{ | ||
"serviceName": "DynamoDBv2", | ||
"type": "minor", | ||
"changeLogMessages": [ | ||
"Add native support for LINQ expression trees in the IDynamoDBContext API for ScanAsync<T>() and QueryAsync<T>()" | ||
] | ||
} | ||
] | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
321 changes: 321 additions & 0 deletions
321
sdk/src/Services/DynamoDBv2/Custom/DataModel/ContextExpression.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,321 @@ | ||
using Amazon.DynamoDBv2.DocumentModel; | ||
using System; | ||
using System.Collections.Generic; | ||
using System.Linq.Expressions; | ||
using ThirdParty.RuntimeBackports; | ||
using System.Diagnostics.CodeAnalysis; | ||
using System.Linq; | ||
using System.Reflection; | ||
using Expression = System.Linq.Expressions.Expression; | ||
irina-herciu marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
namespace Amazon.DynamoDBv2.DataModel | ||
{ | ||
/// <summary> | ||
/// Represents a context expression for DynamoDB operations in the object-persistence programming model. | ||
/// Used to encapsulate filter expressions for query and scan operations. | ||
/// </summary> | ||
public class ContextExpression | ||
{ | ||
/// <summary> | ||
/// Gets the filter expression used to filter results in DynamoDB operations. | ||
/// This expression is typically constructed from a LINQ expression tree. | ||
/// </summary> | ||
public Expression Filter { get; private set; } | ||
|
||
/// <summary> | ||
/// Sets the filter expression for DynamoDB operations. | ||
/// Converts the provided LINQ expression into an internal expression tree for use in DynamoDB queries or scans. | ||
/// </summary> | ||
/// <typeparam name="T">The type of the object being filtered.</typeparam> | ||
/// <param name="filterExpression">A LINQ expression representing the filter condition.</param> | ||
/// <exception cref="ArgumentNullException">Thrown if <paramref name="filterExpression"/> is null.</exception> | ||
public void SetFilter<[DynamicallyAccessedMembers(InternalConstants.DataModelModeledType)] T>(Expression<Func<T, bool>> filterExpression) | ||
{ | ||
if (filterExpression == null) | ||
{ | ||
throw new ArgumentNullException(nameof(filterExpression), "Filter expression cannot be null."); | ||
} | ||
Filter = filterExpression.Body; | ||
} | ||
|
||
/// <summary> | ||
/// Indicates that the value should be compared to see if it falls inclusively between the specified lower and upper bounds. | ||
/// Intended for use in LINQ expressions to generate DynamoDB BETWEEN conditions. | ||
/// This method is only used inside expression trees and should not be called at runtime. | ||
/// </summary> | ||
/// <typeparam name="T">The type of the value being compared.</typeparam> | ||
/// <param name="value">The value to test.</param> | ||
/// <param name="lower">The inclusive lower bound.</param> | ||
/// <param name="upper">The inclusive upper bound.</param> | ||
/// <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> | ||
public static bool Between<[DynamicallyAccessedMembers(InternalConstants.DataModelModeledType)] T>(T value, T lower, T upper) => throw null!; | ||
|
||
/// <summary> | ||
/// Indicates that the attribute exists in the DynamoDB item. | ||
/// Intended for use in LINQ expressions to generate DynamoDB attribute_exists conditions. | ||
/// This method is only used inside expression trees and should not be called at runtime. | ||
/// </summary> | ||
/// <param name="_">The object representing the attribute to check.</param> | ||
/// <returns>True if the attribute exists; otherwise, false.</returns> | ||
public static bool AttributeExists(object _) => throw null!; | ||
|
||
/// <summary> | ||
/// Indicates that the attribute does not exist in the DynamoDB item. | ||
/// Intended for use in LINQ expressions to generate DynamoDB attribute_not_exists conditions. | ||
/// This method is only used inside expression trees and should not be called at runtime. | ||
/// </summary> | ||
/// <param name="_">The object representing the attribute to check.</param> | ||
/// <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> | ||
public static bool AttributeNotExists(object _) => throw null!; | ||
|
||
/// <summary> | ||
/// Indicates that the attribute is of the specified DynamoDB type. | ||
/// Intended for use in LINQ expressions to generate DynamoDB attribute_type conditions. | ||
/// This method is only used inside expression trees and should not be called at runtime. | ||
/// </summary> | ||
/// <param name="_">The object representing the attribute to check.</param> | ||
/// <param name="dynamoDbType">The DynamoDB attribute type to compare against.</param> | ||
/// <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> | ||
public static bool AttributeType(object _, string dynamoDbType) => throw null!; | ||
} | ||
|
||
|
||
|
||
internal static class ContextExpressionsUtils | ||
{ | ||
internal static string GetRangeKeyConditionExpression(string rangeKeyAlias, QueryOperator op) | ||
{ | ||
return op switch | ||
{ | ||
QueryOperator.Equal => $" AND {rangeKeyAlias} = :rangeKey0", | ||
QueryOperator.LessThan => $" AND {rangeKeyAlias} < :rangeKey0", | ||
QueryOperator.LessThanOrEqual => $" AND {rangeKeyAlias} <= :rangeKey0", | ||
QueryOperator.GreaterThan => $" AND {rangeKeyAlias} > :rangeKey0", | ||
QueryOperator.GreaterThanOrEqual => $" AND {rangeKeyAlias} >= :rangeKey0", | ||
QueryOperator.Between => $" AND {rangeKeyAlias} BETWEEN :rangeKey0 AND :rangeKey0", | ||
QueryOperator.BeginsWith => $" AND begins_with({rangeKeyAlias}, :rangeKey0)", | ||
_ => throw new NotSupportedException($"QueryOperator '{op}' is not supported for key conditions.") | ||
}; | ||
} | ||
|
||
internal static bool IsMember(Expression expr) | ||
{ | ||
return expr switch | ||
{ | ||
MemberExpression memberExpr => true, | ||
UnaryExpression ue => IsMember(ue.Operand), | ||
_ => false | ||
}; | ||
} | ||
|
||
internal static object GetConstant(Expression expr) | ||
{ | ||
return EvaluateExpression(expr); | ||
} | ||
|
||
private static object EvaluateExpression(Expression expr) | ||
{ | ||
switch (expr) | ||
{ | ||
case ConstantExpression c: | ||
return c.Value; | ||
|
||
case MemberExpression m: | ||
var instance = m.Expression != null ? EvaluateExpression(m.Expression) : null; | ||
if (m.Member is FieldInfo fi) | ||
return fi.GetValue(instance); | ||
if (m.Member is PropertyInfo pi) | ||
return pi.GetValue(instance); | ||
break; | ||
|
||
case MethodCallExpression call: | ||
var method = call.Method; | ||
|
||
if (method.Name == "get_Item") | ||
{ | ||
var target = EvaluateExpression(call.Object); | ||
var indexArgs = call.Arguments.Select(EvaluateExpression).ToArray(); | ||
return method.Invoke(target, indexArgs); | ||
} | ||
|
||
if (method.Name == "Contains") | ||
{ | ||
throw new NotSupportedException("The 'Contains' method is not supported for constant extraction in expression trees. Use supported property or indexer access instead."); | ||
} | ||
|
||
var targetObj = call.Object != null ? EvaluateExpression(call.Object) : null; | ||
var arguments = call.Arguments.Select(EvaluateExpression).ToArray(); | ||
return method.Invoke(targetObj, arguments); | ||
|
||
case UnaryExpression u when u.NodeType == ExpressionType.Convert: | ||
var operand = EvaluateExpression(u.Operand); | ||
return Convert.ChangeType(operand, u.Type); | ||
|
||
case NewExpression n: | ||
var args = n.Arguments.Select(EvaluateExpression).ToArray(); | ||
return n.Constructor.Invoke(args); | ||
} | ||
|
||
throw new NotSupportedException($"Expression type '{expr.NodeType}' not supported."); | ||
} | ||
|
||
internal static bool IsComparison(ExpressionType type) | ||
{ | ||
return type is ExpressionType.Equal or ExpressionType.NotEqual or | ||
ExpressionType.GreaterThan or ExpressionType.GreaterThanOrEqual or | ||
ExpressionType.LessThan or ExpressionType.LessThanOrEqual; | ||
} | ||
|
||
internal static MemberExpression GetMember(Expression expr) | ||
{ | ||
switch (expr) | ||
{ | ||
case MemberExpression memberExpr: | ||
return memberExpr; | ||
case UnaryExpression ue: | ||
return GetMember(ue.Operand); | ||
// Handle indexer access (get_Item) for lists/arrays/dictionaries | ||
case MethodCallExpression methodCall: | ||
switch (methodCall.Method.Name) | ||
{ | ||
case "get_Item": | ||
return GetMember(methodCall.Object); | ||
case "First": | ||
case "FirstOrDefault": | ||
if (methodCall.Arguments.Count > 0) | ||
return GetMember(methodCall.Arguments[0]); | ||
break; | ||
} | ||
|
||
break; | ||
} | ||
|
||
return null; | ||
} | ||
|
||
internal static List<PathNode> ExtractPathNodes(Expression expr) | ||
{ | ||
var pathNodes = new List<PathNode>(); | ||
int indexDepth = 0; | ||
string indexed = string.Empty; | ||
|
||
while (expr != null) | ||
{ | ||
switch (expr) | ||
{ | ||
case MemberExpression memberExpr: | ||
pathNodes.Insert(0, | ||
new PathNode(memberExpr.Member.Name, indexDepth, false, $"#n{indexed}")); | ||
indexed = string.Empty; | ||
indexDepth = 0; | ||
expr = memberExpr.Expression; | ||
break; | ||
case MethodCallExpression { Method: { Name: "First" or "FirstOrDefault" } } methodCall: | ||
expr = methodCall.Arguments.Count > 0 ? methodCall.Arguments[0] : methodCall.Object; | ||
indexDepth++; | ||
indexed += "[0]"; | ||
break; | ||
case MethodCallExpression { Method: { Name: "get_Item" } } methodCall: | ||
{ | ||
var arg = methodCall.Arguments[0]; | ||
if (arg is ConstantExpression constArg) | ||
{ | ||
var indexValue = constArg.Value; | ||
switch (indexValue) | ||
{ | ||
case int intValue: | ||
indexDepth++; | ||
indexed += $"[{intValue}]"; | ||
break; | ||
case string stringValue: | ||
pathNodes.Insert(0, new PathNode(stringValue, indexDepth, true, $"#n{indexed}")); | ||
indexDepth = 0; | ||
indexed = string.Empty; | ||
break; | ||
default: | ||
throw new NotSupportedException( | ||
$"Indexer argument must be an integer or string, got {indexValue.GetType().Name}."); | ||
} | ||
} | ||
else | ||
{ | ||
throw new NotSupportedException( | ||
$"Method {methodCall.Method.Name} is not supported in property path."); | ||
} | ||
|
||
expr = methodCall.Object; | ||
break; | ||
} | ||
case MethodCallExpression methodCall: | ||
throw new NotSupportedException( | ||
$"Method {methodCall.Method.Name} is not supported in property path."); | ||
case UnaryExpression | ||
{ | ||
NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked | ||
} unaryExpr: | ||
// Handle conversion expressions (e.g., (int)someEnum) | ||
expr = unaryExpr.Operand; | ||
break; | ||
|
||
default: | ||
expr = null; | ||
break; | ||
} | ||
} | ||
|
||
return pathNodes; | ||
} | ||
} | ||
|
||
//internal class ConstantExtractor : ExpressionVisitor | ||
//{ | ||
// public object ConstantValue { get; private set; } | ||
|
||
// protected override Expression VisitBinary(BinaryExpression node) | ||
// { | ||
// if (TryResolveValue(node.Right, out var value)) | ||
// { | ||
// ConstantValue = value; | ||
// } | ||
|
||
// return base.VisitBinary(node); | ||
// } | ||
|
||
// private bool TryResolveValue(Expression expr, out object value) | ||
// { | ||
// value = EvaluateExpression(expr); | ||
// return true; | ||
// } | ||
|
||
|
||
|
||
//} | ||
|
||
/// <summary> | ||
/// Represents a node in a path expression for DynamoDB operations. | ||
/// </summary> | ||
internal class PathNode | ||
{ | ||
public string Path { get; } | ||
|
||
public string FormattedPath { get; } | ||
|
||
public int IndexDepth { get; } | ||
|
||
public bool IsMap { get; } | ||
|
||
public PathNode(string path, int indexDepth, bool isMap, string formattedPath) | ||
{ | ||
Path = path; | ||
IndexDepth = indexDepth; | ||
IsMap = isMap; | ||
FormattedPath = formattedPath; | ||
} | ||
} | ||
|
||
internal class PropertyNode | ||
{ | ||
[DynamicallyAccessedMembers(InternalConstants.DataModelModeledType)] | ||
public Type PropertyType { get; set; } | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should be consistent also make the
Expression
available onScanConfig
. I see currently it is onDynamoDBOperationConfig
andQueryConfig
.Also since you added the
Scan
overloads that take in the expression seems we should also add the overloads forQuery
.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
for
Query
there is no overload that allows passing the QueryFilter as 'List' as arguments, as it is forScan
.Also looks like

QueryFilter
fromScanConfig
is ignored at this moment. ShouldExpression
follow the same pattern and be added to config just for consistency?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are you sure
QueryFilter
is being ignored forScan
? I see it being copied over in theToDynamoDBOperationConfig
operation. If it is not being used that seems like a bug.I saw we didn't have a list of arguments overload and I'm not sure why. It seems like from a discoverability point of view it makes sense to get the feature more upfront to users as an overload. Besides having yet another overload is there a downside?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've added a integration test for this scenario
TestContext_ScanConfigFilter
in 'DataModelTests', and at this point it fails. To be sure my changes did not affect the existing behavior the same test I executed on main and also fails.I will push a fix to this issue in this PR.
As for having both an overload and the config, apart form what should happen when both 'Expression's are provided I am not a big fan of having too many arguments in public interfaces and in
Query
method case, for 'IEnumerable Query(object hashKeyValue, QueryOperator op, IEnumerable values, QueryConfig queryConfig);' will look like 'IEnumerable Query(object hashKeyValue, QueryOperator op, IEnumerable values, ContextExpression filterExpression, QueryConfig queryConfig);' and this just just for visibility.