Updating Cynk with .NET 8.0 and C#

Cynk is a routine I use to synchronize two lists and felt it was a good time to update it using .NET 8.0 and C# 12.

Written by Jonathan "JD" Danylko • Last Updated: • Develop •

Two dogs on a split leash

Notice: This post is part of the Seventh Annual C# Advent Calendar for 2023. Check this link for more than 60 other great articles about C# created by the community. Thanks a lot to Matt Groves (@mgroves) for putting this together again! As always, awesome job, Matt!

When working with lists of entities or arrays, there are times when a user wants to synchronize all of the changes in a list. What's been added, updated, or deleted? While this can be frustrating, this little routine called Cynk (pronounced Sync) examines a list and reports back on what needs added, updated, or deleted.

For example, a master/detail screen is a good example. Everything nowadays can be accomplished and managed in the browser. When sending the data back to the server, you now have two sets of records: one set of records from the user and the existing records saved in a table.

Which records do you update, delete, or add to the table?

Consider these rules for updating a list:

  • Added records - found in the source list, but missing from the target list
  • Updated records - have the same primary key and each property can be examined for changes in the data
  • Deleted records - these records are found in the target, but missing from the source list 

This process requires some type of reconciliation between the two lists.

Overview of Syncing a List

Let's start with a simple native type list like a string list. When synchronizing a list, it's a simple comparison between the source item and the target item.

If we have a two string lists like below:

var source = new List<string> { "Apple", "Banana", "Cranapple", "Date" };
var target = new List<string> { "Apple", "Banana", "Cranapple", "Date", "Cherry" };

The target list has an extra item called "Cherry" and we need it added to the source list.

What if we had the reverse scenario where "Cherry" was in the source, but not in the target?

var source = new List<string> { "Apple", "Banana", "Cranapple", "Date", "Cherry" };
var target = new List<string> { "Apple", "Banana", "Cranapple", "Date" };

We would have to delete the item from the source list.

For the updating, the process is a bit different. Let's examine the following two lists.

var source = new List<string> { "Apple", "Banana", "Cranapple", "Date" };
var target = new List<string> { "Apple", "Banana", "Cranapple", "Data" };

The source has an item called "Date" and the target has an item called "Data". If we perform a comparison to try and find "Date" in the target, we'll assume it needs to be deleted since it can't find it. If we continue with this thinking, we look at the target and notice try to locate "Data" in the source. Since it can't find it, it needs to be added.

Since we're using a string list, we won't have any updated items.

To identify whether we have a simple type or not, we'll create an extension method to determine if a Type is simple or not (BTW, it's not like I haven't written about extension methods before, right?)

public static bool IsSimpleType(this Type type) =>
    type.IsPrimitive
    || type.IsValueType
    || new[]
    {
        typeof(string),
        typeof(decimal),
        typeof(bool),
        typeof(DateTime),
        typeof(DateTimeOffset),
        typeof(TimeSpan),
        typeof(Guid)
    }.Contains(type)
    || Convert.GetTypeCode(type) != TypeCode.Object;

This confirms our list isn't generic and it holds a simple type. We can test to see if it's simple by:

  • Check if it's a Primitive
  • Check if it's a ValueType
  • Check if it's contained in the a list of simple types, or
  • Check if the type cannot be converted into an object

If it matches any of these conditions, it's a simple type.

.NET 8 Feature: Primary Constructors

One feature that stands out above the rest in .NET 8.0 are primary constructors, which I'm guessing a number of authors on the C# Advent would be mentioning in their posts (like @Sadukie). Originally, they were made for records, but now work with all classes and structs in 8.0.

So let's create our class using a primary constructor.

public abstract class BaseCynk<T>(IList<T> source) where T : class
{
    protected IList<T> Source { get; } = source;

   public virtual CynkResult<T> Sync(IList<T> target) => null!;
    protected bool Compare(T target, T source) => target == source; }

If you notice the first line of the class, we're using a primary constructor passing in a List<T>. As we pass in the source list, we'll set the Source property as a getter. The benefit of the primary constructor is to put the class on a diet and remove the extra 3 lines for creating a constructor. More structure, less noise.

We'll make the the class abstract because of the different types of primary keys for the synchronization process (hence, the virtual on the Sync method) For now, we'll use a simple string list and keep the compare method as a string comparison.

Reporting our Results

When performing a synchronization, we need a way to know what will be added, updated, or deleted. Here is our "reporting" result.

