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/.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.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.IntegrationTests/CRUDIntegrationTest.cs b/AzureTableUtils.IntegrationTests/CRUDIntegrationTest.cs index 78964d7..151f7c4 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] @@ -34,9 +32,10 @@ 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); } + [TestMethod] public async Task TestSimplePocoUpdateAsMerge() { @@ -55,9 +54,10 @@ 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); } + [TestMethod] public async Task TestSimplePocoUpdateAsReplace() { @@ -77,9 +77,10 @@ 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); } + [TestMethod] public async Task TestCascadedPocoCreateReadDeleted() { @@ -93,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); } @@ -111,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/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..b74c8f8 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.TableClient.DeleteEntityAsync(result.PartitionKey, result.RowKey); } 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.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 ea493bb..2340a5f 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] @@ -41,12 +39,13 @@ 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); Assert.AreEqual(0, allPocoResult2.Count); } + [TestMethod] public async Task TestMEClientTypeExtraction() { @@ -65,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); @@ -90,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); @@ -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() { @@ -133,9 +131,10 @@ 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); } } + 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/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.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..205f83a 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 { @@ -13,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); @@ -26,41 +22,40 @@ 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() { 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); Assert.IsNotNull(build); Assert.IsNull(build.Id); - } + [TestMethod] 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); Assert.IsInstanceOfType(build, typeof(ParentPoco)); Assert.IsNotNull(build.Child); } + [TestMethod] 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); @@ -81,5 +76,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..31d1720 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 @@ -15,7 +11,7 @@ public static PocoWihtListChildren CreateInitializdedPWLC() PocoWihtListChildren pwlc = new PocoWihtListChildren { Id = "0178301", - Children = new List() + Children = [] }; for (int i = 0; i < 3; i++) { @@ -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..3f6ec9e 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; } @@ -21,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; } @@ -37,6 +38,7 @@ public static SimplePoco CreateInitializedPoco() { Id = "02381012", DoubleValue = 2018101.00812, + DecimalValue = 12345.6789m, IntValue = 9789677, DTOValue = DateTimeOffset.Now, LongValue = 100008937819, @@ -65,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); } } @@ -73,5 +75,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..76a44e8 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] @@ -13,50 +8,52 @@ 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); - + 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("DecimalValue")); + Assert.AreEqual(allEntities["DecimalValue"], spo.DecimalValue.ToString(System.Globalization.CultureInfo.InvariantCulture)); + 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() { SimplePoco spo = SimplePoco.CreatePocoWithoutID(); IDictionary allEntities = ObjectSerializer.Serialize(spo); - Assert.AreEqual(8, allEntities.Count); + Assert.AreEqual(9, allEntities.Count); Assert.IsFalse(allEntities.ContainsKey("Id")); - } + [TestMethod] 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")); @@ -79,9 +76,8 @@ public void TestExtractAllEnitiesFromParentPoco() Assert.IsTrue(allEntities.ContainsKey("Child_DTOValue")); Assert.AreEqual(allEntities["Child_DTOValue"], pp.Child.DTOValue); - - } + [TestMethod] public void TestExtractAllEnitiesFromMainWithParent() { @@ -91,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")); @@ -115,7 +111,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 +143,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/AzureTableUtils.csproj b/AzureTableUtils/AzureTableUtils.csproj index ad291aa..c02c888 100644 --- a/AzureTableUtils/AzureTableUtils.csproj +++ b/AzureTableUtils/AzureTableUtils.csproj @@ -1,31 +1,37 @@ - net8.0 + net10.0 enable enable + true + $(NoWarn);CS1591 + 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/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..3c060c7 100644 --- a/AzureTableUtils/Converters/ConverterFactory.cs +++ b/AzureTableUtils/Converters/ConverterFactory.cs @@ -1,26 +1,23 @@ -using System; -using System.Collections.Generic; -using System.Linq; - 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 ArrayConverter()); - list.Add(new EnumerableConverter()); - return list; + return + [ + new EnumConverter(), + new TimeSpanConverter(), + new DecimalConverter(), + new ArrayConverter(), + new EnumerableConverter() + ]; } } 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/AzureTableUtils/Converters/EnumConverter.cs b/AzureTableUtils/Converters/EnumConverter.cs index 4a87c97..4a947a4 100644 --- a/AzureTableUtils/Converters/EnumConverter.cs +++ b/AzureTableUtils/Converters/EnumConverter.cs @@ -1,26 +1,25 @@ -using System; -using System.Linq; -using System.Collections.Generic; - namespace WebGate.Azure.TableUtils.Converter; + 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) { - return value.ToString()?? ""; + return value.ToString() ?? ""; } + public object? BuildValue(string? value, Type type) { if (string.IsNullOrEmpty(value)) { return null; - }; - return Enum.Parse(type, value); - } + } + Type enumType = Nullable.GetUnderlyingType(type) ?? type; + return Enum.Parse(enumType, value); + } } 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/EntityMapping.cs b/AzureTableUtils/EntityMapping.cs new file mode 100644 index 0000000..5376209 --- /dev/null +++ b/AzureTableUtils/EntityMapping.cs @@ -0,0 +1,29 @@ +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) + { + return PropertyCache.GetOrAdd(type, static t => + t.GetProperties().Where(p => p.CanRead && p.CanWrite).ToArray()); + } + + internal static bool IsPrimitiveTableType(Type type) + { + return 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/ExtendedAzureTableClientService.cs b/AzureTableUtils/ExtendedAzureTableClientService.cs index e19dd22..e9fcd98 100644 --- a/AzureTableUtils/ExtendedAzureTableClientService.cs +++ b/AzureTableUtils/ExtendedAzureTableClientService.cs @@ -1,26 +1,25 @@ -using Azure.Data.Tables; - 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) { var tableClient = new TableClient(_connectionString, tableName); var typedClient = new TypedAzureTableClient(tableClient); _tableClients.Add(typeof(T), typedClient); - typedClient.GetTableClient().CreateIfNotExists(); + typedClient.TableClient.CreateIfNotExists(); return typedClient; } + public void AddInitializedTableClient(TableClient tableClient) { var typedClient = new TypedAzureTableClient(tableClient); _tableClients.Add(typeof(T), typedClient); - typedClient.GetTableClient().CreateIfNotExists(); + typedClient.TableClient.CreateIfNotExists(); } public TypedAzureTableClient GetTypedTableClient() @@ -31,12 +30,13 @@ 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); var meClient = new MultiEntityAzureTableClient(tableClient); _meTableClients.Add(tableName, meClient); - meClient.GetTableClient().CreateIfNotExists(); + meClient.TableClient.CreateIfNotExists(); return meClient; } 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..e48a08e 100644 --- a/AzureTableUtils/MultiEntityAzureTableClient.cs +++ b/AzureTableUtils/MultiEntityAzureTableClient.cs @@ -1,108 +1,258 @@ - -using Azure; -using Azure.Data.Tables; using WebGate.Azure.TableUtils.Models; 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}). +/// +/// +/// +/// 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 { private readonly TableClient _tableClient; - private readonly Dictionary _typeRegistry = new Dictionary(); + 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; } - public TableClient GetTableClient() { return _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() + { + return TableClient; + } - public void RegisterType(){ + /// + /// 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); } - public void RegisterType(string typePrefix) { + + /// + /// 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); + return await QueryAndMapAsync(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) { - var query = $"PartitionKey eq '{partitionKey}'"; - return await GetAllByQueryAsync(query); + return await QueryAndMapAsync(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) { - - //$"PartitionKey eq '{partitionKey}'" - AsyncPageable resultItems = _tableClient.QueryAsync(query); - - 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) { - throw new ArgumentOutOfRangeException($"No registered type foung for Tableentry with ID {item.RowKey}."); - } - items.Add(TableEntityResult.BuildTableEntityResultWithType(entityType, item)); - } - return items; + return await QueryAndMapAsync(query); } + /// + /// 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.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) { + } + + NullableResponse tableEntity = await _tableClient.GetEntityIfExistsAsync(partitionKey, prefix + "_" + rowKey); + if (tableEntity.HasValue) + { return TableEntityResult.BuildTableEntityResult(tableEntity.Value!); } return null; } - public async Task InsertOrReplaceAsync(string rowKey, string partitionKey, object obj) + /// + /// 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) { - 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) + + /// + /// 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) { - 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); } + + /// + /// 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.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); + } + 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. + /// + /// + /// 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); + 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/ODataFilter.cs b/AzureTableUtils/ODataFilter.cs new file mode 100644 index 0000000..19856a8 --- /dev/null +++ b/AzureTableUtils/ODataFilter.cs @@ -0,0 +1,14 @@ +namespace WebGate.Azure.TableUtils; + +internal static class ODataFilter +{ + internal static string EscapeString(string value) + { + return value.Replace("'", "''"); + } + + internal static string PartitionKeyEquals(string partitionKey) + { + return $"PartitionKey eq '{EscapeString(partitionKey)}'"; + } +} diff --git a/AzureTableUtils/ObjectBuilder.cs b/AzureTableUtils/ObjectBuilder.cs index 0b546ce..08b3ec6 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,74 +10,64 @@ 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 => + 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); - } - } - } else { - Type pType = propertyInfo.PropertyType; - 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); - } + propertyInfo.SetValue(obj, DateTime.SpecifyKind(dtoValue.DateTime, DateTimeKind.Utc)); + continue; } - } - }); + if (EntityMapping.IsPrimitiveTableType(pType)) + { + 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; + return tableEntity.Any(p => p.Key.StartsWith(id + "_", StringComparison.Ordinal) && p.Value != null); } - private static string BuildEntityName(string? path, string id) + private static bool IsDateTime(Type pType) { - 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); + return pType == typeof(DateTime) || Nullable.GetUnderlyingType(pType) == typeof(DateTime); } } diff --git a/AzureTableUtils/ObjectSerializer.cs b/AzureTableUtils/ObjectSerializer.cs index 152f877..28470df 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(); @@ -15,42 +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; } - }); - } + Type valueType = value.GetType(); + IConverter? converter = ConverterFactory.FindConverter(valueType); + if (converter != null) + { + entities.Add(EntityMapping.BuildEntityName(path, id), converter.GetValue(propertyInfo.PropertyType, value)); + continue; + } - private static string BuildEntityName(string? path, string id) - { - if (string.IsNullOrEmpty(path)) - { - return id; + if (EntityMapping.IsPrimitiveTableType(valueType)) + { + entities.Add(EntityMapping.BuildEntityName(path, id), value); + } + else + { + ProcessObject(value, EntityMapping.BuildEntityName(path, id), entities); + } } - return path + "_" + id; } } - - diff --git a/AzureTableUtils/TypedAzureTableClient.cs b/AzureTableUtils/TypedAzureTableClient.cs index 38d84bc..110d547 100644 --- a/AzureTableUtils/TypedAzureTableClient.cs +++ b/AzureTableUtils/TypedAzureTableClient.cs @@ -1,59 +1,130 @@ - -using Azure; -using Azure.Data.Tables; using WebGate.Azure.TableUtils.Models; 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. +/// +/// +/// 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 { 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; } - public TableClient GetTableClient() { return _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() + { + return TableClient; + } + /// + /// 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); + return await QueryAndMapAsync(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) { - var query = $"PartitionKey eq '{partitionKey}'"; - return await GetAllByQueryAsync(query); + return await QueryAndMapAsync(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. + /// + /// 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) { - - //$"PartitionKey eq '{partitionKey}'" - AsyncPageable resultItems = _tableClient.QueryAsync(query); - - List> items = new (); - await foreach (var item in resultItems) - { - items.Add(TableEntityResult.BuildTableEntityResult(item)); - } - return items; + return await QueryAndMapAsync(query); } + /// + /// 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); - if (tableEntity.HasValue) { + NullableResponse tableEntity = await _tableClient.GetEntityIfExistsAsync(partitionKey, rowKey); + if (tableEntity.HasValue) + { return TableEntityResult.BuildTableEntityResult(tableEntity.Value!); } return null; } + /// + /// 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); @@ -62,8 +133,19 @@ 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); } + + /// + /// 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); @@ -72,10 +154,48 @@ 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); } + + /// + /// Deletes the entity identified by row key and partition key. + /// + /// 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 17559b0..5f804dd 100644 --- a/README.md +++ b/README.md @@ -1,56 +1,110 @@ # 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. +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 + +| | | +|---|---| +| 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. --- + +## 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**, **decimal** (InvariantCulture string), **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. -### Create a new ExtendedAzureTableClientService -With a valid connectionString to an Azure Storage Account V2, the creation is straightforward. +Registers and resolves `TypedAzureTableClient` and `MultiEntityAzureTableClient` instances. -```c# +### Create a service + +```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 -### 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: +One table per POCO type: -```c# -var simplePocoAzureTableClient = extendedTableService.CreateAndRegisterTableClient("simplePojoTable"); -var parentPocoAzureTableClient = extendedTableService.CreateAndRegisterTableClient("parentPojoTable"); +```csharp +var simplePocoAzureTableClient = extendedTableService.CreateAndRegisterTableClient("simplePocoTable"); +var parentPocoAzureTableClient = extendedTableService.CreateAndRegisterTableClient("parentPocoTable"); ``` -### Accessing a TypedAzureTableClient -To get a specific TypeAzureTableClient, use the following approach with the ExtendedAzureTableClientService: -```c# + +### Register an already initialized TableClient + +```csharp +extendedTableService.AddInitializedTableClient(existingTableClient); +``` + +### 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; @@ -58,113 +112,125 @@ 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.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() -```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) — obsolete -```c# -var query = $"PartitionKey eq '{partitionKey}'"; -List> pocos = await typedTableClient.GetAllByQueryAsync(query); -``` - -Gets all entities that matches the query. +Prefer `GetAllAsync` / `GetAllAsync(partitionKey)`. For custom OData filters, query with `TableClient.QueryAsync` and map via `TableEntityResult.BuildTableEntityResult(…)`. -### 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`. The parameter is `object` so partial DTOs (not necessarily `T`) can be merged. -### 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`. + +### DeleteEntityAsync — obsolete (compile error) + +Parameter order is **reversed** vs the Azure SDK: -### DeleteEntryAsync(string id, string partition) +| | 1st arg | 2nd arg | +|---|---|---| +| This library (obsolete) | `rowKey` | `partitionKey` | +| `TableClient.DeleteEntityAsync` | `partitionKey` | `rowKey` | -```c# -Azure.Response result = await typedTableClient.DeleteEntityAsync("001", "SimplePoco"); +```csharp +// old: +await typedTableClient.DeleteEntityAsync("001", "SimplePoco"); +// new: +await typedTableClient.TableClient.DeleteEntityAsync("SimplePoco", "001"); ``` -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(); @@ -172,96 +238,94 @@ 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.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() -```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) +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) -```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, T 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) -### DeleteEntryAsync\\(string id, string partition) - -```c# -Azure.Response result = await multiEntityTableClient.DeleteEntityAsync("001", "SimplePoco"); +```csharp +Azure.Response result = await multiEntityTableClient.DeleteEntityByTypeAsync("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. +Builds the row key from the registered prefix for `T`. Prefer this when you know the entity type. ---- +### DeleteEntityAsync(completeRowKey, partitionKey) — obsolete (compile error) -## Code Quality Check SonarCloud.io +Parameter order is **reversed** vs the Azure SDK: -[![Quality gate](https://sonarcloud.io/api/project_badges/quality_gate?project=CloudTableUtils&token=b8ea0b7d7b29c7e13fb260bae8cf0d3eb36597ec)](https://sonarcloud.io/dashboard?id=CloudTableUtils) +| | 1st arg | 2nd arg | +|---|---|---| +| This library (obsolete) | `completeRowKey` | `partitionKey` | +| `TableClient.DeleteEntityAsync` | `partitionKey` | `rowKey` | + +```csharp +// old: +await multiEntityTableClient.DeleteEntityAsync(result.RowKey, result.PartitionKey); +// new: +await multiEntityTableClient.TableClient.DeleteEntityAsync(result.PartitionKey, result.RowKey); +``` --- -## Licence +## License -Apache V 2.0 +Apache-2.0 --- ## Copyright -2024, WebGate Consulting AG +2026, WebGate Consulting AG