diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 10e97ea..9b170e9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -7,11 +7,15 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 + with: + submodules: recursive + fetch-depth: 0 + fetch-tags: true - name: Install .Net Core uses: actions/setup-dotnet@v2.0.0 with: - dotnet-version: 8.0.201 + dotnet-version: 8.0.406 - name: Install dotnet-script run: dotnet tool install dotnet-script --global diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..4191890 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "cecil"] + path = cecil + url = https://github.com/jbevain/cecil.git diff --git a/DbReader.sln b/DbReader.sln index 7ef36c3..bdb4693 100644 --- a/DbReader.sln +++ b/DbReader.sln @@ -1,13 +1,17 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.30114.105 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{EEFE241F-68CC-4C49-86B1-C7DA81F24250}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{7D7BAB27-2679-4825-BCAC-506DE94B09BC}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbReader", "src\DbReader\DbReader.csproj", "{99BF0316-D315-4E01-8F79-9EA45782F07B}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbReader", "src\DbReader\DbReader.csproj", "{750224E1-F07A-4F85-AF4A-A58896AE800F}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbReader.Tests", "src\DbReader.Tests\DbReader.Tests.csproj", "{328B68A5-9A0F-40BC-9181-B4922894BFF0}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbReader.Tests", "src\DbReader.Tests\DbReader.Tests.csproj", "{C7D0AF4F-6CF0-4D86-8F71-167557A205A5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbReader.Tracking", "src\DbReader.Tracking\DbReader.Tracking.csproj", "{E1B757E5-F9CD-49AB-AE23-364BEB752711}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbReader.Tracking.SampleAssembly", "src\DbReader.Tracking.SampleAssembly\DbReader.Tracking.SampleAssembly.csproj", "{AF516499-8AE8-4C37-B7D4-444D4C8066DF}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -18,17 +22,27 @@ Global HideSolutionNode = FALSE EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {99BF0316-D315-4E01-8F79-9EA45782F07B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {99BF0316-D315-4E01-8F79-9EA45782F07B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {99BF0316-D315-4E01-8F79-9EA45782F07B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {99BF0316-D315-4E01-8F79-9EA45782F07B}.Release|Any CPU.Build.0 = Release|Any CPU - {328B68A5-9A0F-40BC-9181-B4922894BFF0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {328B68A5-9A0F-40BC-9181-B4922894BFF0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {328B68A5-9A0F-40BC-9181-B4922894BFF0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {328B68A5-9A0F-40BC-9181-B4922894BFF0}.Release|Any CPU.Build.0 = Release|Any CPU + {750224E1-F07A-4F85-AF4A-A58896AE800F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {750224E1-F07A-4F85-AF4A-A58896AE800F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {750224E1-F07A-4F85-AF4A-A58896AE800F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {750224E1-F07A-4F85-AF4A-A58896AE800F}.Release|Any CPU.Build.0 = Release|Any CPU + {C7D0AF4F-6CF0-4D86-8F71-167557A205A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C7D0AF4F-6CF0-4D86-8F71-167557A205A5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C7D0AF4F-6CF0-4D86-8F71-167557A205A5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C7D0AF4F-6CF0-4D86-8F71-167557A205A5}.Release|Any CPU.Build.0 = Release|Any CPU + {E1B757E5-F9CD-49AB-AE23-364BEB752711}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E1B757E5-F9CD-49AB-AE23-364BEB752711}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E1B757E5-F9CD-49AB-AE23-364BEB752711}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E1B757E5-F9CD-49AB-AE23-364BEB752711}.Release|Any CPU.Build.0 = Release|Any CPU + {AF516499-8AE8-4C37-B7D4-444D4C8066DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AF516499-8AE8-4C37-B7D4-444D4C8066DF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AF516499-8AE8-4C37-B7D4-444D4C8066DF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AF516499-8AE8-4C37-B7D4-444D4C8066DF}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution - {99BF0316-D315-4E01-8F79-9EA45782F07B} = {EEFE241F-68CC-4C49-86B1-C7DA81F24250} - {328B68A5-9A0F-40BC-9181-B4922894BFF0} = {EEFE241F-68CC-4C49-86B1-C7DA81F24250} + {750224E1-F07A-4F85-AF4A-A58896AE800F} = {7D7BAB27-2679-4825-BCAC-506DE94B09BC} + {C7D0AF4F-6CF0-4D86-8F71-167557A205A5} = {7D7BAB27-2679-4825-BCAC-506DE94B09BC} + {E1B757E5-F9CD-49AB-AE23-364BEB752711} = {7D7BAB27-2679-4825-BCAC-506DE94B09BC} + {AF516499-8AE8-4C37-B7D4-444D4C8066DF} = {7D7BAB27-2679-4825-BCAC-506DE94B09BC} EndGlobalSection EndGlobal diff --git a/build/build.csx b/build/build.csx index 90eed68..70d0a88 100644 --- a/build/build.csx +++ b/build/build.csx @@ -1,7 +1,9 @@ -#load "nuget:Dotnet.Build, 0.16.0" +#load "nuget:Dotnet.Build, 0.24.0" #load "nuget:dotnet-steps, 0.0.2" -BuildContext.CodeCoverageThreshold = 90; +Console.WriteLine($"Building with the latest tag {BuildContext.LatestTag}"); + +BuildContext.CodeCoverageThreshold = 87; [StepDescription("Runs the tests with test coverage")] Step testcoverage = () => DotNet.TestWithCodeCoverage(); @@ -10,21 +12,30 @@ Step testcoverage = () => DotNet.TestWithCodeCoverage(); Step test = () => DotNet.Test(); [StepDescription("Creates the NuGet packages")] -Step pack = () => +AsyncStep pack = async () => { test(); - testcoverage(); + testcoverage(); ; DotNet.Pack(); + await buildTrackingPackage(); }; [DefaultStep] [StepDescription("Deploys packages if we are on a tag commit in a secure environment.")] AsyncStep deploy = async () => { - pack(); + await pack(); await Artifacts.Deploy(); }; +AsyncStep buildTrackingPackage = async () => +{ + var workingDirectory = Path.Combine(BuildContext.SourceFolder, "DbReader.Tracking"); + await Command.ExecuteAsync("dotnet", $"pack /p:NuspecFile=DbReader.Tracking.nuspec /p:IsPackable=true /p:NuspecProperties=version={BuildContext.LatestTag} -o ../../build/Artifacts/NuGet", workingDirectory); + +}; + + await StepRunner.Execute(Args); return 0; diff --git a/cecil b/cecil new file mode 160000 index 0000000..5de7d8c --- /dev/null +++ b/cecil @@ -0,0 +1 @@ +Subproject commit 5de7d8cbc91f6fd98dcc24b26b8c398db497809c diff --git a/global.json b/global.json index 501e79a..b2ba7a4 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.100", + "version": "8.0.406", "rollForward": "latestFeature" } } \ No newline at end of file diff --git a/omnisharp.json b/omnisharp.json index ca03952..6d18689 100644 --- a/omnisharp.json +++ b/omnisharp.json @@ -9,6 +9,6 @@ }, "script": { "enableScriptNuGetReferences": true, - "defaultTargetFramework": "net7.0" + "defaultTargetFramework": "net8.0" } } \ No newline at end of file diff --git a/readme.md b/readme.md index 7a27f88..b88fd6d 100644 --- a/readme.md +++ b/readme.md @@ -765,7 +765,322 @@ We can now execute the query without specifying the `p_cursor` parameter. connection.ReadAsync(SQL.emp, new {p_name = "A%"}); ``` +## Tracking +As stated previously, `DbReader` is NOT an ORM and the support for simple object tracking does not change that +although it opens up a couple of doors for more dynamic SQL, specially when doing updates in the database. +First of all what do we mean by tracking? Let's illustrate with an example. +Say that we have an WebAPI application that manages customers. We will focus on the update part here. +The table looks something like the following (SQLite) + +```sql +CREATE TABLE Customers ( + CustomerID TEXT NOT NULL, + CompanyName TEXT NOT NULL, + ContactName TEXT, + ContactTitle TEXT, + Address TEXT, + City TEXT, + Region TEXT, + PostalCode TEXT, + Country TEXT, + Phone TEXT, + Fax TEXT, + PRIMARY KEY (CustomerID) +); +``` + +For updating the Customer we could imagine a class or a record like this + +```c# +public record Customer( + string CustomerID, + string CompanyName, + string? ContactName = null, + string? ContactTitle = null, + string? Address = null, + string? City = null, + string? Region = null, + string? PostalCode = null, + string? Country = null, + string? Phone = null, + string? Fax = null +); +``` +The update statement for this table would be + +```sql +UPDATE Customers +SET + CompanyName = @CompanyName, + ContactName = @ContactName, + ContactTitle = @ContactTitle, + Address = @Address, + City = @City, + Region = @Region, + PostalCode = @PostalCode, + Country = @Country, + Phone = @Phone, + Fax = @Fax +WHERE + CustomerID = @CustomerID; +``` + +So in `DbReader`this would be as simple as + +```c# +await dbConnection.ExecuteAsync(sql, customer); +``` +> sql` is the update statement and `customer` is an instance of the `Customer` record. + +Everything looks and works pretty much as expected so again.... +What is `Tracking` and how could it be useful for updating a customer? + +Let's imagine that we wanted to update ONLY the `PostalCode` for a given customer? +What are our options here? + +* Create a new update statement and the plumbing (data access) in C# to update just the `PostalCode` + +* Use the existing update statement supplying the new `PostalCode` along with ALL the other values that hasn't changed + Note that the `Customer`record has a lot of nullable properties. We need to supply them as well. + +* Use `Tracking` to determine which of the properties of the `Customer` record has actually been set and therefore eligible for update. + +Back to the WebAPI application where we have an endpoint for patching a customer + +``` + PATCH api/customers +``` + +where the body of the request is a JSON mapping fully or partially to the `Customer` record. + +> Note: To keep things simple we assume that everything is provided in the body of the request, including the `CustomerID` which normally would be part of the URL. + + +So it could be + +```json +{ + "CustomerID": "ALFKI", + "CompanyName": "Alfreds Futterkiste", + "ContactName": "Maria Anders", + "ContactTitle": "Sales Representative", + "Address": "Obere Str. 57", + "City": "Berlin", + "Region": null, + "PostalCode": "12209", + "Country": "Germany", + "Phone": "030-0074321", + "Fax": "030-0076545" +} +``` +or it could be just the `PostalCode` which is what we are looking to to update in this example. + +```json +{ + "CustomerID": "ALFKI", + "PostalCode": "12209", +} +``` + +The goal here is to use the same endpoint and the same C# data access code when doing a full update and also +when doing a partial update for the `PostalCode` + +When the JSON comes into the endpoint, Asp.Net will deserialize the request into an instance of the `Customer` record populating the record with the properties found in the JSON. +A C# record is just a regular class with "readonly" properties. They can still be set using reflection which is how `System.Text.Json.JsonSerializer`sets these properties. + +The key takeaway here is that it will only set the properties found in the JSON. +So what if we could keep track of which properties that was actually set? + +Lets make some changes to the `Customer` record. In fact, for simplicity lets just make it a class. + +But first an interface to let us retrieve the properties that has been set/modified. + +```c# +/// +/// This interface is implemented by classes that are tracked for changes. +/// +public interface ITrackedObject +{ + /// + /// Gets a list of the properties that have been modified. + /// + /// + HashSet GetModifiedProperties(); +} +``` +Then our new Customer record + +```c# +public record Customer : ITrackedObject +{ + private readonly HashSet modifiedProperties = []; + private string _companyName; + private string _customerId; + + public string CustomerId + { + get => _customerId; + set + { + _customerId = value; + modifiedProperties.Add(nameof(CustomerId)); + } + } + + public string CompanyName + { + get => _companyName; + set + { + _companyName = value; + modifiedProperties.Add(nameof(CompanyName)); + } + } + + // The rest of the properties ............. + + public HashSet GetModifiedProperties() + { + return modifiedProperties; + } +} +``` +This now gives us the ability to figure out which properties has changed and this information can be used when building the arguments object that is passed to `DbReader`when executing the query. + +```c# +var arguments = new ArgumentsBuilder().From(customer).Build(); +``` + +This arguments object will now be an object with properties from the `customer` instance, but since `Customer`implements `ITrackedObject` it will ONLY contain the properties that has been set/modified. + +At this point we have the arguments object to be passed to `DbReader`, but in order for this to work we also need to build the SQL that represents our update statement. + +```c# +var modifiedProperties = ((ITrackedObject)positionalRecord).GetModifiedProperties(); +string sql = $""" + UPDATE Customers + SET {string.Join("\n", modifiedProperties.Select(prop => $"{prop} = @{prop}"))} + WHERE Id = @Id +"""; +``` + +So in the example where we just provided the `PostalCode` the resulting SQL would be + +```sql +UPDATE Customers +SET PostalCode = @PostalCode +WHERE CustomerId = @CustomerId +``` + +We are finally ready to actually perform the update in the database + +```c# +await dbConnection.ExecuteAsync(sql, arguments); +``` + +### DbReader.Tracking + +As we learned previously we can leverage this functionally by rewriting our `Customer` record from + +```c# +public record Customer( + string CustomerID, + string CompanyName, + string? ContactName = null, + string? ContactTitle = null, + string? Address = null, + string? City = null, + string? Region = null, + string? PostalCode = null, + string? Country = null, + string? Phone = null, + string? Fax = null +); +``` + +to a version that supports tracking changes to properties + +```c# +public record Customer : ITrackedObject +{ + private readonly HashSet modifiedProperties = []; + private string _companyName; + private string _customerId; + + public string CustomerId + { + get => _customerId; + set + { + _customerId = value; + modifiedProperties.Add(nameof(CustomerId)); + } + } + + public string CompanyName + { + get => _companyName; + set + { + _companyName = value; + modifiedProperties.Add(nameof(CompanyName)); + } + } + + // The rest of the properties ............. + + public HashSet GetModifiedProperties() + { + return modifiedProperties; + } +} +``` + +This would require a lot of tedious plumbing code every time we would want this type of behavior. + +`DbReader.Tracking` makes this very easy by just requiring an attribute to be set on the class/record that we want to track. + +```c# +[Tracked] +public record Customer( + string CustomerID, + string CompanyName, + string? ContactName = null, + string? ContactTitle = null, + string? Address = null, + string? City = null, + string? Region = null, + string? PostalCode = null, + string? Country = null, + string? Phone = null, + string? Fax = null +); +``` + +By applying the `TrackedAttribute`, `DbReader.Tracking` will automatically implement the `ITrackedObject` interface and modify each property setter to track changes when properties are being set. +This happens at compile time using [Mono.Cecil]([https://](https://github.com/jbevain/cecil)) + +By default, `DbReader.Tracking` will look for an attribute called `TrackedAttribute`. This can be configured using an MsBuild property called `DbReaderTrackingAttributeName` +In the example below , `DbReader.Tracking` will look for an attribute called `PatchAttribute` instead of `TrackedAttribute`. + +```xml + + Exe + net8.0 + enable + enable + PatchAttribute + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + +``` diff --git a/src/DbReader.Tests/ArgumentsBuilderTests.cs b/src/DbReader.Tests/ArgumentsBuilderTests.cs index 9ee4157..82074b6 100644 --- a/src/DbReader.Tests/ArgumentsBuilderTests.cs +++ b/src/DbReader.Tests/ArgumentsBuilderTests.cs @@ -1,94 +1,164 @@ -using Xunit; -using Shouldly; +using System.Collections.Generic; +using System.Linq; +using System.Text; using DbReader.DynamicArguments; +using DbReader.Tracking; +using DbReader.Tracking.SampleAssembly; +using ILVerifier; +using Shouldly; +using Xunit; -namespace DbReader.Tests +namespace DbReader.Tests; + +public class ArgumentsBuilderTests { - public class ArgumentsBuilderTests + [Fact] + public void ShouldAddAndGetMember() { - [Fact] - public void ShouldAddAndGetMember() - { - var dynamicObject = new ArgumentsBuilder().Add("Id", 42).Build(); - dynamicObject.Get("Id"); - dynamicObject.Get("Id").ShouldBe(42); - } + var dynamicObject = new ArgumentsBuilder().Add("Id", 42).Build(); + dynamicObject.Get("Id"); + dynamicObject.Get("Id").ShouldBe(42); + } - [Fact] - public void ShouldBeSameTypeWhenMembersAreEqual() - { - var dynamicObject1 = new ArgumentsBuilder().Add("Id", 42).Build(); - var dynamicObject2 = new ArgumentsBuilder().Add("Id", 42).Build(); - dynamicObject1.GetType().ShouldBeSameAs(dynamicObject2.GetType()); - } + [Fact] + public void ShouldBeSameTypeWhenMembersAreEqual() + { + var dynamicObject1 = new ArgumentsBuilder().Add("Id", 42).Build(); + var dynamicObject2 = new ArgumentsBuilder().Add("Id", 42).Build(); + dynamicObject1.GetType().ShouldBeSameAs(dynamicObject2.GetType()); + } - [Fact] - public void ShouldNotBeSameTypeWhenTypesAreDifferent() - { - var dynamicObject1 = new ArgumentsBuilder().Add("Id", 42).Build(); - var dynamicObject2 = new ArgumentsBuilder().Add("Id", (double)42).Build(); - dynamicObject1.GetType().ShouldNotBeSameAs(dynamicObject2.GetType()); - } + [Fact] + public void ShouldNotBeSameTypeWhenTypesAreDifferent() + { + var dynamicObject1 = new ArgumentsBuilder().Add("Id", 42).Build(); + var dynamicObject2 = new ArgumentsBuilder().Add("Id", (double)42).Build(); + dynamicObject1.GetType().ShouldNotBeSameAs(dynamicObject2.GetType()); + } - [Fact] - public void ShouldNotBeSameTypeWhenNumberOfMembersAreDifferent() - { - var dynamicObject1 = new ArgumentsBuilder().Add("Id", 42).Build(); - var dynamicObject2 = new ArgumentsBuilder().Add("Id", 42).Add("Id2", 42).Build(); - dynamicObject1.GetType().ShouldNotBeSameAs(dynamicObject2.GetType()); - } + [Fact] + public void ShouldNotBeSameTypeWhenNumberOfMembersAreDifferent() + { + var dynamicObject1 = new ArgumentsBuilder().Add("Id", 42).Build(); + var dynamicObject2 = new ArgumentsBuilder().Add("Id", 42).Add("Id2", 42).Build(); + dynamicObject1.GetType().ShouldNotBeSameAs(dynamicObject2.GetType()); + } - [Fact] - public void ShouldNotBeSameTypeWhenMemberNamesAreDifferent() - { - var dynamicObject1 = new ArgumentsBuilder().Add("Id1", 42).Build(); - var dynamicObject2 = new ArgumentsBuilder().Add("Id2", 42).Build(); - dynamicObject1.GetType().ShouldNotBeSameAs(dynamicObject2.GetType()); - object.Equals(dynamicObject1, dynamicObject2).ShouldBeFalse(); - } + [Fact] + public void ShouldNotBeSameTypeWhenMemberNamesAreDifferent() + { + var dynamicObject1 = new ArgumentsBuilder().Add("Id1", 42).Build(); + var dynamicObject2 = new ArgumentsBuilder().Add("Id2", 42).Build(); + dynamicObject1.GetType().ShouldNotBeSameAs(dynamicObject2.GetType()); + object.Equals(dynamicObject1, dynamicObject2).ShouldBeFalse(); + } + [Fact] + public void ShouldCopyValuesFromObjectWithPropertiesAndFields() + { + var dynamicObject = new ArgumentsBuilder().From(new ObjectWithPropertiesAndFields() { Id = 42, Name = "SomeName" }).Build(); + dynamicObject.Get("Id").ShouldBe(42); + dynamicObject.Get("Name").ShouldBe("SomeName"); + } + + [Fact] + public void ShouldTrackChangesForPositionalRecord() + { + var positionalRecord = new PositionalRecord(42, "SomeName"); + // set value to 42 via reflection + positionalRecord.GetType().GetProperty("Id")!.SetValue(positionalRecord, 84); + ((ITrackedObject)positionalRecord).GetModifiedProperties().ShouldContain("Id"); + } + + + [Fact] + public void ShouldBuildArgumentsObjectOnlyForChangedProperties() + { + var positionalRecord = new PositionalRecord(42, "SomeName"); + positionalRecord.GetType().GetProperty("Id")!.SetValue(positionalRecord, 84); + var dynamicObject = new ArgumentsBuilder().From(positionalRecord).Build(); + dynamicObject.GetType().GetProperties().Length.ShouldBe(1); + dynamicObject.GetType().GetProperties().Single().Name.ShouldBe("Id"); + + // var modifiedProperties = ((ITrackedObject)positionalRecord).GetModifiedProperties(); + // string sql = $""" + // UPDATE Customers + // SET {string.Join("\n", modifiedProperties.Select(prop => $"{prop} = @{prop}"))} + // WHERE Id = @Id + // """; + } + + private string BuildUpdateSql(IEnumerable modifiedProperties) + { + return string.Join("\n", modifiedProperties.Select(prop => $"{prop} = @{prop}")); + } + + + [Fact] + public void ShouldUseEqualsForDynamicMember() + { + var firstMember = new DynamicMemberInfo("Id", typeof(int)); + var secondMember = new DynamicMemberInfo("Id", typeof(int)); + object.Equals(firstMember, secondMember).ShouldBeTrue(); + } + + public class DynamicMemberInfoArrayEqualityComparerTests + { [Fact] - public void ShouldCopyValuesFromObjectWithPropertiesAndFields() + public void ShouldNotBeEqualForDifferentTypes() { - var dynamicObject = new ArgumentsBuilder().From(new ObjectWithPropertiesAndFields() { Id = 42, Name = "SomeName" }).Build(); - dynamicObject.Get("Id").ShouldBe(42); - dynamicObject.Get("Name").ShouldBe("SomeName"); + var comparer = new DynamicMemberInfoArrayEqualityComparer(); + var firstList = new DynamicMemberInfo[] { new DynamicMemberInfo("Id1", typeof(int)), new DynamicMemberInfo("Id2", typeof(int)) }; + var secondList = new DynamicMemberInfo[] { new DynamicMemberInfo("Id1", typeof(int)), new DynamicMemberInfo("Id2", typeof(string)) }; + comparer.Equals(firstList, secondList).ShouldBeFalse(); } [Fact] - public void ShouldUseEqualsForDynamicMember() + public void ShouldNotBeEqualForDifferentLengths() { - var firstMember = new DynamicMemberInfo("Id", typeof(int)); - var secondMember = new DynamicMemberInfo("Id", typeof(int)); - object.Equals(firstMember, secondMember).ShouldBeTrue(); + var comparer = new DynamicMemberInfoArrayEqualityComparer(); + var firstList = new DynamicMemberInfo[] { new DynamicMemberInfo("Id1", typeof(int)), new DynamicMemberInfo("Id2", typeof(int)) }; + var secondList = new DynamicMemberInfo[] { new DynamicMemberInfo("Id1", typeof(int)) }; + comparer.Equals(firstList, secondList).ShouldBeFalse(); } + } + + public class ObjectWithPropertiesAndFields + { + public int Id { get; set; } - public class DynamicMemberInfoArrayEqualityComparerTests + public string Name; + } + + public record Customer2 : ITrackedObject + { + private readonly HashSet modifiedProperties = []; + private string _companyName; + private string _customerId; + + public string CustomerId { - [Fact] - public void ShouldNotBeEqualForDifferentTypes() + get => _customerId; + set { - var comparer = new DynamicMemberInfoArrayEqualityComparer(); - var firstList = new DynamicMemberInfo[] { new DynamicMemberInfo("Id1", typeof(int)), new DynamicMemberInfo("Id2", typeof(int)) }; - var secondList = new DynamicMemberInfo[] { new DynamicMemberInfo("Id1", typeof(int)), new DynamicMemberInfo("Id2", typeof(string)) }; - comparer.Equals(firstList, secondList).ShouldBeFalse(); + _customerId = value; + modifiedProperties.Add(nameof(CustomerId)); } + } - [Fact] - public void ShouldNotBeEqualForDifferentLengths() + public string CompanyName + { + get => _companyName; + set { - var comparer = new DynamicMemberInfoArrayEqualityComparer(); - var firstList = new DynamicMemberInfo[] { new DynamicMemberInfo("Id1", typeof(int)), new DynamicMemberInfo("Id2", typeof(int)) }; - var secondList = new DynamicMemberInfo[] { new DynamicMemberInfo("Id1", typeof(int)) }; - comparer.Equals(firstList, secondList).ShouldBeFalse(); + _companyName = value; + modifiedProperties.Add(nameof(CompanyName)); } } - public class ObjectWithPropertiesAndFields + public HashSet GetModifiedProperties() { - public int Id { get; set; } - - public string Name; + return modifiedProperties; } } } \ No newline at end of file diff --git a/src/DbReader.Tests/DbReader.Tests.csproj b/src/DbReader.Tests/DbReader.Tests.csproj index de2f4ea..8ca4f5d 100644 --- a/src/DbReader.Tests/DbReader.Tests.csproj +++ b/src/DbReader.Tests/DbReader.Tests.csproj @@ -2,35 +2,29 @@ + + - - all - runtime; build; native; contentfiles; analyzers - - + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - - + + + runtime; build; native; contentfiles; analyzers all - - + + - - - - net8.0 false + diff --git a/src/DbReader.Tests/coverlet.runsettings b/src/DbReader.Tests/coverlet.runsettings new file mode 100644 index 0000000..e68efd8 --- /dev/null +++ b/src/DbReader.Tests/coverlet.runsettings @@ -0,0 +1,12 @@ + + + + + + lcov,cobertura + [Mono.Cecil]*,[Mono.Cecil.Cil]*,[Mono.Cecil.Metadata]*,[Mono.Cecil.Rocks]* + + + + + \ No newline at end of file diff --git a/src/DbReader.Tracking.SampleAssembly/DbReader.Tracking.SampleAssembly.csproj b/src/DbReader.Tracking.SampleAssembly/DbReader.Tracking.SampleAssembly.csproj new file mode 100644 index 0000000..c89fbd1 --- /dev/null +++ b/src/DbReader.Tracking.SampleAssembly/DbReader.Tracking.SampleAssembly.csproj @@ -0,0 +1,21 @@ + + + + + + + + + $(MSBuildProjectDirectory)/bin/$(Configuration)/net8.0/DbReader.Tracking.dll + net8.0 + enable + enable + false + + + + + + + + diff --git a/src/DbReader.Tracking.SampleAssembly/SampleClasses.cs b/src/DbReader.Tracking.SampleAssembly/SampleClasses.cs new file mode 100644 index 0000000..5e156fe --- /dev/null +++ b/src/DbReader.Tracking.SampleAssembly/SampleClasses.cs @@ -0,0 +1,6 @@ +namespace DbReader.Tracking.SampleAssembly; +using DbReader.Tracking; +[Tracked] +public record PositionalRecord(int Id, string Name); + + diff --git a/src/DbReader.Tracking/DbReader.Tracking.csproj b/src/DbReader.Tracking/DbReader.Tracking.csproj new file mode 100644 index 0000000..4b5784c --- /dev/null +++ b/src/DbReader.Tracking/DbReader.Tracking.csproj @@ -0,0 +1,33 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DbReader.Tracking/DbReader.Tracking.nuspec b/src/DbReader.Tracking/DbReader.Tracking.nuspec new file mode 100644 index 0000000..ac0c472 --- /dev/null +++ b/src/DbReader.Tracking/DbReader.Tracking.nuspec @@ -0,0 +1,35 @@ + + + + + DbReader.Tracking + + 0.0.1 + + Bernhard Richter + + A custom MSBuild task package. + + https://github.com/seesharper/DbReader + + MIT + + DbReader Tracking + + true + + + + + + + + + + + + + + + + diff --git a/src/DbReader.Tracking/DbReader.Tracking.targets b/src/DbReader.Tracking/DbReader.Tracking.targets new file mode 100644 index 0000000..0c1451f --- /dev/null +++ b/src/DbReader.Tracking/DbReader.Tracking.targets @@ -0,0 +1,19 @@ + + + $(MSBuildThisFileDirectory)../lib/net8.0/DbReader.Tracking.dll + TrackedAttribute + + + + + + + + + + + + + + + diff --git a/src/DbReader.Tracking/TrackingAssemblyWeaver.cs b/src/DbReader.Tracking/TrackingAssemblyWeaver.cs new file mode 100644 index 0000000..63618ba --- /dev/null +++ b/src/DbReader.Tracking/TrackingAssemblyWeaver.cs @@ -0,0 +1,151 @@ +using Mono.Cecil; +using Mono.Cecil.Cil; +using Mono.Cecil.Rocks; + +namespace DbReader.Tracking; + +public class TrackingAssemblyWeaver +{ + public void Weave(string assemblyPath, string attributeName) + { + var readerParameters = new ReaderParameters(); + readerParameters.ReadSymbols = true; + readerParameters.ReadWrite = true; + readerParameters.InMemory = true; + var assemblyDefinition = AssemblyDefinition.ReadAssembly(assemblyPath, readerParameters); + var types = assemblyDefinition.MainModule.Types + .Where(t => t.CustomAttributes.Any(a => a.AttributeType.Name == attributeName)) + .ToList(); + foreach (var type in types) + { + WeaveType(type); + } + + + + + + + // File.Delete(assemblyPath); + var writerParameters = new WriterParameters(); + writerParameters.WriteSymbols = true; + assemblyDefinition.Write(assemblyPath, writerParameters); + } + + private static bool HasParameterlessConstructor(TypeDefinition type) + { + return type.GetConstructors().Any(c => c.Parameters.Count == 0); + } + + private static void CreateParameterlessConstructor(TypeDefinition type) + { + // Get the base class's parameterless constructor + MethodReference baseConstructor = type.Module.ImportReference( + type.BaseType.Resolve().Methods.FirstOrDefault(m => m.IsConstructor && !m.HasParameters) + ); + + var constructor = new MethodDefinition(".ctor", MethodAttributes.Public | MethodAttributes.HideBySig | MethodAttributes.SpecialName | MethodAttributes.RTSpecialName, type.Module.ImportReference(typeof(void))); + constructor.Body.Instructions.Add(Instruction.Create(OpCodes.Ldarg_0)); + constructor.Body.Instructions.Add(Instruction.Create(OpCodes.Call, baseConstructor)); + constructor.Body.Instructions.Add(Instruction.Create(OpCodes.Ret)); + type.Methods.Add(constructor); + } + + + private static void ImplementTrackedObject(TypeDefinition type, FieldDefinition modifiedPropertiesField) + { + var trackedObjectInterface = type.Module.ImportReference(typeof(ITrackedObject)); + type.Interfaces.Add(new InterfaceImplementation(trackedObjectInterface)); + + var getModifiedPropertiesMethod = new MethodDefinition("GetModifiedProperties", MethodAttributes.Public | MethodAttributes.HideBySig | MethodAttributes.NewSlot | MethodAttributes.Virtual, type.Module.ImportReference(typeof(HashSet))); + var il = getModifiedPropertiesMethod.Body.GetILProcessor(); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldfld, modifiedPropertiesField); + il.Emit(OpCodes.Ret); + type.Methods.Add(getModifiedPropertiesMethod); + } + + private static void InitializeModifiedPropertiesField(MethodDefinition method, FieldDefinition modifiedPropertiesField) + { + var instructions = method.Body.Instructions; + var lastInstruction = instructions.Last(); + var il = method.Body.GetILProcessor(); + il.InsertBefore(lastInstruction, il.Create(OpCodes.Ldarg_0)); + il.InsertBefore(lastInstruction, il.Create(OpCodes.Newobj, method.Module.ImportReference(typeof(HashSet).GetConstructors().First()))); + il.InsertBefore(lastInstruction, il.Create(OpCodes.Stfld, modifiedPropertiesField)); + } + + private static void AddParameterlessConstructor(TypeDefinition typeDefinition, ModuleDefinition moduleDefinition, FieldDefinition modifiedPropertiesField) + { + // Create a new method definition for the parameterless constructor + MethodDefinition constructor = new MethodDefinition( + ".ctor", // Constructor name + MethodAttributes.Public | MethodAttributes.HideBySig | MethodAttributes.SpecialName | MethodAttributes.RTSpecialName, + moduleDefinition.ImportReference(typeof(void)) // Return type (void for constructors) + ); + + // Get the base class's parameterless constructor + MethodReference baseConstructor = moduleDefinition.ImportReference( + typeDefinition.BaseType.Resolve().Methods.FirstOrDefault(m => m.IsConstructor && !m.HasParameters) + ); + + // Create the method body + ILProcessor il = constructor.Body.GetILProcessor(); + + // Call the base class's parameterless constructor + il.Emit(OpCodes.Ldarg_0); // Load 'this' + il.Emit(OpCodes.Call, baseConstructor); // Call the base constructor + + // initialize the modifiedProperties field + il.Emit(OpCodes.Ldarg_0); // Load 'this' + il.Emit(OpCodes.Newobj, moduleDefinition.ImportReference(typeof(HashSet).GetConstructors().First())); // Create a new HashSet + il.Emit(OpCodes.Stfld, modifiedPropertiesField); // Store the HashSet in the modifiedProperties field + + il.Emit(OpCodes.Ret); // Return + + // Add the constructor to the type + typeDefinition.Methods.Add(constructor); + } + + private static void WeaveType(TypeDefinition type) + { + var modifiedPropertiesField = new FieldDefinition("modifiedProperties", FieldAttributes.Private, type.Module.ImportReference(typeof(HashSet))); + type.Fields.Add(modifiedPropertiesField); + + if (!HasParameterlessConstructor(type)) + { + CreateParameterlessConstructor(type); + } + + var allConstructors = type.GetConstructors(); + foreach (var constructor in allConstructors) + { + InitializeModifiedPropertiesField(constructor, modifiedPropertiesField); + } + + ImplementTrackedObject(type, modifiedPropertiesField); + + var properties = type.Properties + .Where(p => p.SetMethod is not null) + .ToList(); + foreach (var property in properties) + { + WeaveSetMethod(property.SetMethod, modifiedPropertiesField); + } + } + + private static void WeaveSetMethod(MethodDefinition setMethod, FieldDefinition modifiedPropertiesField) + { + var instructions = setMethod.Body.Instructions; + var lastInstruction = instructions.Last(); + var processor = setMethod.Body.GetILProcessor(); + + var propertyName = setMethod.Name.Substring(4); + // Call the add method of the modifiedProperties field + processor.InsertBefore(lastInstruction, processor.Create(OpCodes.Ldarg_0)); // Load 'this' + processor.InsertBefore(lastInstruction, processor.Create(OpCodes.Ldfld, modifiedPropertiesField)); // Load the modifiedProperties field + processor.InsertBefore(lastInstruction, processor.Create(OpCodes.Ldstr, propertyName)); // Load the property name + processor.InsertBefore(lastInstruction, processor.Create(OpCodes.Callvirt, setMethod.Module.ImportReference(typeof(HashSet).GetMethod("Add")))); // Call the Add method + processor.InsertBefore(lastInstruction, processor.Create(OpCodes.Pop)); // Pop the return value + } +} diff --git a/src/DbReader.Tracking/WeaveAssemby.cs b/src/DbReader.Tracking/WeaveAssemby.cs new file mode 100644 index 0000000..fdf801a --- /dev/null +++ b/src/DbReader.Tracking/WeaveAssemby.cs @@ -0,0 +1,17 @@ +namespace DbReader.Tracking; + +public class WeaveAssembly : Microsoft.Build.Utilities.Task +{ + public string TargetAssemblyPath { get; set; } = string.Empty; + + public string TrackingAttributeName { get; set; } = string.Empty; + + private readonly TrackingAssemblyWeaver weaver = new(); + + public override bool Execute() + { + Log.LogMessage("Weaving assembly {0}", TargetAssemblyPath); + weaver.Weave(TargetAssemblyPath, TrackingAttributeName); + return true; + } +} \ No newline at end of file diff --git a/src/DbReader/DbReader.csproj b/src/DbReader/DbReader.csproj index d44d32e..5bcde3a 100644 --- a/src/DbReader/DbReader.csproj +++ b/src/DbReader/DbReader.csproj @@ -1,7 +1,7 @@  - net6.0;netstandard2.0;net462 + net8.0 Bernhard Richter https://github.com/seesharper/DbReader git @@ -18,28 +18,15 @@ $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb latest - - - - - - - + + - - all - runtime; build; native; contentfiles; analyzers - - - + all - + all runtime; build; native; contentfiles; analyzers - - $(DefineConstants);NET46 - \ No newline at end of file diff --git a/src/DbReader/DynamicArguments/ArgumentsBuilder.cs b/src/DbReader/DynamicArguments/ArgumentsBuilder.cs index d4ddac4..9a9e3e7 100644 --- a/src/DbReader/DynamicArguments/ArgumentsBuilder.cs +++ b/src/DbReader/DynamicArguments/ArgumentsBuilder.cs @@ -5,6 +5,7 @@ using System.Reflection; using System.Reflection.Emit; using DbReader.DynamicArguments; +using DbReader.Tracking; namespace DbReader { diff --git a/src/DbReader/DynamicArguments/DynamicValueExtractor.cs b/src/DbReader/DynamicArguments/DynamicValueExtractor.cs index ddcb00c..1e0f893 100644 --- a/src/DbReader/DynamicArguments/DynamicValueExtractor.cs +++ b/src/DbReader/DynamicArguments/DynamicValueExtractor.cs @@ -5,6 +5,7 @@ using System.Reflection; using System.Reflection.Emit; using DbReader.Extensions; +using DbReader.Tracking; namespace DbReader.DynamicArguments { @@ -15,6 +16,7 @@ public class DynamicValueExtractor { private static MethodInfo AddMethod = typeof(List).GetMethod(nameof(List.Add)); private static ConcurrentDictionary delegateCache = new ConcurrentDictionary(); + private static ConcurrentDictionary trackedDelegateCache = new ConcurrentDictionary(); /// /// Creates an array of based upon the given . @@ -25,16 +27,48 @@ public class DynamicValueExtractor public DynamicMemberInfo[] GetDynamicMembers(T value) { var dynamicMembers = new List(); - var extractDelegate = delegateCache.GetOrAdd(typeof(T), d => CreateExtractDelegate()); + Delegate extractDelegate = null; + if (value is ITrackedObject trackedObject) + { + + var modifiedProperties = trackedObject.GetModifiedProperties(); + string[] cacheKeyParts = new string[modifiedProperties.Count + 1]; + modifiedProperties.CopyTo(cacheKeyParts, 0); + cacheKeyParts[modifiedProperties.Count] = typeof(T).FullName; + var cacheKey = cacheKeyParts.CreateCacheKey(); + extractDelegate = trackedDelegateCache.GetOrAdd(cacheKey, d => CreateExtractDelegate(value)); + } + else + { + extractDelegate = delegateCache.GetOrAdd(typeof(T), d => CreateExtractDelegate(value)); + } + + + var typedDelegate = (Action>)extractDelegate; typedDelegate(value, dynamicMembers); return dynamicMembers.ToArray(); } - private Delegate CreateExtractDelegate() + private static Delegate CreateExtractDelegate(T value) { - var fieldsAndProperties = typeof(T).GetMembers(BindingFlags.Instance | BindingFlags.Public). - Where(m => m.MemberType == MemberTypes.Field || m.MemberType == MemberTypes.Property).OrderByDeclaration().ToArray(); + MemberInfo[] fieldsAndProperties = null; + + if (typeof(ITrackedObject).IsAssignableFrom(typeof(T))) + { + var modifiedProperties = ((ITrackedObject)value).GetModifiedProperties(); + fieldsAndProperties = typeof(T).GetMembers(BindingFlags.Instance | BindingFlags.Public) + .Where(m => m.MemberType == MemberTypes.Field || m.MemberType == MemberTypes.Property) + .Where(m => modifiedProperties.Contains(m.Name)).OrderByDeclaration().ToArray(); + + } + else + { + fieldsAndProperties = typeof(T).GetMembers(BindingFlags.Instance | BindingFlags.Public). + Where(m => m.MemberType == MemberTypes.Field || m.MemberType == MemberTypes.Property).OrderByDeclaration().ToArray(); + } + + var extractMethod = new DynamicMethod("Extract", typeof(void), new Type[] { typeof(T), typeof(List) }, typeof(DynamicValueExtractor), true); var generator = extractMethod.GetILGenerator(); diff --git a/src/DbReader/DynamicArguments/ValueAccessor.cs b/src/DbReader/DynamicArguments/ValueAccessor.cs index 40cbca2..5a6f4b4 100644 --- a/src/DbReader/DynamicArguments/ValueAccessor.cs +++ b/src/DbReader/DynamicArguments/ValueAccessor.cs @@ -12,9 +12,7 @@ namespace DbReader.DynamicArguments public static class ValueAccessor { private static ConcurrentDictionary delegateCache = new ConcurrentDictionary(); - - private static Delegate cachedDelegate; - + /// /// Gets the field value based on the given . /// diff --git a/src/DbReader/Extensions/StringExtensions.cs b/src/DbReader/Extensions/StringExtensions.cs index fdb4916..dee1c59 100644 --- a/src/DbReader/Extensions/StringExtensions.cs +++ b/src/DbReader/Extensions/StringExtensions.cs @@ -1,5 +1,8 @@ namespace DbReader.Extensions { + using System; + using System.Security.Cryptography; + using System.Text; using System.Text.RegularExpressions; /// @@ -8,7 +11,7 @@ namespace DbReader.Extensions public static class StringExtensions { private static readonly Regex LowerCaseRegex = new Regex("[a-z]"); - + /// /// Applies the given and returns the formatted string. /// @@ -29,5 +32,50 @@ public static string GetUpperCaseLetters(this string value) { return LowerCaseRegex.Replace(value, string.Empty); } + + /// + /// Creates a cache key based upon the given . + /// + /// The strings for which to create a cache key. + /// A cache key based upon the given . + public static string CreateCacheKey(this string[] keys) + { + // Calculate the total length of the combined key + int totalLength = 0; + foreach (var key in keys) + { + totalLength += key.Length; + } + totalLength += keys.Length - 1; // Account for delimiters + + // Use stackalloc to avoid heap allocations for small keys + Span buffer = totalLength <= 256 ? stackalloc char[totalLength] : new char[totalLength]; + + // Combine the keys into the buffer + int position = 0; + for (int i = 0; i < keys.Length; i++) + { + keys[i].AsSpan().CopyTo(buffer.Slice(position)); + position += keys[i].Length; + + if (i < keys.Length - 1) + { + buffer[position] = '|'; // Delimiter + position++; + } + } + + // Convert the Span to a byte array using UTF8 encoding + int byteCount = Encoding.UTF8.GetByteCount(buffer); + Span byteBuffer = byteCount <= 256 ? stackalloc byte[byteCount] : new byte[byteCount]; + Encoding.UTF8.GetBytes(buffer, byteBuffer); + + // Generate a hash of the combined key + using (var sha256 = SHA256.Create()) + { + byte[] hashBytes = sha256.ComputeHash(byteBuffer.ToArray()); + return Convert.ToHexString(hashBytes).ToLowerInvariant(); + } + } } } \ No newline at end of file diff --git a/src/DbReader/Nuget/DbReader.nuspec b/src/DbReader/Nuget/DbReader.nuspec index cb6acb4..d2a9556 100644 --- a/src/DbReader/Nuget/DbReader.nuspec +++ b/src/DbReader/Nuget/DbReader.nuspec @@ -7,7 +7,7 @@ Bernhard Richter http://opensource.org/licenses/MIT https://github.com/seesharper/DbReader - false + false A simple and fast database reader for the .Net framework. Bernhard Richter data-access orm sql micro-orm diff --git a/src/DbReader/Tracking/ITrackedObject.cs b/src/DbReader/Tracking/ITrackedObject.cs new file mode 100644 index 0000000..102a4f7 --- /dev/null +++ b/src/DbReader/Tracking/ITrackedObject.cs @@ -0,0 +1,15 @@ +namespace DbReader.Tracking; + +using System.Collections.Generic; + +/// +/// This interface is implemented by classes that are tracked for changes. +/// +public interface ITrackedObject +{ + /// + /// Gets a list of the properties that have been modified. + /// + /// + HashSet GetModifiedProperties(); +} \ No newline at end of file diff --git a/src/DbReader/Tracking/TrackedAttribute.cs b/src/DbReader/Tracking/TrackedAttribute.cs new file mode 100644 index 0000000..9b31bd7 --- /dev/null +++ b/src/DbReader/Tracking/TrackedAttribute.cs @@ -0,0 +1,12 @@ +using System; + +namespace DbReader.Tracking; + +/// +/// Used to indicate that a class should be tracked. +/// Note that the DbReader.Tracking package is required to use this attribute. +/// +[AttributeUsage(AttributeTargets.Class)] +public class TrackedAttribute : Attribute +{ +} \ No newline at end of file