public class CynkResult<T> where T : class
{
    public IList<T> Added { get; set; } = new List<T>();
    public IList<T> Updated { get; set; } = new List<T>();
    public IList<T> Deleted { get; set; } = new List<T>();
}

Think of the CynkResult<T> as a preview of what is required to synchronize between the two lists.

Synchonizing a List

Remember the abstract class BaseCynk<T>? Let's set up the general class for synchronizing a simple list.

public class Cynk<T>(IList<T> source) : BaseCynk<T>(source) 
    where T: class
{
    public override CynkResult<T> Sync(IList<T> target) =>
        new()
        {
            Added = target.Where(e => Source.All(r => e != r))
                .ToList(),
            Updated = new List<T>(),
            Deleted = Source.Where(e => target.All(r => e != r))
                .ToList()
        };
}

For our primary constructor, we're only using simple types for our sync process, but we'll need to expand on this further for complex types.

If we take our "Added" example from above and create a test from it, you'll see it's working as expected.

[TestMethod]
public void CynkAddedTest()
{
    // Arrange
    var source = new List<string> { "Apple", "Banana", "Cranapple", "Date" };
    var target = new List<string> { "Apple", "Banana", "Cranapple", "Date", "Cherry" };

   // Act     var results = new Cynk<string>(source).Sync(target);
   // Assert     Assert.IsTrue(results.Added.Count == 1); }

Our single added item is "Cherry" in the Added list. If we test the Update and Delete functionality, they both seem to work.

[TestMethod]
public void CynkDeleteTest()
{
    // Arrange
    var source = new List<string> { "Apple", "Banana", "Cranapple", "Date" };
    var target = new List<string> { "Apple", "Cranapple", "Date" };

    // Act     var results = new Cynk<string>(source)         .Sync(target);
    // Assert     Assert.IsTrue(results.Deleted.Count == 1); }
[TestMethod] public void CynkUpdateTest() {     // Arrange     var source = new List<string> { "Apple", "Banana", "Cranapple", "Date" };     var target = new List<string> { "Apple", "Banana", "Cranapple", "Data" };

   // Act     var results = new Cynk<string>(source)         .Sync(target);
    // Assert     Assert.IsTrue(results.Added.Count == 1);     Assert.IsTrue(results.Deleted.Count == 1);     Assert.IsTrue(results.Updated.Count == 0); }

So why does the Added and Deleted list have 1 element each?

If you look at the source list, we have a element called "Date" and a "Data" in the target list. In the Deleted list, we have a "Date" item since we can't find it in the target list. In the Added list, we have a "Data" item since we can't find it in the source list.

With simple lists, this makes sense with strings, integers, and Guids, but what about complex objects?


Synchronizing a Generic List

Things are a little different when it comes to a generic lists.

What if we have a List of type Product (meaning List<Product>)? How could we synchronize changes between a list of two products? We'd need to identify our primary key for our type (Product).

We need the ability to pass in the primary key so we can locate the item in the list. But this primary identifier can be a string, integer, or even a Guid.

So let's start with a simple Product class list in our tests. The Product class has an Id and a Title.

private List<Product> _sourceProducts = null!;
private List<Product> _targetProducts = null!;
 
[TestInitialize]
public void Setup()
{
    _sourceProducts = new()
    {
        new() { Id = 1, Title = "Product 1" },
        new() { Id = 2, Title = "Product 2" }
    };
    _targetProducts = new List<Product>
    {
        new() { Id = 1, Title = "Product 1" },
        new() { Id = 2, Title = "Product 3" }
    };
}

In our test, we are changing the Title to "Product 3" in the target.

Of course, we need to make some changes to our BaseCynk constructor where we have to accommodate complex types.

We'll start with the Compare method since we'll need to compare two objects.

protected bool Compare(T target, T source)
{
    if (target.GetType().IsSimpleType())
    {
        return target == source;
    }

   if (source == null) return false;
   var sourceProperties = source.ToPropertyDictionary();     var targetProperties = target.ToPropertyDictionary();
    return sourceProperties         .Select(sourceProperty => new         {             sourceProperty,             targetProperty = targetProperties                 .First(r => r.Key == sourceProperty.Key)         })         .Where(t => !t.targetProperty.Value.Equals(t.sourceProperty.Value))         .Select(t => t.sourceProperty)         .Any(); }

The purpose of the Compare method is to compare two items and return whether they are equal or not. With simple types like strings and integers, it's easy.

However, when it comes to looking at complex types (like a Product type), it can be difficult to find out what values changed in an object's properties.

Luckily, we have an extension method called .ToPropertyDictionary().

.NET 8 Feature: FrozenDictionary<TKey,TValue>

The FrozenDictionary is an immutable, read-only dictionary used for extremely fast lookups.

While this is a new feature and NOT where you'd use it, I wanted to see how it worked and show it's implementation. 

The only reason you'd use the FrozenDictionary is when a dictionary is created infrequently, but used frequently at runtime. It's specifically meant for highly-performant lookups. A specific example would be creating a configuration Dictionary at Application Startup making it accessible throughout the application. 

The .ToPropertyDictionary() extension method gets a list of properties and their values.

public static FrozenDictionary<string, string>
    ToPropertyDictionary(this object obj) =>
    obj.GetType().GetProperties()         .Select(e =>             new KeyValuePair<string, string>(                 e.Name, e.GetValue(obj, null)!                     .ToString() ?? string.Empty))         .ToFrozenDictionary();

With our .ToPropertyDictionary() finished, our Compare checks to see which values changed and returns a true if they changed and a false if they haven't.

Creating a Cynk With Integers

Our general Cynk class synchronizes simple lists, but we need to create a process for complex types.

We can add a Func<T> parameter to our new class for identifying a primary key. This is why we created an abstract class in the beginning.

public class CynkWithInt<T>(IList<T> source, Func<T, int> key)
    : BaseCynk<T>(source)
    where T : class
{
    public override CynkResult<T> Sync(IList<T> target) =>
        new()
        {
            Added = target
                .Where(e => Source.All(r => key(e) != key(r)))
                .ToList(),
            Updated = target.Where(e =>
                    Compare(e, Source.FirstOrDefault(r => key(e) == key(r))!))
                .ToList(),
            Deleted = Source
                .Where(e => target.All(r => key(e) != key(r)))
                .ToList()
        };
}

Notice the parameter in our primary constructor? It's a Func<T, int> in a parameter named key.

Since the key parameter is used in the method, the primary constructor makes it a private member of the class so we can use it later in the .Sync() method.

If you wanted to use strings or Guids later, we would create a new class using the primary key type.

public class CynkWithGuid<T>(IList<T> source, Func<T, Guid> key)
    : BaseCynk<T>(source)
    where T : class

The funny thing about the Sync method is it would be an exact copy of the CynkWithInt.Sync() method because the key parameter would be returning a different type for it's primary key since it's scoped to the Sync() method. 

Our unit test for complex objects with integers would be structured as shown below.

[TestMethod]
public void CynkUpdateObjectTest()
{
    // Arrange (in Setup)
 
    // Act
    var results = new CynkWithInt<Product>(_sourceProducts, item => item.Id)
        .Sync(_targetProducts);
 
    // Assert
    Assert.IsTrue(results.Updated.Count == 1);
    Assert.IsTrue(results.Updated[0].Title.Equals("Product 3"));
}

Our unit test shows we have 1 item in the Updated list and the Product instance requires the Title updated to "Product 3". At this point, look at all of the Updated records in the list and pull them by Id and update the properties.

If you want to use this technique, check out the GitHub repository.

Conclusion

In this post, I took an existing routine I use a lot for synchronizing lists and decided to update it to .NET 8.0/C# 12. We also introduced two new features of C# in .NET 8.0: primary constructors and the FrozenDictionary., but there's soooo much more to .NET 8.0.

Make sure you look at all of the updates in .NET 8.0. Not only is it jam-packed with features, it's also a LTS (Long-Term Support) which is good because it'll take us just as long to find everything in the release. ;-)

This is one of the reasons I use C# for just about everything since I haven't found a problem I couldn't solve with C#.

Everyone have a happy holiday and enjoy the C# Advent Calendar...there's more to come, folks!

Was this a helpful post? Did I miss something in the code? Post your comments below and let's discuss.

Did you like this content? Show your support by buying me a coffee.

Buy me a coffee  Buy me a coffee
Picture of Jonathan "JD" Danylko

Jonathan Danylko is a web architect and entrepreneur who's been programming for over 25 years. He's developed websites for small, medium, and Fortune 500 companies since 1996.

He currently works at Insight Enterprises as an Principal Software Engineer Architect.

When asked what he likes to do in his spare time, he replies, "I like to write and I like to code. I also like to write about code."

comments powered by Disqus