From d8b7cf858eea667a76cf01e8db59ddb9cb98c7e4 Mon Sep 17 00:00:00 2001 From: Fabricio Ruch Date: Mon, 3 Aug 2026 11:48:58 +0200 Subject: [PATCH 1/9] Clean up library and test code for consistency and readability. Remove redundant usings, normalize formatting, and tighten nullable handling across clients, converters, and tests. Introduce global usings for Azure.Data.Tables. Co-authored-by: Cursor --- .../CRUDIntegrationTest.cs | 7 +- .../GlobalUsings.cs | 1 + .../LargeDatasetIntegrationTest.cs | 19 +++-- .../MultiEntityIntegrationTest.cs | 22 +++--- .../BuilderIEnumberableTest.cs | 10 +-- AzureTableUtils.Tests/BuilderListArrayTest.cs | 10 +-- AzureTableUtils.Tests/BuilderTests.cs | 17 ++--- AzureTableUtils.Tests/GlobalUsings.cs | 1 + .../Models/ByteAndBooleanPoco.cs | 5 +- .../Models/MainWithParent.cs | 10 ++- AzureTableUtils.Tests/Models/ParentPoco.cs | 11 +-- .../Models/PocoWithListChildren.cs | 6 +- AzureTableUtils.Tests/Models/SimplePoco.cs | 10 +-- .../Models/SimplePocoWithArray.cs | 6 +- .../Models/UnsuportedTypePoco.cs | 2 +- .../SerializerIEnumberableTest.cs | 9 +-- .../SerializerListArrayTest.cs | 8 +- AzureTableUtils.Tests/SerializerTests.cs | 33 +++------ AzureTableUtils/Converters/ArrayConverter.cs | 8 +- .../Converters/ConverterFactory.cs | 6 +- AzureTableUtils/Converters/EnumConverter.cs | 14 ++-- .../Converters/EnumerableConverter.cs | 12 +-- AzureTableUtils/Converters/IConverter.cs | 5 +- .../Converters/TimeSpanConverter.cs | 13 ++-- .../ExtendedAzureTableClientService.cs | 4 +- AzureTableUtils/GlobalUsings.cs | 2 + AzureTableUtils/Models/TabelEntityResult.cs | 13 ++-- .../MultiEntityAzureTableClient.cs | 73 +++++++++++-------- AzureTableUtils/ObjectBuilder.cs | 45 ++++++------ AzureTableUtils/ObjectSerializer.cs | 17 ++--- AzureTableUtils/TypedAzureTableClient.cs | 26 ++++--- 31 files changed, 191 insertions(+), 234 deletions(-) create mode 100644 AzureTableUtils.IntegrationTests/GlobalUsings.cs create mode 100644 AzureTableUtils.Tests/GlobalUsings.cs create mode 100644 AzureTableUtils/GlobalUsings.cs diff --git a/AzureTableUtils.IntegrationTests/CRUDIntegrationTest.cs b/AzureTableUtils.IntegrationTests/CRUDIntegrationTest.cs index 78964d7..1c18322 100644 --- a/AzureTableUtils.IntegrationTests/CRUDIntegrationTest.cs +++ b/AzureTableUtils.IntegrationTests/CRUDIntegrationTest.cs @@ -1,8 +1,6 @@ -using System.Net; -using Azure.Data.Tables; -using Microsoft.VisualStudio.TestTools.UnitTesting; using WebGate.Azure.TableUtils; using WebGate.Azure.TableUtils.Test; + namespace AzureTableUtils.IntegrationTests; [TestClass] @@ -37,6 +35,7 @@ public async Task TestSimplePocoCreateReadDeleted() var deleteResponse = await typedTableClient.DeleteEntityAsync(id, "poco"); Assert.AreEqual(204, deleteResponse.Status); } + [TestMethod] public async Task TestSimplePocoUpdateAsMerge() { @@ -58,6 +57,7 @@ public async Task TestSimplePocoUpdateAsMerge() var deleteResponse = await typedTableClient.DeleteEntityAsync(id, "poco"); Assert.AreEqual(204, deleteResponse.Status); } + [TestMethod] public async Task TestSimplePocoUpdateAsReplace() { @@ -80,6 +80,7 @@ public async Task TestSimplePocoUpdateAsReplace() var deleteResponse = await typedTableClient.DeleteEntityAsync(id, "poco"); Assert.AreEqual(204, deleteResponse.Status); } + [TestMethod] public async Task TestCascadedPocoCreateReadDeleted() { diff --git a/AzureTableUtils.IntegrationTests/GlobalUsings.cs b/AzureTableUtils.IntegrationTests/GlobalUsings.cs new file mode 100644 index 0000000..8b9c870 --- /dev/null +++ b/AzureTableUtils.IntegrationTests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Azure.Data.Tables; diff --git a/AzureTableUtils.IntegrationTests/LargeDatasetIntegrationTest.cs b/AzureTableUtils.IntegrationTests/LargeDatasetIntegrationTest.cs index 22f56e7..50c8f28 100644 --- a/AzureTableUtils.IntegrationTests/LargeDatasetIntegrationTest.cs +++ b/AzureTableUtils.IntegrationTests/LargeDatasetIntegrationTest.cs @@ -1,8 +1,6 @@ -using System.Net; -using Azure.Data.Tables; -using Microsoft.VisualStudio.TestTools.UnitTesting; using WebGate.Azure.TableUtils; using WebGate.Azure.TableUtils.Test; + namespace AzureTableUtils.IntegrationTests; [TestClass] @@ -32,7 +30,8 @@ public async Task TestSimplePocoCreateRead1500EntriesDeleted() var typedTableClient = _extendedTableService.GetTypedTableClient(); Assert.IsNotNull(typedTableClient); var partitionId = Guid.NewGuid().ToString(); - for (int i = 0; i< 1500;i++) { + for (int i = 0; i < 1500; i++) + { var id = Guid.NewGuid().ToString(); var poco = SimplePoco.CreateInitializedPoco(); poco.IntValue = 1; @@ -42,24 +41,24 @@ public async Task TestSimplePocoCreateRead1500EntriesDeleted() var allPocoResult = await typedTableClient.GetAllAsync(partitionId); Assert.IsNotNull(allPocoResult); Assert.AreEqual(1500, allPocoResult.Count); - foreach(var result in allPocoResult) { - await typedTableClient.DeleteEntityAsync(result.RowKey,result.PartitionKey); + foreach (var result in allPocoResult) + { + await typedTableClient.DeleteEntityAsync(result.RowKey, result.PartitionKey); } var allPocoResult2 = await typedTableClient.GetAllAsync(partitionId); Assert.IsNotNull(allPocoResult2); Assert.AreEqual(0, allPocoResult2.Count); } - [TestCleanup] public async Task TestCleanup() { Assert.IsNotNull(_extendedTableService); var typedTableClient = _extendedTableService.GetTypedTableClient(); var allPocoResult = await typedTableClient.GetAllAsync(); - foreach(var result in allPocoResult) { - await typedTableClient.DeleteEntityAsync(result.RowKey,result.PartitionKey); + foreach (var result in allPocoResult) + { + await typedTableClient.DeleteEntityAsync(result.RowKey, result.PartitionKey); } } - } \ No newline at end of file diff --git a/AzureTableUtils.IntegrationTests/MultiEntityIntegrationTest.cs b/AzureTableUtils.IntegrationTests/MultiEntityIntegrationTest.cs index ea493bb..25d0c8e 100644 --- a/AzureTableUtils.IntegrationTests/MultiEntityIntegrationTest.cs +++ b/AzureTableUtils.IntegrationTests/MultiEntityIntegrationTest.cs @@ -1,9 +1,7 @@ -using System.Net; -using Azure.Data.Tables; -using Microsoft.VisualStudio.TestTools.UnitTesting; using WebGate.Azure.TableUtils; using WebGate.Azure.TableUtils.Models; using WebGate.Azure.TableUtils.Test; + namespace AzureTableUtils.IntegrationTests; [TestClass] @@ -47,6 +45,7 @@ public async Task TestMEClientCreateReadDeleted() Assert.IsNotNull(allPocoResult2); Assert.AreEqual(0, allPocoResult2.Count); } + [TestMethod] public async Task TestMEClientTypeExtraction() { @@ -106,16 +105,16 @@ public async Task TestMECRUDOperationOnSingleEntity() string partitionId = Guid.NewGuid().ToString(); string idForEntity = Guid.NewGuid().ToString(); var simplePoco = SimplePoco.CreateInitializedPoco(); - var resultCreate = await meTableClient.InsertOrMergeAsync(idForEntity,partitionId,simplePoco); - Assert.AreEqual(204,resultCreate.Status); - var simplePocoGet = await meTableClient.GetByIdAsync(idForEntity,partitionId); + var resultCreate = await meTableClient.InsertOrMergeAsync(idForEntity, partitionId, simplePoco); + Assert.AreEqual(204, resultCreate.Status); + var simplePocoGet = await meTableClient.GetByIdAsync(idForEntity, partitionId); Assert.IsNotNull(simplePocoGet); - Assert.AreEqual(simplePoco,simplePocoGet.Entity); + Assert.AreEqual(simplePoco, simplePocoGet.Entity); var simplePocDoUpdate = simplePocoGet.Entity; simplePocDoUpdate.EnumValue = SP.VALID; - var resultUpdate = await meTableClient.InsertOrMergeAsync(idForEntity,partitionId,simplePocDoUpdate); - Assert.AreEqual(204,resultUpdate.Status); - var simplePocoUpdated = await meTableClient.GetByIdAsync(idForEntity,partitionId); + var resultUpdate = await meTableClient.InsertOrMergeAsync(idForEntity, partitionId, simplePocDoUpdate); + Assert.AreEqual(204, resultUpdate.Status); + var simplePocoUpdated = await meTableClient.GetByIdAsync(idForEntity, partitionId); Assert.IsNotNull(simplePocoUpdated); Assert.AreEqual(SP.VALID, simplePocoUpdated.Entity.EnumValue); await meTableClient.DeleteEntityByTypeAsync(idForEntity, partitionId); @@ -124,7 +123,6 @@ public async Task TestMECRUDOperationOnSingleEntity() Assert.AreEqual(0, allPocoResult2.Count); } - [TestCleanup] public async Task TestCleanup() { @@ -136,6 +134,7 @@ public async Task TestCleanup() await meTableClient.DeleteEntityAsync(result.RowKey, result.PartitionKey); } } + private static async Task GenerateDataset(MultiEntityAzureTableClient meTableClient) { var partitionId = Guid.NewGuid().ToString(); @@ -155,5 +154,4 @@ private static async Task GenerateDataset(MultiEntityAzureTableClient me return partitionId; } - } \ No newline at end of file diff --git a/AzureTableUtils.Tests/BuilderIEnumberableTest.cs b/AzureTableUtils.Tests/BuilderIEnumberableTest.cs index 99e044d..12a7d9e 100644 --- a/AzureTableUtils.Tests/BuilderIEnumberableTest.cs +++ b/AzureTableUtils.Tests/BuilderIEnumberableTest.cs @@ -1,10 +1,3 @@ -using System; -using System.Collections.Generic; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using WebGate.Azure.TableUtils; -using Newtonsoft.Json; -using Azure.Data.Tables; - namespace WebGate.Azure.TableUtils.Test; [TestClass] @@ -22,6 +15,5 @@ public void TestListObjects() Assert.AreEqual(3, build.Children.Count); CollectionAssert.AreEqual(build.Children, pwlc.Children); - } -} +} \ No newline at end of file diff --git a/AzureTableUtils.Tests/BuilderListArrayTest.cs b/AzureTableUtils.Tests/BuilderListArrayTest.cs index fdd7d76..17442d5 100644 --- a/AzureTableUtils.Tests/BuilderListArrayTest.cs +++ b/AzureTableUtils.Tests/BuilderListArrayTest.cs @@ -1,10 +1,3 @@ -using System; -using System.Collections.Generic; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using WebGate.Azure.TableUtils; -using Newtonsoft.Json; -using Azure.Data.Tables; - namespace WebGate.Azure.TableUtils.Test; [TestClass] @@ -20,6 +13,7 @@ public void TestPocoWithEmptyArray() Assert.AreEqual(0, allEntities.Count); Assert.IsNull(build.DateTimeArray); } + [TestMethod] public void TestPocoWithInitializedArray() { @@ -30,4 +24,4 @@ public void TestPocoWithInitializedArray() Assert.AreEqual(3, allEntities.Count); CollectionAssert.AreEqual(build.DateTimeArray, spwa.DateTimeArray); } -} +} \ No newline at end of file diff --git a/AzureTableUtils.Tests/BuilderTests.cs b/AzureTableUtils.Tests/BuilderTests.cs index e4230e1..9675586 100644 --- a/AzureTableUtils.Tests/BuilderTests.cs +++ b/AzureTableUtils.Tests/BuilderTests.cs @@ -1,10 +1,5 @@ -using System; -using System.Collections.Generic; -using Azure.Data.Tables; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using WebGate.Azure.TableUtils; - namespace WebGate.Azure.TableUtils.Test; + [TestClass] public class BuilderTests { @@ -26,10 +21,8 @@ public void TestBuildAllEnitiesFromSimplePoco() Assert.AreEqual(spo.LongValue, build.LongValue); Assert.AreEqual(spo.TimeSpanValue, build.TimeSpanValue); Assert.AreEqual(spo.EnumValue, build.EnumValue); - - - } + [TestMethod] public void TestBuildAllEnitiesFromSimplePocoWithNullId() { @@ -41,8 +34,8 @@ public void TestBuildAllEnitiesFromSimplePocoWithNullId() SimplePoco build = ObjectBuilder.Build(tableEntity); Assert.IsNotNull(build); Assert.IsNull(build.Id); - } + [TestMethod] public void TestBuildAllEnitiesFromParentPoco() { @@ -55,6 +48,7 @@ public void TestBuildAllEnitiesFromParentPoco() Assert.IsInstanceOfType(build, typeof(ParentPoco)); Assert.IsNotNull(build.Child); } + [TestMethod] public void TestBuildAllEnitiesFromMainWithParent() { @@ -81,5 +75,4 @@ public void TestBuildAllEntitesFromBooleanAndByte() Assert.AreEqual(spo.BooleanValue, build.BooleanValue); Assert.AreEqual(spo.ByteValue, build.ByteValue); } - -} +} \ No newline at end of file diff --git a/AzureTableUtils.Tests/GlobalUsings.cs b/AzureTableUtils.Tests/GlobalUsings.cs new file mode 100644 index 0000000..8b9c870 --- /dev/null +++ b/AzureTableUtils.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Azure.Data.Tables; diff --git a/AzureTableUtils.Tests/Models/ByteAndBooleanPoco.cs b/AzureTableUtils.Tests/Models/ByteAndBooleanPoco.cs index a8b9ab7..be158c6 100644 --- a/AzureTableUtils.Tests/Models/ByteAndBooleanPoco.cs +++ b/AzureTableUtils.Tests/Models/ByteAndBooleanPoco.cs @@ -1,6 +1,7 @@ -using System; using System.Text; + namespace WebGate.Azure.TableUtils.Test; + public class ByteAndBooleanPoco { public bool BoolValue { set; get; } @@ -19,4 +20,4 @@ public static ByteAndBooleanPoco Create() }; return bbp; } -} +} \ No newline at end of file diff --git a/AzureTableUtils.Tests/Models/MainWithParent.cs b/AzureTableUtils.Tests/Models/MainWithParent.cs index dbed7fa..dbb4108 100644 --- a/AzureTableUtils.Tests/Models/MainWithParent.cs +++ b/AzureTableUtils.Tests/Models/MainWithParent.cs @@ -1,4 +1,3 @@ -using System; namespace WebGate.Azure.TableUtils.Test; public class MainWithParent @@ -18,7 +17,9 @@ public static MainWithParent CreateMainWithParent() }; return mwp; } - public override bool Equals(object? other) { + + public override bool Equals(object? other) + { if ((other == null) || !this.GetType().Equals(other.GetType())) { return false; @@ -29,8 +30,9 @@ public override bool Equals(object? other) { return po.GetHashCode() == this.GetHashCode(); } } + public override int GetHashCode() { - return (Id != null ? Id.GetHashCode() : 0) + (Child != null ?Child.GetHashCode():0) + (Parent != null ?Parent.GetHashCode():0); + return (Id != null ? Id.GetHashCode() : 0) + (Child != null ? Child.GetHashCode() : 0) + (Parent != null ? Parent.GetHashCode() : 0); } -} +} \ No newline at end of file diff --git a/AzureTableUtils.Tests/Models/ParentPoco.cs b/AzureTableUtils.Tests/Models/ParentPoco.cs index f8dcfc4..8c3196b 100644 --- a/AzureTableUtils.Tests/Models/ParentPoco.cs +++ b/AzureTableUtils.Tests/Models/ParentPoco.cs @@ -1,4 +1,3 @@ -using System; namespace WebGate.Azure.TableUtils.Test; public class ParentPoco @@ -15,7 +14,9 @@ public static ParentPoco CreateParentWithChild() }; return pp; } - public override bool Equals(object? other) { + + public override bool Equals(object? other) + { if ((other == null) || !this.GetType().Equals(other.GetType())) { return false; @@ -26,9 +27,9 @@ public override bool Equals(object? other) { return po.GetHashCode() == this.GetHashCode(); } } + public override int GetHashCode() { - return (Id != null ? Id.GetHashCode() : 0) + (Child != null ?Child.GetHashCode():0); + return (Id != null ? Id.GetHashCode() : 0) + (Child != null ? Child.GetHashCode() : 0); } - -} +} \ No newline at end of file diff --git a/AzureTableUtils.Tests/Models/PocoWithListChildren.cs b/AzureTableUtils.Tests/Models/PocoWithListChildren.cs index 9d72809..4be3b78 100644 --- a/AzureTableUtils.Tests/Models/PocoWithListChildren.cs +++ b/AzureTableUtils.Tests/Models/PocoWithListChildren.cs @@ -1,7 +1,3 @@ -using System; -using System.Linq; -using System.Collections.Generic; - namespace WebGate.Azure.TableUtils.Test; public class PocoWihtListChildren @@ -23,4 +19,4 @@ public static PocoWihtListChildren CreateInitializdedPWLC() } return pwlc; } -} +} \ No newline at end of file diff --git a/AzureTableUtils.Tests/Models/SimplePoco.cs b/AzureTableUtils.Tests/Models/SimplePoco.cs index c74e9ee..80db4d5 100644 --- a/AzureTableUtils.Tests/Models/SimplePoco.cs +++ b/AzureTableUtils.Tests/Models/SimplePoco.cs @@ -1,10 +1,8 @@ -using System; namespace WebGate.Azure.TableUtils.Test; - -public class SimplePocoPart { +public class SimplePocoPart +{ public SP EnumValue { set; get; } - } public enum SP @@ -12,6 +10,7 @@ public enum SP VALID, INVALID } + public class SimplePoco { public string? Id { set; get; } @@ -73,5 +72,4 @@ public override int GetHashCode() { return (Id != null ? Id.GetHashCode() : 0) + EnumValue.GetHashCode(); } - -} +} \ No newline at end of file diff --git a/AzureTableUtils.Tests/Models/SimplePocoWithArray.cs b/AzureTableUtils.Tests/Models/SimplePocoWithArray.cs index d3a14f4..dc52ed4 100644 --- a/AzureTableUtils.Tests/Models/SimplePocoWithArray.cs +++ b/AzureTableUtils.Tests/Models/SimplePocoWithArray.cs @@ -1,5 +1,3 @@ -using System; - namespace WebGate.Azure.TableUtils.Test; public class SimplePocoWithArray @@ -11,12 +9,12 @@ public class SimplePocoWithArray public DateTime[]? DateTimeArray { set; get; } - public static SimplePocoWithArray CreateEmptySimplePocoWithArray() { SimplePocoWithArray spwa = new(); return spwa; } + public static SimplePocoWithArray CreateFilledSimplePocoWithArray() { SimplePocoWithArray spwa = new(); @@ -28,4 +26,4 @@ public static SimplePocoWithArray CreateFilledSimplePocoWithArray() spwa.DateTimeArray = dtArray; return spwa; } -} +} \ No newline at end of file diff --git a/AzureTableUtils.Tests/Models/UnsuportedTypePoco.cs b/AzureTableUtils.Tests/Models/UnsuportedTypePoco.cs index 68b92b1..11aa10a 100644 --- a/AzureTableUtils.Tests/Models/UnsuportedTypePoco.cs +++ b/AzureTableUtils.Tests/Models/UnsuportedTypePoco.cs @@ -11,4 +11,4 @@ public UnsupportedTypePoco() ShortValue = 2; CharValue = 'C'; } -} +} \ No newline at end of file diff --git a/AzureTableUtils.Tests/SerializerIEnumberableTest.cs b/AzureTableUtils.Tests/SerializerIEnumberableTest.cs index bfbe1c6..67e69e5 100644 --- a/AzureTableUtils.Tests/SerializerIEnumberableTest.cs +++ b/AzureTableUtils.Tests/SerializerIEnumberableTest.cs @@ -1,10 +1,5 @@ -using System; -using System.Collections.Generic; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using WebGate.Azure.TableUtils; -using Newtonsoft.Json; - namespace WebGate.Azure.TableUtils.Test; + [TestClass] public class SerializerIEnumberableTest { @@ -15,4 +10,4 @@ public void TestListObjects() IDictionary allEntities = ObjectSerializer.Serialize(pwlc); Assert.AreEqual(2, allEntities.Count); } -} +} \ No newline at end of file diff --git a/AzureTableUtils.Tests/SerializerListArrayTest.cs b/AzureTableUtils.Tests/SerializerListArrayTest.cs index 20b7fe8..3488c63 100644 --- a/AzureTableUtils.Tests/SerializerListArrayTest.cs +++ b/AzureTableUtils.Tests/SerializerListArrayTest.cs @@ -1,10 +1,7 @@ -using System; -using System.Collections.Generic; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using WebGate.Azure.TableUtils; using Newtonsoft.Json; namespace WebGate.Azure.TableUtils.Test; + [TestClass] public class SerializerListArrayTest { @@ -15,6 +12,7 @@ public void TestPocoWithEmptyArray() IDictionary allEntities = ObjectSerializer.Serialize(spwa); Assert.AreEqual(0, allEntities.Count); } + [TestMethod] public void TestPocoWithInitializedArray() { @@ -24,4 +22,4 @@ public void TestPocoWithInitializedArray() Assert.AreEqual(3, allEntities.Count); Assert.AreEqual(jsonArray, allEntities["DateTimeArray"]); } -} +} \ No newline at end of file diff --git a/AzureTableUtils.Tests/SerializerTests.cs b/AzureTableUtils.Tests/SerializerTests.cs index 6529686..3e2de9b 100644 --- a/AzureTableUtils.Tests/SerializerTests.cs +++ b/AzureTableUtils.Tests/SerializerTests.cs @@ -1,8 +1,3 @@ -using System; -using System.Collections.Generic; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using WebGate.Azure.TableUtils; - namespace WebGate.Azure.TableUtils.Test; [TestClass] @@ -17,30 +12,29 @@ public void TestExtractAllEnitiesFromSimplePoco() //CHECK ID Assert.IsTrue(allEntities.ContainsKey("Id")); Assert.AreEqual(allEntities["Id"], spo.Id); - + Assert.IsTrue(allEntities.ContainsKey("IntValue")); Assert.AreEqual(allEntities["IntValue"], spo.IntValue); - + Assert.IsTrue(allEntities.ContainsKey("LongValue")); Assert.AreEqual(allEntities["LongValue"], spo.LongValue); Assert.IsTrue(allEntities.ContainsKey("DoubleValue")); Assert.AreEqual(allEntities["DoubleValue"], spo.DoubleValue); - + Assert.IsTrue(allEntities.ContainsKey("GuidValue")); Assert.AreEqual(allEntities["GuidValue"], spo.GuidValue); - + Assert.IsTrue(allEntities.ContainsKey("DTValue")); Assert.AreEqual(allEntities["DTValue"], spo.DTValue); - + Assert.IsTrue(allEntities.ContainsKey("DTOValue")); Assert.AreEqual(allEntities["DTOValue"], spo.DTOValue); - Assert.IsTrue(allEntities.ContainsKey("EnumValue")); Assert.AreEqual(allEntities["EnumValue"], spo.EnumValue.ToString()); - } + [TestMethod] public void TestExtractAllEnitiesFromSimplePocoWithNullId() { @@ -48,8 +42,8 @@ public void TestExtractAllEnitiesFromSimplePocoWithNullId() IDictionary allEntities = ObjectSerializer.Serialize(spo); Assert.AreEqual(8, allEntities.Count); Assert.IsFalse(allEntities.ContainsKey("Id")); - } + [TestMethod] public void TestExtractAllEnitiesFromParentPoco() { @@ -79,9 +73,8 @@ public void TestExtractAllEnitiesFromParentPoco() Assert.IsTrue(allEntities.ContainsKey("Child_DTOValue")); Assert.AreEqual(allEntities["Child_DTOValue"], pp.Child.DTOValue); - - } + [TestMethod] public void TestExtractAllEnitiesFromMainWithParent() { @@ -115,7 +108,6 @@ public void TestExtractAllEnitiesFromMainWithParent() Assert.IsTrue(allEntities.ContainsKey("Child_DTOValue")); Assert.AreEqual(allEntities["Child_DTOValue"], mwp.Child.DTOValue); - //CHECK PARENT Assert.IsTrue(allEntities.ContainsKey("Parent_Id")); Assert.AreEqual(allEntities["Parent_Id"], mwp.Parent.Id); @@ -148,14 +140,11 @@ public void TestBooleanAndByte() //CHECK ID Assert.IsTrue(allEntities.ContainsKey("BoolValue")); Assert.AreEqual(allEntities["BoolValue"], spo.BoolValue); - + Assert.IsTrue(allEntities.ContainsKey("BooleanValue")); Assert.AreEqual(allEntities["BooleanValue"], spo.BooleanValue); - + Assert.IsTrue(allEntities.ContainsKey("ByteValue")); Assert.AreEqual(allEntities["ByteValue"], spo.ByteValue); - } - -} - +} \ No newline at end of file diff --git a/AzureTableUtils/Converters/ArrayConverter.cs b/AzureTableUtils/Converters/ArrayConverter.cs index 90c35a7..4edca10 100644 --- a/AzureTableUtils/Converters/ArrayConverter.cs +++ b/AzureTableUtils/Converters/ArrayConverter.cs @@ -1,9 +1,9 @@ -using System; using Newtonsoft.Json; + namespace WebGate.Azure.TableUtils.Converter; + public class ArrayConverter : IConverter { - public bool IsType(Type type) { return type.IsArray && type.Name != "Byte[]"; @@ -13,6 +13,7 @@ public string GetValue(Type type, object value) { return JsonConvert.SerializeObject(value); } + public object? BuildValue(string? value, Type type) { if (!string.IsNullOrEmpty(value)) @@ -21,5 +22,4 @@ public string GetValue(Type type, object value) } return null; } - -} +} \ No newline at end of file diff --git a/AzureTableUtils/Converters/ConverterFactory.cs b/AzureTableUtils/Converters/ConverterFactory.cs index 36d6c4c..03c2d76 100644 --- a/AzureTableUtils/Converters/ConverterFactory.cs +++ b/AzureTableUtils/Converters/ConverterFactory.cs @@ -1,7 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; - namespace WebGate.Azure.TableUtils.Converter; public static class ConverterFactory @@ -23,4 +19,4 @@ private static List InitConverters() list.Add(new EnumerableConverter()); return list; } -} +} \ No newline at end of file diff --git a/AzureTableUtils/Converters/EnumConverter.cs b/AzureTableUtils/Converters/EnumConverter.cs index 4a87c97..9828b43 100644 --- a/AzureTableUtils/Converters/EnumConverter.cs +++ b/AzureTableUtils/Converters/EnumConverter.cs @@ -1,8 +1,5 @@ -using System; -using System.Linq; -using System.Collections.Generic; - namespace WebGate.Azure.TableUtils.Converter; + public class EnumConverter : IConverter { public bool IsType(Type type) @@ -12,15 +9,16 @@ public bool IsType(Type type) public string GetValue(Type type, object value) { - return value.ToString()?? ""; + return value.ToString() ?? ""; } + public object? BuildValue(string? value, Type type) { if (string.IsNullOrEmpty(value)) { return null; - }; + } + ; return Enum.Parse(type, value); } - -} +} \ No newline at end of file diff --git a/AzureTableUtils/Converters/EnumerableConverter.cs b/AzureTableUtils/Converters/EnumerableConverter.cs index 55a7fc6..5b41c7b 100644 --- a/AzureTableUtils/Converters/EnumerableConverter.cs +++ b/AzureTableUtils/Converters/EnumerableConverter.cs @@ -1,25 +1,25 @@ -using System; -using System.Linq; using Newtonsoft.Json; -using System.Collections.Generic; + namespace WebGate.Azure.TableUtils.Converter; public class EnumerableConverter : IConverter { public bool IsType(Type type) { - return type.GetInterfaces().Any(t => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(IEnumerable<>)) && type.Name !="Byte[]" && type.Name != "String"; + return type.GetInterfaces().Any(t => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(IEnumerable<>)) && type.Name != "Byte[]" && type.Name != "String"; } public string GetValue(Type type, object value) { return JsonConvert.SerializeObject(value); } + public object? BuildValue(string? value, Type type) { - if (!string.IsNullOrEmpty(value)){ + if (!string.IsNullOrEmpty(value)) + { return JsonConvert.DeserializeObject(value, type); } return null; } -} +} \ No newline at end of file diff --git a/AzureTableUtils/Converters/IConverter.cs b/AzureTableUtils/Converters/IConverter.cs index e9a7452..e0eb41e 100644 --- a/AzureTableUtils/Converters/IConverter.cs +++ b/AzureTableUtils/Converters/IConverter.cs @@ -1,6 +1,5 @@ -using System; - namespace WebGate.Azure.TableUtils.Converter; + public interface IConverter { public bool IsType(Type type); @@ -8,4 +7,4 @@ public interface IConverter public string GetValue(Type type, object value); public object? BuildValue(string? value, Type type); -} +} \ No newline at end of file diff --git a/AzureTableUtils/Converters/TimeSpanConverter.cs b/AzureTableUtils/Converters/TimeSpanConverter.cs index 1dab252..1ca9a19 100644 --- a/AzureTableUtils/Converters/TimeSpanConverter.cs +++ b/AzureTableUtils/Converters/TimeSpanConverter.cs @@ -1,9 +1,7 @@ -using System; -using System.Linq; -using System.Collections.Generic; using System.Globalization; namespace WebGate.Azure.TableUtils.Converter; + public class TimeSpanConverter : IConverter { public bool IsType(Type type) @@ -13,14 +11,15 @@ public bool IsType(Type type) public string GetValue(Type type, object value) { - return value.ToString() ??""; + return value.ToString() ?? ""; } + public object? BuildValue(string? value, Type type) { - if (string.IsNullOrEmpty(value)){ + if (string.IsNullOrEmpty(value)) + { return null; } return TimeSpan.Parse(value, CultureInfo.InvariantCulture); } - -} +} \ No newline at end of file diff --git a/AzureTableUtils/ExtendedAzureTableClientService.cs b/AzureTableUtils/ExtendedAzureTableClientService.cs index e19dd22..d375d92 100644 --- a/AzureTableUtils/ExtendedAzureTableClientService.cs +++ b/AzureTableUtils/ExtendedAzureTableClientService.cs @@ -1,5 +1,3 @@ -using Azure.Data.Tables; - namespace WebGate.Azure.TableUtils; public class ExtendedAzureTableClientService(string connectionString) @@ -16,6 +14,7 @@ public TypedAzureTableClient CreateAndRegisterTableClient(string tableName typedClient.GetTableClient().CreateIfNotExists(); return typedClient; } + public void AddInitializedTableClient(TableClient tableClient) { var typedClient = new TypedAzureTableClient(tableClient); @@ -31,6 +30,7 @@ public TypedAzureTableClient GetTypedTableClient() } throw new ArgumentOutOfRangeException(typeof(T).Name + " not found as registered TypedTableClient"); } + public MultiEntityAzureTableClient CreateAndRegisterMultiEntityTableClient(string tableName) { var tableClient = new TableClient(_connectionString, tableName); diff --git a/AzureTableUtils/GlobalUsings.cs b/AzureTableUtils/GlobalUsings.cs new file mode 100644 index 0000000..1639abb --- /dev/null +++ b/AzureTableUtils/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Azure; +global using Azure.Data.Tables; diff --git a/AzureTableUtils/Models/TabelEntityResult.cs b/AzureTableUtils/Models/TabelEntityResult.cs index 58b198a..758d1fb 100644 --- a/AzureTableUtils/Models/TabelEntityResult.cs +++ b/AzureTableUtils/Models/TabelEntityResult.cs @@ -1,6 +1,3 @@ -using Azure; -using Azure.Data.Tables; - namespace WebGate.Azure.TableUtils.Models; public class TableEntityResult(ITableEntity tableEntity, T entity) @@ -11,13 +8,15 @@ public class TableEntityResult(ITableEntity tableEntity, T entity) public DateTimeOffset? Timestamp { get; set; } = tableEntity.Timestamp; public T Entity { get; set; } = entity; - public static TableEntityResult BuildTableEntityResult(TableEntity tableEntity) { - var businessEntity = ObjectBuilder.Build(tableEntity); + public static TableEntityResult BuildTableEntityResult(TableEntity tableEntity) + { + var businessEntity = ObjectBuilder.Build(tableEntity); return new TableEntityResult(tableEntity, businessEntity); } - public static TableEntityResult BuildTableEntityResultWithType(Type typeC, TableEntity tableEntity) { - var businessEntity = ObjectBuilder.BuildByType(typeC,tableEntity); + public static TableEntityResult BuildTableEntityResultWithType(Type typeC, TableEntity tableEntity) + { + var businessEntity = ObjectBuilder.BuildByType(typeC, tableEntity); return new TableEntityResult(tableEntity, businessEntity); } } \ No newline at end of file diff --git a/AzureTableUtils/MultiEntityAzureTableClient.cs b/AzureTableUtils/MultiEntityAzureTableClient.cs index d590ac4..a6b5672 100644 --- a/AzureTableUtils/MultiEntityAzureTableClient.cs +++ b/AzureTableUtils/MultiEntityAzureTableClient.cs @@ -1,6 +1,3 @@ - -using Azure; -using Azure.Data.Tables; using WebGate.Azure.TableUtils.Models; namespace WebGate.Azure.TableUtils; @@ -8,25 +5,31 @@ namespace WebGate.Azure.TableUtils; public class MultiEntityAzureTableClient { private readonly TableClient _tableClient; - private readonly Dictionary _typeRegistry = new Dictionary(); + private readonly Dictionary _typeRegistry = new Dictionary(); public MultiEntityAzureTableClient(TableClient tableClient) { _tableClient = tableClient; } - public TableClient GetTableClient() { return _tableClient; } + public TableClient GetTableClient() + { return _tableClient; } - public void RegisterType(){ + public void RegisterType() + { RegisterType(typeof(T).Name); } - public void RegisterType(string typePrefix) { + + public void RegisterType(string typePrefix) + { _typeRegistry.Add(typeof(T), typePrefix); } + public async Task>> GetAllAsync() { return await GetAllByQueryAsync(null); } + public async Task>> GetAllAsync(string partitionKey) { var query = $"PartitionKey eq '{partitionKey}'"; @@ -35,15 +38,15 @@ public async Task>> GetAllAsync(string partitionK public async Task>> GetAllByQueryAsync(string? query) { - //$"PartitionKey eq '{partitionKey}'" AsyncPageable resultItems = _tableClient.QueryAsync(query); - - List> items = new (); + + List> items = new(); await foreach (var item in resultItems) { - Type? entityType = _typeRegistry.Where(kvp=>item.RowKey.StartsWith(kvp.Value +"_")).Select(kvp=>kvp.Key).FirstOrDefault(); - if (entityType == null) { + Type? entityType = _typeRegistry.Where(kvp => item.RowKey.StartsWith(kvp.Value + "_")).Select(kvp => kvp.Key).FirstOrDefault(); + if (entityType == null) + { throw new ArgumentOutOfRangeException($"No registered type foung for Tableentry with ID {item.RowKey}."); } items.Add(TableEntityResult.BuildTableEntityResultWithType(entityType, item)); @@ -53,12 +56,14 @@ public async Task>> GetAllByQueryAsync(string? qu public async Task?> GetByIdAsync(string rowKey, string partitionKey) { - if (!_typeRegistry.ContainsKey(typeof(T))) { + if (!_typeRegistry.ContainsKey(typeof(T))) + { throw new ArgumentOutOfRangeException($"No registered type found for {typeof(T)}."); - } + } string prefix = _typeRegistry[typeof(T)]; - NullableResponse tableEntity = await _tableClient.GetEntityIfExistsAsync(partitionKey,prefix +"_"+rowKey); - if (tableEntity.HasValue) { + NullableResponse tableEntity = await _tableClient.GetEntityIfExistsAsync(partitionKey, prefix + "_" + rowKey); + if (tableEntity.HasValue) + { return TableEntityResult.BuildTableEntityResult(tableEntity.Value!); } return null; @@ -66,43 +71,49 @@ public async Task>> GetAllByQueryAsync(string? qu public async Task InsertOrReplaceAsync(string rowKey, string partitionKey, object obj) { - if (!_typeRegistry.ContainsKey(obj.GetType())) { + if (!_typeRegistry.ContainsKey(obj.GetType())) + { throw new ArgumentOutOfRangeException($"No registered type found for {obj.GetType()}."); - } - string prefix = _typeRegistry[obj.GetType()]; + } + string prefix = _typeRegistry[obj.GetType()]; var properties = ObjectSerializer.Serialize(obj); TableEntity tableEntity = new(properties) { - RowKey = prefix +"_"+rowKey, + RowKey = prefix + "_" + rowKey, PartitionKey = partitionKey }; - return await _tableClient.UpsertEntityAsync(tableEntity,TableUpdateMode.Replace); + return await _tableClient.UpsertEntityAsync(tableEntity, TableUpdateMode.Replace); } + public async Task InsertOrMergeAsync(string rowKey, string partitionKey, object obj) { - if (!_typeRegistry.ContainsKey(obj.GetType())) { + if (!_typeRegistry.ContainsKey(obj.GetType())) + { throw new ArgumentOutOfRangeException($"No registered type found for {obj.GetType()}."); - } - string prefix = _typeRegistry[obj.GetType()]; - + } + string prefix = _typeRegistry[obj.GetType()]; + var properties = ObjectSerializer.Serialize(obj); TableEntity tableEntity = new(properties) { - RowKey = prefix +"_"+rowKey, + RowKey = prefix + "_" + rowKey, PartitionKey = partitionKey }; - return await _tableClient.UpsertEntityAsync(tableEntity,TableUpdateMode.Merge); + return await _tableClient.UpsertEntityAsync(tableEntity, TableUpdateMode.Merge); } + public async Task DeleteEntityByTypeAsync(string rowKey, string partitionKey) { - if (!_typeRegistry.ContainsKey(typeof(T))) { + if (!_typeRegistry.ContainsKey(typeof(T))) + { throw new ArgumentOutOfRangeException($"No registered type found for {typeof(T)}."); - } + } string prefix = _typeRegistry[typeof(T)]; - return await _tableClient.DeleteEntityAsync(partitionKey, prefix +"_"+rowKey); + return await _tableClient.DeleteEntityAsync(partitionKey, prefix + "_" + rowKey); } + public async Task DeleteEntityAsync(string completeRowKey, string partitionKey) { return await _tableClient.DeleteEntityAsync(partitionKey, completeRowKey); } -} +} \ No newline at end of file diff --git a/AzureTableUtils/ObjectBuilder.cs b/AzureTableUtils/ObjectBuilder.cs index 0b546ce..953e38b 100644 --- a/AzureTableUtils/ObjectBuilder.cs +++ b/AzureTableUtils/ObjectBuilder.cs @@ -1,13 +1,8 @@ -using System; -using System.Linq; -using System.Collections.Generic; -using WebGate.Azure.TableUtils.Converter; using System.Runtime.CompilerServices; -using Azure.Data.Tables; -using Microsoft.VisualBasic; -using System.Security.Cryptography; +using WebGate.Azure.TableUtils.Converter; namespace WebGate.Azure.TableUtils; + public static class ObjectBuilder { public static T Build(TableEntity tableEntity) @@ -15,16 +10,14 @@ public static T Build(TableEntity tableEntity) T result = (T)RuntimeHelpers.GetUninitializedObject(typeof(T)); ProcessObject(result, null, tableEntity); return result; - } public static object BuildByType(Type typeT, TableEntity tableEntity) { - object result =RuntimeHelpers.GetUninitializedObject(typeT); + object result = RuntimeHelpers.GetUninitializedObject(typeT); ProcessObject(result, null, tableEntity); return result; - } - + private static void ProcessObject(object obj, string? path, TableEntity tableEntity) { obj.GetType().GetProperties().Where(propertyInfo => propertyInfo.CanRead && propertyInfo.CanWrite).ToList().ForEach(propertyInfo => @@ -37,41 +30,46 @@ private static void ProcessObject(object obj, string? path, TableEntity tableEnt IConverter? converter = ConverterFactory.FindConverter(pType); if (converter != null) { - propertyInfo.SetValue(obj, converter.BuildValue(value != null ? value.ToString():null, pType), index: null); + propertyInfo.SetValue(obj, converter.BuildValue(value != null ? value.ToString() : null, pType), index: null); } else { - if (value.GetType().FullName == "System.DateTimeOffset" && IsDateTime(pType)) { + if (value.GetType().FullName == "System.DateTimeOffset" && IsDateTime(pType)) + { DateTimeOffset dtoValue = (DateTimeOffset)value; - propertyInfo.SetValue(obj,DateTime.SpecifyKind(dtoValue.DateTime, DateTimeKind.Utc), index: null); + propertyInfo.SetValue(obj, DateTime.SpecifyKind(dtoValue.DateTime, DateTimeKind.Utc), index: null); return; } if (pType.IsValueType || pType.Name == "Byte[]" || pType.Name == "String") { propertyInfo.SetValue(obj, value, index: null); - } else { + } + else + { object child = RuntimeHelpers.GetUninitializedObject(pType); ProcessObject(child, id, tableEntity); } } - } else { + } + else + { Type pType = propertyInfo.PropertyType; - if (!pType.IsValueType && pType.Name != "Byte[]" || pType.Name != "String") { - if (HasChildObjectInformation(id,tableEntity)) { + if (!pType.IsValueType && pType.Name != "Byte[]" || pType.Name != "String") + { + if (HasChildObjectInformation(id, tableEntity)) + { object child = RuntimeHelpers.GetUninitializedObject(pType); ProcessObject(child, id, tableEntity); propertyInfo.SetValue(obj, child, index: null); } } } - }); - } private static bool HasChildObjectInformation(string id, TableEntity tableEntity) { - return tableEntity.Where(p=>p.Key.StartsWith(id+"_") && p.Value != null).Count() > 0; + return tableEntity.Where(p => p.Key.StartsWith(id + "_") && p.Value != null).Count() > 0; } private static string BuildEntityName(string? path, string id) @@ -82,7 +80,8 @@ private static string BuildEntityName(string? path, string id) } return path + "_" + id; } - private static bool IsDateTime(Type pType) { + private static bool IsDateTime(Type pType) + { return pType.FullName == "System.DateTime" || Nullable.GetUnderlyingType(pType) == typeof(DateTime); } -} +} \ No newline at end of file diff --git a/AzureTableUtils/ObjectSerializer.cs b/AzureTableUtils/ObjectSerializer.cs index 152f877..5182f57 100644 --- a/AzureTableUtils/ObjectSerializer.cs +++ b/AzureTableUtils/ObjectSerializer.cs @@ -1,11 +1,9 @@ -using System; -using System.Linq; -using System.Collections.Generic; -using WebGate.Azure.TableUtils.Converter; +using WebGate.Azure.TableUtils.Converter; + namespace WebGate.Azure.TableUtils; + public static class ObjectSerializer { - public static IDictionary Serialize(object obj) { IDictionary entities = new Dictionary(); @@ -24,7 +22,7 @@ private static void ProcessObject(object obj, string? path, IDictionary>> GetAllAsync() { return await GetAllByQueryAsync(null); } + public async Task>> GetAllAsync(string partitionKey) { var query = $"PartitionKey eq '{partitionKey}'"; @@ -28,11 +27,10 @@ public async Task>> GetAllAsync(string partitionKey) public async Task>> GetAllByQueryAsync(string? query) { - //$"PartitionKey eq '{partitionKey}'" AsyncPageable resultItems = _tableClient.QueryAsync(query); - - List> items = new (); + + List> items = new(); await foreach (var item in resultItems) { items.Add(TableEntityResult.BuildTableEntityResult(item)); @@ -45,10 +43,12 @@ public async Task>> GetAllByQueryAsync(string? query) string partitionKey = typeof(T).ToString(); return await GetByIdAsync(id, partitionKey); } + public async Task?> GetByIdAsync(string rowKey, string partitionKey) { - NullableResponse tableEntity = await _tableClient.GetEntityIfExistsAsync(partitionKey,rowKey); - if (tableEntity.HasValue) { + NullableResponse tableEntity = await _tableClient.GetEntityIfExistsAsync(partitionKey, rowKey); + if (tableEntity.HasValue) + { return TableEntityResult.BuildTableEntityResult(tableEntity.Value!); } return null; @@ -62,8 +62,9 @@ public async Task InsertOrReplaceAsync(string rowKey, string partition RowKey = rowKey, PartitionKey = partitionKey }; - return await _tableClient.UpsertEntityAsync(tableEntity,TableUpdateMode.Replace); + return await _tableClient.UpsertEntityAsync(tableEntity, TableUpdateMode.Replace); } + public async Task InsertOrMergeAsync(string rowKey, string partitionKey, object obj) { var properties = ObjectSerializer.Serialize(obj); @@ -72,10 +73,11 @@ public async Task InsertOrMergeAsync(string rowKey, string partitionKe RowKey = rowKey, PartitionKey = partitionKey }; - return await _tableClient.UpsertEntityAsync(tableEntity,TableUpdateMode.Merge); + return await _tableClient.UpsertEntityAsync(tableEntity, TableUpdateMode.Merge); } + public async Task DeleteEntityAsync(string rowKey, string partitionKey) { return await _tableClient.DeleteEntityAsync(partitionKey, rowKey); } -} +} \ No newline at end of file From 381378b9facc9c59ccef1b321bfa21955595ada3 Mon Sep 17 00:00:00 2001 From: Fabricio Ruch Date: Mon, 3 Aug 2026 11:50:51 +0200 Subject: [PATCH 2/9] Uplift to .NET 10 and align package version with TFM major. Document the versioning scheme, tighten NuGet publish to main only, and clean up project metadata. Co-authored-by: Cursor --- .github/workflows/dotnet.yml | 6 +++--- .gitignore | 21 +++++++++++-------- .../AzureTableUtils.IntegrationTests.csproj | 4 +++- .../AzureTableUtils.Tests.csproj | 4 ++-- AzureTableUtils/AzureTableUtils.csproj | 20 +++++++++++------- README.md | 19 +++++++++++++++-- 6 files changed, 49 insertions(+), 25 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 98f22dc..83744b4 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -8,7 +8,6 @@ on: jobs: build: - runs-on: ubuntu-latest steps: @@ -16,10 +15,11 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x + dotnet-version: 10.0.x - name: Restore dependencies run: dotnet restore - name: Build run: dotnet build --configuration Release --no-restore - name: Publish NuGet - run: dotnet nuget push AzureTableUtils/bin/Release/*.nupkg -k ${{ secrets.NUGET_API_KEY }} -s https://api.nuget.org/v3/index.json + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: dotnet nuget push AzureTableUtils/bin/Release/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate diff --git a/.gitignore b/.gitignore index 3d2fca7..4c56b3c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,12 @@ -AzureTableUtils/bin -AzureTableUtils/obj -AzureTableUtils.Tests/bin -AzureTableUtils.Tests/obj -AzureTableUtils.Tests/TestResults -AzureTableUtils.IntegrationTests/bin -AzureTableUtils.IntegrationTests/obj -AzureTableUtils.IntegrationTests/TestResults -AzureTableUtils.IntegrationTests/test.runsettings \ No newline at end of file +AzureTableUtils/bin/ +AzureTableUtils/obj/ +AzureTableUtils.Tests/bin/ +AzureTableUtils.Tests/obj/ +AzureTableUtils.Tests/TestResults/ +AzureTableUtils.IntegrationTests/bin/ +AzureTableUtils.IntegrationTests/obj/ +AzureTableUtils.IntegrationTests/TestResults/ +AzureTableUtils.IntegrationTests/test.runsettings +.vs/ +*.user +*.suo diff --git a/AzureTableUtils.IntegrationTests/AzureTableUtils.IntegrationTests.csproj b/AzureTableUtils.IntegrationTests/AzureTableUtils.IntegrationTests.csproj index 98038fb..1298119 100644 --- a/AzureTableUtils.IntegrationTests/AzureTableUtils.IntegrationTests.csproj +++ b/AzureTableUtils.IntegrationTests/AzureTableUtils.IntegrationTests.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 enable enable @@ -15,6 +15,7 @@ + $(MSBuildProjectDirectory)\test.runsettings @@ -22,6 +23,7 @@ + diff --git a/AzureTableUtils.Tests/AzureTableUtils.Tests.csproj b/AzureTableUtils.Tests/AzureTableUtils.Tests.csproj index d5e19e7..5f6f3fb 100644 --- a/AzureTableUtils.Tests/AzureTableUtils.Tests.csproj +++ b/AzureTableUtils.Tests/AzureTableUtils.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 enable enable @@ -19,9 +19,9 @@ + - diff --git a/AzureTableUtils/AzureTableUtils.csproj b/AzureTableUtils/AzureTableUtils.csproj index ad291aa..1c4841a 100644 --- a/AzureTableUtils/AzureTableUtils.csproj +++ b/AzureTableUtils/AzureTableUtils.csproj @@ -1,31 +1,35 @@ - net8.0 + net10.0 enable enable + WebGate.Azure.TableUtils - 0.1.1 - guedeWebGate + + 10.0.0 + WebGate Consulting AG WebGate Consulting AG Apache-2.0 true true - WebGate.Azure.TablesUtils provides extensions to Azure.Data.Table, which allows direct access in the form of CRUD operation to the entities. -Complex entities, arrays and IEnumerable are also supported. + Extensions for Azure.Data.Tables with typed CRUD clients. Supports complex nested entities, arrays, and IEnumerable via flattened table properties. WebGate Consulting AG - Azure Table + Azure;Azure Tables;Table Storage;CRUD;POCOs true snupkg - https://github.com/WebGateConsultingAG/AzureTableUtils.git + https://github.com/WebGateConsultingAG/AzureTableUtils.git git README.md + + - + + diff --git a/README.md b/README.md index 17559b0..052145d 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,27 @@ # WebGate.Azure.TableUtils -WebGate.Azure.TablesUtils provides assets to support Azure.Data.TableClient, which allows direct access of CRUD operation to the entities. -Complex entities, arrays and IEnumerable are supported. +Extensions for Azure.Data.Tables with typed CRUD clients. Supports complex nested entities, arrays, and IEnumerable via flattened table properties. The main focus for this implementation is the usage in Azure Functions. Therefore access to the tables is done with the ConnectionString. SAS and other authentication methods are not supported, but can be implemented when required. +## Target Framework & Versioning + +| | | +|---|---| +| Target Framework | `net10.0` | +| Package version | `10.x.x` | + +The **NuGet package major version matches the .NET target framework major version**. + +- `net10.0` → package version `10.x.x` +- A future uplift to `net11.0` would start at package version `11.0.0` + +Within a major line, use minor/patch for library changes that stay on the same TFM. + --- + ## ExtendedAzureTableClientService + The ExtendedAzureTableClientService provides a class to register and access TypedAzureTableClients as well as MultiEntityAzureTableClients. ### Create a new ExtendedAzureTableClientService From aff3a10e66679be48f1bc4ddf70d76abbac15e9e Mon Sep 17 00:00:00 2001 From: Fabricio Ruch Date: Mon, 3 Aug 2026 11:53:11 +0200 Subject: [PATCH 3/9] Fix README to match the public API and document mapping behavior. Correct method names and signatures, add missing APIs, NuGet install, and entity flattening notes. Co-authored-by: Cursor --- README.md | 278 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 157 insertions(+), 121 deletions(-) diff --git a/README.md b/README.md index 052145d..f44b9e9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,14 @@ Extensions for Azure.Data.Tables with typed CRUD clients. Supports complex nested entities, arrays, and IEnumerable via flattened table properties. -The main focus for this implementation is the usage in Azure Functions. Therefore access to the tables is done with the ConnectionString. SAS and other authentication methods are not supported, but can be implemented when required. +The main focus is usage in Azure Functions. Table access uses a Storage Account **connection string**. SAS and other authentication methods are not supported yet, but can be added when required. + +**Package:** `WebGate.Azure.TableUtils` +**License:** [Apache-2.0](LICENSE) + +```bash +dotnet add package WebGate.Azure.TableUtils +``` ## Target Framework & Versioning @@ -20,52 +27,84 @@ Within a major line, use minor/patch for library changes that stay on the same T --- +## Entity mapping + +POCOs are mapped to Azure Table properties by reflection: + +- Readable/writable properties are included. +- Nested objects are **flattened** with `_` as separator (`Parent.Child` → column `Parent_Child`). +- `null` property values are skipped on serialize. +- Value types, `string`, and `byte[]` are stored directly. +- Dedicated converters handle **enums**, **TimeSpan**, **arrays**, and **IEnumerable** (JSON via Newtonsoft.Json). + +`ObjectSerializer` (POCO → properties) and `ObjectBuilder` (TableEntity → POCO) implement this mapping. Clients use them automatically. + +--- + ## ExtendedAzureTableClientService -The ExtendedAzureTableClientService provides a class to register and access TypedAzureTableClients as well as MultiEntityAzureTableClients. +Registers and resolves `TypedAzureTableClient` and `MultiEntityAzureTableClient` instances. -### Create a new ExtendedAzureTableClientService -With a valid connectionString to an Azure Storage Account V2, the creation is straightforward. +### Create a service -```c# +```csharp var connectionString = "MY_STRING"; var extendedTableService = new ExtendedAzureTableClientService(connectionString); ``` -We recommend to initialize your ExtendedAzureTableClientService in the Startup/Program.cs in an Azure Function. The registration of TypedAzureTableClients is straightforward. +Initialize the service in `Startup` / `Program.cs` of an Azure Function (or host). + +### Register TypedAzureTableClients + +One table per POCO type: + +```csharp +var simplePocoAzureTableClient = extendedTableService.CreateAndRegisterTableClient("simplePocoTable"); +var parentPocoAzureTableClient = extendedTableService.CreateAndRegisterTableClient("parentPocoTable"); +``` -### Create and register some TypedAzureTableClients -You may have 2 Poco you want to save in 2 different tables. A SimplePoco and a ParentPoco. To register these with a table for each, do the following: +### Register an already initialized TableClient -```c# -var simplePocoAzureTableClient = extendedTableService.CreateAndRegisterTableClient("simplePojoTable"); -var parentPocoAzureTableClient = extendedTableService.CreateAndRegisterTableClient("parentPojoTable"); +```csharp +extendedTableService.AddInitializedTableClient(existingTableClient); ``` -### Accessing a TypedAzureTableClient -To get a specific TypeAzureTableClient, use the following approach with the ExtendedAzureTableClientService: -```c# + +### Resolve a TypedAzureTableClient + +```csharp var simplePocoAzureTableClient = extendedTableService.GetTypedTableClient(); ``` -### Create and register a MultiEntityAzureTableClient -The purpose for a MultiEntityAzureTableClient is to store entities of different types / kinds into the same table. The registration of these types is very easy. -```c# -var multiEntityTableClient = _extendedTableService.CreateAndRegisterMultiEntityTableClient("allpocos"); + +Throws `ArgumentOutOfRangeException` if the type was not registered. + +### Register a MultiEntityAzureTableClient + +Store different entity types in one table. Row keys are prefixed with the registered type name (or a custom prefix): + +```csharp +var multiEntityTableClient = extendedTableService.CreateAndRegisterMultiEntityTableClient("allpocos"); multiEntityTableClient.RegisterType(); multiEntityTableClient.RegisterType("mwp"); multiEntityTableClient.RegisterType(); ``` -The example registers a MultiEntityAzureTableClient bound to the table called "allpocos". The types are registered using its TypeName as prefix for the rowkey. MainWithParent is in this example registered with "mwp" as prefix. -### Access a MultiEntityAzureTableClient -Use the following code to access the registered MultiEntityAzureTableClient: -```c# -var multiEntityTableClient = _extendedTableService.GetMultiEntityAzureTableClientByTableName("allpocos"); +`SimplePoco` and `PocoWithListChildren` use their type name as prefix; `MainWithParent` uses `mwp`. + +### Resolve a MultiEntityAzureTableClient + +```csharp +var multiEntityTableClient = extendedTableService.GetMultiEntityAzureTableClientByTableName("allpocos"); ``` -The name of the table during registration is your key. For details about the usage of the client, see below. -## TableEntityResult -Results from TypedAzureTableClient and MultiEntityAzureTableClient are wrapped into the class TableEntityResult. For a result from a TypedAzureTableClient define the type of the client using the generic T. The resulting type from a MultiEntityAzureTableClient is always object. The class has the following signature: -```c# +The table name used at registration is the lookup key. + +--- + +## TableEntityResult\ + +Results from both clients are wrapped in `TableEntityResult`. For `TypedAzureTableClient`, `T` is the POCO type. For `MultiEntityAzureTableClient` list queries, `T` is `object`. + +```csharp public class TableEntityResult(ITableEntity tableEntity, T entity) { public string RowKey { get; set; } = tableEntity.RowKey; @@ -73,113 +112,118 @@ public class TableEntityResult(ITableEntity tableEntity, T entity) public ETag ETag { get; set; } = tableEntity.ETag; public DateTimeOffset? Timestamp { get; set; } = tableEntity.Timestamp; public T Entity { get; set; } = entity; - } +} ``` -## TypedAzureTableClient -The TypedAzureTableClient is a decorator to the AzureTableClient. The main purpose is to extend conversion from and to the defined entity with the capability for complex entities, arrays and IEnumerable's. The client can be initialized via ExtendAzureTableClientService or direct in the code, using the following pattern. +--- + +## TypedAzureTableClient\ + +Decorator around `Azure.Data.Tables.TableClient` with POCO serialize/deserialize (including nested entities, arrays, and IEnumerable). -Get the client from the service: -```c# -var typedTableClient = _extendedTableService.GetTypedTableClient(); +### Get from service + +```csharp +var typedTableClient = extendedTableService.GetTypedTableClient(); ``` -Initialize inline: -```c# -var connectionString = "MY_STRING"; //String to Azure Storage Account V2 -var tableClient = new TableClient(connectionString, "MyPoco"); // From Azure.Data.Table +### Initialize inline + +```csharp +var connectionString = "MY_STRING"; +var tableClient = new TableClient(connectionString, "MyPoco"); // Azure.Data.Tables await tableClient.CreateIfNotExistsAsync(); var typedTableClient = new TypedAzureTableClient(tableClient); ``` -For all examples, we are using a TypedAzureTableClient bound to MyPoco as generic type. -The following operations are provided: +Underlying SDK client: `typedTableClient.GetTableClient()`. + +Examples below use a client bound to `MyPoco`. ### GetAllAsync() -```c# +```csharp List> pocos = await typedTableClient.GetAllAsync(); ``` -Gets all data from a table and convert it into the specified object type. No partition key is applied. +All rows; no partition filter. -### GetAllAsync(string partition) +### GetAllAsync(string partitionKey) -```c# -List> pocos = await typedTableClient.GetAllAsync('mypoco'); +```csharp +List> pocos = await typedTableClient.GetAllAsync("mypoco"); ``` -Gets all data from a table and convert it into the specified object type. A partition key is applied. The current example applies 'mypoco' as partition key. +All rows for the given partition key. ### GetByIdAsync(string id) -```c# -TableEntityResult? poco = await typedTableClient.GetByIdAsync('1018301'); +```csharp +TableEntityResult? poco = await typedTableClient.GetByIdAsync("1018301"); ``` -Gets as specific entity from the table and convert it to the specified object. The name of the type (MyPoco in this example) is used as partition key. -If the id and partition key combination finds no object, null is returned. +Looks up by row key `id`. Partition key is `typeof(T).ToString()` (typically the full type name, e.g. `MyNamespace.MyPoco`). Returns `null` if not found. -### GetByIdAsync(string id, string partition) +### GetByIdAsync(string rowKey, string partitionKey) -```c# -TableEntityResult? poco = await typedTableClient.GetByIdAsync('9201u819','mypoco'); +```csharp +TableEntityResult? poco = await typedTableClient.GetByIdAsync("9201u819", "mypoco"); ``` -Gets as specific entity form the table and convert it to the specified object. The partition key is the 2nd argument. -If the id and partition key combination finds no object, null is returned. +Returns `null` if not found. -### GetAllByQueryAsync(TableQuery query) +### GetAllByQueryAsync(string? query) -```c# +```csharp var query = $"PartitionKey eq '{partitionKey}'"; List> pocos = await typedTableClient.GetAllByQueryAsync(query); ``` -Gets all entities that matches the query. +OData filter string as supported by `TableClient.QueryAsync`. Pass `null` for an unfiltered query. -### InsertOrMergeAsync(string id, string partition, object obj) +### InsertOrMergeAsync(string rowKey, string partitionKey, object obj) -```c# +```csharp MyPoco poco = new MyPoco(); -// Do magicStuff with poco +// populate poco Azure.Response result = await typedTableClient.InsertOrMergeAsync("001", "SimplePoco", poco); ``` -Creates or merges a specific object into the table. The selection is done by id and partition key. +Upsert with `TableUpdateMode.Merge`. -### InsertOrReplaceAsync(string id, string partition, object obj) +### InsertOrReplaceAsync(string rowKey, string partitionKey, object obj) -```c# +```csharp MyPoco poco = new MyPoco(); -// Do magicStuff with poco +// populate poco Azure.Response result = await typedTableClient.InsertOrReplaceAsync("001", "SimplePoco", poco); ``` -Creates or replace a specific object into the table. The selection is done by id and partition key. +Upsert with `TableUpdateMode.Replace`. -### DeleteEntryAsync(string id, string partition) +### DeleteEntityAsync(string rowKey, string partitionKey) -```c# +```csharp Azure.Response result = await typedTableClient.DeleteEntityAsync("001", "SimplePoco"); ``` -Deletes a specific object from the table. The selection is done by id and partition key. +--- ## MultiEntityAzureTableClient -The MultiEntityAzureTableClient is a decorator to the AzureTableClient. The main purpose is to extend the conversion from and to the defined entities with the capability for complex entities, arrays and IEnumerable's. -The client adds the functionality to support different entity types in one table. -The client can be initialized using ExtendAzureTableClientService or direct in the code, using the following pattern. -Get the client from the service: -```c# -var multiEntityTableClient = _extendedTableService.GetMultiEntityAzureTableClientByTableName("allpocos"); +Decorator around `TableClient` with the same mapping capabilities, plus **multiple entity types in one table**. Each registered type gets a row-key prefix (`{prefix}_{rowKey}`). Types must be registered before insert/get-by-type. Unregistered row prefixes on read throw `ArgumentOutOfRangeException`. + +### Get from service + +```csharp +var multiEntityTableClient = extendedTableService.GetMultiEntityAzureTableClientByTableName("allpocos"); ``` -Initialize inline: -```c# -var connectionString = "MY_STRING"; //String to Azure Storage Account V2 -var tableClient = new TableClient(connectionString, "allpocos"); // From Azure.Data.Table +### Initialize inline + +```csharp +var connectionString = "MY_STRING"; +var tableClient = new TableClient(connectionString, "allpocos"); // Azure.Data.Tables await tableClient.CreateIfNotExistsAsync(); var multiEntityTableClient = new MultiEntityAzureTableClient(tableClient); multiEntityTableClient.RegisterType(); @@ -187,81 +231,73 @@ multiEntityTableClient.RegisterType("mwp"); multiEntityTableClient.RegisterType(); ``` -For all examples, we are using a MultiEntityAzureTableClient with SimplePoco, MainWithParent und PocoWithListChildren as registered entity types. -The following operations are provided: +Underlying SDK client: `multiEntityTableClient.GetTableClient()`. + +Examples below assume `SimplePoco`, `MainWithParent`, and `PocoWithListChildren` are registered. ### GetAllAsync() -```c# +```csharp List> allPocos = await multiEntityTableClient.GetAllAsync(); -``` -Gets all data from a table and convert them it the specified object. No partition key is applied. To extract a specific entity type, use the following pattern: - -```c# List simplePocos = allPocos.Select(res => res.Entity).OfType().ToList(); ``` -### GetAllAsync(string partition) - -```c# -List> pocos = await multiEntityTableClient.GetAllAsync('mypoco'); -``` - -Gets all data from a table and convert it into the specified object. A partition key is applied. The current example applies 'mypoco' as partition key. To extract a specific entity type, use the following pattern: +### GetAllAsync(string partitionKey) -```c# +```csharp +List> allPocos = await multiEntityTableClient.GetAllAsync("mypoco"); List simplePocos = allPocos.Select(res => res.Entity).OfType().ToList(); ``` -### GetByIdAsync\\(string id, string partition) +### GetByIdAsync\(string rowKey, string partitionKey) -```c# -TableEntityResult? poco = await multiEntityTableClient.GetByIdAsync('9201u819','mypoco'); +```csharp +TableEntityResult? poco = await multiEntityTableClient.GetByIdAsync("9201u819", "mypoco"); ``` -Gets as specific entity from the table and convert it to the specified object. The partition key is the 2nd argument. -If the id and partition key combination finds no object, null is returned. +Resolves the stored row key as `{registeredPrefix}_{rowKey}`. Returns `null` if not found. Throws if `T` is not registered. -### GetAllByQueryAsync(string query) +### GetAllByQueryAsync(string? query) -```c# +```csharp var query = $"PartitionKey eq '{partitionKey}'"; -List> pocos = await multiEntityTableClient.GetAllByQueryAsync(query); -``` - -Gets alls entities that matches the query. To extract a specific entity type, use the following pattern: - -```c# +List> allPocos = await multiEntityTableClient.GetAllByQueryAsync(query); List simplePocos = allPocos.Select(res => res.Entity).OfType().ToList(); ``` -### InsertOrMergeAsync(string id, string partition, object obj) +### InsertOrMergeAsync(string rowKey, string partitionKey, object obj) -```c# +```csharp MyPoco poco = new MyPoco(); -// Do magicStuff with poco +// populate poco Azure.Response result = await multiEntityTableClient.InsertOrMergeAsync("001", "SimplePoco", poco); ``` -Creates or merges a specific object into the table. The selection is done by id and partition key. +Stores row key as `{prefix}_001`. Type of `obj` must be registered. -### InsertOrReplaceAsync(string id, string partition, object obj) +### InsertOrReplaceAsync(string rowKey, string partitionKey, object obj) -```c# +```csharp MyPoco poco = new MyPoco(); -// Do magicStuff with poco +// populate poco Azure.Response result = await multiEntityTableClient.InsertOrReplaceAsync("001", "SimplePoco", poco); ``` -Creates or replaces a specific object into the table. The selection is done by id and partition key. +### DeleteEntityByTypeAsync\(string rowKey, string partitionKey) + +```csharp +Azure.Response result = await multiEntityTableClient.DeleteEntityByTypeAsync("001", "SimplePoco"); +``` + +Builds the row key from the registered prefix for `T`. Prefer this when you know the entity type. -### DeleteEntryAsync\\(string id, string partition) +### DeleteEntityAsync(string completeRowKey, string partitionKey) -```c# -Azure.Response result = await multiEntityTableClient.DeleteEntityAsync("001", "SimplePoco"); +```csharp +Azure.Response result = await multiEntityTableClient.DeleteEntityAsync("SimplePoco_001", "SimplePoco"); ``` -Deletes a specific object from the table. The selection is done by id and partition key. The entity type must be specified, otherwise the client is not capable to calculate the correct row key. +Deletes by the **full** row key already stored in the table (including prefix). Useful when iterating `GetAllAsync` results (`result.RowKey`). --- @@ -271,12 +307,12 @@ Deletes a specific object from the table. The selection is done by id and partit --- -## Licence +## License -Apache V 2.0 +Apache-2.0 --- ## Copyright -2024, WebGate Consulting AG +2026, WebGate Consulting AG From b841df5c8c9ce2ad1b3537850c3ed38cce7cb73f Mon Sep 17 00:00:00 2001 From: Fabricio Ruch Date: Mon, 3 Aug 2026 11:54:09 +0200 Subject: [PATCH 4/9] Remove outdated SonarCloud badge that no longer resolves. The CloudTableUtils quality-gate project is gone and showed 'Project not found'. Co-authored-by: Cursor --- README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/README.md b/README.md index f44b9e9..189bccc 100644 --- a/README.md +++ b/README.md @@ -301,12 +301,6 @@ Deletes by the **full** row key already stored in the table (including prefix). --- -## Code Quality Check SonarCloud.io - -[![Quality gate](https://sonarcloud.io/api/project_badges/quality_gate?project=CloudTableUtils&token=b8ea0b7d7b29c7e13fb260bae8cf0d3eb36597ec)](https://sonarcloud.io/dashboard?id=CloudTableUtils) - ---- - ## License Apache-2.0 From 19f69e7f6f201ec70ca854d8d78d8a3a84635066 Mon Sep 17 00:00:00 2001 From: Fabricio Ruch Date: Mon, 3 Aug 2026 13:07:21 +0200 Subject: [PATCH 5/9] Add DecimalConverter to store decimals as invariant culture strings. Azure Tables has no native decimal type; converting via string preserves precision for monetary and exact values. Co-authored-by: Cursor --- AzureTableUtils.Tests/BuilderTests.cs | 9 ++++--- AzureTableUtils.Tests/Models/SimplePoco.cs | 5 +++- AzureTableUtils.Tests/SerializerTests.cs | 11 +++++--- .../Converters/ConverterFactory.cs | 1 + .../Converters/DecimalConverter.cs | 25 +++++++++++++++++++ README.md | 2 +- 6 files changed, 43 insertions(+), 10 deletions(-) create mode 100644 AzureTableUtils/Converters/DecimalConverter.cs diff --git a/AzureTableUtils.Tests/BuilderTests.cs b/AzureTableUtils.Tests/BuilderTests.cs index 9675586..205f83a 100644 --- a/AzureTableUtils.Tests/BuilderTests.cs +++ b/AzureTableUtils.Tests/BuilderTests.cs @@ -8,12 +8,13 @@ public void TestBuildAllEnitiesFromSimplePoco() { SimplePoco spo = SimplePoco.CreateInitializedPoco(); IDictionary allEntities = ObjectSerializer.Serialize(spo); - Assert.AreEqual(9, allEntities.Count); + Assert.AreEqual(10, allEntities.Count); TableEntity tableEntity = new TableEntity(allEntities); SimplePoco build = ObjectBuilder.Build(tableEntity); Assert.IsNotNull(build); Assert.AreEqual(spo.Id, build.Id); Assert.AreEqual(spo.DoubleValue, build.DoubleValue); + Assert.AreEqual(spo.DecimalValue, build.DecimalValue); Assert.AreEqual(spo.DTOValue, build.DTOValue); Assert.AreEqual(spo.DTValue, build.DTValue); Assert.AreEqual(spo.GuidValue, build.GuidValue); @@ -28,7 +29,7 @@ public void TestBuildAllEnitiesFromSimplePocoWithNullId() { SimplePoco spo = SimplePoco.CreatePocoWithoutID(); IDictionary allEntities = ObjectSerializer.Serialize(spo); - Assert.AreEqual(8, allEntities.Count); + Assert.AreEqual(9, allEntities.Count); Assert.IsFalse(allEntities.ContainsKey("Id")); TableEntity tableEntity = new TableEntity(allEntities); SimplePoco build = ObjectBuilder.Build(tableEntity); @@ -41,7 +42,7 @@ public void TestBuildAllEnitiesFromParentPoco() { ParentPoco pp = ParentPoco.CreateParentWithChild(); IDictionary allEntities = ObjectSerializer.Serialize(pp); - Assert.AreEqual(10, allEntities.Count); + Assert.AreEqual(11, allEntities.Count); TableEntity tableEntity = new TableEntity(allEntities); ParentPoco build = ObjectBuilder.Build(tableEntity); Assert.IsNotNull(build); @@ -54,7 +55,7 @@ public void TestBuildAllEnitiesFromMainWithParent() { MainWithParent mwp = MainWithParent.CreateMainWithParent(); IDictionary allEntities = ObjectSerializer.Serialize(mwp); - Assert.AreEqual(20, allEntities.Count); + Assert.AreEqual(22, allEntities.Count); Assert.IsTrue(allEntities.ContainsKey("Id")); TableEntity tableEntity = new TableEntity(allEntities); MainWithParent build = ObjectBuilder.Build(tableEntity); diff --git a/AzureTableUtils.Tests/Models/SimplePoco.cs b/AzureTableUtils.Tests/Models/SimplePoco.cs index 80db4d5..3f6ec9e 100644 --- a/AzureTableUtils.Tests/Models/SimplePoco.cs +++ b/AzureTableUtils.Tests/Models/SimplePoco.cs @@ -20,6 +20,8 @@ public class SimplePoco public double DoubleValue { set; get; } + public decimal DecimalValue { set; get; } + public Guid GuidValue { set; get; } public DateTime DTValue { set; get; } @@ -36,6 +38,7 @@ public static SimplePoco CreateInitializedPoco() { Id = "02381012", DoubleValue = 2018101.00812, + DecimalValue = 12345.6789m, IntValue = 9789677, DTOValue = DateTimeOffset.Now, LongValue = 100008937819, @@ -64,7 +67,7 @@ public override bool Equals(object? obj) else { SimplePoco spo = (SimplePoco)obj; - return (Id == spo.Id) && (EnumValue == spo.EnumValue) && (spo.DoubleValue == DoubleValue) && (spo.DTOValue == DTOValue) && (spo.DTValue == DTValue) && (spo.GuidValue == GuidValue); + return (Id == spo.Id) && (EnumValue == spo.EnumValue) && (spo.DoubleValue == DoubleValue) && (spo.DecimalValue == DecimalValue) && (spo.DTOValue == DTOValue) && (spo.DTValue == DTValue) && (spo.GuidValue == GuidValue); } } diff --git a/AzureTableUtils.Tests/SerializerTests.cs b/AzureTableUtils.Tests/SerializerTests.cs index 3e2de9b..76a44e8 100644 --- a/AzureTableUtils.Tests/SerializerTests.cs +++ b/AzureTableUtils.Tests/SerializerTests.cs @@ -8,7 +8,7 @@ public void TestExtractAllEnitiesFromSimplePoco() { SimplePoco spo = SimplePoco.CreateInitializedPoco(); IDictionary allEntities = ObjectSerializer.Serialize(spo); - Assert.AreEqual(9, allEntities.Count); + Assert.AreEqual(10, allEntities.Count); //CHECK ID Assert.IsTrue(allEntities.ContainsKey("Id")); Assert.AreEqual(allEntities["Id"], spo.Id); @@ -22,6 +22,9 @@ public void TestExtractAllEnitiesFromSimplePoco() Assert.IsTrue(allEntities.ContainsKey("DoubleValue")); Assert.AreEqual(allEntities["DoubleValue"], spo.DoubleValue); + Assert.IsTrue(allEntities.ContainsKey("DecimalValue")); + Assert.AreEqual(allEntities["DecimalValue"], spo.DecimalValue.ToString(System.Globalization.CultureInfo.InvariantCulture)); + Assert.IsTrue(allEntities.ContainsKey("GuidValue")); Assert.AreEqual(allEntities["GuidValue"], spo.GuidValue); @@ -40,7 +43,7 @@ public void TestExtractAllEnitiesFromSimplePocoWithNullId() { SimplePoco spo = SimplePoco.CreatePocoWithoutID(); IDictionary allEntities = ObjectSerializer.Serialize(spo); - Assert.AreEqual(8, allEntities.Count); + Assert.AreEqual(9, allEntities.Count); Assert.IsFalse(allEntities.ContainsKey("Id")); } @@ -50,7 +53,7 @@ public void TestExtractAllEnitiesFromParentPoco() ParentPoco pp = ParentPoco.CreateParentWithChild(); IDictionary allEntities = ObjectSerializer.Serialize(pp); Assert.IsNotNull(pp.Child); - Assert.AreEqual(10, allEntities.Count); + Assert.AreEqual(11, allEntities.Count); Assert.IsTrue(allEntities.ContainsKey("Id")); Assert.IsTrue(allEntities.ContainsKey("Child_Id")); @@ -84,7 +87,7 @@ public void TestExtractAllEnitiesFromMainWithParent() Assert.IsNotNull(mwp.Parent); Assert.IsNotNull(mwp.Parent.Child); - Assert.AreEqual(20, allEntities.Count); + Assert.AreEqual(22, allEntities.Count); Assert.IsTrue(allEntities.ContainsKey("Id")); Assert.IsTrue(allEntities.ContainsKey("Child_Id")); diff --git a/AzureTableUtils/Converters/ConverterFactory.cs b/AzureTableUtils/Converters/ConverterFactory.cs index 03c2d76..57bbb7e 100644 --- a/AzureTableUtils/Converters/ConverterFactory.cs +++ b/AzureTableUtils/Converters/ConverterFactory.cs @@ -15,6 +15,7 @@ private static List InitConverters() List list = new List(); list.Add(new EnumConverter()); list.Add(new TimeSpanConverter()); + list.Add(new DecimalConverter()); list.Add(new ArrayConverter()); list.Add(new EnumerableConverter()); return list; diff --git a/AzureTableUtils/Converters/DecimalConverter.cs b/AzureTableUtils/Converters/DecimalConverter.cs new file mode 100644 index 0000000..4d6850b --- /dev/null +++ b/AzureTableUtils/Converters/DecimalConverter.cs @@ -0,0 +1,25 @@ +using System.Globalization; + +namespace WebGate.Azure.TableUtils.Converter; + +public class DecimalConverter : IConverter +{ + public bool IsType(Type type) + { + return type == typeof(decimal) || type == typeof(decimal?); + } + + public string GetValue(Type type, object value) + { + return ((decimal)value).ToString(CultureInfo.InvariantCulture); + } + + public object? BuildValue(string? value, Type type) + { + if (string.IsNullOrEmpty(value)) + { + return null; + } + return decimal.Parse(value, CultureInfo.InvariantCulture); + } +} diff --git a/README.md b/README.md index 189bccc..40d80ad 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ POCOs are mapped to Azure Table properties by reflection: - Nested objects are **flattened** with `_` as separator (`Parent.Child` → column `Parent_Child`). - `null` property values are skipped on serialize. - Value types, `string`, and `byte[]` are stored directly. -- Dedicated converters handle **enums**, **TimeSpan**, **arrays**, and **IEnumerable** (JSON via Newtonsoft.Json). +- Dedicated converters handle **enums**, **TimeSpan**, **decimal** (InvariantCulture string), **arrays**, and **IEnumerable** (JSON via Newtonsoft.Json). `ObjectSerializer` (POCO → properties) and `ObjectBuilder` (TableEntity → POCO) implement this mapping. Clients use them automatically. From 93cbd4b90fc4c239e17f507769223bdb3e5adc90 Mon Sep 17 00:00:00 2001 From: Fabricio Ruch Date: Mon, 3 Aug 2026 13:35:49 +0200 Subject: [PATCH 6/9] Harden mapping internals without changing the public API. Fix ObjectBuilder path/operator bugs, share EntityMapping and ODataFilter helpers, make ConverterFactory lazy, and support nullable enums. Co-authored-by: Cursor --- .../Converters/ConverterFactory.cs | 22 ++--- AzureTableUtils/Converters/EnumConverter.cs | 9 ++- AzureTableUtils/EntityMapping.cs | 25 ++++++ .../MultiEntityAzureTableClient.cs | 6 +- AzureTableUtils/ODataFilter.cs | 9 +++ AzureTableUtils/ObjectBuilder.cs | 80 +++++++------------ AzureTableUtils/ObjectSerializer.cs | 50 +++++------- AzureTableUtils/TypedAzureTableClient.cs | 4 +- 8 files changed, 105 insertions(+), 100 deletions(-) create mode 100644 AzureTableUtils/EntityMapping.cs create mode 100644 AzureTableUtils/ODataFilter.cs diff --git a/AzureTableUtils/Converters/ConverterFactory.cs b/AzureTableUtils/Converters/ConverterFactory.cs index 57bbb7e..3c060c7 100644 --- a/AzureTableUtils/Converters/ConverterFactory.cs +++ b/AzureTableUtils/Converters/ConverterFactory.cs @@ -2,22 +2,22 @@ namespace WebGate.Azure.TableUtils.Converter; public static class ConverterFactory { - private static List? converters = null; + private static readonly Lazy> Converters = new(InitConverters); public static IConverter? FindConverter(Type type) { - converters ??= InitConverters(); - return converters.Find(converter => converter.IsType(type)); + return Converters.Value.Find(converter => converter.IsType(type)); } private static List InitConverters() { - List list = new List(); - list.Add(new EnumConverter()); - list.Add(new TimeSpanConverter()); - list.Add(new DecimalConverter()); - list.Add(new ArrayConverter()); - list.Add(new EnumerableConverter()); - return list; + return + [ + new EnumConverter(), + new TimeSpanConverter(), + new DecimalConverter(), + new ArrayConverter(), + new EnumerableConverter() + ]; } -} \ No newline at end of file +} diff --git a/AzureTableUtils/Converters/EnumConverter.cs b/AzureTableUtils/Converters/EnumConverter.cs index 9828b43..4a947a4 100644 --- a/AzureTableUtils/Converters/EnumConverter.cs +++ b/AzureTableUtils/Converters/EnumConverter.cs @@ -4,7 +4,7 @@ public class EnumConverter : IConverter { public bool IsType(Type type) { - return type.IsEnum; + return type.IsEnum || Nullable.GetUnderlyingType(type)?.IsEnum == true; } public string GetValue(Type type, object value) @@ -18,7 +18,8 @@ public string GetValue(Type type, object value) { return null; } - ; - return Enum.Parse(type, value); + + Type enumType = Nullable.GetUnderlyingType(type) ?? type; + return Enum.Parse(enumType, value); } -} \ No newline at end of file +} diff --git a/AzureTableUtils/EntityMapping.cs b/AzureTableUtils/EntityMapping.cs new file mode 100644 index 0000000..3aefa78 --- /dev/null +++ b/AzureTableUtils/EntityMapping.cs @@ -0,0 +1,25 @@ +using System.Collections.Concurrent; +using System.Reflection; + +namespace WebGate.Azure.TableUtils; + +internal static class EntityMapping +{ + private static readonly ConcurrentDictionary PropertyCache = new(); + + internal static PropertyInfo[] GetWritableProperties(Type type) => + PropertyCache.GetOrAdd(type, static t => + t.GetProperties().Where(p => p.CanRead && p.CanWrite).ToArray()); + + internal static bool IsPrimitiveTableType(Type type) => + type.IsValueType || type == typeof(string) || type == typeof(byte[]); + + internal static string BuildEntityName(string? path, string id) + { + if (string.IsNullOrEmpty(path)) + { + return id; + } + return path + "_" + id; + } +} diff --git a/AzureTableUtils/MultiEntityAzureTableClient.cs b/AzureTableUtils/MultiEntityAzureTableClient.cs index a6b5672..183a7cd 100644 --- a/AzureTableUtils/MultiEntityAzureTableClient.cs +++ b/AzureTableUtils/MultiEntityAzureTableClient.cs @@ -32,13 +32,11 @@ public async Task>> GetAllAsync() public async Task>> GetAllAsync(string partitionKey) { - var query = $"PartitionKey eq '{partitionKey}'"; - return await GetAllByQueryAsync(query); + return await GetAllByQueryAsync(ODataFilter.PartitionKeyEquals(partitionKey)); } public async Task>> GetAllByQueryAsync(string? query) { - //$"PartitionKey eq '{partitionKey}'" AsyncPageable resultItems = _tableClient.QueryAsync(query); List> items = new(); @@ -47,7 +45,7 @@ public async Task>> GetAllByQueryAsync(string? qu Type? entityType = _typeRegistry.Where(kvp => item.RowKey.StartsWith(kvp.Value + "_")).Select(kvp => kvp.Key).FirstOrDefault(); if (entityType == null) { - throw new ArgumentOutOfRangeException($"No registered type foung for Tableentry with ID {item.RowKey}."); + throw new ArgumentOutOfRangeException($"No registered type found for Tableentry with ID {item.RowKey}."); } items.Add(TableEntityResult.BuildTableEntityResultWithType(entityType, item)); } diff --git a/AzureTableUtils/ODataFilter.cs b/AzureTableUtils/ODataFilter.cs new file mode 100644 index 0000000..8d9bcd7 --- /dev/null +++ b/AzureTableUtils/ODataFilter.cs @@ -0,0 +1,9 @@ +namespace WebGate.Azure.TableUtils; + +internal static class ODataFilter +{ + internal static string EscapeString(string value) => value.Replace("'", "''"); + + internal static string PartitionKeyEquals(string partitionKey) => + $"PartitionKey eq '{EscapeString(partitionKey)}'"; +} diff --git a/AzureTableUtils/ObjectBuilder.cs b/AzureTableUtils/ObjectBuilder.cs index 953e38b..0939104 100644 --- a/AzureTableUtils/ObjectBuilder.cs +++ b/AzureTableUtils/ObjectBuilder.cs @@ -11,6 +11,7 @@ public static T Build(TableEntity tableEntity) ProcessObject(result, null, tableEntity); return result; } + public static object BuildByType(Type typeT, TableEntity tableEntity) { object result = RuntimeHelpers.GetUninitializedObject(typeT); @@ -20,68 +21,49 @@ public static object BuildByType(Type typeT, TableEntity tableEntity) private static void ProcessObject(object obj, string? path, TableEntity tableEntity) { - obj.GetType().GetProperties().Where(propertyInfo => propertyInfo.CanRead && propertyInfo.CanWrite).ToList().ForEach(propertyInfo => + foreach (var propertyInfo in EntityMapping.GetWritableProperties(obj.GetType())) { string id = propertyInfo.Name; - string entityName = BuildEntityName(path, id); + string entityName = EntityMapping.BuildEntityName(path, id); + Type pType = propertyInfo.PropertyType; + if (tableEntity.TryGetValue(entityName, out var value)) { - Type pType = propertyInfo.PropertyType; IConverter? converter = ConverterFactory.FindConverter(pType); if (converter != null) { - propertyInfo.SetValue(obj, converter.BuildValue(value != null ? value.ToString() : null, pType), index: null); + propertyInfo.SetValue(obj, converter.BuildValue(value?.ToString(), pType)); + continue; } - else + + if (value is DateTimeOffset dtoValue && IsDateTime(pType)) { - if (value.GetType().FullName == "System.DateTimeOffset" && IsDateTime(pType)) - { - DateTimeOffset dtoValue = (DateTimeOffset)value; - propertyInfo.SetValue(obj, DateTime.SpecifyKind(dtoValue.DateTime, DateTimeKind.Utc), index: null); - return; - } - if (pType.IsValueType || pType.Name == "Byte[]" || pType.Name == "String") - { - propertyInfo.SetValue(obj, value, index: null); - } - else - { - object child = RuntimeHelpers.GetUninitializedObject(pType); - ProcessObject(child, id, tableEntity); - } + propertyInfo.SetValue(obj, DateTime.SpecifyKind(dtoValue.DateTime, DateTimeKind.Utc)); + continue; } - } - else - { - Type pType = propertyInfo.PropertyType; - if (!pType.IsValueType && pType.Name != "Byte[]" || pType.Name != "String") + + if (EntityMapping.IsPrimitiveTableType(pType)) { - if (HasChildObjectInformation(id, tableEntity)) - { - object child = RuntimeHelpers.GetUninitializedObject(pType); - ProcessObject(child, id, tableEntity); - propertyInfo.SetValue(obj, child, index: null); - } + propertyInfo.SetValue(obj, value); + continue; } + + object child = RuntimeHelpers.GetUninitializedObject(pType); + ProcessObject(child, entityName, tableEntity); + propertyInfo.SetValue(obj, child); + } + else if (!EntityMapping.IsPrimitiveTableType(pType) && HasChildObjectInformation(entityName, tableEntity)) + { + object child = RuntimeHelpers.GetUninitializedObject(pType); + ProcessObject(child, entityName, tableEntity); + propertyInfo.SetValue(obj, child); } - }); + } } - private static bool HasChildObjectInformation(string id, TableEntity tableEntity) - { - return tableEntity.Where(p => p.Key.StartsWith(id + "_") && p.Value != null).Count() > 0; - } + private static bool HasChildObjectInformation(string id, TableEntity tableEntity) => + tableEntity.Any(p => p.Key.StartsWith(id + "_", StringComparison.Ordinal) && p.Value != null); - private static string BuildEntityName(string? path, string id) - { - if (string.IsNullOrEmpty(path)) - { - return id; - } - return path + "_" + id; - } - private static bool IsDateTime(Type pType) - { - return pType.FullName == "System.DateTime" || Nullable.GetUnderlyingType(pType) == typeof(DateTime); - } -} \ No newline at end of file + private static bool IsDateTime(Type pType) => + pType == typeof(DateTime) || Nullable.GetUnderlyingType(pType) == typeof(DateTime); +} diff --git a/AzureTableUtils/ObjectSerializer.cs b/AzureTableUtils/ObjectSerializer.cs index 5182f57..28470df 100644 --- a/AzureTableUtils/ObjectSerializer.cs +++ b/AzureTableUtils/ObjectSerializer.cs @@ -13,39 +13,31 @@ public static IDictionary Serialize(object obj) private static void ProcessObject(object obj, string? path, IDictionary entities) { - obj.GetType().GetProperties().Where(propertInfo => propertInfo.CanRead && propertInfo.CanWrite).ToList().ForEach(propertyInfo => + foreach (var propertyInfo in EntityMapping.GetWritableProperties(obj.GetType())) { string id = propertyInfo.Name; - object? value = propertyInfo.GetValue(obj, index: null); - if (value != null) + object? value = propertyInfo.GetValue(obj); + if (value == null) { - IConverter? converter = ConverterFactory.FindConverter(value.GetType()); - if (converter == null) - { - if (value.GetType().IsValueType || value.GetType().Name == "Byte[]" || value.GetType().Name == "String") - { - entities.Add(BuildEntityName(path, id), value); - } - else - { - ProcessObject(value, BuildEntityName(path, id), entities); - } - } - else - { - string ep = converter.GetValue(propertyInfo.GetType(), value); - entities.Add(BuildEntityName(path, id), ep); - } + continue; } - }); - } - private static string BuildEntityName(string? path, string id) - { - if (string.IsNullOrEmpty(path)) - { - return id; + Type valueType = value.GetType(); + IConverter? converter = ConverterFactory.FindConverter(valueType); + if (converter != null) + { + entities.Add(EntityMapping.BuildEntityName(path, id), converter.GetValue(propertyInfo.PropertyType, value)); + continue; + } + + if (EntityMapping.IsPrimitiveTableType(valueType)) + { + entities.Add(EntityMapping.BuildEntityName(path, id), value); + } + else + { + ProcessObject(value, EntityMapping.BuildEntityName(path, id), entities); + } } - return path + "_" + id; } -} \ No newline at end of file +} diff --git a/AzureTableUtils/TypedAzureTableClient.cs b/AzureTableUtils/TypedAzureTableClient.cs index 009d651..4c32bc5 100644 --- a/AzureTableUtils/TypedAzureTableClient.cs +++ b/AzureTableUtils/TypedAzureTableClient.cs @@ -21,13 +21,11 @@ public async Task>> GetAllAsync() public async Task>> GetAllAsync(string partitionKey) { - var query = $"PartitionKey eq '{partitionKey}'"; - return await GetAllByQueryAsync(query); + return await GetAllByQueryAsync(ODataFilter.PartitionKeyEquals(partitionKey)); } public async Task>> GetAllByQueryAsync(string? query) { - //$"PartitionKey eq '{partitionKey}'" AsyncPageable resultItems = _tableClient.QueryAsync(query); List> items = new(); From ce04a406e8eb59071383fd00f350890f2c631273 Mon Sep 17 00:00:00 2001 From: Fabricio Ruch Date: Mon, 3 Aug 2026 13:42:18 +0200 Subject: [PATCH 7/9] Tighten client APIs with TableClient property and typed upserts. Prefer a TableClient property over GetTableClient-only access and replace object parameters with T for clearer insert/merge calls. Co-authored-by: Cursor --- .../ExtendedAzureTableClientService.cs | 6 +- .../MultiEntityAzureTableClient.cs | 65 +++++++++---------- AzureTableUtils/TypedAzureTableClient.cs | 17 +++-- README.md | 12 ++-- 4 files changed, 49 insertions(+), 51 deletions(-) diff --git a/AzureTableUtils/ExtendedAzureTableClientService.cs b/AzureTableUtils/ExtendedAzureTableClientService.cs index d375d92..a53e703 100644 --- a/AzureTableUtils/ExtendedAzureTableClientService.cs +++ b/AzureTableUtils/ExtendedAzureTableClientService.cs @@ -11,7 +11,7 @@ public TypedAzureTableClient CreateAndRegisterTableClient(string tableName var tableClient = new TableClient(_connectionString, tableName); var typedClient = new TypedAzureTableClient(tableClient); _tableClients.Add(typeof(T), typedClient); - typedClient.GetTableClient().CreateIfNotExists(); + typedClient.TableClient.CreateIfNotExists(); return typedClient; } @@ -19,7 +19,7 @@ public void AddInitializedTableClient(TableClient tableClient) { var typedClient = new TypedAzureTableClient(tableClient); _tableClients.Add(typeof(T), typedClient); - typedClient.GetTableClient().CreateIfNotExists(); + typedClient.TableClient.CreateIfNotExists(); } public TypedAzureTableClient GetTypedTableClient() @@ -36,7 +36,7 @@ public MultiEntityAzureTableClient CreateAndRegisterMultiEntityTableClient(strin var tableClient = new TableClient(_connectionString, tableName); var meClient = new MultiEntityAzureTableClient(tableClient); _meTableClients.Add(tableName, meClient); - meClient.GetTableClient().CreateIfNotExists(); + meClient.TableClient.CreateIfNotExists(); return meClient; } diff --git a/AzureTableUtils/MultiEntityAzureTableClient.cs b/AzureTableUtils/MultiEntityAzureTableClient.cs index 183a7cd..c2668f6 100644 --- a/AzureTableUtils/MultiEntityAzureTableClient.cs +++ b/AzureTableUtils/MultiEntityAzureTableClient.cs @@ -5,15 +5,16 @@ namespace WebGate.Azure.TableUtils; public class MultiEntityAzureTableClient { private readonly TableClient _tableClient; - private readonly Dictionary _typeRegistry = new Dictionary(); + private readonly Dictionary _typeRegistry = new(); public MultiEntityAzureTableClient(TableClient tableClient) { _tableClient = tableClient; } - public TableClient GetTableClient() - { return _tableClient; } + public TableClient TableClient => _tableClient; + + public TableClient GetTableClient() => TableClient; public void RegisterType() { @@ -54,11 +55,11 @@ public async Task>> GetAllByQueryAsync(string? qu public async Task?> GetByIdAsync(string rowKey, string partitionKey) { - if (!_typeRegistry.ContainsKey(typeof(T))) + if (!_typeRegistry.TryGetValue(typeof(T), out string? prefix)) { throw new ArgumentOutOfRangeException($"No registered type found for {typeof(T)}."); } - string prefix = _typeRegistry[typeof(T)]; + NullableResponse tableEntity = await _tableClient.GetEntityIfExistsAsync(partitionKey, prefix + "_" + rowKey); if (tableEntity.HasValue) { @@ -67,46 +68,22 @@ public async Task>> GetAllByQueryAsync(string? qu return null; } - public async Task InsertOrReplaceAsync(string rowKey, string partitionKey, object obj) + public async Task InsertOrReplaceAsync(string rowKey, string partitionKey, T obj) { - if (!_typeRegistry.ContainsKey(obj.GetType())) - { - throw new ArgumentOutOfRangeException($"No registered type found for {obj.GetType()}."); - } - string prefix = _typeRegistry[obj.GetType()]; - var properties = ObjectSerializer.Serialize(obj); - TableEntity tableEntity = new(properties) - { - RowKey = prefix + "_" + rowKey, - PartitionKey = partitionKey - }; - return await _tableClient.UpsertEntityAsync(tableEntity, TableUpdateMode.Replace); + return await UpsertAsync(rowKey, partitionKey, obj, TableUpdateMode.Replace); } - public async Task InsertOrMergeAsync(string rowKey, string partitionKey, object obj) + public async Task InsertOrMergeAsync(string rowKey, string partitionKey, T obj) { - if (!_typeRegistry.ContainsKey(obj.GetType())) - { - throw new ArgumentOutOfRangeException($"No registered type found for {obj.GetType()}."); - } - string prefix = _typeRegistry[obj.GetType()]; - - var properties = ObjectSerializer.Serialize(obj); - TableEntity tableEntity = new(properties) - { - RowKey = prefix + "_" + rowKey, - PartitionKey = partitionKey - }; - return await _tableClient.UpsertEntityAsync(tableEntity, TableUpdateMode.Merge); + return await UpsertAsync(rowKey, partitionKey, obj, TableUpdateMode.Merge); } public async Task DeleteEntityByTypeAsync(string rowKey, string partitionKey) { - if (!_typeRegistry.ContainsKey(typeof(T))) + if (!_typeRegistry.TryGetValue(typeof(T), out string? prefix)) { throw new ArgumentOutOfRangeException($"No registered type found for {typeof(T)}."); } - string prefix = _typeRegistry[typeof(T)]; return await _tableClient.DeleteEntityAsync(partitionKey, prefix + "_" + rowKey); } @@ -114,4 +91,22 @@ public async Task DeleteEntityAsync(string completeRowKey, string part { return await _tableClient.DeleteEntityAsync(partitionKey, completeRowKey); } -} \ No newline at end of file + + private async Task UpsertAsync(string rowKey, string partitionKey, T obj, TableUpdateMode updateMode) + { + ArgumentNullException.ThrowIfNull(obj); + Type entityType = obj.GetType(); + if (!_typeRegistry.TryGetValue(entityType, out string? prefix)) + { + throw new ArgumentOutOfRangeException($"No registered type found for {entityType}."); + } + + var properties = ObjectSerializer.Serialize(obj); + TableEntity tableEntity = new(properties) + { + RowKey = prefix + "_" + rowKey, + PartitionKey = partitionKey + }; + return await _tableClient.UpsertEntityAsync(tableEntity, updateMode); + } +} diff --git a/AzureTableUtils/TypedAzureTableClient.cs b/AzureTableUtils/TypedAzureTableClient.cs index 4c32bc5..73952eb 100644 --- a/AzureTableUtils/TypedAzureTableClient.cs +++ b/AzureTableUtils/TypedAzureTableClient.cs @@ -11,9 +11,12 @@ public TypedAzureTableClient(TableClient tableClient) _tableClient = tableClient; } - public TableClient GetTableClient() - { return _tableClient; } + public TableClient TableClient => _tableClient; + [Obsolete("Use TableClient instead")] + public TableClient GetTableClient() => TableClient; + + [Obsolete("Use GetAllAsync(string partitionKey) instead")] public async Task>> GetAllAsync() { return await GetAllByQueryAsync(null); @@ -52,9 +55,9 @@ public async Task>> GetAllByQueryAsync(string? query) return null; } - public async Task InsertOrReplaceAsync(string rowKey, string partitionKey, object obj) + public async Task InsertOrReplaceAsync(string rowKey, string partitionKey, T obj) { - var properties = ObjectSerializer.Serialize(obj); + var properties = ObjectSerializer.Serialize(obj!); TableEntity tableEntity = new(properties) { RowKey = rowKey, @@ -63,9 +66,9 @@ public async Task InsertOrReplaceAsync(string rowKey, string partition return await _tableClient.UpsertEntityAsync(tableEntity, TableUpdateMode.Replace); } - public async Task InsertOrMergeAsync(string rowKey, string partitionKey, object obj) + public async Task InsertOrMergeAsync(string rowKey, string partitionKey, T obj) { - var properties = ObjectSerializer.Serialize(obj); + var properties = ObjectSerializer.Serialize(obj!); TableEntity tableEntity = new(properties) { RowKey = rowKey, @@ -78,4 +81,4 @@ public async Task DeleteEntityAsync(string rowKey, string partitionKey { return await _tableClient.DeleteEntityAsync(partitionKey, rowKey); } -} \ No newline at end of file +} diff --git a/README.md b/README.md index 40d80ad..d5aaa75 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ await tableClient.CreateIfNotExistsAsync(); var typedTableClient = new TypedAzureTableClient(tableClient); ``` -Underlying SDK client: `typedTableClient.GetTableClient()`. +Underlying SDK client: `typedTableClient.TableClient`. Examples below use a client bound to `MyPoco`. @@ -181,7 +181,7 @@ List> pocos = await typedTableClient.GetAllByQueryAsyn OData filter string as supported by `TableClient.QueryAsync`. Pass `null` for an unfiltered query. -### InsertOrMergeAsync(string rowKey, string partitionKey, object obj) +### InsertOrMergeAsync(string rowKey, string partitionKey, T obj) ```csharp MyPoco poco = new MyPoco(); @@ -191,7 +191,7 @@ Azure.Response result = await typedTableClient.InsertOrMergeAsync("001", "Simple Upsert with `TableUpdateMode.Merge`. -### InsertOrReplaceAsync(string rowKey, string partitionKey, object obj) +### InsertOrReplaceAsync(string rowKey, string partitionKey, T obj) ```csharp MyPoco poco = new MyPoco(); @@ -231,7 +231,7 @@ multiEntityTableClient.RegisterType("mwp"); multiEntityTableClient.RegisterType(); ``` -Underlying SDK client: `multiEntityTableClient.GetTableClient()`. +Underlying SDK client: `multiEntityTableClient.TableClient`. Examples below assume `SimplePoco`, `MainWithParent`, and `PocoWithListChildren` are registered. @@ -265,7 +265,7 @@ List> allPocos = await multiEntityTableClient.GetAllBy List simplePocos = allPocos.Select(res => res.Entity).OfType().ToList(); ``` -### InsertOrMergeAsync(string rowKey, string partitionKey, object obj) +### InsertOrMergeAsync\(string rowKey, string partitionKey, T obj) ```csharp MyPoco poco = new MyPoco(); @@ -275,7 +275,7 @@ Azure.Response result = await multiEntityTableClient.InsertOrMergeAsync("001", " Stores row key as `{prefix}_001`. Type of `obj` must be registered. -### InsertOrReplaceAsync(string rowKey, string partitionKey, object obj) +### InsertOrReplaceAsync\(string rowKey, string partitionKey, T obj) ```csharp MyPoco poco = new MyPoco(); From 4879a36dd215a8d543e6f767cf3d536798f63ffa Mon Sep 17 00:00:00 2001 From: Fabricio Ruch Date: Mon, 3 Aug 2026 14:03:11 +0200 Subject: [PATCH 8/9] Document client behavior and finish API consistency polish. Add XML docs for typed and multi-entity clients, keep object upserts for partial merges, obsolete GetTableClient on both clients, bump package deps, and align README/launch settings with net10. Co-authored-by: Cursor --- .vscode/launch.json | 2 +- .../Models/PocoWithListChildren.cs | 2 +- AzureTableUtils/AzureTableUtils.csproj | 6 +- AzureTableUtils/EntityMapping.cs | 12 +- .../ExtendedAzureTableClientService.cs | 4 +- .../MultiEntityAzureTableClient.cs | 116 +++++++++++++++++- AzureTableUtils/ODataFilter.cs | 11 +- AzureTableUtils/ObjectBuilder.cs | 12 +- AzureTableUtils/TypedAzureTableClient.cs | 94 ++++++++++++-- README.md | 10 +- 10 files changed, 237 insertions(+), 32 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 421bc5a..b3608ca 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,7 +9,7 @@ "type": "coreclr", "request": "launch", "preLaunchTask": "build", - "program": "${workspaceFolder}/AzureTableUtils.Tests/bin/Debug/net8.0/AzureTableUtils.Tests.dll", + "program": "${workspaceFolder}/AzureTableUtils.Tests/bin/Debug/net10.0/AzureTableUtils.Tests.dll", "args": [], "cwd": "${workspaceFolder}/AzureTableUtils.Tests", "console": "internalConsole", diff --git a/AzureTableUtils.Tests/Models/PocoWithListChildren.cs b/AzureTableUtils.Tests/Models/PocoWithListChildren.cs index 4be3b78..31d1720 100644 --- a/AzureTableUtils.Tests/Models/PocoWithListChildren.cs +++ b/AzureTableUtils.Tests/Models/PocoWithListChildren.cs @@ -11,7 +11,7 @@ public static PocoWihtListChildren CreateInitializdedPWLC() PocoWihtListChildren pwlc = new PocoWihtListChildren { Id = "0178301", - Children = new List() + Children = [] }; for (int i = 0; i < 3; i++) { diff --git a/AzureTableUtils/AzureTableUtils.csproj b/AzureTableUtils/AzureTableUtils.csproj index 1c4841a..c02c888 100644 --- a/AzureTableUtils/AzureTableUtils.csproj +++ b/AzureTableUtils/AzureTableUtils.csproj @@ -4,6 +4,8 @@ net10.0 enable enable + true + $(NoWarn);CS1591 WebGate.Azure.TableUtils @@ -24,8 +26,8 @@ - - + + diff --git a/AzureTableUtils/EntityMapping.cs b/AzureTableUtils/EntityMapping.cs index 3aefa78..5376209 100644 --- a/AzureTableUtils/EntityMapping.cs +++ b/AzureTableUtils/EntityMapping.cs @@ -7,12 +7,16 @@ internal static class EntityMapping { private static readonly ConcurrentDictionary PropertyCache = new(); - internal static PropertyInfo[] GetWritableProperties(Type type) => - PropertyCache.GetOrAdd(type, static t => + internal static PropertyInfo[] GetWritableProperties(Type type) + { + return PropertyCache.GetOrAdd(type, static t => t.GetProperties().Where(p => p.CanRead && p.CanWrite).ToArray()); + } - internal static bool IsPrimitiveTableType(Type type) => - type.IsValueType || type == typeof(string) || type == typeof(byte[]); + internal static bool IsPrimitiveTableType(Type type) + { + return type.IsValueType || type == typeof(string) || type == typeof(byte[]); + } internal static string BuildEntityName(string? path, string id) { diff --git a/AzureTableUtils/ExtendedAzureTableClientService.cs b/AzureTableUtils/ExtendedAzureTableClientService.cs index a53e703..e9fcd98 100644 --- a/AzureTableUtils/ExtendedAzureTableClientService.cs +++ b/AzureTableUtils/ExtendedAzureTableClientService.cs @@ -3,8 +3,8 @@ namespace WebGate.Azure.TableUtils; public class ExtendedAzureTableClientService(string connectionString) { private readonly string _connectionString = connectionString; - private Dictionary _tableClients = new(); - private Dictionary _meTableClients = new(); + private readonly Dictionary _tableClients = []; + private readonly Dictionary _meTableClients = []; public TypedAzureTableClient CreateAndRegisterTableClient(string tableName) { diff --git a/AzureTableUtils/MultiEntityAzureTableClient.cs b/AzureTableUtils/MultiEntityAzureTableClient.cs index c2668f6..6f51bf3 100644 --- a/AzureTableUtils/MultiEntityAzureTableClient.cs +++ b/AzureTableUtils/MultiEntityAzureTableClient.cs @@ -2,45 +2,110 @@ namespace WebGate.Azure.TableUtils; +/// +/// CRUD wrapper around that stores multiple POCO types in one table. +/// Each registered type gets a row-key prefix ({prefix}_{rowKey}). +/// +/// +/// Register every type with or before insert or typed get/delete. +/// On read, the prefix of each row key must match a registered type; otherwise an exception is thrown. +/// Mapping of nested objects, enums, decimals, arrays, and enumerables matches . +/// public class MultiEntityAzureTableClient { private readonly TableClient _tableClient; - private readonly Dictionary _typeRegistry = new(); + private readonly Dictionary _typeRegistry = []; + /// + /// Creates a client bound to the given Azure Tables . + /// + /// Underlying SDK client; must not be . public MultiEntityAzureTableClient(TableClient tableClient) { _tableClient = tableClient; } + /// + /// Gets the underlying Azure Tables SDK client. + /// public TableClient TableClient => _tableClient; - public TableClient GetTableClient() => TableClient; + /// + /// Gets the underlying Azure Tables SDK client. + /// + /// The same instance as . + [Obsolete("Use TableClient instead")] + public TableClient GetTableClient() + { + return TableClient; + } + /// + /// Registers using typeof(T).Name as row-key prefix. + /// + /// Entity type to store in this table. + /// Thrown if is already registered. public void RegisterType() { RegisterType(typeof(T).Name); } + /// + /// Registers with a custom row-key prefix. + /// + /// Entity type to store in this table. + /// + /// Prefix written before the caller row key ({prefix}_{rowKey}). + /// Avoid prefixes that are prefixes of each other (e.g. A and AB). + /// + /// Thrown if is already registered. public void RegisterType(string typePrefix) { _typeRegistry.Add(typeof(T), typePrefix); } + /// + /// Returns all entities in the table, each deserialized to its registered CLR type + /// and exposed as of type . + /// + /// All rows; empty list if the table has no entities. + /// + /// Thrown if a row key does not start with any registered prefix. + /// public async Task>> GetAllAsync() { return await GetAllByQueryAsync(null); } + /// + /// Returns all entities with the given partition key. + /// + /// Partition key filter. Single quotes are OData-escaped. + /// Matching rows; empty list if none match. + /// + /// Thrown if a row key does not start with any registered prefix. + /// public async Task>> GetAllAsync(string partitionKey) { return await GetAllByQueryAsync(ODataFilter.PartitionKeyEquals(partitionKey)); } + /// + /// Returns entities matching an OData filter. + /// + /// + /// OData filter as accepted by . + /// Pass to return all entities. + /// + /// Matching rows; empty list if none match. + /// + /// Thrown if a row key does not start with any registered prefix. + /// public async Task>> GetAllByQueryAsync(string? query) { AsyncPageable resultItems = _tableClient.QueryAsync(query); - List> items = new(); + List> items = []; await foreach (var item in resultItems) { Type? entityType = _typeRegistry.Where(kvp => item.RowKey.StartsWith(kvp.Value + "_")).Select(kvp => kvp.Key).FirstOrDefault(); @@ -53,6 +118,15 @@ public async Task>> GetAllByQueryAsync(string? qu return items; } + /// + /// Gets a single entity of type by logical row key and partition key. + /// The stored row key is {registeredPrefix}_{rowKey}. + /// + /// Registered entity type. + /// Logical row key without prefix. + /// Partition key. + /// The entity, or if it does not exist. + /// Thrown if is not registered. public async Task?> GetByIdAsync(string rowKey, string partitionKey) { if (!_typeRegistry.TryGetValue(typeof(T), out string? prefix)) @@ -68,16 +142,46 @@ public async Task>> GetAllByQueryAsync(string? qu return null; } + /// + /// Inserts or replaces the entity (upsert with ). + /// Stores row key as {prefix}_{rowKey} using the prefix registered for the runtime type of . + /// + /// Compile-time type of ; registration uses obj.GetType(). + /// Logical row key without prefix. + /// Partition key. + /// Entity to serialize; must not be and must be registered. + /// The Azure Tables response. + /// Thrown if is . + /// Thrown if the runtime type of is not registered. public async Task InsertOrReplaceAsync(string rowKey, string partitionKey, T obj) { return await UpsertAsync(rowKey, partitionKey, obj, TableUpdateMode.Replace); } + /// + /// Inserts or merges the entity (upsert with ). + /// Stores row key as {prefix}_{rowKey} using the prefix registered for the runtime type of . + /// + /// Compile-time type of ; registration uses obj.GetType(). + /// Logical row key without prefix. + /// Partition key. + /// Entity to serialize; must not be and must be registered. + /// The Azure Tables response. + /// Thrown if is . + /// Thrown if the runtime type of is not registered. public async Task InsertOrMergeAsync(string rowKey, string partitionKey, T obj) { return await UpsertAsync(rowKey, partitionKey, obj, TableUpdateMode.Merge); } + /// + /// Deletes an entity by logical row key, building the stored key as {prefix}_{rowKey} for . + /// + /// Registered entity type. + /// Logical row key without prefix. + /// Partition key. + /// The Azure Tables response. + /// Thrown if is not registered. public async Task DeleteEntityByTypeAsync(string rowKey, string partitionKey) { if (!_typeRegistry.TryGetValue(typeof(T), out string? prefix)) @@ -87,6 +191,12 @@ public async Task DeleteEntityByTypeAsync(string rowKey, string par return await _tableClient.DeleteEntityAsync(partitionKey, prefix + "_" + rowKey); } + /// + /// Deletes an entity by the full stored row key (including type prefix). + /// + /// Full row key as stored in the table (e.g. from ). + /// Partition key. + /// The Azure Tables response. public async Task DeleteEntityAsync(string completeRowKey, string partitionKey) { return await _tableClient.DeleteEntityAsync(partitionKey, completeRowKey); diff --git a/AzureTableUtils/ODataFilter.cs b/AzureTableUtils/ODataFilter.cs index 8d9bcd7..19856a8 100644 --- a/AzureTableUtils/ODataFilter.cs +++ b/AzureTableUtils/ODataFilter.cs @@ -2,8 +2,13 @@ namespace WebGate.Azure.TableUtils; internal static class ODataFilter { - internal static string EscapeString(string value) => value.Replace("'", "''"); + internal static string EscapeString(string value) + { + return value.Replace("'", "''"); + } - internal static string PartitionKeyEquals(string partitionKey) => - $"PartitionKey eq '{EscapeString(partitionKey)}'"; + internal static string PartitionKeyEquals(string partitionKey) + { + return $"PartitionKey eq '{EscapeString(partitionKey)}'"; + } } diff --git a/AzureTableUtils/ObjectBuilder.cs b/AzureTableUtils/ObjectBuilder.cs index 0939104..08b3ec6 100644 --- a/AzureTableUtils/ObjectBuilder.cs +++ b/AzureTableUtils/ObjectBuilder.cs @@ -61,9 +61,13 @@ private static void ProcessObject(object obj, string? path, TableEntity tableEnt } } - private static bool HasChildObjectInformation(string id, TableEntity tableEntity) => - tableEntity.Any(p => p.Key.StartsWith(id + "_", StringComparison.Ordinal) && p.Value != null); + private static bool HasChildObjectInformation(string id, TableEntity tableEntity) + { + return tableEntity.Any(p => p.Key.StartsWith(id + "_", StringComparison.Ordinal) && p.Value != null); + } - private static bool IsDateTime(Type pType) => - pType == typeof(DateTime) || Nullable.GetUnderlyingType(pType) == typeof(DateTime); + private static bool IsDateTime(Type pType) + { + return pType == typeof(DateTime) || Nullable.GetUnderlyingType(pType) == typeof(DateTime); + } } diff --git a/AzureTableUtils/TypedAzureTableClient.cs b/AzureTableUtils/TypedAzureTableClient.cs index 73952eb..495a71b 100644 --- a/AzureTableUtils/TypedAzureTableClient.cs +++ b/AzureTableUtils/TypedAzureTableClient.cs @@ -2,36 +2,75 @@ namespace WebGate.Azure.TableUtils; +/// +/// Typed CRUD wrapper around for a single POCO type . +/// Serializes nested objects, enums, decimals, arrays, and enumerables to flattened table properties. +/// +/// Entity type stored in the bound table. +/// +/// Create via or pass an existing . +/// Row key and partition key are always supplied by the caller (except ). +/// public class TypedAzureTableClient { private readonly TableClient _tableClient; + /// + /// Creates a client bound to the given Azure Tables . + /// + /// Underlying SDK client; must not be . public TypedAzureTableClient(TableClient tableClient) { _tableClient = tableClient; } + /// + /// Gets the underlying Azure Tables SDK client. + /// public TableClient TableClient => _tableClient; + /// + /// Gets the underlying Azure Tables SDK client. + /// + /// The same instance as . [Obsolete("Use TableClient instead")] - public TableClient GetTableClient() => TableClient; + public TableClient GetTableClient() + { + return TableClient; + } - [Obsolete("Use GetAllAsync(string partitionKey) instead")] + /// + /// Returns all entities in the table, deserialized as . + /// + /// All matching rows; empty list if the table has no entities. public async Task>> GetAllAsync() { return await GetAllByQueryAsync(null); } + /// + /// Returns all entities with the given partition key, deserialized as . + /// + /// Partition key filter. Single quotes are OData-escaped. + /// Matching rows; empty list if none match. public async Task>> GetAllAsync(string partitionKey) { return await GetAllByQueryAsync(ODataFilter.PartitionKeyEquals(partitionKey)); } + /// + /// Returns entities matching an OData filter, deserialized as . + /// + /// + /// OData filter as accepted by . + /// Pass to return all entities. + /// + /// Matching rows; empty list if none match. public async Task>> GetAllByQueryAsync(string? query) { AsyncPageable resultItems = _tableClient.QueryAsync(query); - List> items = new(); + List> items = []; await foreach (var item in resultItems) { items.Add(TableEntityResult.BuildTableEntityResult(item)); @@ -39,12 +78,27 @@ public async Task>> GetAllByQueryAsync(string? query) return items; } + /// + /// Gets a single entity by row key using typeof(T).ToString() as partition key + /// (typically the full type name, e.g. MyNamespace.MyPoco). + /// + /// Row key. + /// The entity, or if it does not exist. + /// + /// Prefer when you control the partition key explicitly. + /// public async Task?> GetByIdAsync(string id) { string partitionKey = typeof(T).ToString(); return await GetByIdAsync(id, partitionKey); } + /// + /// Gets a single entity by row key and partition key. + /// + /// Row key. + /// Partition key. + /// The entity, or if it does not exist. public async Task?> GetByIdAsync(string rowKey, string partitionKey) { NullableResponse tableEntity = await _tableClient.GetEntityIfExistsAsync(partitionKey, rowKey); @@ -55,9 +109,19 @@ public async Task>> GetAllByQueryAsync(string? query) return null; } - public async Task InsertOrReplaceAsync(string rowKey, string partitionKey, T obj) + /// + /// Inserts or replaces the entity (upsert with ). + /// + /// Row key to store. + /// Partition key to store. + /// + /// Object to serialize. Usually an instance of , but may be another shape + /// (e.g. a partial DTO). Existing properties not present on are removed on replace. + /// + /// The Azure Tables response. + public async Task InsertOrReplaceAsync(string rowKey, string partitionKey, object obj) { - var properties = ObjectSerializer.Serialize(obj!); + var properties = ObjectSerializer.Serialize(obj); TableEntity tableEntity = new(properties) { RowKey = rowKey, @@ -66,9 +130,19 @@ public async Task InsertOrReplaceAsync(string rowKey, string partition return await _tableClient.UpsertEntityAsync(tableEntity, TableUpdateMode.Replace); } - public async Task InsertOrMergeAsync(string rowKey, string partitionKey, T obj) + /// + /// Inserts or merges the entity (upsert with ). + /// + /// Row key to store. + /// Partition key to store. + /// + /// Object to serialize. May be a partial DTO (not necessarily ); + /// only serialized non-null properties are written; other existing columns are kept. + /// + /// The Azure Tables response. + public async Task InsertOrMergeAsync(string rowKey, string partitionKey, object obj) { - var properties = ObjectSerializer.Serialize(obj!); + var properties = ObjectSerializer.Serialize(obj); TableEntity tableEntity = new(properties) { RowKey = rowKey, @@ -77,6 +151,12 @@ public async Task InsertOrMergeAsync(string rowKey, string partitionKe return await _tableClient.UpsertEntityAsync(tableEntity, TableUpdateMode.Merge); } + /// + /// Deletes the entity identified by row key and partition key. + /// + /// Row key. + /// Partition key. + /// The Azure Tables response. public async Task DeleteEntityAsync(string rowKey, string partitionKey) { return await _tableClient.DeleteEntityAsync(partitionKey, rowKey); diff --git a/README.md b/README.md index d5aaa75..211adfa 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ await tableClient.CreateIfNotExistsAsync(); var typedTableClient = new TypedAzureTableClient(tableClient); ``` -Underlying SDK client: `typedTableClient.TableClient`. +Underlying SDK client: `typedTableClient.TableClient` (`GetTableClient()` is obsolete). Examples below use a client bound to `MyPoco`. @@ -181,7 +181,7 @@ List> pocos = await typedTableClient.GetAllByQueryAsyn OData filter string as supported by `TableClient.QueryAsync`. Pass `null` for an unfiltered query. -### InsertOrMergeAsync(string rowKey, string partitionKey, T obj) +### InsertOrMergeAsync(string rowKey, string partitionKey, object obj) ```csharp MyPoco poco = new MyPoco(); @@ -189,9 +189,9 @@ MyPoco poco = new MyPoco(); Azure.Response result = await typedTableClient.InsertOrMergeAsync("001", "SimplePoco", poco); ``` -Upsert with `TableUpdateMode.Merge`. +Upsert with `TableUpdateMode.Merge`. The parameter is `object` so partial DTOs (not necessarily `T`) can be merged. -### InsertOrReplaceAsync(string rowKey, string partitionKey, T obj) +### InsertOrReplaceAsync(string rowKey, string partitionKey, object obj) ```csharp MyPoco poco = new MyPoco(); @@ -231,7 +231,7 @@ multiEntityTableClient.RegisterType("mwp"); multiEntityTableClient.RegisterType(); ``` -Underlying SDK client: `multiEntityTableClient.TableClient`. +Underlying SDK client: `multiEntityTableClient.TableClient` (`GetTableClient()` is obsolete). Examples below assume `SimplePoco`, `MainWithParent`, and `PocoWithListChildren` are registered. From a563a4adbc3c82527c6a02a9b564a3c9e96cc07f Mon Sep 17 00:00:00 2001 From: Fabricio Ruch Date: Mon, 3 Aug 2026 14:27:04 +0200 Subject: [PATCH 9/9] Mark thin SDK wrappers obsolete and document delete parameter order. Keep GetAllByQueryAsync on MultiEntity for type-registry mapping; force migration off reversed DeleteEntityAsync args via Obsolete(error: true). Co-authored-by: Cursor --- .../CRUDIntegrationTest.cs | 10 +-- .../LargeDatasetIntegrationTest.cs | 4 +- .../MultiEntityIntegrationTest.cs | 8 +-- .../MultiEntityAzureTableClient.cs | 70 ++++++++++++++----- AzureTableUtils/TypedAzureTableClient.cs | 59 +++++++++++++--- README.md | 45 ++++++++---- 6 files changed, 144 insertions(+), 52 deletions(-) diff --git a/AzureTableUtils.IntegrationTests/CRUDIntegrationTest.cs b/AzureTableUtils.IntegrationTests/CRUDIntegrationTest.cs index 1c18322..151f7c4 100644 --- a/AzureTableUtils.IntegrationTests/CRUDIntegrationTest.cs +++ b/AzureTableUtils.IntegrationTests/CRUDIntegrationTest.cs @@ -32,7 +32,7 @@ public async Task TestSimplePocoCreateReadDeleted() var tableEntityRespose = await typedTableClient.GetByIdAsync(id, "poco"); Assert.IsNotNull(tableEntityRespose); Assert.AreEqual(poco, tableEntityRespose.Entity); - var deleteResponse = await typedTableClient.DeleteEntityAsync(id, "poco"); + var deleteResponse = await typedTableClient.TableClient.DeleteEntityAsync("poco", id); Assert.AreEqual(204, deleteResponse.Status); } @@ -54,7 +54,7 @@ public async Task TestSimplePocoUpdateAsMerge() Assert.IsNotNull(tableEntityRespose); poco.EnumValue = SP.VALID; Assert.AreEqual(poco, tableEntityRespose.Entity); - var deleteResponse = await typedTableClient.DeleteEntityAsync(id, "poco"); + var deleteResponse = await typedTableClient.TableClient.DeleteEntityAsync("poco", id); Assert.AreEqual(204, deleteResponse.Status); } @@ -77,7 +77,7 @@ public async Task TestSimplePocoUpdateAsReplace() var tableEntityRespose = await typedTableClient.GetByIdAsync(id, "poco"); Assert.IsNotNull(tableEntityRespose); Assert.AreEqual(pocoUpdate, tableEntityRespose.Entity); - var deleteResponse = await typedTableClient.DeleteEntityAsync(id, "poco"); + var deleteResponse = await typedTableClient.TableClient.DeleteEntityAsync("poco", id); Assert.AreEqual(204, deleteResponse.Status); } @@ -94,7 +94,7 @@ public async Task TestCascadedPocoCreateReadDeleted() var tableEntityRespose = await typedTableClient.GetByIdAsync(id, "poco"); Assert.IsNotNull(tableEntityRespose); Assert.AreEqual(poco, tableEntityRespose.Entity); - var deleteResponse = await typedTableClient.DeleteEntityAsync(id, "poco"); + var deleteResponse = await typedTableClient.TableClient.DeleteEntityAsync("poco", id); Assert.AreEqual(204, deleteResponse.Status); } @@ -112,7 +112,7 @@ private async Task TableCleanup(string tableName) var allPocoResult = await typedTableClient.GetAllAsync(); foreach (var result in allPocoResult) { - await tableClient.DeleteEntityAsync(result.RowKey, result.PartitionKey); + await tableClient.DeleteEntityAsync(result.PartitionKey, result.RowKey); } } } \ No newline at end of file diff --git a/AzureTableUtils.IntegrationTests/LargeDatasetIntegrationTest.cs b/AzureTableUtils.IntegrationTests/LargeDatasetIntegrationTest.cs index 50c8f28..b74c8f8 100644 --- a/AzureTableUtils.IntegrationTests/LargeDatasetIntegrationTest.cs +++ b/AzureTableUtils.IntegrationTests/LargeDatasetIntegrationTest.cs @@ -43,7 +43,7 @@ public async Task TestSimplePocoCreateRead1500EntriesDeleted() Assert.AreEqual(1500, allPocoResult.Count); foreach (var result in allPocoResult) { - await typedTableClient.DeleteEntityAsync(result.RowKey, result.PartitionKey); + await typedTableClient.TableClient.DeleteEntityAsync(result.PartitionKey, result.RowKey); } var allPocoResult2 = await typedTableClient.GetAllAsync(partitionId); Assert.IsNotNull(allPocoResult2); @@ -58,7 +58,7 @@ public async Task TestCleanup() var allPocoResult = await typedTableClient.GetAllAsync(); foreach (var result in allPocoResult) { - await typedTableClient.DeleteEntityAsync(result.RowKey, result.PartitionKey); + await typedTableClient.TableClient.DeleteEntityAsync(result.PartitionKey, result.RowKey); } } } \ No newline at end of file diff --git a/AzureTableUtils.IntegrationTests/MultiEntityIntegrationTest.cs b/AzureTableUtils.IntegrationTests/MultiEntityIntegrationTest.cs index 25d0c8e..2340a5f 100644 --- a/AzureTableUtils.IntegrationTests/MultiEntityIntegrationTest.cs +++ b/AzureTableUtils.IntegrationTests/MultiEntityIntegrationTest.cs @@ -39,7 +39,7 @@ public async Task TestMEClientCreateReadDeleted() Assert.AreEqual(13, allPocoResult.Count); foreach (var resultPoco in allPocoResult) { - await meTableClient.DeleteEntityAsync(resultPoco.RowKey, resultPoco.PartitionKey); + await meTableClient.TableClient.DeleteEntityAsync(resultPoco.PartitionKey, resultPoco.RowKey); } var allPocoResult2 = await meTableClient.GetAllAsync(partitionId); Assert.IsNotNull(allPocoResult2); @@ -64,7 +64,7 @@ public async Task TestMEClientTypeExtraction() Assert.AreEqual(7, mainWithParents.Count); foreach (var resultPoco in allPocoResult) { - await meTableClient.DeleteEntityAsync(resultPoco.RowKey, resultPoco.PartitionKey); + await meTableClient.TableClient.DeleteEntityAsync(resultPoco.PartitionKey, resultPoco.RowKey); } var allPocoResult2 = await meTableClient.GetAllAsync(partitionId); Assert.IsNotNull(allPocoResult2); @@ -89,7 +89,7 @@ public async Task TestMEClientTypeInfomationAndRowKey() Assert.AreEqual(7, mainWithParents.Count(x => x.RowKey.StartsWith("mwp_"))); foreach (var resultPoco in allPocoResult) { - await meTableClient.DeleteEntityAsync(resultPoco.RowKey, resultPoco.PartitionKey); + await meTableClient.TableClient.DeleteEntityAsync(resultPoco.PartitionKey, resultPoco.RowKey); } var allPocoResult2 = await meTableClient.GetAllAsync(partitionId); Assert.IsNotNull(allPocoResult2); @@ -131,7 +131,7 @@ public async Task TestCleanup() var allPocoResult = await meTableClient.GetAllAsync(); foreach (var result in allPocoResult) { - await meTableClient.DeleteEntityAsync(result.RowKey, result.PartitionKey); + await meTableClient.TableClient.DeleteEntityAsync(result.PartitionKey, result.RowKey); } } diff --git a/AzureTableUtils/MultiEntityAzureTableClient.cs b/AzureTableUtils/MultiEntityAzureTableClient.cs index 6f51bf3..e48a08e 100644 --- a/AzureTableUtils/MultiEntityAzureTableClient.cs +++ b/AzureTableUtils/MultiEntityAzureTableClient.cs @@ -7,9 +7,17 @@ namespace WebGate.Azure.TableUtils; /// Each registered type gets a row-key prefix ({prefix}_{rowKey}). /// /// -/// Register every type with or before insert or typed get/delete. +/// +/// Preferred surface in 10.x: , upserts, , +/// / / , +/// and . +/// Use for raw deletes by full row key and advanced SDK operations. +/// +/// +/// Register every type before insert or typed get/delete. /// On read, the prefix of each row key must match a registered type; otherwise an exception is thrown. /// Mapping of nested objects, enums, decimals, arrays, and enumerables matches . +/// /// public class MultiEntityAzureTableClient { @@ -34,7 +42,7 @@ public MultiEntityAzureTableClient(TableClient tableClient) /// Gets the underlying Azure Tables SDK client. /// /// The same instance as . - [Obsolete("Use TableClient instead")] + [Obsolete("Use TableClient instead.")] public TableClient GetTableClient() { return TableClient; @@ -74,7 +82,7 @@ public void RegisterType(string typePrefix) /// public async Task>> GetAllAsync() { - return await GetAllByQueryAsync(null); + return await QueryAndMapAsync(null); } /// @@ -87,7 +95,7 @@ public async Task>> GetAllAsync() /// public async Task>> GetAllAsync(string partitionKey) { - return await GetAllByQueryAsync(ODataFilter.PartitionKeyEquals(partitionKey)); + return await QueryAndMapAsync(ODataFilter.PartitionKeyEquals(partitionKey)); } /// @@ -103,19 +111,7 @@ public async Task>> GetAllAsync(string partitionK /// public async Task>> GetAllByQueryAsync(string? query) { - AsyncPageable resultItems = _tableClient.QueryAsync(query); - - List> items = []; - await foreach (var item in resultItems) - { - Type? entityType = _typeRegistry.Where(kvp => item.RowKey.StartsWith(kvp.Value + "_")).Select(kvp => kvp.Key).FirstOrDefault(); - if (entityType == null) - { - throw new ArgumentOutOfRangeException($"No registered type found for Tableentry with ID {item.RowKey}."); - } - items.Add(TableEntityResult.BuildTableEntityResultWithType(entityType, item)); - } - return items; + return await QueryAndMapAsync(query); } /// @@ -197,11 +193,51 @@ public async Task DeleteEntityByTypeAsync(string rowKey, string par /// Full row key as stored in the table (e.g. from ). /// Partition key. /// The Azure Tables response. + /// + /// + /// Obsolete. Migrate to TableClient.DeleteEntityAsync(partitionKey, rowKey). + /// + /// + /// Parameter order differs: this method is (rowKey, partitionKey); + /// expects (partitionKey, rowKey). + /// + /// + /// // old (this API): + /// await client.DeleteEntityAsync(result.RowKey, result.PartitionKey); + /// // new (SDK): + /// await client.TableClient.DeleteEntityAsync(result.PartitionKey, result.RowKey); + /// + /// + [Obsolete( + "Use TableClient.DeleteEntityAsync(partitionKey, rowKey). " + + "Parameter order is reversed: this method is (rowKey, partitionKey), the SDK is (partitionKey, rowKey).", + error: true)] public async Task DeleteEntityAsync(string completeRowKey, string partitionKey) { return await _tableClient.DeleteEntityAsync(partitionKey, completeRowKey); } + private async Task>> QueryAndMapAsync(string? query) + { + AsyncPageable resultItems = _tableClient.QueryAsync(query); + + List> items = []; + await foreach (var item in resultItems) + { + Type? entityType = _typeRegistry + .Where(registration => item.RowKey.StartsWith(registration.Value + "_")) + .Select(registration => registration.Key) + .FirstOrDefault(); + + if (entityType == null) + { + throw new ArgumentOutOfRangeException($"No registered type found for Tableentry with ID {item.RowKey}."); + } + items.Add(TableEntityResult.BuildTableEntityResultWithType(entityType, item)); + } + return items; + } + private async Task UpsertAsync(string rowKey, string partitionKey, T obj, TableUpdateMode updateMode) { ArgumentNullException.ThrowIfNull(obj); diff --git a/AzureTableUtils/TypedAzureTableClient.cs b/AzureTableUtils/TypedAzureTableClient.cs index 495a71b..110d547 100644 --- a/AzureTableUtils/TypedAzureTableClient.cs +++ b/AzureTableUtils/TypedAzureTableClient.cs @@ -8,8 +8,15 @@ namespace WebGate.Azure.TableUtils; /// /// Entity type stored in the bound table. /// +/// +/// Preferred surface in 10.x: , , +/// , / . +/// Use for deletes and advanced SDK operations. +/// +/// /// Create via or pass an existing . /// Row key and partition key are always supplied by the caller (except ). +/// /// public class TypedAzureTableClient { @@ -33,7 +40,7 @@ public TypedAzureTableClient(TableClient tableClient) /// Gets the underlying Azure Tables SDK client. /// /// The same instance as . - [Obsolete("Use TableClient instead")] + [Obsolete("Use TableClient instead.")] public TableClient GetTableClient() { return TableClient; @@ -45,7 +52,7 @@ public TableClient GetTableClient() /// All matching rows; empty list if the table has no entities. public async Task>> GetAllAsync() { - return await GetAllByQueryAsync(null); + return await QueryAndMapAsync(null); } /// @@ -55,7 +62,7 @@ public async Task>> GetAllAsync() /// Matching rows; empty list if none match. public async Task>> GetAllAsync(string partitionKey) { - return await GetAllByQueryAsync(ODataFilter.PartitionKeyEquals(partitionKey)); + return await QueryAndMapAsync(ODataFilter.PartitionKeyEquals(partitionKey)); } /// @@ -66,16 +73,15 @@ public async Task>> GetAllAsync(string partitionKey) /// Pass to return all entities. /// /// Matching rows; empty list if none match. + /// + /// Prefer / . + /// For custom filters, query via and map with + /// . + /// + [Obsolete("Prefer GetAllAsync / GetAllAsync(partitionKey). For custom filters use TableClient.QueryAsync and TableEntityResult.BuildTableEntityResult.")] public async Task>> GetAllByQueryAsync(string? query) { - AsyncPageable resultItems = _tableClient.QueryAsync(query); - - List> items = []; - await foreach (var item in resultItems) - { - items.Add(TableEntityResult.BuildTableEntityResult(item)); - } - return items; + return await QueryAndMapAsync(query); } /// @@ -157,8 +163,39 @@ public async Task InsertOrMergeAsync(string rowKey, string partitionKe /// Row key. /// Partition key. /// The Azure Tables response. + /// + /// + /// Obsolete. Migrate to TableClient.DeleteEntityAsync(partitionKey, rowKey). + /// + /// + /// Parameter order differs: this method is (rowKey, partitionKey); + /// expects (partitionKey, rowKey). + /// + /// + /// // old (this API): + /// await client.DeleteEntityAsync(rowKey, partitionKey); + /// // new (SDK): + /// await client.TableClient.DeleteEntityAsync(partitionKey, rowKey); + /// + /// + [Obsolete( + "Use TableClient.DeleteEntityAsync(partitionKey, rowKey). " + + "Parameter order is reversed: this method is (rowKey, partitionKey), the SDK is (partitionKey, rowKey).", + error: true)] public async Task DeleteEntityAsync(string rowKey, string partitionKey) { return await _tableClient.DeleteEntityAsync(partitionKey, rowKey); } + + private async Task>> QueryAndMapAsync(string? query) + { + AsyncPageable resultItems = _tableClient.QueryAsync(query); + + List> items = []; + await foreach (var item in resultItems) + { + items.Add(TableEntityResult.BuildTableEntityResult(item)); + } + return items; + } } diff --git a/README.md b/README.md index 211adfa..5f804dd 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,8 @@ var typedTableClient = new TypedAzureTableClient(tableClient); Underlying SDK client: `typedTableClient.TableClient` (`GetTableClient()` is obsolete). +Preferred 10.x surface: **upserts + gets** (serialize/deserialize). Use `TableClient` for deletes and other raw SDK calls. + Examples below use a client bound to `MyPoco`. ### GetAllAsync() @@ -172,14 +174,9 @@ TableEntityResult? poco = await typedTableClient.GetByIdAsync("9201u819" Returns `null` if not found. -### GetAllByQueryAsync(string? query) +### GetAllByQueryAsync(string? query) — obsolete -```csharp -var query = $"PartitionKey eq '{partitionKey}'"; -List> pocos = await typedTableClient.GetAllByQueryAsync(query); -``` - -OData filter string as supported by `TableClient.QueryAsync`. Pass `null` for an unfiltered query. +Prefer `GetAllAsync` / `GetAllAsync(partitionKey)`. For custom OData filters, query with `TableClient.QueryAsync` and map via `TableEntityResult.BuildTableEntityResult(…)`. ### InsertOrMergeAsync(string rowKey, string partitionKey, object obj) @@ -201,10 +198,20 @@ Azure.Response result = await typedTableClient.InsertOrReplaceAsync("001", "Simp Upsert with `TableUpdateMode.Replace`. -### DeleteEntityAsync(string rowKey, string partitionKey) +### DeleteEntityAsync — obsolete (compile error) + +Parameter order is **reversed** vs the Azure SDK: + +| | 1st arg | 2nd arg | +|---|---|---| +| This library (obsolete) | `rowKey` | `partitionKey` | +| `TableClient.DeleteEntityAsync` | `partitionKey` | `rowKey` | ```csharp -Azure.Response result = await typedTableClient.DeleteEntityAsync("001", "SimplePoco"); +// old: +await typedTableClient.DeleteEntityAsync("001", "SimplePoco"); +// new: +await typedTableClient.TableClient.DeleteEntityAsync("SimplePoco", "001"); ``` --- @@ -233,6 +240,8 @@ multiEntityTableClient.RegisterType(); Underlying SDK client: `multiEntityTableClient.TableClient` (`GetTableClient()` is obsolete). +Preferred 10.x surface: **registry + upserts + gets** (+ `DeleteEntityByTypeAsync` for prefix-aware delete). Raw delete by full row key: `TableClient.DeleteEntityAsync`. + Examples below assume `SimplePoco`, `MainWithParent`, and `PocoWithListChildren` are registered. ### GetAllAsync() @@ -265,6 +274,8 @@ List> allPocos = await multiEntityTableClient.GetAllBy List simplePocos = allPocos.Select(res => res.Entity).OfType().ToList(); ``` +OData filter as supported by `TableClient.QueryAsync`. Pass `null` for an unfiltered query. Needed here so row keys are still resolved via the type registry. + ### InsertOrMergeAsync\(string rowKey, string partitionKey, T obj) ```csharp @@ -291,14 +302,22 @@ Azure.Response result = await multiEntityTableClient.DeleteEntityByTypeAsync