Repository: Azure/azure-sdk-for-net
Pull request #49967: fix: support deserializing timespan properties
Author: login: jorgerangel-msft
Labels: name: Tables
Created: 2025-05-09T18:19:42Z | Merged: 2025-05-13T20:01:15Z

This PR addresses an issue where `TimeSpan` properties within strongly-typed table entities were not being deserialized.

fixes: https://github.com/Azure/azure-sdk-for-net/issues/49221


Diff:
diff --git a/sdk/tables/Azure.Data.Tables/CHANGELOG.md b/sdk/tables/Azure.Data.Tables/CHANGELOG.md
index 3f676afcb4bb..772e9be59e8e 100644
--- a/sdk/tables/Azure.Data.Tables/CHANGELOG.md
+++ b/sdk/tables/Azure.Data.Tables/CHANGELOG.md
@@ -7,6 +7,8 @@
 ### Breaking Changes
 
 ### Bugs Fixed
+- Fixed an issue where `TimeSpan` properties in strongly typed table entities were not being deserialized.
+- Fixed an issue when deserializing strongly typed table entities with enum properties. Enum values that aren't defined in the enum type are now skipped during deserialization of the table entity.
 
 ### Other Changes
 
@@ -313,4 +315,4 @@ Thank you to our developer community members who helped to make Azure Tables bet
 This is the first beta of the `Azure.Data.Tables` client library. The Azure Tables client library can seamlessly target either Azure Table storage or Azure Cosmos DB table service endpoints with no code changes.
 
 This package's [documentation](https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/tables/Azure.Data.Tables/README.md)
