Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions generator/.DevConfigs/c952ab1e-3056-4598-9d0e-f7f02187e982.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"services": [
{
"serviceName": "DynamoDBv2",
"type": "minor",
"changeLogMessages": [
"Add support for DynamoDBAutoGeneratedTimestampAttribute that sets current timestamp during persistence operations."
]
}
]
}
45 changes: 45 additions & 0 deletions sdk/src/Services/DynamoDBv2/Custom/DataModel/Attributes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -688,4 +688,49 @@ public DynamoDBLocalSecondaryIndexRangeKeyAttribute(params string[] indexNames)
IndexNames = indexNames.Distinct(StringComparer.Ordinal).ToArray();
}
}

/// <summary>
/// Specifies that the decorated property or field should have its value automatically
/// set to the current timestamp during persistence operations.
/// </summary>
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true, AllowMultiple = false)]
public sealed class DynamoDBAutoGeneratedTimestampAttribute : DynamoDBPropertyAttribute
{

/// <summary>
/// Default constructor. Timestamp is set on both create and update.
/// </summary>
public DynamoDBAutoGeneratedTimestampAttribute()
: base()
{
}


/// <summary>
/// Constructor that specifies an alternate attribute name.
/// </summary>
/// <param name="attributeName">Name of attribute to be associated with property or field.</param>
public DynamoDBAutoGeneratedTimestampAttribute(string attributeName)
: base(attributeName)
{
}
/// <summary>
/// Constructor that specifies a custom converter.
/// </summary>
/// <param name="converter">Custom converter type.</param>
public DynamoDBAutoGeneratedTimestampAttribute([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] Type converter)
: base(converter)
{
}

/// <summary>
/// Constructor that specifies an alternate attribute name and a custom converter.
/// </summary>
/// <param name="attributeName">Name of attribute to be associated with property or field.</param>
/// <param name="converter">Custom converter type.</param>
public DynamoDBAutoGeneratedTimestampAttribute(string attributeName, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] Type converter)
: base(attributeName, converter)
{
}
}
}
2 changes: 1 addition & 1 deletion sdk/src/Services/DynamoDBv2/Custom/DataModel/Context.cs
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,7 @@ private async Task SaveHelperAsync([DynamicallyAccessedMembers(InternalConstants
.ConfigureAwait(false);
}

if (counterConditionExpression == null && versionExpression == null) return;
if (counterConditionExpression == null && versionExpression == null && !storage.Config.HasAutogeneratedProperties) return;

if (returnValues == ReturnValues.AllNewAttributes)
{
Expand Down
29 changes: 27 additions & 2 deletions sdk/src/Services/DynamoDBv2/Custom/DataModel/ContextInternal.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
using System.Globalization;
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
using Amazon.Util;
using ThirdParty.RuntimeBackports;
using Expression = System.Linq.Expressions.Expression;

Expand Down Expand Up @@ -139,7 +140,16 @@ private static PropertyStorage[] GetCounterProperties(ItemStorage storage)
{
var counterProperties = storage.Config.BaseTypeStorageConfig.Properties.
Where(propertyStorage => propertyStorage.IsCounter).ToArray();

var flatten= storage.Config.BaseTypeStorageConfig.Properties.
Where(propertyStorage => propertyStorage.FlattenProperties.Any()).ToArray();
while (flatten.Any())
{
var flattenCounters = flatten.SelectMany(p => p.FlattenProperties.Where(fp => fp.IsCounter)).ToArray();
counterProperties = counterProperties.Concat(flattenCounters).ToArray();
flatten = flatten.SelectMany(p => p.FlattenProperties.Where(fp => fp.FlattenProperties.Any())).ToArray();
}


return counterProperties;
}

Expand Down Expand Up @@ -541,6 +551,8 @@ private void PopulateItemStorage(object toStore, ItemStorage storage, DynamoDBFl
storageConfig = config.BaseTypeStorageConfig;
}

var now = AWSSDKUtils.CorrectedUtcNow;

foreach (PropertyStorage propertyStorage in storageConfig.AllPropertyStorage)
{
// if only keys are being serialized, skip non-key properties
Expand All @@ -557,7 +569,7 @@ private void PopulateItemStorage(object toStore, ItemStorage storage, DynamoDBFl
object value;
if (TryGetValue(toStore, propertyStorage.Member, out value))
{
DynamoDBEntry dbe = ToDynamoDBEntry(propertyStorage, value, flatConfig, propertyStorage.ShouldFlattenChildProperties);
DynamoDBEntry dbe = ToDynamoDBEntry(propertyStorage, value, flatConfig);
Copy link

Copilot AI Sep 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method signature change removes the propertyStorage.ShouldFlattenChildProperties parameter but there's no clear indication of how this affects the flattening behavior. This could be a breaking change that needs verification.

Copilot uses AI. Check for mistakes.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

whats the reason for this change?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the propertyStorage.ShouldFlattenChildProperties was wrongly sent as canReturnScalarInsteadOfList params

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@normj should we mention this as well in the change log that we fixed this incorrect logic? Also wondering since we changed this, how is that that no unit tests broke (were there not any existing ones that cover this case?)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DynamoDbFlatten annotation was introduced in this pr: #3833 and this specific case was not covered (list properties in flatten objects)


if (ShouldSave(dbe, ignoreNullValues))
{
Expand All @@ -572,6 +584,14 @@ private void PopulateItemStorage(object toStore, ItemStorage storage, DynamoDBFl
{
document[pair.Key] = pair.Value;
}

if (propertyStorage.FlattenProperties.Any(p => p.IsVersion))
{
var innerVersionProperty =
propertyStorage.FlattenProperties.First(p => p.IsVersion);
storage.CurrentVersion =
innerDocument[innerVersionProperty.AttributeName] as Primitive;
}
}
else
{
Expand All @@ -590,6 +610,11 @@ private void PopulateItemStorage(object toStore, ItemStorage storage, DynamoDBFl
storage.CurrentVersion = dbePrimitive;
}
}

if (dbe == null && propertyStorage.IsAutoGeneratedTimestamp)
{
document[attributeName] = new Primitive(now.ToString("o"));
}
}
else
throw new InvalidOperationException(
Expand Down
39 changes: 34 additions & 5 deletions sdk/src/Services/DynamoDBv2/Custom/DataModel/InternalModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -137,21 +137,36 @@ internal class PropertyStorage : SimplePropertyStorage
public bool IsGSIKey { get { return IsGSIHashKey || IsGSIRangeKey; } }
public bool IsIgnored { get; set; }

// whether to store DateTime as epoch seconds integer
/// <summary>
/// Whether to store DateTime as epoch seconds integer.
/// </summary>
public bool StoreAsEpoch { get; set; }

// whether to store DateTime as epoch seconds integer (with support for dates AFTER 2038)
/// <summary>
/// Whether to store DateTime as epoch seconds integer (with support for dates AFTER 2038).
/// </summary>
public bool StoreAsEpochLong { get; set; }

// whether to store Type Discriminator for polymorphic serialization
/// <summary>
/// Whether to store Type Discriminator for polymorphic serialization.
/// </summary>
public bool PolymorphicProperty { get; set; }

// whether to store child properties at the same level as the parent property
/// <summary>
/// Whether to store child properties at the same level as the parent property.
/// </summary>
public bool ShouldFlattenChildProperties { get; set; }

// whether to store property at parent level
/// <summary>
/// Whether to store property at parent level.
/// </summary>
public bool IsFlattened { get; set; }

/// <summary>
/// Whether to store the property as a timestamp that is automatically generated.
/// </summary>
public bool IsAutoGeneratedTimestamp { get; set; }

// corresponding IndexNames, if applicable
public List<string> IndexNames { get; set; }

Expand Down Expand Up @@ -242,6 +257,9 @@ public void Validate(DynamoDBContext context)
if (StoreAsEpoch || StoreAsEpochLong)
throw new InvalidOperationException("Converter for " + PropertyName + " must not be set at the same time as StoreAsEpoch or StoreAsEpochLong is set to true");

if (IsAutoGeneratedTimestamp)
throw new InvalidOperationException("Converter for " + PropertyName + " must not be set at the same time as AutoGeneratedTimestamp is set to true.");

if (!Utils.CanInstantiateConverter(ConverterType) || !Utils.ImplementsInterface(ConverterType, typeof(IPropertyConverter)))
throw new InvalidOperationException("Converter for " + PropertyName + " must be instantiable with no parameters and must implement IPropertyConverter");

Expand All @@ -250,6 +268,9 @@ public void Validate(DynamoDBContext context)

if (StoreAsEpoch && StoreAsEpochLong)
throw new InvalidOperationException(PropertyName + " must not set both StoreAsEpoch and StoreAsEpochLong as true at the same time.");

if (IsAutoGeneratedTimestamp)
Utils.ValidateTimestampType(MemberType);

IPropertyConverter converter;
if (context.ConverterCache.TryGetValue(MemberType, out converter) && converter != null)
Expand Down Expand Up @@ -470,6 +491,8 @@ internal class ItemStorageConfig
public string VersionPropertyName { get; private set; }
public bool HasVersion { get { return !string.IsNullOrEmpty(VersionPropertyName); } }

public bool HasAutogeneratedProperties { get; internal set; }

// attribute-to-index mapping
public Dictionary<string, List<string>> AttributeToIndexesNameMapping { get; set; }

Expand Down Expand Up @@ -1082,6 +1105,12 @@ private static PropertyStorage MemberInfoToPropertyStorage(ItemStorageConfig con
propertyStorage.IsRangeKey = true;
}

if (propertyAttribute is DynamoDBAutoGeneratedTimestampAttribute)
{
propertyStorage.IsAutoGeneratedTimestamp = true;
config.HasAutogeneratedProperties = true;
}

DynamoDBLocalSecondaryIndexRangeKeyAttribute lsiRangeKeyAttribute = propertyAttribute as DynamoDBLocalSecondaryIndexRangeKeyAttribute;
if (lsiRangeKeyAttribute != null)
{
Expand Down
15 changes: 15 additions & 0 deletions sdk/src/Services/DynamoDBv2/Custom/DataModel/Utils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,21 @@ internal static void ValidateNumericType(Type memberType)
throw new InvalidOperationException("Version property must be of primitive, numeric, integer, nullable type (e.g. int?, long?, byte?)");
}

internal static void ValidateTimestampType(Type memberType)
{
if (memberType.IsGenericType && memberType.GetGenericTypeDefinition() == typeof(Nullable<>) &&
(memberType.IsAssignableFrom(typeof(DateTime)) ||
memberType.IsAssignableFrom(typeof(DateTimeOffset))))
{
return;
Comment on lines +163 to +167
Copy link

Copilot AI Sep 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The IsAssignableFrom check is incorrect for generic nullable types. Should use memberType.GetGenericArguments()[0] to get the underlying type, then check if it equals typeof(DateTime) or typeof(DateTimeOffset).

Suggested change
if (memberType.IsGenericType && memberType.GetGenericTypeDefinition() == typeof(Nullable<>) &&
(memberType.IsAssignableFrom(typeof(DateTime)) ||
memberType.IsAssignableFrom(typeof(DateTimeOffset))))
{
return;
if (memberType.IsGenericType && memberType.GetGenericTypeDefinition() == typeof(Nullable<>))
{
var underlyingType = memberType.GetGenericArguments()[0];
if (underlyingType == typeof(DateTime) || underlyingType == typeof(DateTimeOffset))
{
return;
}

Copilot uses AI. Check for mistakes.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is the intent of this function to only allow DateTime/DateTimeOffsets to be used? if so can you check this suggestion to see if its accurate or not?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unit tests added for the method and the suggestion does not look to be accurate

}
throw new InvalidOperationException(
$"Timestamp properties must be of type Nullable<DateTime> (DateTime?) or Nullable<DateTimeOffset> (DateTimeOffset?). " +
$"Invalid type: {memberType.FullName}. " +
"Please ensure your property is declared as 'DateTime?' or 'DateTimeOffset?'."
);
}

[return: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.PublicConstructors)]
internal static Type GetPrimitiveElementType(Type collectionType)
{
Expand Down
Loading