diff --git a/generator/.DevConfigs/3d369d65-77a9-4b2d-a0b0-ac6cf5ff384c.json b/generator/.DevConfigs/3d369d65-77a9-4b2d-a0b0-ac6cf5ff384c.json new file mode 100644 index 000000000000..3c5459f27dbc --- /dev/null +++ b/generator/.DevConfigs/3d369d65-77a9-4b2d-a0b0-ac6cf5ff384c.json @@ -0,0 +1,11 @@ +{ + "services": [ + { + "serviceName": "DynamoDBv2", + "type": "minor", + "changeLogMessages": [ + "Add native support for LINQ expression trees in the IDynamoDBContext API for ScanAsync() and QueryAsync()" + ] + } + ] +} \ No newline at end of file diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Configs.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Configs.cs index 0f6bc30780d5..8f053e8b5617 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Configs.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Configs.cs @@ -269,6 +269,14 @@ public class DynamoDBOperationConfig /// public List QueryFilter { get; set; } + /// + /// Represents a filter expression that can be used to filter results in DynamoDB operations. + /// + /// + /// Note: Conditions must be against non-key properties. + /// + public ContextExpression Expression { get; set; } + /// /// Default constructor /// @@ -281,6 +289,14 @@ public DynamoDBOperationConfig() /// Checks if the IndexName is set on the config /// internal bool IsIndexOperation { get { return !string.IsNullOrEmpty(IndexName); } } + + internal void ValidateFilter() + { + if (QueryFilter is { Count: > 0 } && Expression is { Filter: not null } ) + { + throw new InvalidOperationException("Cannot specify both QueryFilter and ExpressionFilter in the same operation configuration. Please use one or the other."); + } + } } /// diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Context.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Context.cs index 57a43bdba112..e7ddf9f40def 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/Context.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/Context.cs @@ -59,6 +59,7 @@ public partial class DynamoDBContext : IDynamoDBContext #endregion #region Public methods + /// public void RegisterTableDefinition(Table table) { diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/ContextExpression.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/ContextExpression.cs new file mode 100644 index 000000000000..3c1cb71c04ae --- /dev/null +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/ContextExpression.cs @@ -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; + +namespace Amazon.DynamoDBv2.DataModel +{ + /// + /// Represents a context expression for DynamoDB operations in the object-persistence programming model. + /// Used to encapsulate filter expressions for query and scan operations. + /// + public class ContextExpression + { + /// + /// Gets the filter expression used to filter results in DynamoDB operations. + /// This expression is typically constructed from a LINQ expression tree. + /// + public Expression Filter { get; private set; } + + /// + /// Sets the filter expression for DynamoDB operations. + /// Converts the provided LINQ expression into an internal expression tree for use in DynamoDB queries or scans. + /// + /// The type of the object being filtered. + /// A LINQ expression representing the filter condition. + /// Thrown if is null. + public void SetFilter<[DynamicallyAccessedMembers(InternalConstants.DataModelModeledType)] T>(Expression> filterExpression) + { + if (filterExpression == null) + { + throw new ArgumentNullException(nameof(filterExpression), "Filter expression cannot be null."); + } + Filter = filterExpression.Body; + } + } + + /// + /// 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. + /// + public static class LinqDdbExtensions + { + /// + /// 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. + /// + /// The type of the value being compared. + /// The value to test. + /// The inclusive lower bound. + /// The inclusive upper bound. + /// True if the value is between the bounds; otherwise, false. + public static bool Between<[DynamicallyAccessedMembers(InternalConstants.DataModelModeledType)] T>(this T value, T lower, T upper) => throw null!; + + /// + /// 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. + /// + /// The object representing the attribute to check. + /// True if the attribute exists; otherwise, false. + public static bool AttributeExists(this object _) => throw null!; + + /// + /// 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. + /// + /// The object representing the attribute to check. + /// True if the attribute does not exist; otherwise, false. + public static bool AttributeNotExists(this object _) => throw null!; + + /// + /// 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. + /// + /// The object representing the attribute to check. + /// The DynamoDB attribute type to compare against. + /// True if the attribute is of the specified type; otherwise, false. + public static bool AttributeType(this object _, DynamoDBAttributeType dynamoDbType) => throw null!; + } + + /// + /// Represents a node in a path expression for DynamoDB operations. + /// + 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 ExtractPathNodes(Expression expr) + { + var pathNodes = new List(); + 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; + } + } +} \ No newline at end of file diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/ContextInternal.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/ContextInternal.cs index 4beb34fd68cb..8f8171f92be6 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/ContextInternal.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/ContextInternal.cs @@ -26,7 +26,9 @@ using Amazon.Util.Internal; using System.Globalization; using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; using ThirdParty.RuntimeBackports; +using Expression = System.Linq.Expressions.Expression; namespace Amazon.DynamoDBv2.DataModel { @@ -89,9 +91,9 @@ private static Document CreateExpectedDocumentForVersion(ItemStorage storage) return document; } - internal static Expression CreateConditionExpressionForVersion(ItemStorage storage, DynamoDBEntry.AttributeConversionConfig conversionConfig) + internal static DocumentModel.Expression CreateConditionExpressionForVersion(ItemStorage storage, DynamoDBEntry.AttributeConversionConfig conversionConfig) { - if (!storage.Config.HasVersion) return new Expression(); + if (!storage.Config.HasVersion) return new DocumentModel.Expression(); bool shouldExist = storage.CurrentVersion?.ConvertToExpectedAttributeValue(conversionConfig).Exists ?? false; string variableName = Common.GetVariableName("version"); @@ -100,7 +102,7 @@ internal static Expression CreateConditionExpressionForVersion(ItemStorage stora if (!shouldExist) { - return new Expression + return new DocumentModel.Expression { ExpressionStatement = $"attribute_not_exists({attributeReference})", ExpressionAttributeNames = { [attributeReference] = versionAttributeName } @@ -108,7 +110,7 @@ internal static Expression CreateConditionExpressionForVersion(ItemStorage stora } string attributeValueReference = Common.GetAttributeValueReference(variableName); - return new Expression + return new DocumentModel.Expression { ExpressionStatement = $"{attributeReference} = {attributeValueReference}", ExpressionAttributeNames = { [attributeReference] = versionAttributeName }, @@ -120,10 +122,10 @@ internal static Expression CreateConditionExpressionForVersion(ItemStorage stora #region Atomic counters - internal static Expression BuildCounterConditionExpression(ItemStorage storage) + internal static DocumentModel.Expression BuildCounterConditionExpression(ItemStorage storage) { var atomicCounters = GetCounterProperties(storage); - Expression counterConditionExpression = null; + DocumentModel.Expression counterConditionExpression = null; if (atomicCounters.Length != 0) { @@ -141,11 +143,11 @@ private static PropertyStorage[] GetCounterProperties(ItemStorage storage) return counterProperties; } - private static Expression CreateUpdateExpressionForCounterProperties(PropertyStorage[] counterPropertyStorages) + private static DocumentModel.Expression CreateUpdateExpressionForCounterProperties(PropertyStorage[] counterPropertyStorages) { if (counterPropertyStorages.Length == 0) return null; - Expression updateExpression = new Expression(); + DocumentModel.Expression updateExpression = new DocumentModel.Expression(); var asserts = string.Empty; foreach (var propertyStorage in counterPropertyStorages) @@ -198,7 +200,7 @@ internal Table GetTargetTable(ItemStorageConfig storageConfig, DynamoDBFlatConfi return table; } -// This is the call we want to avoid with disableFetchingTableMetadata = true, but as long as we still support false, we still need to call the discouraged sync-over-async 'Table.LoadTable(Client, emptyConfig)' + // This is the call we want to avoid with disableFetchingTableMetadata = true, but as long as we still support false, we still need to call the discouraged sync-over-async 'Table.LoadTable(Client, emptyConfig)' #pragma warning disable CS0618 // Retrieves Config-less Table from cache or constructs it on cache-miss @@ -207,7 +209,7 @@ internal Table GetTargetTable(ItemStorageConfig storageConfig, DynamoDBFlatConfi internal Table GetUnconfiguredTable(string tableName, bool disableFetchingTableMetadata = false) { Table table; - + try { _readerWriterLockSlim.EnterReadLock(); @@ -219,7 +221,7 @@ internal Table GetUnconfiguredTable(string tableName, bool disableFetchingTableM } finally { - if(_readerWriterLockSlim.IsReadLockHeld) + if (_readerWriterLockSlim.IsReadLockHeld) { _readerWriterLockSlim.ExitReadLock(); } @@ -228,19 +230,19 @@ internal Table GetUnconfiguredTable(string tableName, bool disableFetchingTableM try { _readerWriterLockSlim.EnterWriteLock(); - + // Check to see if another thread got the write lock before this thread and filled the cache. if (tablesMap.TryGetValue(tableName, out table)) { return table; } - + if (disableFetchingTableMetadata) { return null; } - + var emptyConfig = new TableConfig(tableName, conversion: null, consumer: Table.DynamoDBConsumer.DataModel, storeAsEpoch: null, storeAsEpochLong: null, isEmptyStringValueEnabled: false, metadataCachingMode: Config.MetadataCachingMode); table = Table.LoadTable(Client, emptyConfig) as Table; @@ -250,7 +252,7 @@ internal Table GetUnconfiguredTable(string tableName, bool disableFetchingTableM } finally { - if(_readerWriterLockSlim.IsWriteLockHeld) + if (_readerWriterLockSlim.IsWriteLockHeld) { _readerWriterLockSlim.ExitWriteLock(); } @@ -331,14 +333,14 @@ private static void ValidateConfigAgainstTable(ItemStorageConfig config, Table t private static void CompareKeys(ItemStorageConfig config, Table table, List attributes, List properties, string keyType) { if (attributes.Count != properties.Count) - throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Number of {0} keys on table {1} does not match number of hash keys on type {2}", keyType, table.TableName, config.BaseTypeStorageConfig.TargetType.FullName)); foreach (string hashProperty in properties) { PropertyStorage property = config.BaseTypeStorageConfig.GetPropertyStorage(hashProperty); if (!attributes.Contains(property.AttributeName)) - throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Key property {0} on type {1} does not correspond to a {2} key on table {3}", hashProperty, config.BaseTypeStorageConfig.TargetType.FullName, keyType, table.TableName)); } @@ -605,7 +607,7 @@ private object FromDynamoDBEntry(SimplePropertyStorage propertyStorage, DynamoDB var conversion = flatConfig.Conversion; var targetType = propertyStorage.MemberType; - + if (conversion.HasConverter(targetType)) { var output = conversion.ConvertFromEntry(targetType, entry); @@ -715,14 +717,14 @@ private bool TryFromListToArray([DynamicallyAccessedMembers(InternalConstants.Da } var elementType = Utils.GetElementType(targetType); - var array = (Array)Utils.InstantiateArray(targetType,list.Entries.Count); + var array = (Array)Utils.InstantiateArray(targetType, list.Entries.Count); var propertyStorage = new SimplePropertyStorage(elementType, parentPropertyStorage); for (int i = 0; i < list.Entries.Count; i++) { var entry = list.Entries[i]; var item = FromDynamoDBEntry(propertyStorage, entry, flatConfig); - array.SetValue(item,i); + array.SetValue(item, i); } output = array; @@ -889,6 +891,7 @@ private bool TryToList(object value, [DynamicallyAccessedMembers(DynamicallyAcce } return true; } + private bool TryToScalar(object value, Type type, DynamoDBFlatConfig flatConfig, ref DynamoDBEntry entry) { var elementType = Utils.GetElementType(type); @@ -1008,7 +1011,7 @@ private static bool TryGetValue(object instance, MemberInfo member, out object v { FieldInfo fieldInfo = member as FieldInfo; PropertyInfo propertyInfo = member as PropertyInfo; - + if (fieldInfo != null) { value = fieldInfo.GetValue(instance); @@ -1060,16 +1063,20 @@ private ScanFilter ComposeScanFilter(IEnumerable conditions, Item private QueryFilter ComposeQueryFilter(DynamoDBFlatConfig currentConfig, object hashKeyValue, IEnumerable conditions, ItemStorageConfig storageConfig, out List indexNames) { - if (hashKeyValue == null) - throw new ArgumentNullException("hashKeyValue"); + ValidateHashKey(hashKeyValue, storageConfig); + var hashKeyEntry = HashKeyValueToDynamoDBEntry(currentConfig, hashKeyValue, storageConfig); - if (storageConfig.HashKeyPropertyNames == null || storageConfig.HashKeyPropertyNames.Count == 0) + Document hashKey = new Document { - throw new InvalidOperationException($"Attempted to make a query without a defined hash key attribute. " + - $"If using {nameof(DynamoDBContextConfig.DisableFetchingTableMetadata)}, ensure that the table's hash key " + - $"is annotated with {nameof(DynamoDBHashKeyAttribute)}."); - } + [hashKeyEntry.Item1] = hashKeyEntry.Item2 + }; + + return ComposeQueryFilterHelper(currentConfig, hashKey, conditions, storageConfig, out indexNames); + } + private (string, DynamoDBEntry) HashKeyValueToDynamoDBEntry(DynamoDBFlatConfig currentConfig, object hashKeyValue, + ItemStorageConfig storageConfig) + { // Set hash key property name // In case of index queries, if GSI, different key could be used string hashKeyProperty = storageConfig.HashKeyPropertyNames[0]; @@ -1081,13 +1088,39 @@ private QueryFilter ComposeQueryFilter(DynamoDBFlatConfig currentConfig, object DynamoDBEntry hashKeyEntry = ValueToDynamoDBEntry(propertyStorage, hashKeyValue, currentConfig); if (hashKeyEntry == null) throw new InvalidOperationException("Unable to convert hash key value for property " + hashKeyProperty); - Document hashKey = new Document(); - hashKey[hashAttributeName] = hashKeyEntry; + return (hashAttributeName, hashKeyEntry); + } - return ComposeQueryFilterHelper(currentConfig, hashKey, conditions, storageConfig, out indexNames); + private static void ValidateHashKey(object hashKeyValue, ItemStorageConfig storageConfig) + { + if (hashKeyValue == null) + throw new ArgumentNullException("hashKeyValue"); + + if (storageConfig.HashKeyPropertyNames == null || storageConfig.HashKeyPropertyNames.Count == 0) + { + throw new InvalidOperationException($"Attempted to make a query without a defined hash key attribute. " + + $"If using {nameof(DynamoDBContextConfig.DisableFetchingTableMetadata)}, ensure that the table's hash key " + + $"is annotated with {nameof(DynamoDBHashKeyAttribute)}."); + } } private static string NO_INDEX = DynamoDBFlatConfig.DefaultIndexName; + + private void ValidateQueryKeyConfiguration(ItemStorageConfig storageConfig, DynamoDBFlatConfig currentConfig) + { + if (storageConfig.HashKeyPropertyNames.Count != 1) + { + var tableName = GetTableName(storageConfig.TableName, currentConfig); + throw new InvalidOperationException("Must have one hash key defined for the table " + tableName); + } + + if (storageConfig.RangeKeyPropertyNames.Count != 1 && storageConfig.IndexNameToGSIMapping.Count == 0) + { + var tableName = GetTableName(storageConfig.TableName, currentConfig); + throw new InvalidOperationException("Must have one range key or a GSI index defined for the table " + tableName); + } + } + // This method composes the query filter and determines the possible indexes that the filter // may be used against. In the case where the condition property is also a RANGE key on the // table and not just on LSI/GSI, the potential index will be "" (absent). @@ -1101,17 +1134,7 @@ private QueryFilter ComposeQueryFilterHelper( if (hashKey == null) throw new ArgumentNullException("hashKey"); - if (storageConfig.HashKeyPropertyNames.Count != 1) - { - var tableName = GetTableName(storageConfig.TableName, currentConfig); - throw new InvalidOperationException("Must have one hash key defined for the table " + tableName); - } - - if (storageConfig.RangeKeyPropertyNames.Count != 1 && storageConfig.IndexNameToGSIMapping.Count == 0) - { - var tableName = GetTableName(storageConfig.TableName, currentConfig); - throw new InvalidOperationException("Must have one range key or a GSI index defined for the table " + tableName); - } + ValidateQueryKeyConfiguration(storageConfig, currentConfig); QueryFilter filter = new QueryFilter(); @@ -1373,6 +1396,37 @@ public ContextSearch(Search search, DynamoDBFlatConfig flatConfig) return new ContextSearch(scan, flatConfig); } + + internal ContextSearch ConvertScan<[DynamicallyAccessedMembers(InternalConstants.DataModelModeledType)] T>(ContextExpression filterExpression, DynamoDBOperationConfig operationConfig) + { + DynamoDBFlatConfig flatConfig = new DynamoDBFlatConfig(operationConfig, this.Config); + ItemStorageConfig storageConfig = StorageConfigCache.GetConfig(flatConfig); + + DocumentModel.Expression expression = null; + if (filterExpression is not { Filter: null }) + { + if (flatConfig.QueryFilter != null && flatConfig.QueryFilter.Count != 0) + { + throw new InvalidOperationException("QueryFilter is not supported with filter expression. Use either QueryFilter or filter expression, but not both."); + } + expression = ComposeExpression(filterExpression.Filter, storageConfig, flatConfig); + } + + Table table = GetTargetTable(storageConfig, flatConfig); + var scanConfig = new ScanOperationConfig + { + AttributesToGet = storageConfig.AttributesToGet, + Select = SelectValues.SpecificAttributes, + FilterExpression = expression, + IndexName = flatConfig.IndexName, + ConsistentRead = flatConfig.ConsistentRead.GetValueOrDefault(false) + }; + + // table.Scan() returns the ISearch interface but we explicitly cast it to a Search object since we rely on its internal behavior + Search scan = table.Scan(scanConfig) as Search; + return new ContextSearch(scan, flatConfig); + } + private ContextSearch ConvertFromScan<[DynamicallyAccessedMembers(InternalConstants.DataModelModeledType)] T>(ScanOperationConfig scanConfig, DynamoDBOperationConfig operationConfig) { DynamoDBFlatConfig flatConfig = new DynamoDBFlatConfig(operationConfig, Config); @@ -1397,24 +1451,165 @@ public ContextSearch(Search search, DynamoDBFlatConfig flatConfig) private ContextSearch ConvertQueryByValue<[DynamicallyAccessedMembers(InternalConstants.DataModelModeledType)] T>(object hashKeyValue, QueryOperator op, IEnumerable values, DynamoDBOperationConfig operationConfig) { + if (operationConfig != null) + { + operationConfig.ValidateFilter(); + } + DynamoDBFlatConfig flatConfig = new DynamoDBFlatConfig(operationConfig, Config); ItemStorageConfig storageConfig = StorageConfigCache.GetConfig(flatConfig); - List conditions = CreateQueryConditions(flatConfig, op, values, storageConfig); - ContextSearch query = ConvertQueryByValue(hashKeyValue, conditions, operationConfig, storageConfig); + + ContextSearch query; + if (operationConfig is { Expression: { Filter: not null } }) + { + query = ConvertQueryByValueWithExpression(hashKeyValue, op, values, operationConfig.Expression.Filter, + operationConfig, storageConfig); + } + else + { + List conditions = CreateQueryConditions(flatConfig, op, values, storageConfig); + query = ConvertQueryByValue(hashKeyValue, conditions, operationConfig, storageConfig); + } return query; } - private ContextSearch ConvertQueryByValue<[DynamicallyAccessedMembers(InternalConstants.DataModelModeledType)] T>(object hashKeyValue, IEnumerable conditions, DynamoDBOperationConfig operationConfig, ItemStorageConfig storageConfig = null) + private ContextSearch ConvertQueryByValueWithExpression<[DynamicallyAccessedMembers(InternalConstants.DataModelModeledType)] T>(object hashKeyValue, QueryOperator op, IEnumerable values, + Expression filterExpression, DynamoDBOperationConfig operationConfig, ItemStorageConfig storageConfig) { DynamoDBFlatConfig flatConfig = new DynamoDBFlatConfig(operationConfig, Config); + if (storageConfig == null) storageConfig = StorageConfigCache.GetConfig(flatConfig); + if (operationConfig.QueryFilter != null && operationConfig.QueryFilter.Count != 0) + { + throw new InvalidOperationException("QueryFilter is not supported with filter expression. Use either QueryFilter or filter expression, but not both."); + } + return ConvertQueryHelper(hashKeyValue, op, values, flatConfig, storageConfig, filterExpression); + + } + + internal ContextSearch + ConvertQueryByValue<[DynamicallyAccessedMembers(InternalConstants.DataModelModeledType)] T>( + object hashKeyValue, IEnumerable conditions, DynamoDBOperationConfig operationConfig, + ItemStorageConfig storageConfig = null) + { + if (operationConfig != null) + { + operationConfig.ValidateFilter(); + } + + DynamoDBFlatConfig flatConfig = new DynamoDBFlatConfig(operationConfig, Config); + + storageConfig ??= StorageConfigCache.GetConfig(flatConfig); - List indexNames; - QueryFilter filter = ComposeQueryFilter(flatConfig, hashKeyValue, conditions, storageConfig, out indexNames); - return ConvertQueryHelper(flatConfig, storageConfig, filter, indexNames); + ContextSearch query; + if (operationConfig is { Expression: { Filter: not null } }) + { + if(conditions!=null && conditions.Any()) + { + throw new InvalidOperationException("Query conditions are not supported with filter expression. Use either Query conditions or filter expression, but not both."); + } + query = ConvertQueryByValueWithExpression(hashKeyValue, QueryOperator.Equal, null, + operationConfig.Expression.Filter, operationConfig, storageConfig); + } + else + { + + List indexNames; + QueryFilter filter = ComposeQueryFilter(flatConfig, hashKeyValue, conditions, storageConfig, out indexNames); + query = ConvertQueryHelper(flatConfig, storageConfig, filter, indexNames); + } + + return query; } + private ContextSearch ConvertQueryHelper<[DynamicallyAccessedMembers(InternalConstants.DataModelModeledType)] T>( + object hashKeyValue, QueryOperator op, IEnumerable values, DynamoDBFlatConfig flatConfig, + ItemStorageConfig storageConfig, + Expression filterExpression) + { + ValidateHashKey(hashKeyValue, storageConfig); + ValidateQueryKeyConfiguration(storageConfig, flatConfig); + + var hashKeyEntry = HashKeyValueToDynamoDBEntry(flatConfig, hashKeyValue, storageConfig); + var keyExpression = new DocumentModel.Expression + { + ExpressionStatement = "#hashKey = :hashKey", + ExpressionAttributeValues = new Dictionary + { + { ":hashKey", hashKeyEntry.Item2 } + }, + ExpressionAttributeNames = new Dictionary + { + { "#hashKey", hashKeyEntry.Item1 } + } + }; + + string indexName = flatConfig.IndexName; + + var rangeKeyPropertyName = string.IsNullOrEmpty(indexName) ? + storageConfig.RangeKeyPropertyNames.FirstOrDefault() : storageConfig.GetRangeKeyByIndex(indexName); + + if (!string.IsNullOrEmpty(rangeKeyPropertyName) && values != null) + { + keyExpression.ExpressionStatement += ContextExpressionsUtils.GetRangeKeyConditionExpression($"#rangeKey", op); + keyExpression.ExpressionAttributeNames.Add("#rangeKey", rangeKeyPropertyName); + var valuesList = values?.ToList(); + var rangeKeyProperty = storageConfig.BaseTypeStorageConfig.GetPropertyStorage(rangeKeyPropertyName); + if (op == QueryOperator.Between && valuesList != null && valuesList.Count() == 2) + { + keyExpression.ExpressionAttributeValues.Add(":rangeKey0", ToDynamoDBEntry( + rangeKeyProperty, + valuesList.ElementAt(0), + flatConfig, + true)); + keyExpression.ExpressionAttributeValues.Add(":rangeKey1", ToDynamoDBEntry( + rangeKeyProperty, + valuesList.ElementAt(1), + flatConfig, + true)); + } + else + { + keyExpression.ExpressionAttributeValues.Add(":rangeKey0", ToDynamoDBEntry( + rangeKeyProperty, + valuesList.FirstOrDefault(), + flatConfig, + true + )); + } + } + + Table table = GetTargetTable(storageConfig, flatConfig); + var queryConfig = new QueryOperationConfig + { + ConsistentRead = flatConfig.ConsistentRead.Value, + BackwardSearch = flatConfig.BackwardQuery.Value, + KeyExpression = keyExpression, + }; + + var expression = ComposeExpression(filterExpression, storageConfig, flatConfig); + + queryConfig.FilterExpression = expression; + + if (string.IsNullOrEmpty(indexName)) + { + queryConfig.Select = SelectValues.SpecificAttributes; + List attributesToGet = storageConfig.AttributesToGet; + queryConfig.AttributesToGet = attributesToGet; + } + else + { + queryConfig.IndexName = indexName; + queryConfig.Select = SelectValues.AllProjectedAttributes; + } + Search query = table.Query(queryConfig) as Search; + + return new ContextSearch(query, flatConfig); + } + + + private ContextSearch ConvertQueryHelper(DynamoDBFlatConfig currentConfig, ItemStorageConfig storageConfig, QueryFilter filter, List indexNames) { Table table = GetTargetTable(storageConfig, currentConfig); @@ -1449,6 +1644,477 @@ private ContextSearch ConvertQueryHelper(DynamoDBFlatConfig currentConfig, It return new AsyncSearch(this, contextSearch); } + #endregion + + #region Expression Building + + private DocumentModel.Expression ComposeExpression(Expression filterExpression, ItemStorageConfig storageConfig, + DynamoDBFlatConfig flatConfig) + { + DocumentModel.Expression filter = new DocumentModel.Expression(); + if (filterExpression == null) return filter; + + + var aliasList = new KeyAttributeAliasList(); + var expressionNode = BuildExpressionNode(filterExpression, storageConfig, flatConfig); + + filter.ExpressionStatement = expressionNode.BuildExpressionString(aliasList, "C"); + if (aliasList.NamesList != null && aliasList.NamesList.Count != 0) + { + var namesDictionary = new Dictionary(); + for (int i = 0; i < aliasList.NamesList.Count; i++) + { + namesDictionary[$"#C{i}"] = aliasList.NamesList[i]; + } + + filter.ExpressionAttributeNames = namesDictionary; + } + + if (aliasList.ValuesList != null && aliasList.ValuesList.Count != 0) + { + var values = new Dictionary(); + for (int i = 0; i < aliasList.ValuesList.Count; i++) + { + values[$":C{i}"] = aliasList.ValuesList[i]; + } + + filter.ExpressionAttributeValues = values; + } + + return filter; + } + + private ExpressionNode BuildExpressionNode(Expression expr, ItemStorageConfig storageConfig, + DynamoDBFlatConfig flatConfig) + { + var node = new ExpressionNode(); + + switch (expr) + { + case LambdaExpression lambda: + // Recursively process the body of the lambda + return BuildExpressionNode(lambda.Body, storageConfig, flatConfig); + case BinaryExpression binary when ContextExpressionsUtils.IsComparison(binary.NodeType): + node = HandleBinaryComparison(binary, storageConfig, flatConfig); + break; + + case BinaryExpression binary: + // Handle AND/OR expressions + var left = BuildExpressionNode(binary.Left, storageConfig, flatConfig); + var right = BuildExpressionNode(binary.Right, storageConfig, flatConfig); + node.Children.Enqueue(left); + node.Children.Enqueue(right); + var condition = binary.NodeType == ExpressionType.AndAlso ? "AND" : "OR"; + node.FormatedExpression = $"(#c) {condition} (#c)"; + break; + + case MethodCallExpression method: + node = HandleMethodCall(method, storageConfig, flatConfig); + break; + + case UnaryExpression { NodeType: ExpressionType.Not } unary: + var notUnary = BuildExpressionNode(unary.Operand, storageConfig, flatConfig); + node.Children.Enqueue(notUnary); + node.FormatedExpression = ExpressionFormatConstants.Not; + break; + + default: + throw new InvalidOperationException($"Unsupported expression type: {expr.NodeType}"); + } + + return node; + } + + private ExpressionNode HandleBinaryComparison(BinaryExpression expr, ItemStorageConfig storageConfig, DynamoDBFlatConfig flatConfig) + { + Expression member = null; + ConstantExpression constant = null; + + if (ContextExpressionsUtils.IsMember(expr.Left)) + { + member = expr.Left; + constant = ContextExpressionsUtils.GetConstant(expr.Right); + } + else if (ContextExpressionsUtils.IsMember(expr.Right)) + { + member = expr.Right; + constant = ContextExpressionsUtils.GetConstant(expr.Left); + } + + if (member == null) + throw new NotSupportedException("Expected member access"); + + var node = new ExpressionNode + { + FormatedExpression = expr.NodeType switch + { + ExpressionType.Equal => ExpressionFormatConstants.Equal, + ExpressionType.NotEqual => ExpressionFormatConstants.NotEqual, + ExpressionType.LessThan => ExpressionFormatConstants.LessThan, + ExpressionType.LessThanOrEqual => ExpressionFormatConstants.LessThanOrEqual, + ExpressionType.GreaterThan => ExpressionFormatConstants.GreaterThan, + ExpressionType.GreaterThanOrEqual => ExpressionFormatConstants.GreaterThanOrEqual, + _ => throw new InvalidOperationException($"Unsupported mode: {expr.NodeType}") + } + }; + + SetExpressionNodeAttributes(storageConfig, member, constant, node, flatConfig); + + return node; + } + + private ExpressionNode HandleMethodCall(MethodCallExpression expr, ItemStorageConfig storageConfig, + DynamoDBFlatConfig flatConfig) + { + // Handle method calls like Equals, Between, In, AttributeExists, AttributeNotExists, AttributeType, BeginsWith, Contains + return expr.Method.Name switch + { + "Equals" => HandleEqualsMethodCall(expr, storageConfig, flatConfig), + "Contains" => HandleContainsMethodCall(expr, storageConfig, flatConfig), + "StartsWith" => HandleStartsWithMethodCall(expr, storageConfig, flatConfig), + "In" => HandleInMethodCall(expr, storageConfig, flatConfig), + "Between" => HandleBetweenMethodCall(expr, storageConfig, flatConfig), + "AttributeExists" => HandleExistsMethodCall(expr, storageConfig, flatConfig), + "IsNull" or "AttributeNotExists" => HandleIsNullMethodCall(expr, storageConfig, flatConfig), + "AttributeType" => HandleAttributeTypeMethodCall(expr, storageConfig, flatConfig), + _ => throw new NotSupportedException($"Unsupported method call: {expr.Method.Name}") + }; + } + + private ExpressionNode HandleAttributeTypeMethodCall(MethodCallExpression expr, ItemStorageConfig storageConfig, + DynamoDBFlatConfig flatConfig) + { + var node = new ExpressionNode + { + FormatedExpression = ExpressionFormatConstants.AttributeType + }; + + if (expr.Arguments.Count == 2 && expr.Object == null) + { + if (expr.Arguments[0] is MemberExpression memberObj && + expr.Arguments[1] is ConstantExpression typeExpr) + { + SetExpressionNodeAttributes(storageConfig, memberObj, typeExpr, node, flatConfig); + } + else + { + throw new NotSupportedException("Expected MemberExpression and ConstantExpression as arguments for AttributeType method call."); + } + } + else + { + throw new NotSupportedException("Expected MemberExpression and ConstantExpression as arguments for AttributeType method call."); + } + return node; + } + + private ExpressionNode HandleIsNullMethodCall(MethodCallExpression expr, ItemStorageConfig storageConfig, + DynamoDBFlatConfig flatConfig) + { + var node = new ExpressionNode + { + FormatedExpression = ExpressionFormatConstants.AttributeNotExists + }; + + if (expr.Arguments.Count == 1 && expr.Object == null) + { + var collectionExpr = expr.Arguments[0] as MemberExpression; + if (collectionExpr != null) + { + SetExpressionNameNode(storageConfig, collectionExpr, node, flatConfig); + } + else + { + throw new NotSupportedException("Expected MemberExpression as argument for AttributeNotExists method call."); + } + } + else + { + throw new NotSupportedException("Expected MemberExpression as argument for AttributeNotExists method call."); + } + + return node; + } + + private ExpressionNode HandleExistsMethodCall(MethodCallExpression expr, ItemStorageConfig storageConfig, + DynamoDBFlatConfig flatConfig) + { + var node = new ExpressionNode + { + FormatedExpression = ExpressionFormatConstants.AttributeExists + }; + + if (expr.Arguments.Count == 1 && expr.Object == null) + { + var collectionExpr = expr.Arguments[0] as MemberExpression; + if (collectionExpr != null) + { + SetExpressionNameNode(storageConfig, collectionExpr, node, flatConfig); + } + else + { + throw new NotSupportedException("Expected MemberExpression as argument for AttributeExists method call."); + } + } + + return node; + } + + private ExpressionNode HandleInMethodCall(MethodCallExpression expr, ItemStorageConfig storageConfig, + DynamoDBFlatConfig flatConfig) + { + var node = new ExpressionNode + { + FormatedExpression = ExpressionFormatConstants.In + }; + + if (expr.Object is MemberExpression memberObj && expr.Arguments[0] is NewArrayExpression arrayExpr) + { + var propertyStorage = SetExpressionNameNode(storageConfig, memberObj, node, flatConfig); + + foreach (var arg in arrayExpr.Expressions) + { + if (arg is not ConstantExpression constExpr) continue; + + node.FormatedExpression += "#c, "; + + SetExpressionValueNode(constExpr, node, propertyStorage, flatConfig); + } + } + else + { + throw new NotSupportedException("Expected MemberExpression with NewArrayExpression as argument for In method call."); + } + + if (node.FormatedExpression.EndsWith(", ")) + { + node.FormatedExpression = node.FormatedExpression.Substring(0, node.FormatedExpression.Length - 2); + } + node.FormatedExpression += ")"; + return node; + } + + private ExpressionNode HandleBetweenMethodCall(MethodCallExpression expr, + ItemStorageConfig storageConfig, DynamoDBFlatConfig flatConfig) + { + var node = new ExpressionNode + { + FormatedExpression = ExpressionFormatConstants.Between + }; + + + if (expr.Arguments.Count == 3 && expr.Object == null) + { + var collectionExpr = expr.Arguments[0] as MemberExpression; + var constExprLeft = expr.Arguments[1] as ConstantExpression; + var constExprRight = expr.Arguments[2] as ConstantExpression; + + if (collectionExpr != null && constExprLeft != null && constExprRight != null) + { + var propertyStorage = SetExpressionNameNode(storageConfig, collectionExpr, node, flatConfig); + SetExpressionValueNode(constExprLeft, node, propertyStorage, flatConfig); + SetExpressionValueNode(constExprRight, node, propertyStorage, flatConfig); + } + } + else + { + throw new NotSupportedException("Expected MemberExpression with NewArrayExpression as argument for In method call."); + } + + return node; + } + + private ExpressionNode HandleStartsWithMethodCall(MethodCallExpression expr, ItemStorageConfig storageConfig, + DynamoDBFlatConfig flatConfig) + { + var node = new ExpressionNode + { + FormatedExpression = ExpressionFormatConstants.BeginsWith + }; + if (expr.Object is MemberExpression memberObj && expr.Arguments[0] is ConstantExpression argConst) + { + SetExpressionNodeAttributes(storageConfig, memberObj, argConst, node, flatConfig); + } + else + { + throw new NotSupportedException("Expected MemberExpression with ConstantExpression as argument for StartsWith method call."); + } + + return node; + } + + private ExpressionNode HandleContainsMethodCall(MethodCallExpression expr, + ItemStorageConfig storageConfig, DynamoDBFlatConfig flatConfig) + { + var node = new ExpressionNode + { + FormatedExpression = ExpressionFormatConstants.Contains + }; + if (expr.Object is MemberExpression memberObj && expr.Arguments[0] is ConstantExpression argConst) + { + SetExpressionNodeAttributes(storageConfig, memberObj, argConst, node, flatConfig); + } + else if (expr.Arguments.Count == 2 && expr.Object == null) + { + var collectionExpr = expr.Arguments[0] as MemberExpression; + var constExpr = expr.Arguments[1] as ConstantExpression; + + if (collectionExpr != null && constExpr != null) + { + SetExpressionNodeAttributes(storageConfig, collectionExpr, constExpr, node, flatConfig); + } + else + { + throw new NotSupportedException( + "Expected MemberExpression with ConstantExpression as argument for Contains method call."); + } + } + else + { + throw new NotSupportedException( + "Expected MemberExpression with ConstantExpression as argument for Contains method call."); + } + + return node; + } + + private ExpressionNode HandleEqualsMethodCall(MethodCallExpression expr, ItemStorageConfig storageConfig, + DynamoDBFlatConfig flatConfig) + { + const string formatedExpression = ExpressionFormatConstants.Equal; + var node = new ExpressionNode + { + FormatedExpression = formatedExpression + }; + + if (expr.Object is MemberExpression member && + expr.Arguments[0] is ConstantExpression constant && + constant.Value == null) + { + SetExpressionNodeAttributes(storageConfig, member, constant, node, flatConfig); + return node; + } + else if (expr.Arguments.Count == 2 && expr.Object == null) + { + var memberObj = ContextExpressionsUtils.GetMember(expr.Arguments[0]) + ?? ContextExpressionsUtils.GetMember(expr.Arguments[1]); + var argConst = ContextExpressionsUtils.GetConstant(expr.Arguments[1]) + ?? ContextExpressionsUtils.GetConstant(expr.Arguments[0]); + if (memberObj != null && argConst != null) + { + SetExpressionNodeAttributes(storageConfig, memberObj, argConst, node, flatConfig); + return node; + } + } + + throw new NotSupportedException("Expected MemberExpression with ConstantExpression as argument for Equals method call."); + } + + private void SetExpressionNodeAttributes(ItemStorageConfig storageConfig, Expression memberObj, + ConstantExpression argConst, ExpressionNode node, DynamoDBFlatConfig flatConfig) + { + var propertyStorage = SetExpressionNameNode(storageConfig, memberObj, node, flatConfig); + SetExpressionValueNode(argConst, node, propertyStorage, flatConfig); + } + + private void SetExpressionValueNode(ConstantExpression argConst, ExpressionNode node, PropertyStorage propertyStorage, DynamoDBFlatConfig flatConfig) + { + DynamoDBEntry entry = ToDynamoDBEntry(propertyStorage, argConst?.Value, flatConfig, canReturnScalarInsteadOfList: true); + var valuesNode = new ExpressionNode() + { + FormatedExpression = ExpressionFormatConstants.Value + }; + valuesNode.Values.Enqueue(entry); + node.Children.Enqueue(valuesNode); + } + + private PropertyStorage ResolveNestedPropertyStorage(StorageConfig rootConfig, DynamoDBFlatConfig flatConfig, + List path, Queue namesNodeNames) + { + StorageConfig currentConfig = rootConfig; + PropertyStorage propertyStorage = null; + for (int i = 0; i < path.Count; i++) + { + var pathNode = path[i]; + + // If the path node is a map, just add the name to the queue + if (pathNode.IsMap) + { + namesNodeNames.Enqueue(pathNode.Path); + continue; + } + + propertyStorage = currentConfig.GetPropertyStorage(pathNode.Path); + if (propertyStorage == null) + throw new InvalidOperationException($"Property '{pathNode.Path}' not found in storage config."); + // If the property is ignored, throw an exception + if (propertyStorage.IsIgnored) + { + throw new InvalidOperationException($"Property '{pathNode.Path}' is marked as ignored and cannot be used in a filter expression."); + } + + namesNodeNames.Enqueue(propertyStorage.AttributeName); + // If not the last segment, descend into the nested StorageConfig + if (i >= path.Count - 1) continue; + + // Only descend if the property is a complex type (not primitive/string) + var propertyType = propertyStorage.MemberType; + if (Utils.IsPrimitive(propertyType)) + throw new InvalidOperationException($"Property '{pathNode.Path}' is not a complex type."); + + // Determine the element type if the property is a collection + var nextPathNode = path[i + 1]; + + Type elementType = null; + var depth = pathNode.IndexDepth; + if (nextPathNode is { IsMap: true }) + { + depth += nextPathNode.IndexDepth; + } + + var nodePropertyType = propertyType; + var currentDepth = 0; + + while (currentDepth <= depth && nodePropertyType != null && Utils.ImplementsInterface(nodePropertyType, typeof(ICollection<>)) + && nodePropertyType != typeof(string)) + { + elementType = Utils.GetElementType(nodePropertyType); + if (elementType == null) + { + IsSupportedDictionaryType(nodePropertyType, out elementType); + } + nodePropertyType = elementType; + currentDepth++; + } + elementType ??= propertyType; + + ItemStorageConfig config = StorageConfigCache.GetConfig(elementType, flatConfig); + currentConfig = config.BaseTypeStorageConfig; + } + + return propertyStorage; + } + + private PropertyStorage SetExpressionNameNode(ItemStorageConfig storageConfig, Expression memberObj, + ExpressionNode node, DynamoDBFlatConfig flatConfig) + { + var path = ContextExpressionsUtils.ExtractPathNodes(memberObj); + if (path.Count == 0) + { + throw new InvalidOperationException("Expected a valid property path in the expression."); + } + var namesNode = new ExpressionNode() + { + FormatedExpression = string.Join(".", path.Select(pn => pn.FormattedPath)) + }; + + var propertyStorage = ResolveNestedPropertyStorage(storageConfig.BaseTypeStorageConfig, flatConfig, path, namesNode.Names); + node.Children.Enqueue(namesNode); + + return propertyStorage; + } + + #endregion } } diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/QueryConfig.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/QueryConfig.cs index 46d28c5566ce..82b334002186 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/QueryConfig.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/QueryConfig.cs @@ -64,6 +64,14 @@ public class QueryConfig : BaseOperationConfig /// public List QueryFilter { get; set; } + /// + /// Represents a filter expression that can be used to filter results in DynamoDB operations. + /// + /// + /// Note: Expression filters must be against non-key properties. + /// + public ContextExpression Expression { get; set; } + /// /// Property that directs to use consistent reads. /// If property is not set, behavior defaults to non-consistent reads. @@ -91,6 +99,7 @@ internal override DynamoDBOperationConfig ToDynamoDBOperationConfig() config.IndexName = IndexName; config.ConditionalOperator = ConditionalOperator; config.QueryFilter = QueryFilter; + config.Expression = Expression; config.ConsistentRead = ConsistentRead; config.RetrieveDateTimeInUtc = RetrieveDateTimeInUtc; diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/TransactWrite.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/TransactWrite.cs index eaa5f5ba06a0..b416b6c78be6 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/TransactWrite.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/TransactWrite.cs @@ -425,7 +425,7 @@ private void CheckUseVersioning() } } - private Expression CreateConditionExpressionForVersion(ItemStorage storage) + private DocumentModel.Expression CreateConditionExpressionForVersion(ItemStorage storage) { if (!ShouldUseVersioning()) return null; var conversionConfig = new DynamoDBEntry.AttributeConversionConfig( diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/_async/Context.Async.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/_async/Context.Async.cs index 20e834aa2097..84861df63c98 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/_async/Context.Async.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/_async/Context.Async.cs @@ -21,6 +21,7 @@ using System.Threading; using System.Threading.Tasks; using Amazon.DynamoDBv2.DocumentModel; +using System.Linq.Expressions; namespace Amazon.DynamoDBv2.DataModel { @@ -343,6 +344,16 @@ public async Task ExecuteBatchGetAsync(params IBatchGet[] batches) } } + /// + public IAsyncSearch ScanAsync<[DynamicallyAccessedMembers(InternalConstants.DataModelModeledType)] T>(ContextExpression filterExpression) + { + using (DynamoDBTelemetry.CreateSpan(this, nameof(ScanAsync))) + { + var scan = ConvertScan(filterExpression, null); + return FromSearchAsync(scan); + } + } + /// [Obsolete("Use the ScanAsync overload that takes ScanConfig instead, since DynamoDBOperationConfig contains properties that are not applicable to ScanAsync.")] public IAsyncSearch ScanAsync<[DynamicallyAccessedMembers(InternalConstants.DataModelModeledType)] T>(IEnumerable conditions, DynamoDBOperationConfig operationConfig = null) @@ -364,6 +375,17 @@ public async Task ExecuteBatchGetAsync(params IBatchGet[] batches) } } + /// + public IAsyncSearch ScanAsync<[DynamicallyAccessedMembers(InternalConstants.DataModelModeledType)] T>(ContextExpression filterExpression, ScanConfig scanConfig) + { + using (DynamoDBTelemetry.CreateSpan(this, nameof(ScanAsync))) + { + var scan = ConvertScan(filterExpression, scanConfig?.ToDynamoDBOperationConfig()); + return FromSearchAsync(scan); + } + } + + /// public IAsyncSearch FromScanAsync<[DynamicallyAccessedMembers(InternalConstants.DataModelModeledType)] T>(ScanOperationConfig scanConfig) { diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/_async/IDynamoDBContext.Async.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/_async/IDynamoDBContext.Async.cs index b2f25ee4e703..4e0f65902f01 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/_async/IDynamoDBContext.Async.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/_async/IDynamoDBContext.Async.cs @@ -17,6 +17,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; using System.Threading; using System.Threading.Tasks; using Amazon.DynamoDBv2.DocumentModel; @@ -499,6 +500,18 @@ partial interface IDynamoDBContext /// AsyncSearch which can be used to retrieve DynamoDB data. IAsyncSearch ScanAsync<[DynamicallyAccessedMembers(InternalConstants.DataModelModeledType)] T>(IEnumerable conditions); + /// + /// Configures an async Scan operation against DynamoDB, finding items + /// that match the specified filter expression. + /// + /// Type of object. + /// + /// A representing a LINQ expression tree used to filter the results. + /// The expression is translated to a DynamoDB filter expression and applied server-side. + /// + /// AsyncSearch which can be used to retrieve DynamoDB data. + IAsyncSearch ScanAsync<[DynamicallyAccessedMembers(InternalConstants.DataModelModeledType)] T>(ContextExpression filterExpression); + /// /// Configures an async Scan operation against DynamoDB, finding items /// that match the specified conditions. @@ -525,6 +538,20 @@ partial interface IDynamoDBContext /// AsyncSearch which can be used to retrieve DynamoDB data. IAsyncSearch ScanAsync<[DynamicallyAccessedMembers(InternalConstants.DataModelModeledType)] T>(IEnumerable conditions, ScanConfig scanConfig); + /// + /// Configures an async Scan operation against DynamoDB, finding items + /// that match the specified conditions. + /// + /// Type of object. + /// + /// A representing a LINQ expression tree used to filter the results. + /// The expression is translated to a DynamoDB filter expression and applied server-side. + /// + /// Config object that can be used to override properties on the table's context for this request. + /// AsyncSearch which can be used to retrieve DynamoDB data. + IAsyncSearch ScanAsync<[DynamicallyAccessedMembers(InternalConstants.DataModelModeledType)] T>(ContextExpression filterExpression, ScanConfig scanConfig); + + /// /// Configures an async Scan operation against DynamoDB, finding items /// that match the specified conditions. @@ -631,7 +658,7 @@ partial interface IDynamoDBContext /// /// Value(s) of the condition. /// For all operations except QueryOperator.Between, values should be one value. - /// For QueryOperator.Betwee, values should be two values. + /// For QueryOperator.Between, values should be two values. /// /// Config object that can be used to override properties on the table's context for this request. /// AsyncSearch which can be used to retrieve DynamoDB data. @@ -639,7 +666,7 @@ partial interface IDynamoDBContext /// /// Configures an async Query operation against DynamoDB using a mid-level document model - /// query configration, finding items that match the specified conditions. + /// query configuration, finding items that match the specified conditions. /// /// Type of object. /// Mid-level, document model query request object. diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/_bcl/Context.Sync.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/_bcl/Context.Sync.cs index a3d3892053b7..8582f9252f0f 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/_bcl/Context.Sync.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/_bcl/Context.Sync.cs @@ -297,6 +297,16 @@ public IEnumerable Scan(params ScanCondition[] conditions) } } + /// + public IEnumerable Scan(ContextExpression filterExpression) + { + using (DynamoDBTelemetry.CreateSpan(this, nameof(Scan))) + { + var scan = ConvertScan(filterExpression, null); + return FromSearch(scan); + } + } + /// [Obsolete("Use the Scan overload that takes ScanConfig instead, since DynamoDBOperationConfig contains properties that are not applicable to Scan.")] public IEnumerable Scan(IEnumerable conditions, DynamoDBOperationConfig operationConfig) @@ -318,6 +328,16 @@ public IEnumerable Scan(IEnumerable conditions, ScanConfig } } + /// + public IEnumerable Scan(ContextExpression filterExpression, ScanConfig scanConfig) + { + using (DynamoDBTelemetry.CreateSpan(this, nameof(Scan))) + { + var scan = ConvertScan(filterExpression, scanConfig?.ToDynamoDBOperationConfig()); + return FromSearch(scan); + } + } + /// public IEnumerable FromScan(ScanOperationConfig scanConfig) { diff --git a/sdk/src/Services/DynamoDBv2/Custom/DataModel/_bcl/IDynamoDBContext.Sync.cs b/sdk/src/Services/DynamoDBv2/Custom/DataModel/_bcl/IDynamoDBContext.Sync.cs index e500bcb20ce8..bd62db611f49 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DataModel/_bcl/IDynamoDBContext.Sync.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DataModel/_bcl/IDynamoDBContext.Sync.cs @@ -15,7 +15,7 @@ using System; using System.Collections.Generic; - +using System.Linq.Expressions; using Amazon.DynamoDBv2.DocumentModel; namespace Amazon.DynamoDBv2.DataModel @@ -413,6 +413,20 @@ partial interface IDynamoDBContext /// Lazy-loaded collection of results. IEnumerable Scan(params ScanCondition[] conditions); + /// + /// Executes a Scan operation against DynamoDB, + /// returning items that match the specified filter expression. + /// + /// Type of object. + /// + /// A representing a LINQ expression tree used to filter the results. + /// The expression is translated to a DynamoDB filter expression and applied server-side. + /// + /// + /// A lazy-loaded collection of results of type that match the filter expression. + /// + IEnumerable Scan(ContextExpression filterExpression); + /// /// Executes a Scan operation against DynamoDB, finding items /// that match the specified conditions. @@ -439,6 +453,19 @@ partial interface IDynamoDBContext /// Lazy-loaded collection of results. IEnumerable Scan(IEnumerable conditions, ScanConfig scanConfig); + /// + /// Executes a Scan operation against DynamoDB, finding items + /// that match the specified filter expression. + /// + /// Type of object. + /// + /// A representing a LINQ expression tree used to filter the results. + /// The expression is translated to a DynamoDB filter expression and applied server-side. + /// + /// Config object that can be used to override properties on the table's context for this request. + /// Lazy-loaded collection of results. + IEnumerable Scan(ContextExpression filterExpression, ScanConfig scanConfig); + /// /// Executes a Scan operation against DynamoDB, finding items /// that match the specified conditions. diff --git a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/ExpressionBuilder.cs b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/ExpressionBuilder.cs index bbc20dd329b3..3edd1f69bef2 100644 --- a/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/ExpressionBuilder.cs +++ b/sdk/src/Services/DynamoDBv2/Custom/DocumentModel/ExpressionBuilder.cs @@ -646,7 +646,7 @@ private Queue BuildChildNodes() private ExpressionNode NotBuildCondition(ExpressionNode node) { - node.FormatedExpression = "NOT (#c)"; + node.FormatedExpression = ExpressionFormatConstants.Not; return node; } @@ -670,27 +670,27 @@ private ExpressionNode CompareBuildCondition(ConditionMode conditionMode, Expres { case ConditionMode.Equal: node.FormatedExpression = - $"#c = #c"; + ExpressionFormatConstants.Equal; break; case ConditionMode.NotEqual: node.FormatedExpression = - $"#c <> #c"; + ExpressionFormatConstants.NotEqual; break; case ConditionMode.LessThan: node.FormatedExpression = - $"#c < #c"; + ExpressionFormatConstants.LessThan; break; case ConditionMode.LessThanOrEqual: node.FormatedExpression = - $"#c <= #c"; + ExpressionFormatConstants.LessThanOrEqual; break; case ConditionMode.GreaterThan: node.FormatedExpression = - $"#c > #c"; + ExpressionFormatConstants.GreaterThan; break; case ConditionMode.GreaterThanOrEqual: node.FormatedExpression = - $"#c >= #c"; + ExpressionFormatConstants.GreaterThanOrEqual; break; default: throw new InvalidOperationException($"Unsupported mode: {conditionMode}"); @@ -701,37 +701,37 @@ private ExpressionNode CompareBuildCondition(ConditionMode conditionMode, Expres private ExpressionNode ContainsBuildCondition(ExpressionNode node) { - node.FormatedExpression = "contains (#c, #c)"; + node.FormatedExpression = ExpressionFormatConstants.Contains; return node; } private ExpressionNode BeginsWithBuildCondition(ExpressionNode node) { - node.FormatedExpression = "begins_with (#c, #c)"; + node.FormatedExpression = ExpressionFormatConstants.BeginsWith; return node; } private ExpressionNode AttributeTypeBuildCondition(ExpressionNode node) { - node.FormatedExpression = "attribute_type (#c, #c)"; + node.FormatedExpression = ExpressionFormatConstants.AttributeType; return node; } private ExpressionNode AttributeNotExistsBuildCondition(ExpressionNode node) { - node.FormatedExpression = "attribute_not_exists (#c)"; + node.FormatedExpression = ExpressionFormatConstants.AttributeNotExists; return node; } private ExpressionNode AttributeExistsBuildCondition(ExpressionNode node) { - node.FormatedExpression = "attribute_exists (#c)"; + node.FormatedExpression = ExpressionFormatConstants.AttributeExists; return node; } private ExpressionNode InBuildCondition(ConditionExpressionBuilder conditionBuilder, ExpressionNode node) { - node.FormatedExpression = "#c IN ("; + node.FormatedExpression = ExpressionFormatConstants.In; for(int i = 1; i < node.Children.Count; i++){ node.FormatedExpression += "#c, "; @@ -747,7 +747,7 @@ private ExpressionNode InBuildCondition(ConditionExpressionBuilder conditionBuil private ExpressionNode BetweenBuildCondition(ExpressionNode node) { - node.FormatedExpression = "#c BETWEEN #c AND #c"; + node.FormatedExpression = ExpressionFormatConstants.Between; return node; } @@ -1184,7 +1184,7 @@ internal override ExpressionNode Build() return new ExpressionNode { Values = values, - FormatedExpression = "#v" + FormatedExpression = ExpressionFormatConstants.Value }; } } @@ -1487,10 +1487,10 @@ internal override ExpressionNode Build() node.FormatedExpression = _mode switch { - SetValueMode.Plus => "#c + #c", - SetValueMode.Minus => "#c - #c", - SetValueMode.ListAppend=> "list_append(#c, #c)", - SetValueMode.IfNotExists => "if_not_exists(#c, #c)", + SetValueMode.Plus => ExpressionFormatConstants.Plus, + SetValueMode.Minus => ExpressionFormatConstants.Minus, + SetValueMode.ListAppend=> ExpressionFormatConstants.ListAppend, + SetValueMode.IfNotExists => ExpressionFormatConstants.IfNotExists, _ => throw new InvalidOperationException($"Unsupported SetValueMode: '{_mode}'.") }; @@ -1614,4 +1614,31 @@ internal class KeyAttributeAliasList /// public List ValuesList { get; set; } = new(); } + + + /// + /// Contains constants for formatted DynamoDB expression templates. + /// + internal static class ExpressionFormatConstants + { + public const string Equal = "#c = #c"; + public const string NotEqual = "#c <> #c"; + public const string LessThan = "#c < #c"; + public const string LessThanOrEqual = "#c <= #c"; + public const string GreaterThan = "#c > #c"; + public const string GreaterThanOrEqual = "#c >= #c"; + public const string AttributeType = "attribute_type (#c, #c)"; + public const string AttributeNotExists = "attribute_not_exists (#c)"; + public const string AttributeExists = "attribute_exists (#c)"; + public const string In = "#c IN ("; + public const string Between = "#c BETWEEN #c AND #c"; + public const string BeginsWith = "begins_with (#c, #c)"; + public const string Contains = "contains (#c, #c)"; + public const string Not = "NOT (#c)"; + public const string Value = "#v"; + public const string Plus = "#c + #c"; + public const string Minus = "#c - #c"; + public const string ListAppend = "list_append(#c, #c)"; + public const string IfNotExists = "if_not_exists(#c, #c)"; + } } \ No newline at end of file diff --git a/sdk/test/Services/DynamoDBv2/IntegrationTests/DataModelTests.cs b/sdk/test/Services/DynamoDBv2/IntegrationTests/DataModelTests.cs index b5389a4dae68..dfec6a3a1b05 100644 --- a/sdk/test/Services/DynamoDBv2/IntegrationTests/DataModelTests.cs +++ b/sdk/test/Services/DynamoDBv2/IntegrationTests/DataModelTests.cs @@ -11,6 +11,9 @@ using Amazon.DynamoDBv2.DocumentModel; using Amazon.DynamoDBv2.DataModel; using System.Threading.Tasks; +using System.Collections.ObjectModel; +using Amazon.S3.Model; +using Amazon.Runtime.Internal.Transform; using static AWSSDK_DotNet.IntegrationTests.Tests.DynamoDB.DynamoDBTests; @@ -271,7 +274,7 @@ public void TestTransactWrite_AddSaveItem_DocumentTransaction() /// /// Tests that the DynamoDB operations can retrieve attributes in UTC and local timezone. - /// + /// [TestMethod] [TestCategory("DynamoDBv2")] [DataRow(true)] @@ -568,6 +571,488 @@ public void TestContext_RetrieveDateTimeInUtc_OperationConfig(bool retrieveDateT Assert.AreEqual(employee.Age, storedEmployee.Age); } + [TestMethod] + [TestCategory("DynamoDBv2")] + public void TestContext_ScanWithExpression_NestedPaths() + { + TableCache.Clear(); + CleanupTables(); + TableCache.Clear(); + + var product1 = new Product + { + Id = 1, + Name = "Widget", + CompanyInfo = new CompanyInfo + { + Name = "Acme", + Founded = new DateTime(2000, 1, 1), + AllProducts = new List + { + new Product { Id = 2, Name = "Gadget" } + }, + FeaturedBrands = new[] { "Acme", "Contoso" } + }, + Price = 100 + }; + + var product2 = new Product + { + Id = 3, + Name = "Thing", + CompanyInfo = new CompanyInfo + { + Name = "Contoso", + Founded = new DateTime(2010, 5, 5), + AllProducts = new List + { + new Product { Id = 4, Name = "Device" } + }, + FeaturedBrands = new[] { "Contoso" } + }, + Price = 200 + }; + + var product3 = new Product + { + Id = 5, + Name = "CloudSpotter", + CompanyInfo = new CompanyInfo + { + Name = "Contoso", + Founded = new DateTime(2010, 5, 5), + AllProducts = new List + { + new Product + { + Id = 6, Name = "Service", Components = new List + { + "Code", + "Storage", + "Network" + } + } + }, + CompetitorProducts = new Dictionary>() + { + { + "CloudsAreOK", new List() + { + new Product() + { + Id = 8, Name = "CloudSpotter RipOff" + } + } + } + }, + FeaturedBrands = new[] { "Contoso" } + }, + Price = 200 + }; + + Context.Save(product1); + Context.Save(product2); + Context.Save(product3); + + // 1. Filter on a nested property (CompanyInfo.Name) + var expr1 = new ContextExpression(); + expr1.SetFilter(p => p.CompanyInfo.Name == "Acme"); + var byCompanyName = Context.Scan(expr1).ToList(); + Assert.AreEqual(1, byCompanyName.Count); + Assert.AreEqual("Widget", byCompanyName[0].Name); + + // 2. Filter on a nested array property (FeaturedBrands contains "Acme") + var expr2 = new ContextExpression(); + expr2.SetFilter(p => p.CompanyInfo.FeaturedBrands.Contains("Acme")); + var byFeaturedBrand = Context.Scan(expr2).ToList(); + Assert.AreEqual(1, byFeaturedBrand.Count); + Assert.AreEqual("Widget", byFeaturedBrand[0].Name); + + // 3. Filter on a double-nested property + var expr3 = new ContextExpression(); + expr3.SetFilter(p => p.CompanyInfo.AllProducts.First().Name == "Device"); + var byDoubleNested = Context.Scan(expr3).ToList(); + Assert.AreEqual(1, byDoubleNested.Count); + Assert.AreEqual("Thing", byDoubleNested[0].Name); + + var expr4 = new ContextExpression(); + expr4.SetFilter(p => p.CompanyInfo.AllProducts[0].Name == "Device"); + var byDoubleNested1 = Context.Scan(expr4).ToList(); + Assert.AreEqual(1, byDoubleNested1.Count); + Assert.AreEqual("Thing", byDoubleNested1[0].Name); + + // 4. Filter on a value inside a dictionary of lists + var expr5 = new ContextExpression(); + expr5.SetFilter(p => p.CompanyInfo.CompetitorProducts["CloudsAreOK"][0].Name == "CloudSpotter RipOff"); + var byDictionaryNested = Context.Scan(expr5).ToList(); + Assert.AreEqual(1, byDictionaryNested.Count); + Assert.AreEqual("CloudSpotter", byDictionaryNested[0].Name); + } + + + [TestMethod] + [TestCategory("DynamoDBv2")] + public void TestContext_Scan_WithExpressionFilter() + { + TableCache.Clear(); + CleanupTables(); + TableCache.Clear(); + + var employee = new Employee() + { + Name = "Bob", + Age = 45, + CurrentStatus = Status.Active, + CompanyName = "test", + }; + + var employee3 = new Employee + { + Name = "Cob", + Age = 45, + CurrentStatus = Status.Inactive, + CompanyName = "test1", + }; + + var employee2 = new Employee + { + Name = "Rob", + Age = 35, + CurrentStatus = Status.Active, + CompanyName = "test", + }; + + var employee4 = new Employee + { + Name = "Sam", + Age = 20, + CurrentStatus = Status.Upcoming, + CompanyName = "test2", + }; + + Context.Save(employee); + Context.Save(employee2); + Context.Save(employee3); + Context.Save(employee4); + + // Numeric equality + var exprAgeEq = new ContextExpression(); + exprAgeEq.SetFilter(e => e.Age == 45); + var ageEqResult = Context.Scan(exprAgeEq).ToList(); + Assert.AreEqual(2, ageEqResult.Count); + + var exprAgeEqM = new ContextExpression(); + exprAgeEqM.SetFilter(e => Equals(e.Age, 45)); + var ageEqMResult = Context.Scan(exprAgeEqM).ToList(); + Assert.AreEqual(2, ageEqMResult.Count); + + // AND expression with BinaryComparisons + var exprAnd = new ContextExpression(); + exprAnd.SetFilter(e => e.Age > 40 && e.CompanyName == "test"); + var andResults = Context.Scan(exprAnd).ToList(); + + var s1 = Context.Scan(new List() + { + new ScanCondition("Age", ScanOperator.GreaterThan, 40), + new ScanCondition("CompanyName", ScanOperator.Equal, "test") + }, new ScanConfig { RetrieveDateTimeInUtc = true }).ToList(); + + Assert.IsNotNull(s1); + Assert.AreEqual(s1.Count, 1); + Assert.AreEqual(s1.FirstOrDefault().Name, "Bob"); + + Assert.IsNotNull(andResults); + Assert.AreEqual(andResults.Count, 1); + Assert.AreEqual(andResults.FirstOrDefault().Name, "Bob"); + + // NOT expression + var exprNot = new ContextExpression(); + exprNot.SetFilter(e => !(e.CompanyName == "test1")); + var notResult = Context.Scan(exprNot).ToList(); + Assert.AreEqual(3, notResult.Count); + Assert.IsTrue(notResult.All(e => e.CompanyName != "test1")); + + // OR expression + var exprOr = new ContextExpression(); + exprOr.SetFilter(e => e.Name == "Bob" || e.Name == "Rob"); + var orResult = Context.Scan(exprOr).ToList(); + Assert.AreEqual(2, orResult.Count); + Assert.IsTrue(orResult.Any(e => e.Name == "Bob")); + Assert.IsTrue(orResult.Any(e => e.Name == "Rob")); + + // Contains on list property (Aliases) + var empWithAliases = new Employee + { + Name = "Ali", + Age = 50, + CurrentStatus = Status.Active, + MiddleName = "MiddleName", + CompanyName = "test", + Aliases = new List { "Al", "A", "B" } + }; + Context.Save(empWithAliases); + + var exprContains = new ContextExpression(); + exprContains.SetFilter(e => e.Aliases.Contains("Al")); + var containsResult = Context.Scan(exprContains).ToList(); + Assert.IsTrue(containsResult.Any(e => e.Name == "Ali")); + + var exprContainsEnumerable = new ContextExpression(); + exprContainsEnumerable.SetFilter(e => Enumerable.Contains(e.Aliases, "Al")); + var containsEnumerableResult = Context.Scan(exprContainsEnumerable).ToList(); + Assert.IsTrue(containsEnumerableResult.Any(e => e.Name == "Ali")); + + // String.StartsWith + var exprStartsWith = new ContextExpression(); + exprStartsWith.SetFilter(e => e.Name.StartsWith("B")); + var startsWithResult = Context.Scan(exprStartsWith).ToList(); + Assert.IsTrue(startsWithResult.Any(e => e.Name == "Bob")); + + // Between + var exprBetween = new ContextExpression(); + exprBetween.SetFilter(e => e.Age.Between(40, 50)); + var betweenResult = Context.Scan(exprBetween).ToList(); + Assert.AreEqual(3, betweenResult.Count); + Assert.IsTrue(betweenResult.All(e => e.Age >= 40 && e.Age <= 50)); + + // String.Contains + var exprStringContains = new ContextExpression(); + exprStringContains.SetFilter(e => e.Name.Contains("o")); + var stringContainsResult = Context.Scan(exprStringContains).ToList(); + Assert.IsTrue(stringContainsResult.Any(e => e.Name == "Bob" || e.Name == "Rob" || e.Name == "Cob")); + + var exprNullCheck = new ContextExpression(); + exprNullCheck.SetFilter(e => e.MiddleName.AttributeExists()); + var nullCheckResult = Context.Scan(exprNullCheck).ToList(); + Assert.IsTrue(nullCheckResult.Count == 1); + + var exprNull = new ContextExpression(); + exprNull.SetFilter(e => e.MiddleName.AttributeNotExists()); + var nullResult = Context.Scan(exprNull).ToList(); + Assert.IsTrue(nullResult.Count == 4); + + // --- Enum scenario --- + // Scan for employees with CurrentStatus == Status.Active + var exprActiveEnum = new ContextExpression(); + exprActiveEnum.SetFilter(e => e.CurrentStatus == Status.Active); + var activeEnumResult = Context.Scan(exprActiveEnum).ToList(); + Assert.AreEqual(3, activeEnumResult.Count); + Assert.IsTrue(activeEnumResult.All(e => e.CurrentStatus == Status.Active)); + + // Scan for employees with CurrentStatus == Status.Upcoming + var exprUpcomingEnum = new ContextExpression(); + exprUpcomingEnum.SetFilter(e => e.CurrentStatus == Status.Upcoming); + var upcomingEnumResult = Context.Scan(exprUpcomingEnum).ToList(); + Assert.AreEqual(1, upcomingEnumResult.Count); + Assert.AreEqual("Sam", upcomingEnumResult[0].Name); + } + + + [TestMethod] + [TestCategory("DynamoDBv2")] + public void TestContext_Query_WithExpressionFilter() + { + TableCache.Clear(); + CleanupTables(); + TableCache.Clear(); + + // Seed data + var employee1 = new Employee + { + Name = "Alice", + Age = 30, + CompanyName = "Contoso", + CurrentStatus = Status.Active + }; + var employee11 = new Employee + { + Name = "Alice", + Age = 35, + CompanyName = "ContosoTest", + CurrentStatus = Status.Active + }; + var employee2 = new Employee + { + Name = "Bob", + Age = 40, + CompanyName = "Acme", + CurrentStatus = Status.Inactive + }; + var employee3 = new Employee + { + Name = "Charlie", + Age = 35, + CompanyName = "Contoso", + CurrentStatus = Status.Active + }; + + Context.Save(employee1); + Context.Save(employee2); + Context.Save(employee3); + + var contextExpression = new ContextExpression(); + contextExpression.SetFilter(e => e.CompanyName == "Contoso"); + + var employees = Context.Query( + "Alice", + new QueryConfig + { + Expression = contextExpression + }).ToList(); + + Assert.AreEqual(1, employees.Count); + Assert.AreEqual("Alice", employees[0].Name); + + employees = Context.Query( + "Charlie", + new QueryConfig + { + Expression = contextExpression + }).ToList(); + + Assert.AreEqual(1, employees.Count); + Assert.AreEqual("Charlie", employees[0].Name); + + employees = Context.Query( + "Bob", + new QueryConfig + { + Expression = contextExpression + }).ToList(); + + Assert.AreEqual(0, employees.Count); + } + + [TestMethod] + [TestCategory("DynamoDBv2")] + public void TestContext_Query_QueryFilter_vs_ExpressionFilter() + { + TableCache.Clear(); + CleanupTables(); + TableCache.Clear(); + + // Seed data + var employee1 = new Employee + { + Name = "Diane", + Age = 40, + CompanyName = "Big River", + CurrentStatus = Status.Active, + Score = 140, + ManagerName = "Eva" + }; + var employee2 = new Employee + { + Name = "Diane", + Age = 24, + CompanyName = "Big River", + CurrentStatus = Status.Inactive, + Score = 101, + ManagerName = "Eva" + }; + var employee3 = new Employee + { + Name = "Diane", + Age = 31, + CompanyName = "Small River", + CurrentStatus = Status.Active, + Score = 120, + ManagerName = "Barbara" + }; + Context.Save(employee1); + Context.Save(employee2); + Context.Save(employee3); + + // 1. QueryFilter only: filter by ManagerName == "Eva" + var queryFilter = new List + { + new ScanCondition("ManagerName", ScanOperator.Equal, "Eva") + }; + var resultQueryFilter = Context.Query("Diane", new QueryConfig + { + QueryFilter = queryFilter + }).ToList(); + + // 2. ExpressionFilter only: filter by ManagerName == "Eva" + var contextExpression = new ContextExpression(); + contextExpression.SetFilter(e => e.ManagerName == "Eva"); + var resultExpressionFilter = Context.Query("Diane", new QueryConfig + { + Expression = contextExpression + }).ToList(); + + // Assert both results are equivalent + Assert.AreEqual(resultQueryFilter.Count, resultExpressionFilter.Count, "Result counts should match between QueryFilter and ExpressionFilter."); + CollectionAssert.AreEquivalent( + resultQueryFilter.Select(e => e.Age).ToList(), + resultExpressionFilter.Select(e => e.Age).ToList(), + "Result items should match between QueryFilter and ExpressionFilter." + ); + + // 3. Simulate combined filter: CurrentStatus == Inactive AND ManagerName == "Barbara" + var inactiveFilter = new List + { + new ScanCondition("CurrentStatus", ScanOperator.Equal, Status.Active), + new ScanCondition("ManagerName", ScanOperator.Equal, "Barbara") + }; + var contextExpressionBarbara = new ContextExpression(); + contextExpressionBarbara.SetFilter(e => e.ManagerName == "Barbara" && e.CurrentStatus == Status.Active); + + // Run each filter separately and take intersection + var resultActive = Context.Query("Diane", new QueryConfig + { + QueryFilter = inactiveFilter, + ConditionalOperator = ConditionalOperatorValues.And + }).ToList(); + var resultBarbara = Context.Query("Diane", new QueryConfig + { + Expression = contextExpressionBarbara + }).ToList(); + + Assert.AreEqual(resultActive.Count, resultBarbara.Count, "Result counts should match between QueryFilter and ExpressionFilter."); + CollectionAssert.AreEquivalent( + resultActive.Select(e => e.Age).ToList(), + resultBarbara.Select(e => e.Age).ToList(), + "Result items should match between QueryFilter and ExpressionFilter." + ); + // 4. QueryFilter with ConditionalOperator.Or (CurrentStatus == Active OR Score == 101) + var orFilter = new List + { + new ScanCondition("CurrentStatus", ScanOperator.Equal, Status.Active), + new ScanCondition("Score", ScanOperator.Equal, 101) + }; + var resultOrQueryFilter = Context.Query("Diane", new QueryConfig + { + QueryFilter = orFilter, + ConditionalOperator = ConditionalOperatorValues.Or + }).ToList(); + + var contextExpressionOr = new ContextExpression(); + contextExpressionOr.SetFilter(e => e.CurrentStatus == Status.Active || e.Score == 101); + + var resultOrExpressionFilter = Context.Query("Diane", new QueryConfig + { + Expression = contextExpressionOr + }).ToList(); + + // Assert both results are equivalent + Assert.AreEqual(resultOrQueryFilter.Count, resultOrExpressionFilter.Count, "Result counts should match between QueryFilter (OR) and ExpressionFilter (OR)."); + CollectionAssert.AreEquivalent( + resultOrQueryFilter.Select(e => e.Age).ToList(), + resultOrExpressionFilter.Select(e => e.Age).ToList(), + "Result items should match between QueryFilter (OR) and ExpressionFilter (OR)." + ); + + // 5. ExpressionFilter with index + var resultIndex = Context.Query("Big River", new QueryConfig + { + IndexName = "GlobalIndex", + Expression = contextExpression + }).ToList(); + Assert.AreEqual(2, resultIndex.Count); + Assert.IsTrue(resultIndex.All(e => e.ManagerName == "Eva")); + } + /// /// Tests that the DynamoDB operations can read and write polymorphic items. /// diff --git a/sdk/test/Services/DynamoDBv2/UnitTests/Custom/ContextExpressionsUtilsTests.cs b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/ContextExpressionsUtilsTests.cs new file mode 100644 index 000000000000..ec4b00c2f2fe --- /dev/null +++ b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/ContextExpressionsUtilsTests.cs @@ -0,0 +1,187 @@ +using Amazon.DynamoDBv2.DataModel; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using Amazon.DynamoDBv2.DocumentModel; +using Expression = System.Linq.Expressions.Expression; + +namespace AWSSDK_DotNet.UnitTests +{ + [TestClass] + public class ContextExpressionsUtilsTests + { + [TestMethod] + [DataRow(QueryOperator.Equal, " AND myKey = :rangeKey0")] + [DataRow(QueryOperator.LessThan, " AND myKey < :rangeKey0")] + [DataRow(QueryOperator.LessThanOrEqual, " AND myKey <= :rangeKey0")] + [DataRow(QueryOperator.GreaterThan, " AND myKey > :rangeKey0")] + [DataRow(QueryOperator.GreaterThanOrEqual, " AND myKey >= :rangeKey0")] + [DataRow(QueryOperator.Between, " AND myKey BETWEEN :rangeKey0 AND :rangeKey0")] + [DataRow(QueryOperator.BeginsWith, " AND begins_with(myKey, :rangeKey0)")] + public void GetRangeKeyConditionExpression_ReturnsExpected(QueryOperator op, string expected) + { + var result = ContextExpressionsUtils.GetRangeKeyConditionExpression("myKey", op); + Assert.AreEqual(expected, result); + } + + [TestMethod] + public void GetRangeKeyConditionExpression_ThrowsOnUnsupported() + { + Assert.ThrowsException(() => + ContextExpressionsUtils.GetRangeKeyConditionExpression("myKey", (QueryOperator)999)); + } + + [TestMethod] + public void IsMember_ReturnsTrueForMemberExpression() + { + Expression> expr = () => "".Length; + Assert.IsTrue(ContextExpressionsUtils.IsMember(expr.Body)); + } + + [TestMethod] + public void IsMember_ReturnsTrueForUnaryMemberExpression() + { + Expression> expr = () => (object)"".Length; + Assert.IsTrue(ContextExpressionsUtils.IsMember(expr.Body)); + } + + [TestMethod] + public void IsMember_ReturnsFalseForOtherExpressions() + { + Expression> expr = () => 5 + 3; + Assert.IsFalse(ContextExpressionsUtils.IsMember(expr.Body)); + } + + [TestMethod] + public void GetConstant_ReturnsConstantExpression() + { + var constExpr = Expression.Constant(42); + var result = ContextExpressionsUtils.GetConstant(constExpr); + Assert.AreEqual(constExpr, result); + } + + [TestMethod] + public void GetConstant_ReturnsConstantFromUnary() + { + var constExpr = Expression.Constant(42); + var unaryExpr = Expression.Convert(constExpr, typeof(object)); + var result = ContextExpressionsUtils.GetConstant(unaryExpr); + Assert.AreEqual(constExpr, result); + } + + [TestMethod] + public void GetConstant_ReturnsNullForUnsupported() + { + var paramExpr = Expression.Parameter(typeof(int), "x"); + Assert.IsNull(ContextExpressionsUtils.GetConstant(paramExpr)); + } + + [TestMethod] + public void IsComparison_ReturnsTrueForComparisonTypes() + { + Assert.IsTrue(ContextExpressionsUtils.IsComparison(ExpressionType.Equal)); + Assert.IsTrue(ContextExpressionsUtils.IsComparison(ExpressionType.NotEqual)); + Assert.IsTrue(ContextExpressionsUtils.IsComparison(ExpressionType.GreaterThan)); + Assert.IsTrue(ContextExpressionsUtils.IsComparison(ExpressionType.GreaterThanOrEqual)); + Assert.IsTrue(ContextExpressionsUtils.IsComparison(ExpressionType.LessThan)); + Assert.IsTrue(ContextExpressionsUtils.IsComparison(ExpressionType.LessThanOrEqual)); + } + + [TestMethod] + public void IsComparison_ReturnsFalseForOtherTypes() + { + Assert.IsFalse(ContextExpressionsUtils.IsComparison(ExpressionType.Add)); + } + + [TestMethod] + public void GetMember_ReturnsNullForNonMember() + { + Expression> expr = () => 5 + 3; + Assert.IsNull(ContextExpressionsUtils.GetMember(expr.Body)); + } + + [TestMethod] + public void ExtractPathNodes_PropertyPath() + { + Expression> expr = d => d.Child.Value; + var nodes = ContextExpressionsUtils.ExtractPathNodes(expr.Body); + Assert.AreEqual(2, nodes.Count); + Assert.AreEqual("Child", nodes[0].Path); + Assert.AreEqual("Value", nodes[1].Path); + } + + [TestMethod] + public void ExtractPathNodes_ListIndexer() + { + Expression> expr = d => d.Children[2].Value; + var nodes = ContextExpressionsUtils.ExtractPathNodes(expr.Body); + Assert.AreEqual(2, nodes.Count); + Assert.AreEqual("Children", nodes[0].Path); + Assert.AreEqual(1, nodes[0].IndexDepth); + Assert.AreEqual("Value", nodes[1].Path); + } + + [TestMethod] + public void ExtractPathNodes_NestedListIndexer() + { + Expression> expr = d => d.Children[1].Child.Value; + var nodes = ContextExpressionsUtils.ExtractPathNodes(expr.Body); + Assert.AreEqual(3, nodes.Count); + Assert.AreEqual("Children", nodes[0].Path); + Assert.AreEqual(1, nodes[0].IndexDepth); + Assert.AreEqual("Child", nodes[1].Path); + Assert.AreEqual("Value", nodes[2].Path); + } + + [TestMethod] + public void ExtractPathNodes_DictionaryStringIndexer() + { + Expression> expr = d => d.Map["foo"].Value; + var nodes = ContextExpressionsUtils.ExtractPathNodes(expr.Body); + Assert.AreEqual(3, nodes.Count); + Assert.AreEqual("Map", nodes[0].Path); + Assert.AreEqual("foo", nodes[1].Path); + Assert.IsTrue(nodes[1].IsMap); + Assert.AreEqual("Value", nodes[2].Path); + } + + [TestMethod] + [ExpectedException(typeof(NotSupportedException))] + public void ExtractPathNodes_UnsupportedMethodCall_Throws() + { + Expression, int>> expr = l => l.Sum(); + ContextExpressionsUtils.ExtractPathNodes(expr.Body); + } + + [TestMethod] + public void ExtractPathNodes_ConversionExpression() + { + Expression> expr = d => (object)d.Value; + var nodes = ContextExpressionsUtils.ExtractPathNodes(expr.Body); + Assert.AreEqual(1, nodes.Count); + Assert.AreEqual("Value", nodes[0].Path); + } + + [TestMethod] + public void ExtractPathNodes_FirstOrDefault() + { + Expression> expr = d => d.Children.FirstOrDefault().Value; + var nodes = ContextExpressionsUtils.ExtractPathNodes(expr.Body); + Assert.AreEqual(2, nodes.Count); + Assert.AreEqual("Children", nodes[0].Path); + Assert.AreEqual(1, nodes[0].IndexDepth); + Assert.AreEqual("Value", nodes[1].Path); + } + + class Dummy { public Dummy Child { get; set; } public int Value { get; set; } } + + class ComplexDummy + { + public List Children { get; set; } + public Dictionary Map { get; set; } + public Dummy Child { get; set; } + } + } +} \ No newline at end of file diff --git a/sdk/test/Services/DynamoDBv2/UnitTests/Custom/ContextInternalTests.cs b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/ContextInternalTests.cs new file mode 100644 index 000000000000..43eaa59a0720 --- /dev/null +++ b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/ContextInternalTests.cs @@ -0,0 +1,216 @@ +using Amazon.DynamoDBv2; +using Amazon.DynamoDBv2.DataModel; +using Amazon.DynamoDBv2.DocumentModel; +using Amazon.DynamoDBv2.Model; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System; +using System.Linq.Expressions; +using System.Reflection; +using System.Runtime.Remoting.Contexts; + +namespace AWSSDK_DotNet.UnitTests +{ + [TestClass] + public class ContextInternalTests + { + public class TestEntity + { + [DynamoDBHashKey] + public int Id { get; set; } + [DynamoDBRangeKey] + public string Name { get; set; } + } + + private Mock mockClient; + private DynamoDBContext context; + + [TestInitialize] + public void TestInitialize() + { + mockClient = new Mock(MockBehavior.Strict); + mockClient.Setup(m => m.Config).Returns(new AmazonDynamoDBConfig()); + mockClient.Setup(m => m.DescribeTable(It.IsAny())) + .Returns(new DescribeTableResponse + { + Table = new TableDescription + { + TableName = "TestEntity", + KeySchema = new System.Collections.Generic.List + { + new KeySchemaElement + { + AttributeName = "Id", + KeyType = KeyType.HASH + }, + new KeySchemaElement + { + AttributeName = "Name", + KeyType = KeyType.RANGE + } + }, + AttributeDefinitions = new System.Collections.Generic.List + { + new AttributeDefinition + { + AttributeName = "Id", + AttributeType = ScalarAttributeType.N + }, + new AttributeDefinition + { + AttributeName = "Name", + AttributeType = ScalarAttributeType.S + } + } + } + }); + + context = new DynamoDBContext(mockClient.Object, new DynamoDBContextConfig()); + } + + + [TestMethod] + public void ConvertScan_WithFilterExpression_ReturnsMappedFilterExpression() + { + // Create a filter expression (e => e.Id == 1) + Expression> expr = e => e.Id == 1; + var filterExpr = new ContextExpression(); + filterExpr.SetFilter(expr); + + var result = context.ConvertScan(filterExpr, null); + + Assert.IsNotNull(result); + Assert.IsNotNull(result.Search.FilterExpression); + var actualFilterExpression = result.Search.FilterExpression; + Assert.AreEqual("#C0 = :C0", actualFilterExpression.ExpressionStatement); + Assert.IsNotNull(actualFilterExpression.ExpressionAttributeNames); + Assert.IsTrue(actualFilterExpression.ExpressionAttributeNames.ContainsKey("#C0")); + Assert.AreEqual("Id", actualFilterExpression.ExpressionAttributeNames["#C0"]); + + Assert.IsNotNull(actualFilterExpression.ExpressionAttributeValues); + Assert.IsTrue(actualFilterExpression.ExpressionAttributeValues.ContainsKey(":C0")); + Assert.AreEqual(1, actualFilterExpression.ExpressionAttributeValues[":C0"].AsInt()); + } + + [TestMethod] + public void ConvertScan_WithNameFilterExpression_ReturnsMappedFilterExpression() + { + // Filter: e => e.Name == "foo" + Expression> expr = e => e.Name == "foo"; + var filterExpr = new ContextExpression(); + filterExpr.SetFilter(expr); + + var result = context.ConvertScan(filterExpr, null); + + Assert.IsNotNull(result); + Assert.IsNotNull(result.Search.FilterExpression); + var actualFilterExpression = result.Search.FilterExpression; + Assert.AreEqual("#C0 = :C0", actualFilterExpression.ExpressionStatement); + Assert.IsNotNull(actualFilterExpression.ExpressionAttributeNames); + Assert.IsTrue(actualFilterExpression.ExpressionAttributeNames.ContainsKey("#C0")); + Assert.AreEqual("Name", actualFilterExpression.ExpressionAttributeNames["#C0"]); + Assert.IsNotNull(actualFilterExpression.ExpressionAttributeValues); + Assert.IsTrue(actualFilterExpression.ExpressionAttributeValues.ContainsKey(":C0")); + Assert.AreEqual("foo", actualFilterExpression.ExpressionAttributeValues[":C0"].AsString()); + } + + [TestMethod] + public void ConvertScan_WithGreaterThanFilterExpression_ReturnsMappedFilterExpression() + { + // Filter: e => e.Id > 10 + Expression> expr = e => e.Id > 10; + var filterExpr = new ContextExpression(); + filterExpr.SetFilter(expr); + + var result = context.ConvertScan(filterExpr, null); + + Assert.IsNotNull(result); + Assert.IsNotNull(result.Search.FilterExpression); + var actualFilterExpression = result.Search.FilterExpression; + Assert.AreEqual("#C0 > :C0", actualFilterExpression.ExpressionStatement); + Assert.IsNotNull(actualFilterExpression.ExpressionAttributeNames); + Assert.IsTrue(actualFilterExpression.ExpressionAttributeNames.ContainsKey("#C0")); + Assert.AreEqual("Id", actualFilterExpression.ExpressionAttributeNames["#C0"]); + Assert.IsNotNull(actualFilterExpression.ExpressionAttributeValues); + Assert.IsTrue(actualFilterExpression.ExpressionAttributeValues.ContainsKey(":C0")); + Assert.AreEqual(10, actualFilterExpression.ExpressionAttributeValues[":C0"].AsInt()); + } + + [TestMethod] + public void ConvertScan_WithAndFilterExpression_ReturnsMappedFilterExpression() + { + // Filter: e => e.Id == 1 && e.Name == "foo" + Expression> expr = e => e.Id == 1 && e.Name == "foo"; + var filterExpr = new ContextExpression(); + filterExpr.SetFilter(expr); + + var result = context.ConvertScan(filterExpr, null); + + Assert.IsNotNull(result); + Assert.IsNotNull(result.Search.FilterExpression); + var actualFilterExpression = result.Search.FilterExpression; + Assert.AreEqual("(#C0 = :C0) AND (#C1 = :C1)", actualFilterExpression.ExpressionStatement); + Assert.IsNotNull(actualFilterExpression.ExpressionAttributeNames); + Assert.AreEqual("Id", actualFilterExpression.ExpressionAttributeNames["#C0"]); + Assert.AreEqual("Name", actualFilterExpression.ExpressionAttributeNames["#C1"]); + Assert.IsNotNull(actualFilterExpression.ExpressionAttributeValues); + Assert.AreEqual(1, actualFilterExpression.ExpressionAttributeValues[":C0"].AsInt()); + Assert.AreEqual("foo", actualFilterExpression.ExpressionAttributeValues[":C1"].AsString()); + } + + [TestMethod] + public void ConvertQueryByValue_WithHashKeyOnly() + { + // Act + var result = context.ConvertQueryByValue(1, null, null); + + // Assert + Assert.IsNotNull(result); + Assert.IsNotNull(result.Search); + var actualResult = result.Search; + Assert.IsNotNull(actualResult.Filter); + Assert.AreEqual(1,actualResult.Filter.ToConditions().Count); + Assert.IsNull(actualResult.FilterExpression); + Assert.IsNotNull(actualResult.AttributesToGet); + Assert.AreEqual(2,actualResult.AttributesToGet.Count); + } + + [TestMethod] + public void ConvertQueryByValue_WithHashKeyAndExpressionFilter() + { + // Arrange + Expression> expr = e => e.Name == "bar"; + var filterExpr = new ContextExpression(); + filterExpr.SetFilter(expr); + + var operationConfig = new DynamoDBOperationConfig + { + Expression = filterExpr + }; + + // Act + var result = context.ConvertQueryByValue(1, null, operationConfig); + + // Assert + Assert.IsNotNull(result); + + var search = (dynamic)result; + Assert.IsNotNull(search.Search); + Assert.IsNotNull(search.Search.KeyExpression); + Assert.IsNotNull(search.Search.KeyExpression.ExpressionStatement); + Assert.IsTrue(search.Search.KeyExpression.ExpressionStatement.Contains("#hashKey = :hashKey")); + Assert.IsNotNull(search.Search.KeyExpression.ExpressionAttributeNames); + Assert.IsTrue(search.Search.KeyExpression.ExpressionAttributeNames.ContainsKey("#hashKey")); + Assert.IsNotNull(search.Search.KeyExpression.ExpressionAttributeValues); + Assert.IsTrue(search.Search.KeyExpression.ExpressionAttributeValues.ContainsKey(":hashKey")); + var keyValue = search.Search.KeyExpression.ExpressionAttributeValues[":hashKey"]; + Assert.AreEqual(1, keyValue.AsInt()); + + // Assert filter expression + Assert.IsNotNull(search.Search.FilterExpression); + Assert.AreEqual("#C0 = :C0", search.Search.FilterExpression.ExpressionStatement); + Assert.AreEqual("Name", search.Search.FilterExpression.ExpressionAttributeNames["#C0"]); + Assert.AreEqual("bar", search.Search.FilterExpression.ExpressionAttributeValues[":C0"].ToString()); + } + } +} \ No newline at end of file diff --git a/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DataModelOperationSpecificConfigTests.cs b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DataModelOperationSpecificConfigTests.cs index cd568f7a1b61..42241a02a5f1 100644 --- a/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DataModelOperationSpecificConfigTests.cs +++ b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/DataModelOperationSpecificConfigTests.cs @@ -261,7 +261,7 @@ public void QueryConfig() { // If this fails because you've added a property, be sure to add it to // `ToDynamoDBOperationConfig` before updating this unit test - Assert.AreEqual(10, typeof(QueryConfig).GetProperties().Length); + Assert.AreEqual(11, typeof(QueryConfig).GetProperties().Length); } [TestMethod] diff --git a/sdk/test/Services/DynamoDBv2/UnitTests/Custom/MockabilityTests/AsyncSearchTests.cs b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/MockabilityTests/AsyncSearchTests.cs index 825a203e9179..e2b8496429c8 100644 --- a/sdk/test/Services/DynamoDBv2/UnitTests/Custom/MockabilityTests/AsyncSearchTests.cs +++ b/sdk/test/Services/DynamoDBv2/UnitTests/Custom/MockabilityTests/AsyncSearchTests.cs @@ -20,7 +20,7 @@ public async Task TestMockability_ScanAsync() .Returns(CreateMockAsyncSearch(new List { "item1", "item2" })); var ddbContext = mockContext.Object; - var asyncSearch = ddbContext.ScanAsync(null); + var asyncSearch = ddbContext.ScanAsync((IEnumerable)null); var results = await asyncSearch.GetNextSetAsync(); Assert.AreEqual(2, results.Count);