-and [samples](https://github.com/Azure/azure-sdk-for-net/tree/main/sdk/tables/Azure.Data.Tables/samples) demonstrate the new API.
+and [samples](https://github.com/Azure/azure-sdk-for-net/tree/main/sdk/tables/Azure.Data.Tables/samples) demonstrate the new API.
\ No newline at end of file
diff --git a/sdk/tables/Azure.Data.Tables/assets.json b/sdk/tables/Azure.Data.Tables/assets.json
index 0d3d4dd447e8..026fc94c37bd 100644
--- a/sdk/tables/Azure.Data.Tables/assets.json
+++ b/sdk/tables/Azure.Data.Tables/assets.json
@@ -2,5 +2,5 @@
   "AssetsRepo": "Azure/azure-sdk-assets",
   "AssetsRepoPrefixPath": "net",
   "TagPrefix": "net/tables/Azure.Data.Tables",
-  "Tag": "net/tables/Azure.Data.Tables_9bdee2bad4"
+  "Tag": "net/tables/Azure.Data.Tables_f74f623264"
 }
diff --git a/sdk/tables/Azure.Data.Tables/src/Extensions/TablesTypeBinder.cs b/sdk/tables/Azure.Data.Tables/src/Extensions/TablesTypeBinder.cs
index f9b49e898d6b..22e9556979a5 100644
--- a/sdk/tables/Azure.Data.Tables/src/Extensions/TablesTypeBinder.cs
+++ b/sdk/tables/Azure.Data.Tables/src/Extensions/TablesTypeBinder.cs
@@ -139,32 +139,46 @@ protected override bool TryGet<T>(BoundMemberInfo memberInfo, IDictionary<string
             {
                 value = (T)(object) DateTime.Parse(propertyValue as string, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
             }
+            else if (typeof(T) == typeof(TimeSpan))
+            {
+                value = (T)(object)TypeFormatters.ParseTimeSpan(propertyValue as string, "P");
+            }
+            else if (typeof(T) == typeof(TimeSpan?))
+            {
+                value = (T)(object)TypeFormatters.ParseTimeSpan(propertyValue as string, "P");
+            }
             else if (typeof(T) == typeof(string))
             {
-                value = (T)(object) (propertyValue as string);
+                value = (T)(object)(propertyValue as string);
             }
             else if (typeof(T) == typeof(int))
             {
-                value = (T)(object) (int)propertyValue;
+                value = (T)(object)(int)propertyValue;
             }
             else if (typeof(T) == typeof(int?))
             {
-                value = (T)(object) (int?)propertyValue;
+                value = (T)(object)(int?)propertyValue;
             }
             else if (typeof(T).IsEnum)
             {
-                value = (T)Enum.Parse(memberInfo.Type, propertyValue as string );
+                if (!Enum.IsDefined(memberInfo.Type, propertyValue as string))
+                    return false;
+
+                value = (T)Enum.Parse(memberInfo.Type, propertyValue as string);
             }
             else if (typeof(T).IsGenericType &&
                      typeof(T).GetGenericTypeDefinition() == typeof(Nullable<>) &&
                      typeof(T).GetGenericArguments() is { Length: 1 } arguments &&
                      arguments[0].IsEnum)
             {
-                value = (T)Enum.Parse(arguments[0], propertyValue as string );
+                if (!Enum.IsDefined(arguments[0], propertyValue as string))
+                    return false;
+
+                value = (T)Enum.Parse(arguments[0], propertyValue as string);
             }
             else if (typeof(T) == typeof(ETag))
             {
-                value = (T)(object) new ETag(propertyValue as string);
+                value = (T)(object)new ETag(propertyValue as string);
             }
 
             return true;
diff --git a/sdk/tables/Azure.Data.Tables/tests/TableClientLiveTests.cs b/sdk/tables/Azure.Data.Tables/tests/TableClientLiveTests.cs
index c2644ddfcd24..b5aadcee66e6 100644
--- a/sdk/tables/Azure.Data.Tables/tests/TableClientLiveTests.cs
+++ b/sdk/tables/Azure.Data.Tables/tests/TableClientLiveTests.cs
@@ -937,6 +937,29 @@ public async Task CustomEntityCanBeUpserted()
             Assert.That(updatedEntity[propertyName], Is.EqualTo(updatedValue), $"The property value should be {updatedValue}");
         }
 
+        [RecordedTest]
+        public async Task TimespanPropertyInCustomEntityCanBeUpsertedAndRead()
+        {
+            const string rowKeyValue = "1";
+            DateTime date1 = new DateTime(2010, 1, 1, 8, 0, 15);
+            DateTime date2 = new DateTime(2010, 8, 18, 13, 30, 30);
+            TimeSpan interval = date2 - date1;
+            var entity = new TimeSpanTestEntity { PartitionKey = PartitionKeyValue, RowKey = rowKeyValue, TimespanProperty = interval, };
+
+            // Create the new entity.
+            await client.UpsertEntityAsync(entity, TableUpdateMode.Replace).ConfigureAwait(false);
+
+            // Fetch the created entity from the service.
+            var retrievedEntity = (await client.QueryAsync<TimeSpanTestEntity>($"PartitionKey eq '{PartitionKeyValue}' and RowKey eq '{rowKeyValue}'")
+                .ToEnumerableAsync()
+                .ConfigureAwait(false)).Single();
+
+            Assert.IsNotNull(retrievedEntity, "The entity should not be null");
+            Assert.IsTrue(TimeSpan.Compare(
+                retrievedEntity!.TimespanProperty.Value, interval) == 0,
+                "The property value should be equal to the original value");
+        }
+
         /// <summary>
         /// Validates the functionality of the TableClient.
         /// </summary>
diff --git a/sdk/tables/Azure.Data.Tables/tests/TableClientTests.cs b/sdk/tables/Azure.Data.Tables/tests/TableClientTests.cs
index 83d43f28d652..0d1ff251a74a 100644
--- a/sdk/tables/Azure.Data.Tables/tests/TableClientTests.cs
+++ b/sdk/tables/Azure.Data.Tables/tests/TableClientTests.cs
@@ -6,7 +6,6 @@
 using System.Diagnostics.Tracing;
 using System.Linq;
 using System.Net;
-using System.Reflection.Metadata;
 using System.Threading.Tasks;
 using Azure.Core;
 using Azure.Core.Diagnostics;
@@ -370,6 +369,33 @@ public void EnumPropertiesAreDeSerializedProperly()
             Assert.That(dictEntity.TryGetValue(TableConstants.PropertyNames.Timestamp, out var _), Is.False, "Only PK, RK, and user properties should be sent");
         }
 
+        // This test validates that a table entity with an enum property that has an unknown enum value is not deserialized.
+        [Test]
+        public void EnumPropertiesWithUnknownValuesAreNotDeserialized()
+        {
+            Foo foo = (Foo)3;
+            NullableFoo? nullableFoo = (NullableFoo)3;
+            var entity = new EnumEntity
+            {
+                PartitionKey = "partitionKey",
+                RowKey = "01",
+                Timestamp = DateTime.Now,
+                MyFoo = foo,
+                MyNullableFoo = nullableFoo,
+                ETag = ETag.All
+            };
+
+            // Create the new entities.
+            var dictEntity = entity.ToOdataAnnotatedDictionary();
+            var deserializedEntity = dictEntity.ToTableEntity<EnumEntity>();
+            Assert.That(deserializedEntity.PartitionKey, Is.EqualTo(entity.PartitionKey), "The entities should be equivalent");
+            Assert.That(deserializedEntity.RowKey, Is.EqualTo(entity.RowKey), "The entities should be equivalent");
+            Assert.That(deserializedEntity.MyFoo.ToString(), Is.EqualTo(default(Foo).ToString()), "The non-existing enum value should not be deserialized.");
+            Assert.IsNull(deserializedEntity.MyNullableFoo, "The non-existing nullable enum value should not be deserialized.");
+            Assert.IsNull(deserializedEntity.MyNullableFoo2, "The entities should be equivalent.");
+            Assert.That(dictEntity.TryGetValue(TableConstants.PropertyNames.Timestamp, out var _), Is.False, "Only PK, RK, and user properties should be sent");
+        }
+
         [Test]
         public void RoundTripContinuationTokenWithPartitionKeyAndRowKey()
         {
diff --git a/sdk/tables/Azure.Data.Tables/tests/TableServiceLiveTestsBase.cs b/sdk/tables/Azure.Data.Tables/tests/TableServiceLiveTestsBase.cs
index 72c43e1c9f11..1b34f28ee8c5 100644
--- a/sdk/tables/Azure.Data.Tables/tests/TableServiceLiveTestsBase.cs
+++ b/sdk/tables/Azure.Data.Tables/tests/TableServiceLiveTestsBase.cs
@@ -421,6 +421,15 @@ public class SimpleTestEntity : ITableEntity
             public ETag ETag { get; set; }
         }
 
+        public class TimeSpanTestEntity : ITableEntity
+        {
+            public string PartitionKey { get; set; }
+            public string RowKey { get; set; }
+            public DateTimeOffset? Timestamp { get; set; }
+            public ETag ETag { get; set; }
+            public TimeSpan? TimespanProperty { get; set; }
+        }
+
         public class ComplexEntity : ITableEntity
         {
             public const int NumberOfNonNullProperties = 28;


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR fixes the issue where TimeSpan properties in strongly-typed table entities are not correctly deserialized.  
- Added a new TimeSpanTestEntity class to validate the TimeSpan property round-trip.  
- Updated the TablesTypeBinder to support parsing TimeSpan and nullable TimeSpan values.  
- Added a new test case in TableClientLiveTests to verify correct deserialization of TimeSpan properties.

### Reviewed Changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

<details>
<summary>Show a summary per file</summary>

| File | Description |
| ---- | ----------- |
| sdk/tables/Azure.Data.Tables/tests/TableServiceLiveTestsBase.cs | Introduces TimeSpanTestEntity for testing deserialization support. |
| sdk/tables/Azure.Data.Tables/tests/TableClientLiveTests.cs | Adds a test to upsert and read a TimeSpan property. |
| sdk/tables/Azure.Data.Tables/src/Extensions/TablesTypeBinder.cs | Updates type binder to parse TimeSpan (and nullable) values using TypeFormatters. |
| sdk/tables/Azure.Data.Tables/assets.json | Updates the asset tag for the tables package. |
| sdk/tables/Azure.Data.Tables/CHANGELOG.md | Documents the bugfix for deserializing TimeSpan properties. |
</details>





state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T22:50:20Z
user: login: jsquire
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-12T23:09:44Z
user: login: jorgerangel-msft
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T18:06:11Z
user: login: christothes
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T19:17:34Z
user: login: jorgerangel-msft
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T19:33:37Z
user: login: christothes
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-13T19:59:33Z

Review comments:
diff_hunk: @@ -937,6 +937,29 @@ public async Task CustomEntityCanBeUpserted()
             Assert.That(updatedEntity[propertyName], Is.EqualTo(updatedValue), $"The property value should be {updatedValue}");
         }
 
+        [RecordedTest]
+        public async Task TimespanPropertyInCustomEntityCanBeUpsertedAndRead()
+        {
+            const string rowKeyValue = "1";
+            DateTime date1 = new DateTime(2010, 1, 1, 8, 0, 15);
+            DateTime date2 = new DateTime(2010, 8, 18, 13, 30, 30);
+            TimeSpan interval = date2 - date1;
+            var entity = new TimeSpanTestEntity { PartitionKey = PartitionKeyValue, RowKey = rowKeyValue, TimespanProperty = interval, };
+
+            // Create the new entity.
+            await client.UpsertEntityAsync(entity, TableUpdateMode.Replace).ConfigureAwait(false);
+
+            // Fetch the created entity from the service.
+            var retrievedEntity = (await client.QueryAsync<TimeSpanTestEntity>($"PartitionKey eq '{PartitionKeyValue}' and RowKey eq '{rowKeyValue}'")
+                .ToEnumerableAsync()
+                .ConfigureAwait(false)).Single();
+
+            Assert.IsNotNull(retrievedEntity, "The entity should not be null");
+            Assert.IsTrue(TimeSpan.Compare(
+                retrievedEntity!.TimespanProperty.Value, retrievedEntity!.TimespanProperty.Value) == 0,
path: sdk/tables/Azure.Data.Tables/tests/TableClientLiveTests.cs
user: login: Copilot
body: The test assertion compares the TimespanProperty value to itself rather than comparing it against the originally computed 'interval'. Consider updating the assertion to compare 'retrievedEntity!.TimespanProperty.Value' with the local variable 'interval'.
```suggestion
                retrievedEntity!.TimespanProperty.Value, interval) == 0,
```
created_at: 2025-05-12T22:50:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -139,32 +139,40 @@ protected override bool TryGet<T>(BoundMemberInfo memberInfo, IDictionary<string
             {
                 value = (T)(object) DateTime.Parse(propertyValue as string, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
             }
+            else if (typeof(T) == typeof(TimeSpan))
+            {
+                value = (T)(object)TypeFormatters.ParseTimeSpan(propertyValue as string, "P");
+            }
+            else if (typeof(T) == typeof(TimeSpan?))
+            {
+                value = (T)(object)TypeFormatters.ParseTimeSpan(propertyValue as string, "P");
+            }
             else if (typeof(T) == typeof(string))
             {
-                value = (T)(object) (propertyValue as string);
+                value = (T)(object)(propertyValue as string);
             }
             else if (typeof(T) == typeof(int))
             {
-                value = (T)(object) (int)propertyValue;
+                value = (T)(object)(int)propertyValue;
             }
             else if (typeof(T) == typeof(int?))
             {
-                value = (T)(object) (int?)propertyValue;
+                value = (T)(object)(int?)propertyValue;
             }
             else if (typeof(T).IsEnum)
             {
-                value = (T)Enum.Parse(memberInfo.Type, propertyValue as string );
+                value = (T)Enum.Parse(memberInfo.Type, propertyValue as string);
path: sdk/tables/Azure.Data.Tables/src/Extensions/TablesTypeBinder.cs
user: login: jsquire
body: I know this isn't part of your scope, but I wonder if we should do an `Enum.IsDefined` check here.   The `Parse` will allow unknown values but not labels.   For example:

```csharp
// Legal
var whatHappened = (SomeType)Enum.Parse(typeof(SomeType), "27");

// This is false
var forReal = Enum.Defined(typeof(SomeType), whatHappened);

public enum SomeType
{
  Unknown,
  MyLegIsGone,
  HeSavedMeFromMyself,
  HelperManagerHelper
}
```
created_at: 2025-05-12T23:09:29Z
author_association: MEMBER
diff_hunk: @@ -139,32 +139,40 @@ protected override bool TryGet<T>(BoundMemberInfo memberInfo, IDictionary<string
             {
                 value = (T)(object) DateTime.Parse(propertyValue as string, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
             }
+            else if (typeof(T) == typeof(TimeSpan))
+            {
+                value = (T)(object)TypeFormatters.ParseTimeSpan(propertyValue as string, "P");
+            }
+            else if (typeof(T) == typeof(TimeSpan?))
+            {
+                value = (T)(object)TypeFormatters.ParseTimeSpan(propertyValue as string, "P");
+            }
             else if (typeof(T) == typeof(string))
             {
-                value = (T)(object) (propertyValue as string);
+                value = (T)(object)(propertyValue as string);
             }
             else if (typeof(T) == typeof(int))
             {
-                value = (T)(object) (int)propertyValue;
+                value = (T)(object)(int)propertyValue;
             }
             else if (typeof(T) == typeof(int?))
             {
-                value = (T)(object) (int?)propertyValue;
+                value = (T)(object)(int?)propertyValue;
             }
             else if (typeof(T).IsEnum)
             {
-                value = (T)Enum.Parse(memberInfo.Type, propertyValue as string );
+                value = (T)Enum.Parse(memberInfo.Type, propertyValue as string);
path: sdk/tables/Azure.Data.Tables/src/Extensions/TablesTypeBinder.cs
user: login: jorgerangel-msft
body: This seems like a reasonable change. I've gone ahead and added it to the PR. Thanks for catching that.
created_at: 2025-05-13T18:06:11Z
author_association: MEMBER
diff_hunk: @@ -7,6 +7,7 @@
 ### Breaking Changes
 
 ### Bugs Fixed
+- Fixed an issue where `TimeSpan` properties in strongly typed table entities were not being deserialized.
path: sdk/tables/Azure.Data.Tables/CHANGELOG.md
user: login: christothes
body: should we mention the Enum fix also?
created_at: 2025-05-13T19:17:34Z
author_association: MEMBER
diff_hunk: @@ -7,6 +7,7 @@
 ### Breaking Changes
 
 ### Bugs Fixed
+- Fixed an issue where `TimeSpan` properties in strongly typed table entities were not being deserialized.
path: sdk/tables/Azure.Data.Tables/CHANGELOG.md
user: login: jorgerangel-msft
body: sure, done.
created_at: 2025-05-13T19:33:37Z
author_association: MEMBER

PR comments:
user: login: azure-sdk
created_at: 2025-05-09T18:29:23Z
body: **API change check**

API changes are not detected in this pull request.
author_association: COLLABORATOR
user: login: jorgerangel-msft
created_at: 2025-05-12T16:02:21Z
body: /azp run net - tables - tests
author_association: MEMBER
user: login: azure-pipelines[bot]
created_at: 2025-05-12T16:02:39Z
body: <samp>
Azure Pipelines successfully started running 1 pipeline(s).<br>

</samp>
author_association: NONE
user: login: jorgerangel-msft
created_at: 2025-05-12T17:36:54Z
body: /azp run net - tables - tests
author_association: MEMBER
user: login: azure-pipelines[bot]
created_at: 2025-05-12T17:37:11Z
body: <samp>
Azure Pipelines successfully started running 1 pipeline(s).<br>

</samp>
author_association: NONE
user: login: jorgerangel-msft
created_at: 2025-05-12T21:51:19Z
body: /azp run net - tables - tests
author_association: MEMBER
user: login: azure-pipelines[bot]
created_at: 2025-05-12T21:51:34Z
body: <samp>
Azure Pipelines successfully started running 1 pipeline(s).<br>

</samp>
author_association: NONE
user: login: jorgerangel-msft
created_at: 2025-05-13T18:11:04Z
body: /azp run net - tables - tests
author_association: MEMBER
user: login: azure-pipelines[bot]
created_at: 2025-05-13T18:11:23Z
body: <samp>
Azure Pipelines successfully started running 1 pipeline(s).<br>

</samp>
author_association: NONE