-
Notifications
You must be signed in to change notification settings - Fork 870
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
Merged
GarrettBeatty
merged 18 commits into
aws:development
from
irina-herciu:features/LinqExpressions
Jul 16, 2025
Merged
Changes from 9 commits
Commits
Show all changes
18 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 35b60ed
remove commented out code
irina-herciu 1fb9e15
Apply suggestions from code review
irina-herciu f8bee48
uodate unit tests
irina-herciu aadcb60
apply review suggestions
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
242 changes: 242 additions & 0 deletions
242
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,242 @@ | ||
using Amazon.DynamoDBv2.DocumentModel; | ||
using System; | ||
using System.Collections.Generic; | ||
using System.Linq.Expressions; | ||
using ThirdParty.RuntimeBackports; | ||
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> | ||
/// Provides extension methods for use in LINQ-to-DynamoDB expression trees. | ||
/// These methods are intended for query translation and should not be called directly at runtime. | ||
/// </summary> | ||
public static class LinqDdbExtensions | ||
normj marked this conversation as resolved.
Show resolved
Hide resolved
|
||
{ | ||
/// <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>True if the value is between the bounds; otherwise, false.</returns> | ||
public static bool Between<[DynamicallyAccessedMembers(InternalConstants.DataModelModeledType)] T>(this 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(this 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>True if the attribute does not exist; otherwise, false.</returns> | ||
public static bool AttributeNotExists(this 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>True if the attribute is of the specified type; otherwise, false.</returns> | ||
public static bool AttributeType(this object _, DynamoDBAttributeType dynamoDbType) => throw null!; | ||
} | ||
|
||
/// <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 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 ConstantExpression GetConstant(Expression expr) | ||
{ | ||
return expr switch | ||
{ | ||
ConstantExpression constant => constant, | ||
// If the expression is a UnaryExpression, check its Operand | ||
UnaryExpression unary => unary.Operand as ConstantExpression, | ||
NewExpression => throw new NotSupportedException($"Unsupported expression type {expr.Type}"), | ||
_ => null | ||
}; | ||
} | ||
|
||
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) | ||
{ | ||
if (expr is MemberExpression memberExpr) | ||
return memberExpr; | ||
|
||
if (expr is UnaryExpression ue) | ||
return GetMember(ue.Operand); | ||
|
||
// Handle indexer access (get_Item) for lists/arrays/dictionaries | ||
if (expr is MethodCallExpression methodCall && methodCall.Method.Name == "get_Item") | ||
return GetMember(methodCall.Object); | ||
|
||
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; | ||
} | ||
} | ||
} |
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.