carlfranklin / BlazorRepositoryDemo

A Blazor demo of implementing a generic repository that can be used on the client and the server, and includes query filtering.
42 stars 11 forks source link

Building Reusable Back-End Repositories

Overview

In this module, we will create a Blazor WebAssembly application with an API layer that uses the repository pattern to access two different data layers using a common interface, IRepository, which we will define.

We will use IRepository on the server to access data, and also on the client to define a generic API Repository, which wraps calls to the API with an HttpClient object.

In fact, we will be using generics everywhere to build our classes. We want to avoid repeating boilerplate code for each entity we want to access, or data store.

This demo goes way beyond using the repository pattern. This is what we will accomplish:

Create a Global WebAssembly Blazor Web App

Create a Blazor Web App called RepositoryDemo. This will create two projects: RepositoryDemo (server) and RepositoryDemo.Client (client).

Make sure you select the following options:

image-20240423085451476

Install NewtonSoft.Json

Right-click on the Solution file, and select Manage NuGet Packages for Solution...

Browse for "Newtonsoft.Json" and install in both the Client and Server projects.

Install AvnRepository

Browse for "AvnRepository" and install in both the Client and Server projects.

AvnRepository contains all the plumbing code for building repositories.

Add Models

To the Client app, add a Models folder and add the following files to it:

Customer.cs

namespace RepositoryDemo.Client.Models;

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public string Email { get; set; } = "";
}

Customer will serve as our primary demo model.

Examine the classes in AvnRepository

APIEntityResponse.cs

public class APIEntityResponse<TEntity> where TEntity : class
{
    public bool Success { get; set; }
    public List<string> ErrorMessages { get; set; } = new List<string>();
    public TEntity Data { get; set; }
}

APIListOfEntityResponse.cs

public class APIListOfEntityResponse<TEntity> where TEntity : class
{
    public bool Success { get; set; }
    public List<string> ErrorMessages { get; set; } = new List<string>();
    public IEnumerable<TEntity> Data { get; set; }
}

These two classes will be used as return types for our API controllers to add a little context to the actual entities returned.

IRepository.cs

using Microsoft.EntityFrameworkCore;
using System.Linq.Expressions;
using System.Reflection;
#nullable disable
/// <summary>
/// Generic repository interface that uses
/// </summary>
/// <typeparam name="TEntity"></typeparam>
public interface IRepository<TEntity> where TEntity : class
{
    Task<bool> DeleteAsync(TEntity EntityToDelete);
    Task<bool> DeleteByIdAsync(object Id);
    Task DeleteAllAsync(); // Be Careful!!!
    Task<IEnumerable<TEntity>> GetAsync(QueryFilter<TEntity> Filter);
    Task<IEnumerable<TEntity>> GetAllAsync();
    Task<TEntity> GetByIdAsync(object Id);
    Task<TEntity> InsertAsync(TEntity Entity);
    Task<TEntity> UpdateAsync(TEntity EntityToUpdate);
}

The IRepository<TEntity> interface will be used on the server as well as the client to ensure compatibility accessing data, no matter where the code resides.

This class file also includes code to describe custom queries that can easily be sent and received as json.

QueryFilter.cs:

using Microsoft.EntityFrameworkCore;
using System.Linq.Expressions;
using System.Reflection;
#nullable disable
namespace AvnRepository;
/// <summary>
/// A serializable filter. An alternative to trying to serialize and deserialize LINQ expressions,
/// which are very finicky. This class uses standard types. 
/// </summary>
/// <typeparam name="TEntity"></typeparam>
public class QueryFilter<TEntity> where TEntity : class
{
    /// <summary>
    /// If you want to return a subset of the properties, you can specify only
    /// the properties that you want to retrieve in the SELECT clause.
    /// Leave empty to return all columns
    /// </summary>
    public List<string> IncludePropertyNames { get; set; } = new List<string>();

    /// <summary>
    /// Defines the property names and values in the WHERE clause
    /// </summary>
    public List<FilterProperty> FilterProperties { get; set; } = new List<FilterProperty>();

    /// <summary>
    /// Specify the property to ORDER BY, if any 
    /// </summary>
    public string OrderByPropertyName { get; set; } = "";

    /// <summary>
    /// Set to true if you want to order DESCENDING
    /// </summary>
    public bool OrderByDescending { get; set; } = false;

    /// <summary>
    /// A custome query that returns a list of entities with the current filter settings.
    /// </summary>
    /// <param name="AllItems"></param>
    /// <returns></returns>
    public IEnumerable<TEntity> GetFilteredList(IEnumerable<TEntity> AllItems)
    {
        // Convert to IQueryable
        var query = AllItems.AsQueryable<TEntity>();

        // the expression will be used for each FilterProperty
        Expression<Func<TEntity, bool>> expression = null;

        // Process each property
        foreach (var filterProperty in FilterProperties)
        {
            // use reflection to get the property info
            PropertyInfo prop = typeof(TEntity).GetProperty(filterProperty.Name);

            // string
            if (prop.PropertyType == typeof(string))
            {
                string value = filterProperty.Value.ToString();
                if (filterProperty.CaseSensitive == false)
                    value = value.ToLower();

                if (filterProperty.Operator == FilterOperator.Equals)
                    if (filterProperty.CaseSensitive == false)
                        expression = s => s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString().ToLower() == value;
                    else
                        expression = s => s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString() == value;
                else if (filterProperty.Operator == FilterOperator.NotEquals)
                    if (filterProperty.CaseSensitive == false)
                        expression = s => s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString().ToLower() != value;
                    else
                        expression = s => s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString() != value;
                else if (filterProperty.Operator == FilterOperator.StartsWith)
                    if (filterProperty.CaseSensitive == false)
                        expression = s => s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString().ToLower().StartsWith(value);
                    else
                        expression = s => s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString().StartsWith(value);
                else if (filterProperty.Operator == FilterOperator.EndsWith)
                    if (filterProperty.CaseSensitive == false)
                        expression = s => s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString().ToLower().EndsWith(value);
                    else
                        expression = s => s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString().EndsWith(value);
                else if (filterProperty.Operator == FilterOperator.Contains)
                    if (filterProperty.CaseSensitive == false)
                        expression = s => s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString().ToLower().Contains(value);
                    else
                        expression = s => s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString().Contains(value);
            }
            // Int16
            else if (prop.PropertyType == typeof(Int16))
            {
                int value = Convert.ToInt16(filterProperty.Value);

                if (filterProperty.Operator == FilterOperator.Equals)
                    expression = s => Convert.ToInt16(s.GetType().GetProperty(filterProperty.Name).GetValue(s)) == value;
                else if (filterProperty.Operator == FilterOperator.NotEquals)
                    expression = s => Convert.ToInt16(s.GetType().GetProperty(filterProperty.Name).GetValue(s)) != value;
                else if (filterProperty.Operator == FilterOperator.LessThan)
                    expression = s => Convert.ToInt16(s.GetType().GetProperty(filterProperty.Name).GetValue(s)) < value;
                else if (filterProperty.Operator == FilterOperator.GreaterThan)
                    expression = s => Convert.ToInt16(s.GetType().GetProperty(filterProperty.Name).GetValue(s)) > value;
                else if (filterProperty.Operator == FilterOperator.LessThanOrEqual)
                    expression = s => Convert.ToInt16(s.GetType().GetProperty(filterProperty.Name).GetValue(s)) <= value;
                else if (filterProperty.Operator == FilterOperator.GreaterThanOrEqual)
                    expression = s => Convert.ToInt16(s.GetType().GetProperty(filterProperty.Name).GetValue(s)) >= value;
            }
            // Int32
            else if (prop.PropertyType == typeof(Int32))
            {
                int value = Convert.ToInt32(filterProperty.Value);

                if (filterProperty.Operator == FilterOperator.Equals)
                    expression = s => Convert.ToInt32(s.GetType().GetProperty(filterProperty.Name).GetValue(s)) == value;
                else if (filterProperty.Operator == FilterOperator.NotEquals)
                    expression = s => Convert.ToInt32(s.GetType().GetProperty(filterProperty.Name).GetValue(s)) != value;
                else if (filterProperty.Operator == FilterOperator.LessThan)
                    expression = s => Convert.ToInt32(s.GetType().GetProperty(filterProperty.Name).GetValue(s)) < value;
                else if (filterProperty.Operator == FilterOperator.GreaterThan)
                    expression = s => Convert.ToInt32(s.GetType().GetProperty(filterProperty.Name).GetValue(s)) > value;
                else if (filterProperty.Operator == FilterOperator.LessThanOrEqual)
                    expression = s => Convert.ToInt32(s.GetType().GetProperty(filterProperty.Name).GetValue(s)) <= value;
                else if (filterProperty.Operator == FilterOperator.GreaterThanOrEqual)
                    expression = s => Convert.ToInt32(s.GetType().GetProperty(filterProperty.Name).GetValue(s)) >= value;
            }
            // Int64
            else if (prop.PropertyType == typeof(Int64))
            {
                Int64 value = Convert.ToInt64(filterProperty.Value);

                if (filterProperty.Operator == FilterOperator.Equals)
                    expression = s => Convert.ToInt64(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) == value;
                else if (filterProperty.Operator == FilterOperator.NotEquals)
                    expression = s => Convert.ToInt64(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) != value;
                else if (filterProperty.Operator == FilterOperator.LessThan)
                    expression = s => Convert.ToInt64(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) < value;
                else if (filterProperty.Operator == FilterOperator.GreaterThan)
                    expression = s => Convert.ToInt64(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) > value;
                else if (filterProperty.Operator == FilterOperator.LessThanOrEqual)
                    expression = s => Convert.ToInt64(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) <= value;
                else if (filterProperty.Operator == FilterOperator.GreaterThanOrEqual)
                    expression = s => Convert.ToInt64(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) >= value;
            }
            // UInt16
            else if (prop.PropertyType == typeof(UInt16))
            {
                UInt16 value = Convert.ToUInt16(filterProperty.Value);

                if (filterProperty.Operator == FilterOperator.Equals)
                    expression = s => Convert.ToUInt16(s.GetType().GetProperty(filterProperty.Name).GetValue(s)) == value;
                else if (filterProperty.Operator == FilterOperator.NotEquals)
                    expression = s => Convert.ToUInt16(s.GetType().GetProperty(filterProperty.Name).GetValue(s)) != value;
                else if (filterProperty.Operator == FilterOperator.LessThan)
                    expression = s => Convert.ToUInt16(s.GetType().GetProperty(filterProperty.Name).GetValue(s)) < value;
                else if (filterProperty.Operator == FilterOperator.GreaterThan)
                    expression = s => Convert.ToUInt16(s.GetType().GetProperty(filterProperty.Name).GetValue(s)) > value;
                else if (filterProperty.Operator == FilterOperator.LessThanOrEqual)
                    expression = s => Convert.ToUInt16(s.GetType().GetProperty(filterProperty.Name).GetValue(s)) <= value;
                else if (filterProperty.Operator == FilterOperator.GreaterThanOrEqual)
                    expression = s => Convert.ToUInt16(s.GetType().GetProperty(filterProperty.Name).GetValue(s)) >= value;
            }
            // UInt32
            else if (prop.PropertyType == typeof(UInt32))
            {
                UInt32 value = Convert.ToUInt32(filterProperty.Value);

                if (filterProperty.Operator == FilterOperator.Equals)
                    expression = s => Convert.ToUInt32(s.GetType().GetProperty(filterProperty.Name).GetValue(s)) == value;
                else if (filterProperty.Operator == FilterOperator.NotEquals)
                    expression = s => Convert.ToUInt32(s.GetType().GetProperty(filterProperty.Name).GetValue(s)) != value;
                else if (filterProperty.Operator == FilterOperator.LessThan)
                    expression = s => Convert.ToUInt32(s.GetType().GetProperty(filterProperty.Name).GetValue(s)) < value;
                else if (filterProperty.Operator == FilterOperator.GreaterThan)
                    expression = s => Convert.ToUInt32(s.GetType().GetProperty(filterProperty.Name).GetValue(s)) > value;
                else if (filterProperty.Operator == FilterOperator.LessThanOrEqual)
                    expression = s => Convert.ToUInt32(s.GetType().GetProperty(filterProperty.Name).GetValue(s)) <= value;
                else if (filterProperty.Operator == FilterOperator.GreaterThanOrEqual)
                    expression = s => Convert.ToUInt32(s.GetType().GetProperty(filterProperty.Name).GetValue(s)) >= value;
            }
            // UInt64
            else if (prop.PropertyType == typeof(UInt64))
            {
                UInt64 value = Convert.ToUInt64(filterProperty.Value);

                if (filterProperty.Operator == FilterOperator.Equals)
                    expression = s => Convert.ToUInt64(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) == value;
                else if (filterProperty.Operator == FilterOperator.NotEquals)
                    expression = s => Convert.ToUInt64(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) != value;
                else if (filterProperty.Operator == FilterOperator.LessThan)
                    expression = s => Convert.ToUInt64(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) < value;
                else if (filterProperty.Operator == FilterOperator.GreaterThan)
                    expression = s => Convert.ToUInt64(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) > value;
                else if (filterProperty.Operator == FilterOperator.LessThanOrEqual)
                    expression = s => Convert.ToUInt64(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) <= value;
                else if (filterProperty.Operator == FilterOperator.GreaterThanOrEqual)
                    expression = s => Convert.ToUInt64(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) >= value;
            }
            // DateTime
            else if (prop.PropertyType == typeof(DateTime))
            {
                DateTime value = DateTime.Parse(filterProperty.Value);

                if (filterProperty.Operator == FilterOperator.Equals)
                    expression = s => DateTime.Parse(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) == value;
                else if (filterProperty.Operator == FilterOperator.NotEquals)
                    expression = s => DateTime.Parse(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) != value;
                else if (filterProperty.Operator == FilterOperator.LessThan)
                    expression = s => DateTime.Parse(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) < value;
                else if (filterProperty.Operator == FilterOperator.GreaterThan)
                    expression = s => DateTime.Parse(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) > value;
                else if (filterProperty.Operator == FilterOperator.LessThanOrEqual)
                    expression = s => DateTime.Parse(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) <= value;
                else if (filterProperty.Operator == FilterOperator.GreaterThanOrEqual)
                    expression = s => DateTime.Parse(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) >= value;
            }
            // decimal
            else if (prop.PropertyType == typeof(decimal))
            {
                decimal value = Convert.ToDecimal(filterProperty.Value);

                if (filterProperty.Operator == FilterOperator.Equals)
                    expression = s => Convert.ToDecimal(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) == value;
                else if (filterProperty.Operator == FilterOperator.NotEquals)
                    expression = s => Convert.ToDecimal(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) != value;
                else if (filterProperty.Operator == FilterOperator.LessThan)
                    expression = s => Convert.ToDecimal(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) < value;
                else if (filterProperty.Operator == FilterOperator.GreaterThan)
                    expression = s => Convert.ToDecimal(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) > value;
                else if (filterProperty.Operator == FilterOperator.LessThanOrEqual)
                    expression = s => Convert.ToDecimal(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) <= value;
                else if (filterProperty.Operator == FilterOperator.GreaterThanOrEqual)
                    expression = s => Convert.ToDecimal(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) >= value;
            }
            // Single
            else if (prop.PropertyType == typeof(Single))
            {
                Single value = Convert.ToSingle(filterProperty.Value);

                if (filterProperty.Operator == FilterOperator.Equals)
                    expression = s => Convert.ToSingle(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) == value;
                else if (filterProperty.Operator == FilterOperator.NotEquals)
                    expression = s => Convert.ToSingle(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) != value;
                else if (filterProperty.Operator == FilterOperator.LessThan)
                    expression = s => Convert.ToSingle(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) < value;
                else if (filterProperty.Operator == FilterOperator.GreaterThan)
                    expression = s => Convert.ToSingle(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) > value;
                else if (filterProperty.Operator == FilterOperator.LessThanOrEqual)
                    expression = s => Convert.ToSingle(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) <= value;
                else if (filterProperty.Operator == FilterOperator.GreaterThanOrEqual)
                    expression = s => Convert.ToSingle(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) >= value;
            }
            // Double
            else if (prop.PropertyType == typeof(Single))
            {
                Double value = Convert.ToDouble(filterProperty.Value);

                if (filterProperty.Operator == FilterOperator.Equals)
                    expression = s => Convert.ToDouble(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) == value;
                else if (filterProperty.Operator == FilterOperator.NotEquals)
                    expression = s => Convert.ToDouble(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) != value;
                else if (filterProperty.Operator == FilterOperator.LessThan)
                    expression = s => Convert.ToDouble(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) < value;
                else if (filterProperty.Operator == FilterOperator.GreaterThan)
                    expression = s => Convert.ToDouble(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) > value;
                else if (filterProperty.Operator == FilterOperator.LessThanOrEqual)
                    expression = s => Convert.ToDouble(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) <= value;
                else if (filterProperty.Operator == FilterOperator.GreaterThanOrEqual)
                    expression = s => Convert.ToDouble(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) >= value;
            }
            // Boolean
            else if (prop.PropertyType == typeof(bool))
            {
                bool value = Convert.ToBoolean(filterProperty.Value);

                if (filterProperty.Operator == FilterOperator.Equals)
                    expression = s => Convert.ToBoolean(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) == value;
                else if (filterProperty.Operator == FilterOperator.NotEquals)
                    expression = s => Convert.ToBoolean(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) != value;
            }
            // Byte
            else if (prop.PropertyType == typeof(Byte))
            {
                Byte value = Convert.ToByte(filterProperty.Value);

                if (filterProperty.Operator == FilterOperator.Equals)
                    expression = s => Convert.ToByte(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) == value;
                else if (filterProperty.Operator == FilterOperator.NotEquals)
                    expression = s => Convert.ToByte(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) != value;
                else if (filterProperty.Operator == FilterOperator.LessThan)
                    expression = s => Convert.ToByte(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) < value;
                else if (filterProperty.Operator == FilterOperator.GreaterThan)
                    expression = s => Convert.ToByte(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) > value;
                else if (filterProperty.Operator == FilterOperator.LessThanOrEqual)
                    expression = s => Convert.ToByte(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) <= value;
                else if (filterProperty.Operator == FilterOperator.GreaterThanOrEqual)
                    expression = s => Convert.ToByte(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) >= value;
            }
            // Char
            else if (prop.PropertyType == typeof(Char))
            {
                Char value = Convert.ToChar(filterProperty.Value);

                if (filterProperty.Operator == FilterOperator.Equals)
                    expression = s => Convert.ToChar(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) == value;
                else if (filterProperty.Operator == FilterOperator.NotEquals)
                    expression = s => Convert.ToChar(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) != value;
                else if (filterProperty.Operator == FilterOperator.LessThan)
                    expression = s => Convert.ToChar(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) < value;
                else if (filterProperty.Operator == FilterOperator.GreaterThan)
                    expression = s => Convert.ToChar(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) > value;
                else if (filterProperty.Operator == FilterOperator.LessThanOrEqual)
                    expression = s => Convert.ToChar(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) <= value;
                else if (filterProperty.Operator == FilterOperator.GreaterThanOrEqual)
                    expression = s => Convert.ToChar(s.GetType().GetProperty(filterProperty.Name).GetValue(s).ToString()) >= value;
            }
            // Add expression creation code for other data types here.

            // apply the expression
            query = query.Where(expression);

        }

        // Include the specified properties
        foreach (var includeProperty in IncludePropertyNames)
        {
            query = query.Include(includeProperty);
        }

        // order by
        if (OrderByPropertyName != "")
        {
            PropertyInfo prop = typeof(TEntity).GetProperty(OrderByPropertyName);
            if (prop != null)
            {
                if (OrderByDescending)
                    query = query.Where(expression).OrderByDescending(x => prop.GetValue(x, null));
                else
                    query = query.Where(expression).OrderBy(x => prop.GetValue(x, null));
            }
        }
        // execute and return the list
        return query.ToList();
    }
}

The QueryFilter<TEntity> can be used on the client as well as the server to define the same level of filter as using LINQ, except that it easily travels across the wire.

FilterProperty.cs:

/// <summary>
/// Defines a property for the WHERE clause
/// </summary>
public class FilterProperty
{
    public string Name { get; set; } = "";
    public string Value { get; set; } = "";
    public FilterOperator Operator { get; set; }
    public bool CaseSensitive { get; set; } = false;
}

The FilterProperty class defines columns to compare:

FilterOperator.cs:

/// <summary>
/// Specify the compare operator
/// </summary>
public enum FilterOperator
{
    Equals,
    NotEquals,
    StartsWith,
    EndsWith,
    Contains,
    LessThan,
    GreaterThan,
    LessThanOrEqual,
    GreaterThanOrEqual
}

Add Global Usings to Server

In the RepositoryDemo project, add the following statements to the very top of Program.cs:

global using System.Linq.Expressions;
global using System.Reflection;
global using AvnRepository;

Implement an In-Memory Repository

We're going to start by implementing an in-memory repository based on IRepository. Once that is working we'll move on to using Entity Framework against a local SQL database.

To the server project, add a Data folder, and add this class to it:

MemoryRepository.cs

public class MemoryRepository<TEntity> : IRepository<TEntity> where TEntity : class
{
    private List<TEntity> Data;
    private PropertyInfo IdProperty = null;
    private string IdPropertyName = "";
    public MemoryRepository(string idPropertyName)
    {
        IdPropertyName = idPropertyName;
        Data = new List<TEntity>();
        IdProperty = typeof(TEntity).GetProperty(idPropertyName);
    }

    public async Task<IEnumerable<TEntity>> GetAsync(QueryFilter<TEntity> Filter)
    {
        var allitems = (await GetAllAsync()).ToList();
        return Filter.GetFilteredList(allitems);
    }

    public async Task<IEnumerable<TEntity>> GetAllAsync()
    {
        return await Task.FromResult(Data); 
    }

    public async Task<TEntity> GetByIdAsync(object Id)
    {
        if (IdProperty == null) return default(TEntity);
        TEntity entity = null;
        if (IdProperty.PropertyType.IsValueType)
        {
            entity = (from x in Data
                      where IdProperty.GetValue(x).ToString() == Id.ToString()
                      select x).FirstOrDefault();
        }
        else
        {
            entity = (from x in Data
                      where IdProperty.GetValue(x) == Id
                      select x).FirstOrDefault();
        }
        return await Task.FromResult(entity);
    }

    public async Task<TEntity> InsertAsync(TEntity Entity)
    {
        if (Entity == null) 
            return await Task.FromResult(default(TEntity));

        try
        {
            lock (Data)
            {
                Data.Add(Entity);
            }
            return await Task.FromResult(Entity);
        }
        catch { }
        return await Task.FromResult(default(TEntity));
    }

    public async Task<TEntity> UpdateAsync(TEntity EntityToUpdate)
    {
        if (EntityToUpdate == null)
            return await Task.FromResult(default(TEntity));
        if (IdProperty == null)
            return await Task.FromResult(default(TEntity));
        try
        {
            var id = IdProperty.GetValue(EntityToUpdate);
            var entity = await GetByIdAsync(id);
            if (entity != null)
            {
                lock (Data)
                {
                    var index = Data.IndexOf(entity);
                    Data[index] = EntityToUpdate;
                }
                return await Task.FromResult(EntityToUpdate);
            }
            else
                return await Task.FromResult(default(TEntity));
        }
        catch { }
        return await Task.FromResult(default(TEntity));
    }

    public async Task<bool> DeleteAsync(TEntity EntityToDelete)
    {
        if (EntityToDelete == null)
            return await Task.FromResult(false);

        try
        {
            if (Data.Contains(EntityToDelete))
            {
                lock (Data)
                {
                    Data.Remove(EntityToDelete);
                }
                return await Task.FromResult(true);
            }
        }
        catch { }
        return await Task.FromResult(false);
    }

    public async Task<bool> DeleteByIdAsync(object Id)
    {
        try
        {
            var EntityToDelete = await GetByIdAsync(Id);
            return await DeleteAsync(EntityToDelete);
        }
        catch { }
        return false;
    }

    public async Task DeleteAllAsync()
    {
        await Task.Delay(0);
        lock (Data)
        {
            Data.Clear();
        }
    }
}

We're starting with a simple implementation of IRepository<TEntity> that simply stores data in memory.

It is completely generic, meaning we can define one for any entity type.

We're using a bit of Reflection to access the primary key property and get its value when we need to. Other than that, it's pretty straightforward. Take a moment to read through the code so you understand it.

Add MemoryRepository as a service

Add the following to the server project's Program.cs file, just before the line var app = builder.Build();

builder.Services.AddSingleton<MemoryRepository<Customer>>(x =>
    new MemoryRepository<Customer>("Id"));

You might be wondering why I didn't define it as IRepository<Customer>. The reason is so we can differentiate it from another implementation of IRepository<Customer> which we will be adding next.

We're adding it as a Singleton because we only want one instance on the server shared between all clients.

In the above code we're configuring this manager telling it that the primary key property of Customer is named "Id."

Add an API Controller

To the server project, add a Controllers folder, add the following:

InMemoryCustomersController.cs

using Microsoft.AspNetCore.Mvc;
using RepositoryDemo.Data;

namespace RepositoryDemo.Controllers;

[Route("[controller]")]
[ApiController]
public class InMemoryCustomersController : ControllerBase
{
    MemoryRepository<Customer> customersManager;

    public InMemoryCustomersController(MemoryRepository<Customer> _customersManager)
    {
        customersManager = _customersManager;
    }

    [HttpGet]
    public async Task<ActionResult<APIListOfEntityResponse<Customer>>> Get()
    {
        try
        {
            var result = await customersManager.GetAllAsync();
            return Ok(new APIListOfEntityResponse<Customer>()
            {
                Success = true,
                Data = result
            });
        }
        catch (Exception ex)
        {
            // log exception here
            return StatusCode(500);
        }
    }

    [HttpPost("getwithfilter")]
    public async Task<ActionResult<APIListOfEntityResponse<Customer>>>
        GetWithFilter([FromBody] QueryFilter<Customer> Filter)
    {
        try
        {
            var result = await customersManager.GetAsync(Filter);
            return Ok(new APIListOfEntityResponse<Customer>()
            {
                Success = true,
                Data = result
            });
        }
        catch (Exception ex)
        {
            // log exception here
            var msg = ex.Message;
            return StatusCode(500);
        }
    }

    [HttpGet("{Id}")]
    public async Task<ActionResult<APIEntityResponse<Customer>>> GetById(int Id)
    {
        try
        {
            var result = await customersManager.GetByIdAsync(Id);
            if (result != null)
            {
                return Ok(new APIEntityResponse<Customer>()
                {
                    Success = true,
                    Data = result
                });
            }
            else
            {
                return Ok(new APIEntityResponse<Customer>()
                {
                    Success = false,
                    ErrorMessages = new List<string>() { "Customer Not Found" },
                    Data = null
                });
            }
        }
        catch (Exception ex)
        {
            // log exception here
            return StatusCode(500);
        }
    }

    [HttpPost]
    public async Task<ActionResult<APIEntityResponse<Customer>>>
     Insert([FromBody] Customer Customer)
    {
        try
        {
            var result = await customersManager.InsertAsync(Customer);
            if (result != null)
            {
                return Ok(new APIEntityResponse<Customer>()
                {
                    Success = true,
                    Data = result
                });
            }
            else
            {
                return Ok(new APIEntityResponse<Customer>()
                {
                    Success = false,
                    ErrorMessages = new List<string>()
               { "Could not find customer after adding it." },
                    Data = null
                });
            }
        }
        catch (Exception ex)
        {
            // log exception here
            return StatusCode(500);
        }
    }

    [HttpPut]
    public async Task<ActionResult<APIEntityResponse<Customer>>>
        Update([FromBody] Customer Customer)
    {
        try
        {
            var result = await customersManager.UpdateAsync(Customer);
            if (result != null)
            {
                return Ok(new APIEntityResponse<Customer>()
                {
                    Success = true,
                    Data = result
                });
            }
            else
            {
                return Ok(new APIEntityResponse<Customer>()
                {
                    Success = false,
                    ErrorMessages = new List<string>()
               { "Could not find customer after updating it." },
                    Data = null
                });
            }
        }
        catch (Exception ex)
        {
            // log exception here
            return StatusCode(500);
        }
    }

    [HttpDelete("{Id}")]
    public async Task<ActionResult<bool>> Delete(int Id)
    {
        try
        {
            return await customersManager.DeleteByIdAsync(Id);
        }
        catch (Exception ex)
        {
            // log exception here
            var msg = ex.Message;
            return StatusCode(500);
        }
    }

    [HttpGet("deleteall")]
    public async Task<ActionResult> DeleteAll()
    {
        try
        {
            await customersManager.DeleteAllAsync();
            return NoContent();
        }
        catch (Exception ex)
        {
            // log exception here
            return StatusCode(500);
        }
    }
}

Because we added MemoryDataManager<Customer> as a singleton service, we can inject that right into our controller and use it to access the data store, in this case, an in-memory implementation of IRepository<Customer>

Note the use of Task<ActionResult<T>> in every endpoint. That's good practice. Also, any time we're returning an entity or list of entities we are wrapping the result in either APIEntityResponse<T> or APIListOfEntityResponse<T>.

The Get() method has the most complex return type:

Task<ActionResult<APIListOfEntityResponse<Customer>>>

Configure the server to use API Controllers

In the Server project's Program.cs, add the following before the line var app = builder.Build();:

builder.Services.AddControllers();

Then add the following after the line var app = builder.Build();:

app.MapControllers();

Also, let's add this to the top of Program.cs:

global using RepositoryDemo.Client.Models;

Add Global Usings to the Client

Add the following statements to the very top of the Client project's Program.cs

global using System.Net.Http.Json;
global using Newtonsoft.Json;
global using System.Net;
global using System.Linq.Expressions;
global using AvnRepository;
global using RepositoryDemo.Client.Models;

Add an APIRepository class to the Client

To the client app, add a Services folder and add the following:

APIRepository.cs

namespace RepositoryDemo.Client.Services;

/// <summary>
/// Reusable API Repository base class that provides access to CRUD APIs
/// </summary>
/// <typeparam name="TEntity"></typeparam>
public class APIRepository<TEntity> : IRepository<TEntity>
  where TEntity : class
{
    string controllerName;
    string primaryKeyName;
    HttpClient http;

    public APIRepository(HttpClient _http,
      string _controllerName, string _primaryKeyName)
    {
        http = _http;
        controllerName = _controllerName;
        primaryKeyName = _primaryKeyName;
    }

    public async Task<IEnumerable<TEntity>> GetAllAsync()
    {
        try
        {
            var result = await http.GetAsync(controllerName);
            result.EnsureSuccessStatusCode();
            string responseBody = await result.Content.ReadAsStringAsync();
            var response =
              JsonConvert.DeserializeObject<APIListOfEntityResponse<TEntity>>
               (responseBody);
            if (response.Success)
                return response.Data;
            else
                return new List<TEntity>();
        }
        catch (Exception ex)
        {
            return null;
        }
    }

    public async Task<IEnumerable<TEntity>> GetAsync(QueryFilter<TEntity> Expression)
    {
        try
        {
            string url = $"{controllerName}/getwithfilter";
            var result = await http.PostAsJsonAsync(url, Expression);
            result.EnsureSuccessStatusCode();
            string responseBody = await result.Content.ReadAsStringAsync();
            var response =
              JsonConvert.DeserializeObject<APIListOfEntityResponse<TEntity>>
               (responseBody);
            if (response.Success)
                return response.Data;
            else
                return new List<TEntity>();
        }
        catch (Exception ex)
        {
            return null;
        }
    }

    public async Task<TEntity> GetByIdAsync(object id)
    {
        try
        {
            var arg = WebUtility.HtmlEncode(id.ToString());
            var url = controllerName + "/" + arg;
            var result = await http.GetAsync(url);
            result.EnsureSuccessStatusCode();
            string responseBody = await result.Content.ReadAsStringAsync();
            var response = JsonConvert.DeserializeObject<APIEntityResponse<TEntity>>
               (responseBody);
            if (response.Success)
                return response.Data;
            else
                return null;
        }
        catch (Exception ex)
        {
            var msg = ex.Message;
            return null;
        }
    }

    public async Task<TEntity> InsertAsync(TEntity entity)
    {
        try
        {
            var result = await http.PostAsJsonAsync(controllerName, entity);
            result.EnsureSuccessStatusCode();
            string responseBody = await result.Content.ReadAsStringAsync();
            var response = JsonConvert.DeserializeObject<APIEntityResponse<TEntity>>
               (responseBody);
            if (response.Success)
                return response.Data;
            else
                return null;
        }
        catch (Exception ex)
        {
            return null;
        }
    }

    public async Task<TEntity> UpdateAsync(TEntity entityToUpdate)
    {
        try
        {
            var result = await http.PutAsJsonAsync(controllerName, entityToUpdate);
            result.EnsureSuccessStatusCode();
            string responseBody = await result.Content.ReadAsStringAsync();
            var response = JsonConvert.DeserializeObject<APIEntityResponse<TEntity>>
               (responseBody);
            if (response.Success)
                return response.Data;
            else
                return null;
        }
        catch (Exception ex)
        {
            return null;
        }
    }
    public async Task<bool> DeleteAsync(TEntity entityToDelete)
    {
        try
        {
            var value = entityToDelete.GetType()
               .GetProperty(primaryKeyName)
               .GetValue(entityToDelete, null)
               .ToString();

            var arg = WebUtility.HtmlEncode(value);
            var url = controllerName + "/" + arg;
            var result = await http.DeleteAsync(url);
            result.EnsureSuccessStatusCode();
            return true;
        }
        catch (Exception ex)
        {
            return false;
        }
    }
    public async Task<bool> DeleteByIdAsync(object id)
    {
        try
        {
            var url = controllerName + "/" + WebUtility.HtmlEncode(id.ToString());
            var result = await http.DeleteAsync(url);
            result.EnsureSuccessStatusCode();
            return true;
        }
        catch (Exception ex)
        {
            return false;
        }
    }

    public async Task DeleteAllAsync()
    {
        try
        {
            var url = controllerName + "/deleteall";
            var result = await http.GetAsync(url);
            result.EnsureSuccessStatusCode();
        }
        catch (Exception ex)
        {

        }
    }
}

APIRepository<TEntity> is a re-usable generic implementation of IRepository<TEntity> that we can use to create custom managers on the client side without having to rewrite the plumbing code for accessing every controller.

Take a look at the constructor. We're passing in an HttpClient with it's BaseAddress property already set, the controller name, and the primary key name.

string controllerName;
string primaryKeyName;
HttpClient http;

public APIRepository(
    HttpClient _http,
    string _controllerName, 
    string _primaryKeyName)
{
    http = _http;
    controllerName = _controllerName;
    primaryKeyName = _primaryKeyName;
}

Create a Customer Repository on the client

To the client project's Services folder, add the following:

CustomerRepository.cs

namespace RepositoryDemo.Client.Services;

public class CustomerRepository : APIRepository<Customer>
{
    HttpClient http;

    static string controllerName = "inmemorycustomers";

    public CustomerRepository(HttpClient _http)
       : base(_http, controllerName, "Id")
    {
        http = _http;
    }
}

This is now how easy it is to add support on the client to access a new controller. In the constructor, we're just passing the http object, telling APIRepository that we'll be calling the InMemoryCustomers controller, and that the primary key property name is "Id."

This is where you could implement additional methods in lieu of using the filtered Get method, such as a method to search customers by name.

Add a CustomerRepository service

To the client project's Program.cs file, add the following:

builder.Services.AddScoped<CustomerRepository>();

Add using statements to _Imports.razor

@using RepositoryDemo.Client.Models
@using RepositoryDemo.Client.Services
@using AvnRepository

Adding these ensures we can access classes in these namespaces from .razor components.

Implement the Blazor code and markup

Change Home.razor to the following:

@page "/"
@inject CustomerRepository CustomerManager

<h1>Repository Demo</h1>

@foreach (var customer in Customers)
{
    <p>(@customer.Id) @customer.Name, @customer.Email</p>
}

<button @onclick="UpdateIsadora">Update Isadora</button>
<button @onclick="DeleteRocky">Delete Rocky</button>
<button @onclick="DeleteHugh">Delete Hugh</button>
<button @onclick="GetJenny">GetJenny</button>
<button @onclick="ResetData">Reset Data</button>
<br />
<br />
<p>
    Search by Name: <input @bind=@SearchFilter />
    <button @onclick="Search">Search</button>
    <br />
    <br />
    <input style="zoom:1.5;" type="checkbox" @bind="CaseSensitive" /> Case Sensitive
    <br />
    <input style="zoom:1.5;" type="checkbox" @bind="Descending" /> Descending Order
    <br />
    <br />
    Options:<br />
    <select @onchange="SelectedOptionChanged" size=3 style="padding:10px;">
        <option selected value="StartsWith">Starts With</option>
        <option value="EndsWith">Ends With</option>
        <option value="Contains">Contains</option>
    </select>

</p>

<br />
<br />
<p>@JennyMessage</p>

@code
{
    List<Customer> Customers = new List<Customer>();
    string JennyMessage = "";
    string SearchFilter = "";
    bool CaseSensitive = false;
    bool Descending = false;
    FilterOperator filterOption = FilterOperator.StartsWith;

    void SelectedOptionChanged(ChangeEventArgs args)
    {
        switch (args.Value.ToString())
        {
            case "StartsWith":
                filterOption = FilterOperator.StartsWith;
                break;
            case "EndsWith":
                filterOption = FilterOperator.EndsWith;
                break;
            case "Contains":
                filterOption = FilterOperator.Contains;
                break;
        }
    }

    async Task Search()
    {
        try
        {
            var expression = new QueryFilter<Customer>();

            expression.FilterProperties.Add(new FilterProperty { Name = "Name", Value = SearchFilter, Operator = filterOption, CaseSensitive = CaseSensitive });
            expression.OrderByPropertyName = "Name";
            expression.OrderByDescending = Descending;

            //// Example, return where Id = 2
            //expression.FilterProperties.Add(new FilterProperty { Name = "Id", Value = "2", Operator = FilterOperator.Equals });
            //expression.OrderByPropertyName = "Name";
            //expression.OrderByDescending = Descending;

            //// Example, return where Id > 1
            //expression.FilterProperties.Add(new FilterProperty { Name = "Id", Value = "1", Operator = FilterOperator.GreaterThan });
            //expression.OrderByPropertyName = "Name";
            //expression.OrderByDescending = Descending;

            var list = await CustomerManager.GetAsync(expression);
            Customers = list.ToList();

        }
        catch (Exception ex)
        {
            var msg = ex.Message;
        }
    }

    async Task DeleteRocky()
    {
        var rocky = (from x in Customers
                     where x.Email == "rocky@rhodes.com"
                     select x).FirstOrDefault();
        if (rocky != null)
        {
            await CustomerManager.DeleteAsync(rocky);
            await Reload();
        }
    }

    async Task DeleteHugh()
    {
        var hugh = (from x in Customers
                    where x.Email == "hugh@jass.com"
                    select x).FirstOrDefault();
        if (hugh != null)
        {
            await CustomerManager.DeleteByIdAsync(hugh.Id);
            await Reload();
        }
    }

    async Task UpdateIsadora()
    {
        var isadora = (from x in Customers
                       where x.Email == "isadora@jarr.com"
                       select x).FirstOrDefault();
        if (isadora != null)
        {
            isadora.Email = "isadora@isadorajarr.com";
            await CustomerManager.UpdateAsync(isadora);
            await Reload();
        }
    }

    async Task GetJenny()
    {
        JennyMessage = "";
        var jenny = (from x in Customers
                     where x.Email == "jenny@jones.com"
                     select x).FirstOrDefault();
        if (jenny != null)
        {
            var jennyDb = await CustomerManager.GetByIdAsync(jenny.Id);
            if (jennyDb != null)
            {
                JennyMessage = $"Retrieved Jenny via Id {jennyDb.Id}";
            }
        }
        await InvokeAsync(StateHasChanged);
    }

    protected override async Task OnInitializedAsync()
    {
        await AddCustomers();
    }

    async Task ResetData()
    {
        await CustomerManager.DeleteAllAsync();
        await AddCustomers();
    }

    async Task Reload()
    {
        JennyMessage = "";
        var list = await CustomerManager.GetAllAsync();
        if (list != null)
        {
            Customers = list.ToList();
            await InvokeAsync(StateHasChanged);
        }
    }

    async Task AddCustomers()
    {
        // Added these lines to not clobber the existing data
        var all = await CustomerManager.GetAllAsync();
        if (all.Count() > 0)
        {
            await Reload();
            return;
        }

        Customers.Clear();

        await CustomerManager.InsertAsync(new Customer
            {
                Id = 1,
                Name = "Isadora Jarr",
                Email = "isadora@jarr.com"
            });

        await CustomerManager.InsertAsync(new Customer
            {
                Id = 2,
                Name = "Rocky Rhodes",
                Email = "rocky@rhodes.com"
            });

        await CustomerManager.InsertAsync(new Customer
            {
                Id = 3,
                Name = "Jenny Jones",
                Email = "jenny@jones.com"
            });

        await CustomerManager.InsertAsync(new Customer
            {
                Id = 4,
                Name = "Hugh Jass",
                Email = "hugh@jass.com"
            });

        await Reload();
    }
}

This page exercises all features of IRepository<TEntity>: GetAll, Get with a query filter,GetById, Insert, Update, and Delete two ways: by Id and by Entity.

There's one more thing we should do to keep this demo as simple as possible, and that is to turn off pre-rendering so we don't have to duplicate our services and code on the server.

Replace App.razor with the following:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <base href="https://github.com/carlfranklin/BlazorRepositoryDemo/blob/master/" />
    <link rel="stylesheet" href="https://github.com/carlfranklin/BlazorRepositoryDemo/blob/master/bootstrap/bootstrap.min.css" />
    <link rel="stylesheet" href="https://github.com/carlfranklin/BlazorRepositoryDemo/blob/master/app.css" />
    <link rel="stylesheet" href="https://github.com/carlfranklin/BlazorRepositoryDemo/blob/master/RepositoryDemo.styles.css" />
    <link rel="icon" type="image/png" href="https://github.com/carlfranklin/BlazorRepositoryDemo/blob/master/favicon.png" />
    <HeadOutlet @rendermode="new InteractiveWebAssemblyRenderMode(false)" />
</head>

<body>
    <Routes @rendermode="new InteractiveWebAssemblyRenderMode(false)" />
    <script src="https://github.com/carlfranklin/BlazorRepositoryDemo/raw/master/_framework/blazor.web.js"></script>
</body>

</html>

Now run the app:

image-20240423093155261

The app displays four customers, their Ids, names, and email addresses.

If you press image-20210514130755412then Isadora's email address changes to isadora@isadorajarr.com:

image-20210514130900651

If you press image-20210514130923188then Rocky will be deleted by entity:

image-20210514131058298

If you press image-20210514131219347then Hugh will be deleted by Id:

image-20210514131241975

If you press image-20210514131305800then Jenny's record will be retrieved using GetById

image-20220322175149382

If you press image-20210514131416003or refresh the page the data will be reset to it's original state.

Try out the search functionality.

image-20220410180617860

The finished code for this leg of the demo can be found in the 1-In Memory Only folder

Implement an Entity Framework Repository

Now we're going to make a special Repository for working with Entity Framework. It will be generic, and will therefore be usable with any DbContext and Model.

Create the SQL Database

In Visual Studio, open the SQL Server Object Explorer, expand MSSQLLocalDB, right click on Databases, and select Add New Database. Name the new database RepositoryDemo and select the OK button.

image-20220323174510706

Next, right-click on the RepositoryDemo database and select New Query...

image-20220323174917490

Enter the following SQL:

CREATE TABLE [dbo].[Customer]
(
    [Id] INT NOT NULL PRIMARY KEY IDENTITY, 
    [Name] NVARCHAR(50) NOT NULL, 
    [Email] NVARCHAR(50) NOT NULL
)

Press the green Play button to execute the statement

image-20220323175142470

Double-click on the RepositoryDemo project in the Solution Explorer to expose the .csproj file, and add the latest versions of the following packages:

<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4">
    <PrivateAssets>all</PrivateAssets>
    <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="8.0.2" />

Scaffold an EF DbContext from the database

Open the Package Manager Console window, select the RepositoryDemo project, and enter the following command:

Scaffold-DbContext "Server=(localdb)\mssqllocaldb;Database=RepositoryDemo;Trusted_Connection=True;" Microsoft.EntityFrameworkCore.SqlServer -OutputDir Data

image-20240423094028562

IMPORTANT: Delete the Customer.cs file from the Server's Data folder. The app won't work if you don't delete it. We already have a Customer.cs in the client project.

image-20240423094115654

Create the EF Repository

Add the following global using statement to the top of the Server project's Program.cs file:

global using Microsoft.EntityFrameworkCore;

To the Data folder, add a new class called EFRepository.cs :

namespace RepositoryDemo.Data;

public class EFRepository<TEntity, TDataContext> : IRepository<TEntity>
  where TEntity : class
  where TDataContext : DbContext
{
    protected readonly TDataContext context;
    internal DbSet<TEntity> dbSet;

    public EFRepository(TDataContext dataContext)
    {
        context = dataContext;
        context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
        dbSet = context.Set<TEntity>();
    }
    public async Task<IEnumerable<TEntity>> GetAllAsync()
    {
        return await Task.FromResult(dbSet);
    }

    public async Task<TEntity> GetByIdAsync(object Id)
    {
        return await dbSet.FindAsync(Id);
    }

    public async Task<IEnumerable<TEntity>> GetAsync(QueryFilter<TEntity> Filter)
    {
        var allitems = (await GetAllAsync()).ToList();
        return Filter.GetFilteredList(allitems);
    }

    public async Task<TEntity> InsertAsync(TEntity entity)
    {
        await dbSet.AddAsync(entity);
        await context.SaveChangesAsync();
        return entity;
    }

    public async Task<TEntity> UpdateAsync(TEntity entityToUpdate)
    {
        var dbSet = context.Set<TEntity>();
        dbSet.Attach(entityToUpdate);
        context.Entry(entityToUpdate).State = EntityState.Modified;
        await context.SaveChangesAsync();
        return entityToUpdate;
    }

    public async Task<bool> DeleteAsync(TEntity entityToDelete)
    {
        if (context.Entry(entityToDelete).State == EntityState.Detached)
        {
            dbSet.Attach(entityToDelete);
        }
        dbSet.Remove(entityToDelete);
        return await context.SaveChangesAsync() >= 1;
    }

    public async Task<bool> DeleteByIdAsync(object id)
    {
        TEntity entityToDelete = await dbSet.FindAsync(id);
        return await DeleteAsync(entityToDelete);
    }

    public async Task DeleteAllAsync()
    {
        await context.Database.ExecuteSqlRawAsync("TRUNCATE TABLE Customer");
    }
}

Add an EF Customer API Controller

To the Controllers folder, add EFCustomersController.cs :

using Microsoft.AspNetCore.Mvc;

namespace RepositoryDemo.Data;

[Route("[controller]")]
[ApiController]
public class EFCustomersController : ControllerBase
{
    EFRepository<Customer, RepositoryDemoContext> customersManager;

    public EFCustomersController(EFRepository<Customer, RepositoryDemoContext> _customersManager)
    {
        customersManager = _customersManager;
    }

    [HttpGet]
    public async Task<ActionResult<APIListOfEntityResponse<Customer>>> Get()
    {
        try
        {
            var result = await customersManager.GetAllAsync();
            return Ok(new APIListOfEntityResponse<Customer>()
            {
                Success = true,
                Data = result
            });
        }
        catch (Exception ex)
        {
            // log exception here
            return StatusCode(500);
        }
    }

    [HttpPost("getwithfilter")]
    public async Task<ActionResult<APIListOfEntityResponse<Customer>>>
        GetWithFilter([FromBody] QueryFilter<Customer> Filter)
    {
        try
        {
            var result = await customersManager.GetAsync(Filter);
            return Ok(new APIListOfEntityResponse<Customer>()
            {
                Success = true,
                Data = result.ToList()
            });
        }
        catch (Exception ex)
        {
            // log exception here
            var msg = ex.Message;
            return StatusCode(500);
        }
    }

    [HttpGet("{Id}")]
    public async Task<ActionResult<APIEntityResponse<Customer>>> GetById(int Id)
    {
        try
        {
            var result = await customersManager.GetByIdAsync(Id);
            if (result != null)
            {
                return Ok(new APIEntityResponse<Customer>()
                {
                    Success = true,
                    Data = result
                });
            }
            else
            {
                return Ok(new APIEntityResponse<Customer>()
                {
                    Success = false,
                    ErrorMessages = new List<string>() { "Customer Not Found" },
                    Data = null
                });
            }
        }
        catch (Exception ex)
        {
            // log exception here
            return StatusCode(500);
        }
    }

    [HttpPost]
    public async Task<ActionResult<APIEntityResponse<Customer>>>
     Insert([FromBody] Customer Customer)
    {
        try
        {
            Customer.Id = 0; // Make sure you do this!
            var result = await customersManager.InsertAsync(Customer);
            if (result != null)
            {
                return Ok(new APIEntityResponse<Customer>()
                {
                    Success = true,
                    Data = result
                });
            }
            else
            {
                return Ok(new APIEntityResponse<Customer>()
                {
                    Success = false,
                    ErrorMessages = new List<string>()
               { "Could not find customer after adding it." },
                    Data = null
                });
            }
        }
        catch (Exception ex)
        {
            // log exception here
            return StatusCode(500);
        }
    }

    [HttpPut]
    public async Task<ActionResult<APIEntityResponse<Customer>>>
     Update([FromBody] Customer Customer)
    {
        try
        {
            var result = await customersManager.UpdateAsync(Customer);
            if (result != null)
            {
                return Ok(new APIEntityResponse<Customer>()
                {
                    Success = true,
                    Data = result
                });
            }
            else
            {
                return Ok(new APIEntityResponse<Customer>()
                {
                    Success = false,
                    ErrorMessages = new List<string>()
               { "Could not find customer after updating it." },
                    Data = null
                });
            }
        }
        catch (Exception ex)
        {
            // log exception here
            return StatusCode(500);
        }
    }

    [HttpDelete("{Id}")]
    public async Task<ActionResult<bool>> Delete(int Id)
    {
        try
        {
            return await customersManager.DeleteByIdAsync(Id);
        }
        catch (Exception ex)
        {
            // log exception here
            var msg = ex.Message;
            return StatusCode(500);
        }
    }

    [HttpGet("deleteall")]
    public async Task<ActionResult> DeleteAll()
    {
        try
        {
            await customersManager.DeleteAllAsync();
            return NoContent();
        }
        catch (Exception ex)
        {
            // log exception here
            return StatusCode(500);
        }
    }
}

Add Services to the Server app

Add the following lines to the Program.cs file:

builder.Services.AddTransient<RepositoryDemoContext, RepositoryDemoContext>();
builder.Services.AddTransient<EFRepository<Customer, RepositoryDemoContext>>();

The RepositoryDemoContext and EFRepository need to be defined as transient services, because the controller is transient also. Transient means the services are created on demand and do not persist. However, they can be configured with the DI system.

Modify the Client ever so slightly

Change \Services\CustomerRepository.cs to point to the EF Controller:

namespace RepositoryDemo.Client.Services;

public class CustomerRepository : APIRepository<Customer>
{
    HttpClient http;

    // swap out the controller name
    //static string controllerName = "inmemorycustomers";
    static string controllerName = "efcustomers";

    public CustomerRepository(HttpClient _http)
       : base(_http, controllerName, "Id")
    {
        http = _http;
    }
}

Run the app

Give it a little time to bring the database up and generate the initial records. It will look exactly the same as when we were using the In Memory repository:

image-20240423095032139

After running the app, take a look at the database by right-clicking on the Customer table in the SQL Server Object Explorer and selecting View Data:

image-20220323191200976

The finished code for this leg of the demo can be found in the 2-Added EF Repository folder

Add a Dapper Repository

Update the appsettings.json file in the Server project to add the RepositoryDemo database connection string:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "RepositoryDemoConnectionString": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=RepositoryDemo;Integrated Security=True;Connect Timeout=30;Encrypt=False;TrustServerCertificate=False;ApplicationIntent=ReadWrite;MultiSubnetFailover=False",
  }
}

Add the latest versions of the following packages to the Server project's .csproj file:

<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="Dapper.Contrib" Version="2.0.78" />
<PackageReference Include="System.Data.SqlClient" Version="4.8.6" />

Add the following global using statements to the top of the Server project's Program.cs file:

global using System.Data.SqlClient;
global using Dapper;
global using Dapper.Contrib.Extensions;
global using System.Data;

To the Client project's .csproj file, add the following:

<PackageReference Include="Dapper.Contrib" Version="2.0.78" />

We need this because Dapper.Contrib requires that we add a few attributes.

In the Client project, change Customer.cs to the following:

using System.ComponentModel.DataAnnotations.Schema;
using System.ComponentModel.DataAnnotations;
namespace RepositoryDemo.Client.Models;

[Table("Customer")]
public class Customer
{
    [Key]
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public string Email { get; set; } = "";
}

If you do not specify the Table attribute, Dapper.Contrib assumes a plural version of the model (Customers). But the table name is Customer, so we have to specify that with the attribute.

The Key attribute tells Dapper.Contrib that Id is not only a primary key, but it is an identity column, autogenerated by the data store.

If the primary key needed to be supplied by the calling code, we could use the [ExplicitKey] attribute.

To the Data folder, add DapperSqlHelper.cs:

namespace RepositoryDemo.Data;
public class DapperSqlHelper
{
    public static string GetDapperInsertStatement(object Entity, string TableName)
    {
        // let's get the SQL string started.
        string sql = $"insert into {TableName} (";

        // Get the type, and the list of public properties
        var EntityType = Entity.GetType();
        var Properties = EntityType.GetProperties();

        foreach (var property in Properties)
        {
            // Is this property nullable?
            if (Nullable.GetUnderlyingType(property.PropertyType) != null)
            {
                // yes. get the value.
                var value = property.GetValue(Entity);
                // is the value null?
                if (value != null)
                    // only add if the value is not null
                    sql += $"{property.Name}, ";
            }
            // is this property virtual (like Customer.Invoices)?
            else if (property.GetGetMethod().IsVirtual == false)
            {
                // not virtual. Include
                sql += $"{property.Name}, ";
            }
        }

        // At this point there is a trailing ", " that we need to remove
        sql = sql.Substring(0, sql.Length - 2);

        // add the start of the values clause
        sql += ") values (";

        // Once more through the properties
        foreach (var property in Properties)
        {
            if (Nullable.GetUnderlyingType(property.PropertyType) != null)
            {
                var value = property.GetValue(Entity);
                if (value != null)
                    // inserts in Dapper are paramterized, so at least
                    // we don't have to figure out data types, quotes, etc.
                    sql += $"@{property.Name}, ";
            }
            else if (property.GetGetMethod().IsVirtual == false)
            {
                sql += $"@{property.Name}, ";
            }
        }

        // again, remove the trailing ", " and finish with a closed paren 
        sql = sql.Substring(0, sql.Length - 2) + ")";

        // we're outta here!
        return sql;
    }
}

This helper method let's you create a custom parameterized SQL INSERT string based on the primary key.

To the Data folder, add DapperRepository.cs:

namespace RepositoryDemo.Data;
public class DapperRepository<TEntity> : IRepository<TEntity> where TEntity : class
{
    private string _sqlConnectionString;
    private string entityName;
    private Type entityType;

    private string primaryKeyName;
    private string primaryKeyType;
    private bool PKNotIdentity = false;

    public DapperRepository(string sqlConnectionString)
    {
        _sqlConnectionString = sqlConnectionString;
        entityType = typeof(TEntity);
        entityName = entityType.Name;

        var props = entityType.GetProperties().Where(
            prop => Attribute.IsDefined(prop,
            typeof(KeyAttribute)));
        if (props.Count() > 0)
        {
            primaryKeyName = props.First().Name;
            primaryKeyType = props.First().PropertyType.Name;
        }
        else
        {
            // Default
            primaryKeyName = "Id";
            primaryKeyType = "Int32";
        }

        // look for [ExplicitKey]
        props = entityType.GetProperties().Where(
            prop => Attribute.IsDefined(prop,
            typeof(ExplicitKeyAttribute)));
        if (props.Count() > 0)
        {
            PKNotIdentity = true;
            primaryKeyName = props.First().Name;
            primaryKeyType = props.First().PropertyType.Name;
        }
    }

    public async Task<IEnumerable<TEntity>> GetAsync(QueryFilter<TEntity> Filter)
    {
        using (IDbConnection db = new SqlConnection(_sqlConnectionString))
        {
            try
            {
                var dictionary = new Dictionary<string, object>();
                foreach (var column in Filter.FilterProperties)
                {
                    dictionary.Add(column.Name, column.Value);
                }
                var parameters = new DynamicParameters(dictionary);
                var sql = "select "; // * from products where ProductId = @ProductId";
                if (Filter.IncludePropertyNames.Count > 0)
                {
                    foreach (var propertyName in Filter.IncludePropertyNames)
                    {
                        sql += propertyName;
                        if (propertyName != Filter.IncludePropertyNames.Last())
                            sql += ", ";
                    }
                }
                else
                    sql += "* ";

                sql += $"from {entityName} ";
                if (dictionary.Count > 0)
                {
                    sql += "where ";
                    int count = 0;

                    foreach (var key in dictionary.Keys)
                    {
                        switch (Filter.FilterProperties[count].Operator)
                        {
                            case FilterOperator.Equals:
                                sql += $"{key} = @{key} ";
                                break;
                            case FilterOperator.NotEquals:
                                sql += $"{key} <> @{key} ";
                                break;
                            case FilterOperator.StartsWith:
                                sql += $"{key} like @{key} + '%' ";
                                break;
                            case FilterOperator.EndsWith:
                                sql += $"{key} like '%' + @{key} ";
                                break;
                            case FilterOperator.Contains:
                                sql += $"{key} like '%' + @{key} + '%' ";
                                break;
                            case FilterOperator.LessThan:
                                sql += $"{key} < @{key} ";
                                break;
                            case FilterOperator.LessThanOrEqual:
                                sql += $"{key} =< @{key} ";
                                break;
                            case FilterOperator.GreaterThan:
                                sql += $"{key} > @{key} ";
                                break;
                            case FilterOperator.GreaterThanOrEqual:
                                sql += $"{key} >= @{key} ";
                                break;
                        }

                        if (Filter.FilterProperties[count].CaseSensitive)
                        {
                            sql += "COLLATE Latin1_General_CS_AS ";
                        }

                        if (key != dictionary.Keys.Last())
                        {
                            sql += "and ";
                        }
                        count++;
                    }
                }
                if (Filter.OrderByPropertyName != "")
                {
                    sql += $"order by {Filter.OrderByPropertyName}";
                    if (Filter.OrderByDescending)
                    {
                        sql += " desc";
                    }
                }

                var result = await db.QueryAsync<TEntity>(sql, parameters);
                return result;
            }
            catch (Exception ex)
            {
                return (IEnumerable<TEntity>)new List<TEntity>();
            }
        }
    }

    public async Task<TEntity> GetByIdAsync(object Id)
    {
        using (IDbConnection db = new SqlConnection(_sqlConnectionString))
        {
            db.Open();
            var item = db.Get<TEntity>(Id);
            return await Task.FromResult(item);
        }
    }

    public async Task<IEnumerable<TEntity>> GetAllAsync()
    {
        using (IDbConnection db = new SqlConnection(_sqlConnectionString))
        {
            db.Open();
            //string sql = $"select * from {entityName}";
            //IEnumerable<TEntity> result = await db.QueryAsync<TEntity>(sql);
            //return result;
            return await db.GetAllAsync<TEntity>();
        }
    }

    public async Task<TEntity> InsertAsync(TEntity entity)
    {
        using (IDbConnection db = new SqlConnection(_sqlConnectionString))
        {
            db.Open();
            // start a transaction in case something goes wrong
            await db.ExecuteAsync("begin transaction");
            try
            {
                // Get the primary key property
                var prop = entityType.GetProperty(primaryKeyName);

                // int key?
                if (primaryKeyType == "Int32")
                {
                    // not an identity?
                    if (PKNotIdentity == true)
                    {
                        // get the highest value
                        var sql = $"select max({primaryKeyName}) from {entityName}";
                        // and add 1 to it
                        var Id = Convert.ToInt32(db.ExecuteScalar(sql)) + 1;
                        // update the entity
                        prop.SetValue(entity, Id);
                        // do the insert
                        db.Insert<TEntity>(entity);
                    }
                    else
                    {
                        // key will be created by the database
                        var Id = (int)db.Insert<TEntity>(entity);
                        // set the value
                        prop.SetValue(entity, Id);
                    }
                }
                else if (primaryKeyType == "String")
                {
                    // string primary key. Use my helper
                    string sql = DapperSqlHelper.GetDapperInsertStatement(entity, entityName);
                    await db.ExecuteAsync(sql, entity);
                }
                // if we got here, we're good!
                await db.ExecuteAsync("commit transaction");
                return entity;
            }
            catch (Exception ex)
            {
                var msg = ex.Message;
                await db.ExecuteAsync("rollback transaction");
                return null;
            }
        }
    }

    public async Task<TEntity> UpdateAsync(TEntity entity)
    {
        using (IDbConnection db = new SqlConnection(_sqlConnectionString))
        {
            db.Open();
            try
            {
                //string sql = DapperSqlHelper.GetDapperUpdateStatement(entity, entityName, primaryKeyName);
                //await db.ExecuteAsync(sql, entity);
                await db.UpdateAsync<TEntity>(entity);
                return entity;
            }
            catch (Exception ex)
            {
                return null;
            }
        }
    }
    public async Task<bool> DeleteAsync(TEntity entityToDelete)
    {
        using (IDbConnection db = new SqlConnection(_sqlConnectionString))
        {
            //string sql = $"delete from {entityName} where {primaryKeyName}" +
            //    $" = @{primaryKeyName}";
            try
            {
                //await db.ExecuteAsync(sql, entityToDelete);
                await db.DeleteAsync<TEntity>(entityToDelete);
                return true;
            }
            catch (Exception ex)
            {
                return false;
            }
        }
    }

    public async Task<bool> DeleteByIdAsync(object Id)
    {
        var item = await GetByIdAsync(Id);
        var status = await DeleteAsync(item);
        return status;
    }

    public async Task DeleteAllAsync()
    {
        using (IDbConnection db = new SqlConnection(_sqlConnectionString))
        {
            try
            {
                // Use at your own risk!
                await db.ExecuteAsync($"TRUNCATE TABLE {entityName}");
            }
            catch (Exception ex)
            {
                var msg = ex.Message;
            }
        }
    }
}

Add this to the Server project's Program.cs file:

builder.Services.AddTransient<DapperRepository<Customer>>(s =>
    new DapperRepository<Customer>(
        builder.Configuration.GetConnectionString("RepositoryDemoConnectionString")));

To the Server project's Controllers folder, add DapperCustomersController.cs:

using Microsoft.AspNetCore.Mvc;
namespace RepositoryDemo.Server.Controllers
{
    [Route("[controller]")]
    [ApiController]
    public class DapperCustomersController : ControllerBase
    {
        DapperRepository<Customer> customersManager;

        public DapperCustomersController(DapperRepository<Customer> _customersManager)
        {
            customersManager = _customersManager;
        }

        [HttpGet]
        public async Task<ActionResult<APIListOfEntityResponse<Customer>>> Get()
        {
            try
            {
                var result = await customersManager.GetAllAsync();
                return Ok(new APIListOfEntityResponse<Customer>()
                {
                    Success = true,
                    Data = result
                });
            }
            catch (Exception ex)
            {
                // log exception here
                return StatusCode(500);
            }
        }

        [HttpPost("getwithfilter")]
        public async Task<ActionResult<APIListOfEntityResponse<Customer>>>
            GetWithFilter([FromBody] QueryFilter<Customer> Filter)
        {
            try
            {
                var result = await customersManager.GetAsync(Filter);
                return Ok(new APIListOfEntityResponse<Customer>()
                {
                    Success = true,
                    Data = result.ToList()
                });
            }
            catch (Exception ex)
            {
                // log exception here
                var msg = ex.Message;
                return StatusCode(500);
            }
        }

        [HttpGet("{Id}")]
        public async Task<ActionResult<APIEntityResponse<Customer>>> GetById(int Id)
        {
            try
            {
                var result = await customersManager.GetByIdAsync(Id);
                if (result != null)
                {
                    return Ok(new APIEntityResponse<Customer>()
                    {
                        Success = true,
                        Data = result
                    });
                }
                else
                {
                    return Ok(new APIEntityResponse<Customer>()
                    {
                        Success = false,
                        ErrorMessages = new List<string>() { "Customer Not Found" },
                        Data = null
                    });
                }
            }
            catch (Exception ex)
            {
                // log exception here
                return StatusCode(500);
            }
        }

        [HttpPost]
        public async Task<ActionResult<APIEntityResponse<Customer>>>
         Insert([FromBody] Customer Customer)
        {
            try
            {
                Customer.Id = 0; // Make sure you do this!
                var result = await customersManager.InsertAsync(Customer);
                if (result != null)
                {
                    return Ok(new APIEntityResponse<Customer>()
                    {
                        Success = true,
                        Data = result
                    });
                }
                else
                {
                    return Ok(new APIEntityResponse<Customer>()
                    {
                        Success = false,
                        ErrorMessages = new List<string>()
               { "Could not find customer after adding it." },
                        Data = null
                    });
                }
            }
            catch (Exception ex)
            {
                // log exception here
                return StatusCode(500);
            }
        }

        [HttpPut]
        public async Task<ActionResult<APIEntityResponse<Customer>>>
         Update([FromBody] Customer Customer)
        {
            try
            {
                var result = await customersManager.UpdateAsync(Customer);
                if (result != null)
                {
                    return Ok(new APIEntityResponse<Customer>()
                    {
                        Success = true,
                        Data = result
                    });
                }
                else
                {
                    return Ok(new APIEntityResponse<Customer>()
                    {
                        Success = false,
                        ErrorMessages = new List<string>()
               { "Could not find customer after updating it." },
                        Data = null
                    });
                }
            }
            catch (Exception ex)
            {
                // log exception here
                return StatusCode(500);
            }
        }

        [HttpDelete("{Id}")]
        public async Task<ActionResult<bool>> Delete(int Id)
        {
            try
            {
                return await customersManager.DeleteByIdAsync(Id);
            }
            catch (Exception ex)
            {
                // log exception here
                var msg = ex.Message;
                return StatusCode(500);
            }
        }

        [HttpGet("deleteall")]
        public async Task<ActionResult> DeleteAll()
        {
            try
            {
                await customersManager.DeleteAllAsync();
                return NoContent();
            }
            catch (Exception ex)
            {
                // log exception here
                return StatusCode(500);
            }
        }
    }
}

On the client, just tweak the CustomerRepository.cs file to call the Dapper controller:

namespace RepositoryDemo.Client.Services;

public class CustomerRepository : APIRepository<Customer>
{
    HttpClient http;

    // swap out the controller name
    //static string controllerName = "inmemorycustomers";
    //static string controllerName = "efcustomers";
    static string controllerName = "dappercustomers";

    public CustomerRepository(HttpClient _http)
       : base(_http, controllerName, "Id")
    {
        http = _http;
    }
}

Run the app.

It will look the same, but when you search, unlike the other repositories, the DapperRepository will create a custom SQL statement based on the parameters in the QueryFilter

The finished code for this leg of the demo can be found in the 3-Added Dapper Repository folder

Add a Client-Side Repository based on IndexedDB

IndexedDB is a client-side database that you can use from JavaScript. There is no limit besides hard drive space to the amount of data you can store. However, the JavaScript API has been historically hard to use. We are going to skirt that issue by using a NuGet package that wraps it all up in a .NET library that you can call from Blazor.

From https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API:

IndexedDB is a low-level API for client-side storage of significant amounts of structured data, including files/blobs. This API uses indexes to enable high-performance searches of this data. While Web Storage is useful for storing smaller amounts of data, it is less useful for storing larger amounts of structured data. IndexedDB provides a solution.

From https://web.dev/indexeddb/:

IndexedDB is a large-scale, NoSQL storage system. It lets you store just about anything in the user's browser. In addition to the usual search, get, and put actions, IndexedDB also supports transactions. Each IndexedDB database is unique to an origin (typically, this is the site domain or subdomain), meaning it cannot access or be accessed by any other origin. Data storage limits are usually quite large, if they exist at all, but different browsers handle limits and data eviction differently.

You may be wondering why I chose IndexedDB over say, SQLite, the documentation to which can be found at https://www.sqlite.org/index.html and is described thusly:

SQLite is a C-language library that implements a small, fast, self-contained, high-reliability, full-featured, SQL database engine. SQLite is the most used database engine in the world. SQLite is built into all mobile phones and most computers and comes bundled inside countless other applications that people use every day.

The main drawback of using SQLite with Blazor, is that you can't persist it directly to a store on the client machine. The only way to do that is to use sync techniques, such as Jeremy Likness does in this video:

https://www.youtube.com/watch?v=2UPiKgHv8YE

Since I am bullish on the Repository Pattern, as you can see here, it makes more sense to use IndexedDB directly from Blazor, bypassing SQLite or any other intermediary altogether.

So, I went looking for an abstraction over the JavaScript IndexedDB API, and I (like most people) started with Steve Sanderson's blog post from August, 2019: https://blog.stevensanderson.com/2019/08/03/blazor-indexeddb/. The package he used (https://github.com/Reshiru/Blazor.IndexedDB.Framework) has gone stale. What's more, it seems a bit invasive. So, I kept looking until I found BlazorDB.

BlazorDB

BlazorDB is "an easy, fast way to use IndexedDB in a Blazor application." and is located at https://github.com/nwestfall/BlazorDB

First, install the NuGet Package BlazorIndexedDB in the client app.

You can alternatively add the latest version of this package declaration to the RepositoryDemo.Client project's .csproj file:

<PackageReference Include="BlazorIndexedDB" Version="0.3.1" />

Next, add the following <script> tags to the App.razor file:

<script src="https://github.com/carlfranklin/BlazorRepositoryDemo/raw/master/_content/BlazorIndexedDB/dexie.min.js"></script>
<script src="https://github.com/carlfranklin/BlazorRepositoryDemo/raw/master/_content/BlazorIndexedDB/blazorDB.js"></script>

Add the following to the top of the RepositoryDemo.Client project's Program.cs file:

global using BlazorDB;

Add the following to the RepositoryDemo.Client project's _Imports.razor file:

@using BlazorDB

Create the IndexedDBRepository

Add IndexedDBRepository.cs to the RepositoryDemo.Client project's Services folder:

using System.Reflection;
namespace RepositoryDemo.Client.Services;

public class IndexedDBRepository<TEntity> : IRepository<TEntity> where TEntity : class
{
    // injected
    IBlazorDbFactory _dbFactory;
    string _dbName = "";
    string _primaryKeyName = "";
    bool _autoGenerateKey;

    IndexedDbManager manager;
    string storeName = "";
    Type entityType;
    PropertyInfo primaryKey;

    public IndexedDBRepository(string dbName, string primaryKeyName,
          bool autoGenerateKey, IBlazorDbFactory dbFactory)
    {
        _dbName = dbName;
        _dbFactory = dbFactory;
        _primaryKeyName = primaryKeyName;
        _autoGenerateKey = autoGenerateKey;

        entityType = typeof(TEntity);
        storeName = entityType.Name;
        primaryKey = entityType.GetProperty(primaryKeyName);
    }

    private async Task EnsureManager()
    {
        if (manager == null)
        {
            manager = await _dbFactory.GetDbManager(_dbName);
            await manager.OpenDb();
        }
    }

    public async Task DeleteAllAsync()
    {
        await EnsureManager();
        await manager.ClearTableAsync(storeName);
    }

    public async Task<bool> DeleteAsync(TEntity EntityToDelete)
    {
        await EnsureManager();
        var Id = primaryKey.GetValue(EntityToDelete);
        return await DeleteByIdAsync(Id);
    }

    public async Task<bool> DeleteByIdAsync(object Id)
    {
        await EnsureManager();
        try
        {
            await manager.DeleteRecordAsync(storeName, Id);
            return true;
        }
        catch (Exception ex)
        {
            // log exception
            return false;
        }
    }

    public async Task<IEnumerable<TEntity>> GetAllAsync()
    {
        await EnsureManager();
        var array = await manager.ToArray<TEntity>(storeName);
        if (array == null)
            return new List<TEntity>();
        else
            return array.ToList();
    }

    public async Task<IEnumerable<TEntity>> GetAsync(QueryFilter<TEntity> Filter)
    {
        // We have to load all items and use LINQ to filter them. :(
        var allitems = await GetAllAsync();
        return Filter.GetFilteredList(allitems);
    }

    public async Task<TEntity> GetByIdAsync(object Id)
    {
        await EnsureManager();
        var items = await manager.Where<TEntity>(storeName, _primaryKeyName, Id);
        if (items.Any())
            return items.First();
        else
            return null;
    }

    public async Task<TEntity> InsertAsync(TEntity Entity)
    {
        await EnsureManager();

        // set Id field to zero if the key is autogenerated
        if (_autoGenerateKey)
        {
            primaryKey.SetValue(Entity, 0);
        }

        try
        {
            var record = new StoreRecord<TEntity>()
            {
                StoreName = storeName,
                Record = Entity
            };
            await manager.AddRecordAsync<TEntity>(record);
            var allItems = await GetAllAsync();
            var last = allItems.Last();
            return last;
        }
        catch (Exception ex)
        {
            // log exception
            return null;
        }
    }

    public async Task<TEntity> UpdateAsync(TEntity EntityToUpdate)
    {
        await EnsureManager();
        object Id = primaryKey.GetValue(EntityToUpdate);
        try
        {
            await manager.UpdateRecord(new UpdateRecord<TEntity>()
            {
                StoreName = storeName,
                Record = EntityToUpdate,
                Key = Id
            });
            return EntityToUpdate;
        }
        catch (Exception ex)
        {
            // log exception
            return null;
        }
    }
}

A couple issues:

First of all, unlike the DapperRepository, the IndexedDBRepository requires us to use the QueryFilter's GetFiltererdList method, which requires you to pass a list of all the items.

This is because IndexedDB and therefore BlazorDB do not have a way to pass a custom SQL query for selecting records.

public async Task<IEnumerable<TEntity>> GetAsync(QueryFilter<TEntity> Filter)
{
    // We have to load all items and use LINQ to filter them. :(
    var allitems = await GetAllAsync();
    return Filter.GetFilteredList(allitems);
}

Also, BlazorDB's Add methods do not return an entity with the primary key set, or a way to get the primary key of the last record added. So after adding a new record, our Insert method fetches all the records and returns the last one, which will have the primary key value set.

public async Task<TEntity> InsertAsync(TEntity Entity)
{
    await EnsureManager();

    try
    {
        var record = new StoreRecord<TEntity>()
        {
            StoreName = storeName,
            Record = Entity
        };
        await manager.AddRecordAsync<TEntity>(record);
        // get all items and return the last one
        var allItems = await GetAllAsync();
        var last = allItems.Last();
        return last;
    }
    catch (Exception ex)
    {
        // log exception
        return null;
    }
}
However!

These issues shouldn't cause too much concern unless client memory constraints are an issue.

Difference between Add and Put

The IndexedDbManager (and IndexedDB) have two ways to insert a record: Add and Put. I chose to use Add. If you try to insert an element with key that already exists using the Put function it will trigger an update of the existing element, however if you use the Add function and an element with same id exists you will get an error with the following message: "Key already exists in the object store."

Create a custom IndexedDBRepository class for the Customer entity

Add CustomerIndexedDBRepository.cs to the RepositoryDemo.Client project's Services folder:

namespace RepositoryDemo.Client.Services;

public class CustomerIndexedDBRepository : IndexedDBRepository<Customer>
{
    public CustomerIndexedDBRepository(IBlazorDbFactory dbFactory)
        : base("RepositoryDemo", "Id", true, dbFactory)
    {
    }
}

As with the CustomerRepository, which inherits APIRepository<Customer>, so are we doing the same here, calling the base class with the required constructor parameters.

Configuration

Add the following to the RepositoryDemo.Client project's Program.cs file just before the line await builder.Build().RunAsync();:

builder.Services.AddBlazorDB(options =>
{
    options.Name = "RepositoryDemo";
    options.Version = 1;

    // List all your entities here, but as StoreSchema objects
    options.StoreSchemas = new List<StoreSchema>()
    {
        new StoreSchema()
        {
            Name = "Customer",      // Name of entity
            PrimaryKey = "Id",      // Primary Key of entity
            PrimaryKeyAuto = true,  // Whether or not the Primary key is generated
            Indexes = new List<string> { "Id" }
        }
    };
});
builder.Services.AddScoped<CustomerIndexedDBRepository>();

Change line 2 of the RepositoryDemo.Client project's Home.razor file

@inject CustomerIndexedDBRepository CustomerManager

Run the app!

Try using the browser tools to simulate being offline. Note that the app still works.

The finished code for this leg of the demo can be found in the 4-Added IndexedDB Repository folder

Synchronize Data to a Server

In this demo, the idea is to have the application use the CustomerRepository repository when the network connectivity is available and the CustomerIndexedDBRepository repository when not. When offline, we will build up a list of transactions (insert, update, delete, delete all) which will be "replayed" when we come back online.

This seemingly-simple feature introduces more complexity to the application, and a bit more overhead.

In order for the app to truly work offline, we have to get all records from each table we want to access when we're offline. That means, when you run the app for the first time and you're online, you'll need to download all that data.

Our demo app just does it without fanfare because the Customers table is so small. However, in the real world, you may want to tell your user to please wait while you download all the data.

Online/Offline Indicator

Let's first add an Online/Offline indicator in the UI. The idea is to use JavaScript Interop to take advantage of navigator.onLine, and conversely have the JavaScript code notify the Blazor app of any changes by subscribing to online and offline events.

Add a Razor component to the Client project, and call it ConnectivityIndicator.razor.

Add the following code:

@inject IJSRuntime _jsRuntime;
@implements IAsyncDisposable

@if (IsOnline)
{
    @ShowOnline
}
else
{
    @ShowOffline
}

@code {
    [Parameter]
    public RenderFragment ShowOnline { get; set; }

    [Parameter]
    public RenderFragment ShowOffline { get; set; }

    public bool IsOnline { get; set; }

    [JSInvokable("ConnectivityChanged")]
    public void OnConnectivityChanged(bool isOnline)
    {
        if (IsOnline != isOnline)
        {
            IsOnline = isOnline;
        }

        StateHasChanged();
    }

    protected override async Task OnInitializedAsync() {
        await base.OnInitializedAsync();

        await _jsRuntime.InvokeVoidAsync("connectivity.initialize",
            DotNetObjectReference.Create(this));
    }

    public async ValueTask DisposeAsync() {
        await _jsRuntime.InvokeVoidAsync("connectivity.`dispose`");
    }
}

In the server project, create a js folder under wwwroot, and add a new JavaScript file called connectivity.js, with the following code:

let notify;

window.connectivity = {
    initialize: function (interop) {

        notify = function () {
            interop.invokeMethodAsync("ConnectivityChanged", navigator.onLine);
        }

        window.addEventListener("online", notify);
        window.addEventListener("offline", notify);

        notify(navigator.onLine);
    },
    dispose: function () {

        if (handler != null) {

            window.removeEventListener("online", notify);
            window.removeEventListener("offline", notify);
        }
    }
};

Open App.razor and add a reference to connectivity.js below the BlazorDB.js reference we added earlier.

<script src="https://github.com/carlfranklin/BlazorRepositoryDemo/raw/master/js/connectivity.js"></script>

The complete App.razor file should look like this:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <base href="https://github.com/carlfranklin/BlazorRepositoryDemo/blob/master/" />
    <link rel="stylesheet" href="https://github.com/carlfranklin/BlazorRepositoryDemo/blob/master/bootstrap/bootstrap.min.css" />
    <link rel="stylesheet" href="https://github.com/carlfranklin/BlazorRepositoryDemo/blob/master/app.css" />
    <link rel="stylesheet" href="https://github.com/carlfranklin/BlazorRepositoryDemo/blob/master/RepositoryDemo.styles.css" />
    <link rel="icon" type="image/png" href="https://github.com/carlfranklin/BlazorRepositoryDemo/blob/master/favicon.png" />
    <HeadOutlet @rendermode="new InteractiveWebAssemblyRenderMode(false)" />
</head>

<body>
    <Routes @rendermode="new InteractiveWebAssemblyRenderMode(false)" />
    <script src="https://github.com/carlfranklin/BlazorRepositoryDemo/raw/master/_framework/blazor.web.js"></script>
    <script src="https://github.com/carlfranklin/BlazorRepositoryDemo/raw/master/_content/BlazorIndexedDB/dexie.min.js"></script>
    <script src="https://github.com/carlfranklin/BlazorRepositoryDemo/raw/master/_content/BlazorIndexedDB/blazorDB.js"></script>
    <script src="https://github.com/carlfranklin/BlazorRepositoryDemo/raw/master/js/connectivity.js"></script>
</body>

</html>

In the client project, add a wwwroot\images folder, and to it add the internet-off.png and internet-on.png files which we are going to use to display network connectivity status.

These images can be found in the Completed Projects folder.

Once the images are in your project, select them and set their Copy to Output Directory properties to Copy if newer.

image-20240423115656651

Open MainLayout.razor in the client project, and add the new ConnectivityIndicator component, above the About line.

@inherits LayoutComponentBase

<div class="page">
    <div class="sidebar">
        <NavMenu />
    </div>

    <main>
        <div class="top-row px-4">
            <ConnectivityIndicator>
                <ShowOnline>
                    <img alt="Online"
                         title="Application running online."
                         src="https://github.com/carlfranklin/BlazorRepositoryDemo/raw/master/images/internet-on.png" />
                </ShowOnline>
                <ShowOffline>
                    <img alt="Offline"
                         title="Application running offline."
                         src="https://github.com/carlfranklin/BlazorRepositoryDemo/raw/master/images/internet-off.png" />
                </ShowOffline>
            </ConnectivityIndicator>
            <a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
        </div>

        <article class="content px-4">
            @Body
        </article>
    </main>
</div>

<div id="blazor-error-ui">
    An unhandled error has occurred.
    <a href="https://github.com/carlfranklin/BlazorRepositoryDemo/blob/master/" class="reload">Reload</a>
    <a class="dismiss">🗙</a>
</div>

Run the application, you should be able to see a green connectivity icon when running online, and grey when running offline.

Running online:

image-20220609212248231

Running offline:

image-20220609212350230

TIP: Use the Browser's Network/Offline mode to test the functionality. Make sure the cache is enabled.

We need to add a couple more classes in the Services folder.

This will support our mapping between local and online primary keys:

OnlineOfflineKey.cs:

namespace RepositoryDemo.Client.Services;

public class OnlineOfflineKey
{
    public int Id { get; set; }
    public object OnlineId { get; set; }
    public object LocalId { get; set; }
}

This class will support the OnlineStatusChanged event:

OnlineStatusEventArgs.cs:

namespace RepositoryDemo.Client.Services;

public class OnlineStatusEventArgs : EventArgs
{
    public bool IsOnline { get; set; }
}

When recording the transaction details for each CRUD operation, we are going to need constants for the suffix of the table name, and also the suffix for the key mapping table name so let's add a Globals.cs file at the project level.

namespace RepositoryDemo.Client;

public static class Globals
{
    public const string LocalTransactionsSuffix = "_transactions";
    public const string KeysSuffix = "_keys";
}

We are going to need a transaction type, to store the operation performed, so add a LocalTransactionTypes.cs in the Services folder to hold the following enum.

namespace RepositoryDemo.Client.Services;

public enum LocalTransactionTypes
{
    Insert = 0,
    Update = 1,
    Delete = 2,
    DeleteAll = 3
}

We are also going to need a data object, to hold the information we are going to record. Add a LocalTransaction.cs file with the following code:

namespace RepositoryDemo.Client.Services;

public class LocalTransaction<TEntity>
{
    public TEntity Entity { get; set; }
    public LocalTransactionTypes Action { get; set; }
    public string ActionName { get; set; }
    public object Id { get; set; }
}

IndexedDBSyncRepository

Now we are going to add the ability to use the CustomerRepository repository when working online, and automatically fallback to IndexedDBSyncRepository when working offline, by leveraging the connectivity.js file we added above.

We made several changes to the repository, to accomplish the following tasks:

We need to track whether there is connectivity or not, so we are going to leverage our connectivity.js code, so we are injecting IJSRuntime.

The code is using APIRepository when online, so we have injected that as well.

We also added an OnlineStatusChanged event to let our user know about online status changes. That will let them reload data from the appropriate source.

Add the "Sync" versions of the repository base and customer repository to the Services folder:

IndexedDBSyncRepository.cs:

using Microsoft.JSInterop;
using RepositoryDemo.Client;
using System.Reflection;

namespace RepositoryDemo.Client.Services;

public class IndexedDBSyncRepository<TEntity> : IRepository<TEntity>
    where TEntity : class
{
    // injected
    IBlazorDbFactory _dbFactory;
    private readonly APIRepository<TEntity> _apiRepository;
    private readonly IJSRuntime _jsRuntime;
    string _dbName = "";
    string _primaryKeyName = "";
    bool _autoGenerateKey;

    IndexedDbManager manager;
    string storeName = "";
    string keyStoreName = "";
    Type entityType;
    PropertyInfo primaryKey;
    public bool IsOnline { get; set; } = true;

    public delegate void OnlineStatusEventHandler(object sender,
        OnlineStatusEventArgs e);
    public event OnlineStatusEventHandler OnlineStatusChanged;

    public IndexedDBSyncRepository(string dbName, string primaryKeyName,
        bool autoGenerateKey, IBlazorDbFactory dbFactory,
        APIRepository<TEntity> apiRepository, IJSRuntime jsRuntime)
    {
        _dbName = dbName;
        _dbFactory = dbFactory;
        _apiRepository = apiRepository;
        _jsRuntime = jsRuntime;
        _primaryKeyName = primaryKeyName;
        _autoGenerateKey = autoGenerateKey;

        entityType = typeof(TEntity);
        storeName = entityType.Name;
        keyStoreName = $"{storeName}{Globals.KeysSuffix}";
        primaryKey = entityType.GetProperty(primaryKeyName);

        _ = _jsRuntime.InvokeVoidAsync("connectivity.initialize",
            DotNetObjectReference.Create(this));
    }

    public string LocalStoreName
    {
        get { return $"{storeName}{Globals.LocalTransactionsSuffix}"; }
    }

    [JSInvokable("ConnectivityChanged")]
    public async void OnConnectivityChanged(bool isOnline)
    {
        IsOnline = isOnline;

        if (!isOnline)
        {
            OnlineStatusChanged?.Invoke(this,
                new OnlineStatusEventArgs { IsOnline = false });
        }
        else
        {
            await SyncLocalToServer();
            OnlineStatusChanged?.Invoke(this,
                new OnlineStatusEventArgs { IsOnline = true });
        }

    }

    private async Task EnsureManager()
    {
        if (manager == null)
        {
            manager = await _dbFactory.GetDbManager(_dbName);
            await manager.OpenDb();
        }
    }
    public async Task DeleteAllAsync()
    {
        if (IsOnline)
            await _apiRepository.DeleteAllAsync();

        await DeleteAllOfflineAsync();
    }

    private async Task DeleteAllOfflineAsync()
    {
        await EnsureManager();

        // clear the keys table
        await manager.ClearTableAsync(keyStoreName);

        // clear the data table
        await manager.ClearTableAsync(storeName);

        RecordDeleteAllAsync();
    }

    public async void RecordDeleteAllAsync()
    {
        if (IsOnline) return;

        var action = LocalTransactionTypes.DeleteAll;
        var record = new StoreRecord<LocalTransaction<TEntity>>()
        {
            StoreName = LocalStoreName,
            Record = new LocalTransaction<TEntity>
            {
                Entity = null,
                Action = action,
                ActionName = action.ToString()
            }
        };

        await manager.AddRecordAsync(record);
    }

    public async Task<bool> DeleteAsync(TEntity EntityToDelete)
    {
        bool deleted = false;

        if (IsOnline)
        {
            var onlineId = primaryKey.GetValue(EntityToDelete);
            deleted = await _apiRepository.DeleteAsync(EntityToDelete);
            var localEntity = await UpdateKeyToLocal(EntityToDelete);
            await DeleteOfflineAsync(localEntity);
        }
        else
        {
            deleted = await DeleteOfflineAsync(EntityToDelete);
        }

        return deleted;
    }

    public async Task<bool> DeleteOfflineAsync(TEntity EntityToDelete)
    {
        await EnsureManager();
        var Id = primaryKey.GetValue(EntityToDelete);
        return await DeleteByIdAsync(Id);
    }

    public async Task<bool> DeleteByIdAsync(object Id)
    {
        bool deleted = false;

        if (IsOnline)
        {
            var localId = await GetLocalId(Id);
            await DeleteByIdOfflineAsync(localId);
            deleted = await _apiRepository.DeleteByIdAsync(Id);
        }
        else
        {
            deleted = await DeleteByIdOfflineAsync(Id);
        }

        return deleted;
    }

    public async Task<bool> DeleteByIdOfflineAsync(object Id)
    {
        await EnsureManager();
        try
        {
            RecordDeleteByIdAsync(Id);
            var result = await manager.DeleteRecordAsync(storeName, Id);
            if (result.Failed) return false;

            if (IsOnline)
            {
                // delete key map only if we're online.
                var keys = await GetKeys();
                if (keys.Count > 0)
                {
                    var key = (from x in keys
                               where x.LocalId.ToString() == Id.ToString()
                               select x).FirstOrDefault();
                    if (key != null)
                        await manager.DeleteRecordAsync(keyStoreName, key.Id);
                }
            }

            return true;
        }
        catch (Exception ex)
        {
            // log exception
            return false;
        }
    }

    public async void RecordDeleteByIdAsync(object id)
    {
        if (IsOnline) return;
        var action = LocalTransactionTypes.Delete;

        var entity = await GetByIdAsync(id);

        var record = new StoreRecord<LocalTransaction<TEntity>>()
        {
            StoreName = LocalStoreName,
            Record = new LocalTransaction<TEntity>
            {
                Entity = entity,
                Action = action,
                ActionName = action.ToString(),
                Id = int.Parse(id.ToString())
            }
        };

        await manager.AddRecordAsync(record);
    }

    /// <summary>
    /// just to satisfy the contract
    /// </summary>
    /// <returns></returns>
    public async Task<IEnumerable<TEntity>> GetAllAsync()
    {
        return await GetAllAsync(false);
    }

    public async Task<IEnumerable<TEntity>> GetAllAsync(bool dontSync = false)
    {
        if (IsOnline)
        {
            // retrieve all the data
            var list = await _apiRepository.GetAllAsync();
            if (list != null)
            {
                var allData = list.ToList();
                if (!dontSync)
                {
                    // clear the local db
                    await DeleteAllOfflineAsync();
                    // write the values into IndexedDB
                    var result = await manager.BulkAddRecordAsync<TEntity>
                        (storeName, allData);
                    // get all the local data
                    var localList = await GetAllOfflineAsync();
                    var localData = (localList).ToList();
                    // record the primary keys
                    var keys = new List<OnlineOfflineKey>();
                    for (int i = 0; i < allData.Count(); i++)
                    {
                        var localId = primaryKey.GetValue(localData[i]);
                        var key = new OnlineOfflineKey()
                        {
                            Id = Convert.ToInt32(localId),
                            OnlineId = primaryKey.GetValue(allData[i]),
                            LocalId = localId,
                        };
                        keys.Add(key);
                    };
                    // remove all the keys
                    await manager.ClearTableAsync(keyStoreName);
                    // store all of the keys
                    result = await manager.BulkAddRecordAsync<OnlineOfflineKey>
                        (keyStoreName, keys);
                }
                // return the data
                return allData;
            }
            else
                return null;
        }
        else
            return await GetAllOfflineAsync();
    }

    public async Task<IEnumerable<TEntity>> GetAllOfflineAsync()
    {
        await EnsureManager();
        var array = await manager.ToArray<TEntity>(storeName);
        if (array == null)
            return new List<TEntity>();
        else
            return array.ToList();
    }

    public async Task<IEnumerable<TEntity>> GetAsync(QueryFilter<TEntity> Filter)
    {
        // We have to load all items and use LINQ to filter them. :(
        var allitems = await GetAllAsync(true);
        return Filter.GetFilteredList(allitems);
    }

    public async Task<TEntity> GetByIdAsync(object Id)
    {
        if (IsOnline)
            return await _apiRepository.GetByIdAsync(Id);
        else
            return await GetByIdOfflineAsync(Id);
    }

    public async Task<TEntity> GetByIdOfflineAsync(object Id)
    {
        await EnsureManager();
        var items = await manager.Where<TEntity>(storeName, _primaryKeyName, Id);
        if (items.Any())
            return items.First();
        else
            return null;
    }

    public async Task<TEntity> InsertAsync(TEntity Entity)
    {
        TEntity returnValue;

        if (IsOnline)
        {
            returnValue = await _apiRepository.InsertAsync(Entity);
            var Id = primaryKey.GetValue(returnValue);
            await InsertOfflineAsync(returnValue);
        }
        else
        {
            returnValue = await InsertOfflineAsync(Entity);
        }
        return returnValue;
    }

    public async Task<TEntity> InsertOfflineAsync(TEntity Entity)
    {
        await EnsureManager();

        try
        {
            var onlineId = primaryKey.GetValue(Entity);

            var record = new StoreRecord<TEntity>()
            {
                StoreName = storeName,
                Record = Entity
            };
            var result = await manager.AddRecordAsync<TEntity>(record);
            var allItems = await GetAllOfflineAsync();
            var last = allItems.Last();
            var localId = primaryKey.GetValue(last);

            // record in the keys database
            var key = new OnlineOfflineKey()
            {
                Id = Convert.ToInt32(localId),
                OnlineId = onlineId,
                LocalId = localId
            };
            var storeRecord = new StoreRecord<OnlineOfflineKey>
            {
                DbName = _dbName,
                StoreName = keyStoreName,
                Record = key
            };
            await manager.AddRecordAsync(storeRecord);

            RecordInsertAsync(last);

            return last;
        }
        catch (Exception ex)
        {
            // log exception
            return null;
        }
    }

    public async void RecordInsertAsync(TEntity Entity)
    {
        if (IsOnline) return;
        try
        {
            var action = LocalTransactionTypes.Insert;

            var record = new StoreRecord<LocalTransaction<TEntity>>()
            {
                StoreName = LocalStoreName,
                Record = new LocalTransaction<TEntity>
                {
                    Entity = Entity,
                    Action = action,
                    ActionName = action.ToString()
                }
            };

            await manager.AddRecordAsync(record);
        }
        catch (Exception ex)
        {
            // log exception
        }
    }

    public async Task<TEntity> UpdateAsync(TEntity EntityToUpdate)
    {
        TEntity returnValue;

        if (IsOnline)
        {
            returnValue = await _apiRepository.UpdateAsync(EntityToUpdate);
            var Id = primaryKey.GetValue(returnValue);
            returnValue = await UpdateKeyToLocal(returnValue);
            await UpdateOfflineAsync(returnValue);
        }
        else
        {
            returnValue = await UpdateOfflineAsync(EntityToUpdate);
        }
        return returnValue;
    }

    public async Task<TEntity> UpdateOfflineAsync(TEntity EntityToUpdate)
    {
        await EnsureManager();
        object Id = primaryKey.GetValue(EntityToUpdate);
        try
        {
            await manager.UpdateRecord(new UpdateRecord<TEntity>()
            {
                StoreName = storeName,
                Record = EntityToUpdate,
                Key = Id
            });

            RecordUpdateAsync(EntityToUpdate);

            return EntityToUpdate;
        }
        catch (Exception ex)
        {
            // log exception
            return null;
        }
    }

    public async void RecordUpdateAsync(TEntity Entity)
    {
        if (IsOnline) return;
        try
        {
            var action = LocalTransactionTypes.Update;

            var record = new StoreRecord<LocalTransaction<TEntity>>()
            {
                StoreName = LocalStoreName,
                Record = new LocalTransaction<TEntity>
                {
                    Entity = Entity,
                    Action = action,
                    ActionName = action.ToString()
                }
            };

            await manager.AddRecordAsync(record);
        }
        catch (Exception ex)
        {
            // log exception
        }
    }

    private async Task<object> GetLocalId(object OnlineId)
    {
        var keys = await GetKeys();
        var item = (from x in keys
                    where x.OnlineId.ToString() == OnlineId.ToString()
                    select x).FirstOrDefault();
        var localId = item.LocalId;
        localId = JsonConvert.DeserializeObject<object>(localId.ToString());
        return localId;
    }

    private async Task<object> GetOnlineId(object LocalId)
    {
        var keys = await GetKeys();
        var item = (from x in keys
                    where x.LocalId.ToString() == LocalId.ToString()
                    select x).FirstOrDefault();
        var onlineId = item.OnlineId;
        onlineId = JsonConvert.DeserializeObject<object>(onlineId.ToString());
        return onlineId;
    }
    private async Task<List<OnlineOfflineKey>> GetKeys()
    {
        await EnsureManager();
        var returnList = new List<OnlineOfflineKey>();

        var array = await manager.ToArray<OnlineOfflineKey>(keyStoreName);
        if (array == null) return null;

        foreach (var key in array)
        {
            var onlineId = key.OnlineId;
            key.OnlineId = JsonConvert.DeserializeObject<object>(onlineId.ToString());

            var localId = key.LocalId;
            key.LocalId = JsonConvert.DeserializeObject<object>(localId.ToString());

            returnList.Add(key);
        }

        return returnList;
    }

    private async Task<TEntity> UpdateKeyToLocal(TEntity Entity)
    {
        var OnlineId = primaryKey.GetValue(Entity);
        OnlineId = JsonConvert.DeserializeObject<object>(OnlineId.ToString());

        var keys = await GetKeys();
        if (keys == null) return null;

        var item = (from x in keys
                    where x.OnlineId.ToString() == OnlineId.ToString()
                    select x).FirstOrDefault();

        if (item == null) return null;

        var key = item.LocalId;

        var typeName = key.GetType().Name;

        if (typeName == nameof(Int64))
        {
            if (primaryKey.PropertyType.Name == nameof(Int32))
                key = Convert.ToInt32(key);
        }
        else if (typeName == "string")
        {
            if (primaryKey.PropertyType.Name != "string")
                key = key.ToString();
        }

        primaryKey.SetValue(Entity, key);

        return Entity;
    }

    private async Task<TEntity> UpdateKeyFromLocal(TEntity Entity)
    {
        var LocalId = primaryKey.GetValue(Entity);
        LocalId = JsonConvert.DeserializeObject<object>(LocalId.ToString());

        var keys = await GetKeys();
        if (keys == null) return null;

        var item = (from x in keys
                    where x.LocalId.ToString() == LocalId.ToString()
                    select x).FirstOrDefault();

        if (item == null) return null;

        var key = item.OnlineId;

        var typeName = key.GetType().Name;

        if (typeName == nameof(Int64))
        {
            if (primaryKey.PropertyType.Name == nameof(Int32))
                key = Convert.ToInt32(key);
        }
        else if (typeName == "string")
        {
            if (primaryKey.PropertyType.Name != "string")
                key = key.ToString();
        }

        primaryKey.SetValue(Entity, key);

        return Entity;
    }

    public async Task<bool> SyncLocalToServer()
    {
        if (!IsOnline) return false;

        await EnsureManager();

        var array = await manager.ToArray<LocalTransaction<TEntity>>(LocalStoreName);
        if (array == null || array.Count == 0)
            return true;
        else
        {
            foreach (var localTransaction in array.ToList())
            {
                try
                {
                    switch (localTransaction.Action)
                    {
                        case LocalTransactionTypes.Insert:
                            var insertedEntity = await
                                _apiRepository.InsertAsync(localTransaction.Entity);
                            // update the keys table
                            var localId = primaryKey.GetValue(localTransaction.Entity);
                            var onlineId = primaryKey.GetValue(insertedEntity);
                            var key = new OnlineOfflineKey()
                            {
                                Id = Convert.ToInt32(localId),
                                OnlineId = onlineId,
                                LocalId = localId
                            };
                            await manager.AddRecordAsync<OnlineOfflineKey>
                                (new StoreRecord<OnlineOfflineKey>
                                {
                                    StoreName = keyStoreName,
                                    Record = key
                                });
                            break;

                        case LocalTransactionTypes.Update:
                            localTransaction.Entity = await UpdateKeyFromLocal
                                (localTransaction.Entity);
                            await _apiRepository.UpdateAsync(localTransaction.Entity);
                            onlineId = primaryKey.GetValue(localTransaction.Entity);
                            break;

                        case LocalTransactionTypes.Delete:
                            localTransaction.Entity = await UpdateKeyFromLocal
                                (localTransaction.Entity);
                            onlineId = primaryKey.GetValue(localTransaction.Entity);
                            await _apiRepository.DeleteAsync(localTransaction.Entity);
                            break;

                        case LocalTransactionTypes.DeleteAll:
                            await _apiRepository.DeleteAllAsync();
                            break;

                        default:
                            break;
                    }
                }
                catch (Exception ex)
                {
                }
            }

            await DeleteAllTransactionsAsync();

            // TODO: Get all new records since last online
            // Get last record id
            // ask for new records since that id was recorded in the database
            // may require a time stamp field in the data record (invasive!)

            return true;
        }
    }

    private async Task DeleteAllTransactionsAsync()
    {
        await EnsureManager();
        await manager.ClearTableAsync(LocalStoreName);
    }

    public async Task<LocalTransaction<TEntity>>
        UpdateOfflineAsync(LocalTransaction<TEntity> entityToUpdate,
        TEntity onlineEntity)
    {
        await EnsureManager();

        object Id = primaryKey.GetValue(entityToUpdate.Entity);

        entityToUpdate.Entity = onlineEntity;

        try
        {
            await manager.UpdateRecord(new UpdateRecord<LocalTransaction<TEntity>>()
            {
                StoreName = LocalStoreName,
                Record = entityToUpdate,
                Key = Id
            });

            return entityToUpdate;
        }
        catch (Exception ex)
        {
            // log exception
            return null;
        }
    }

    public async ValueTask DisposeAsync()
    {
        await _jsRuntime.InvokeVoidAsync("connectivity.dispose");
    }
}

CustomerIndexedDBSyncRepository.cs:

using Microsoft.JSInterop;
namespace RepositoryDemo.Client.Services;

public class CustomerIndexedDBSyncRepository : IndexedDBSyncRepository<Customer>
{
    public CustomerIndexedDBSyncRepository(IBlazorDbFactory dbFactory,
        CustomerRepository customerRepository, IJSRuntime jsRuntime)
        : base("RepositoryDemo", "Id", true, dbFactory, customerRepository, jsRuntime)
    {
    }
}

Open Program.cs and add two new StoreSchema objects under the options.StoreSchemas BlazorDB options.

// List all your entities here, but as StoreSchema objects
options.StoreSchemas = new List<StoreSchema>()
{
    new StoreSchema()
    {
        Name = "Customer",      // Name of entity
        PrimaryKey = "Id",      // Primary Key of entity
        PrimaryKeyAuto = true,  // Whether or not the Primary key is generated
        Indexes = new List<string> { "Id" }
    },
    new StoreSchema()
    {
        Name = $"Customer{Globals.LocalTransactionsSuffix}",
        PrimaryKey = "Id",
        PrimaryKeyAuto = true,
        Indexes = new List<string> { "Id" }
    },
    new StoreSchema()
    {
        Name = $"Customer{Globals.KeysSuffix}",
        PrimaryKey = "Id",
        PrimaryKeyAuto = true,
        Indexes = new List<string> { "Id" }
    }
};

Next, register CustomerIndexedDBSyncRepository below the CustomerIndexedDBRepository registration with builder.Services.AddScoped<CustomerIndexedDBSyncRepository>();.

The complete code should look like this:

global using BlazorDB;
global using System.Net.Http.Json;
global using Newtonsoft.Json;
global using System.Net;
global using System.Linq.Expressions;
global using AvnRepository;
global using RepositoryDemo.Client.Models;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using RepositoryDemo.Client.Services;
using RepositoryDemo.Client;

var builder = WebAssemblyHostBuilder.CreateDefault(args);

builder.Services.AddScoped(sp => new HttpClient
{ BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });

builder.Services.AddScoped<CustomerRepository>();

builder.Services.AddBlazorDB(options =>
{
    options.Name = "RepositoryDemo";
    options.Version = 1;

    // List all your entities here, but as StoreSchema objects
    options.StoreSchemas = new List<StoreSchema>()
    {
        new StoreSchema()
        {
            Name = "Customer",      // Name of entity
            PrimaryKey = "Id",      // Primary Key of entity
            PrimaryKeyAuto = true,  // Whether or not the Primary key is generated
            Indexes = new List<string> { "Id" }
        },
        new StoreSchema()
        {
            Name = $"Customer{Globals.LocalTransactionsSuffix}",
            PrimaryKey = "Id",
            PrimaryKeyAuto = true,
            Indexes = new List<string> { "Id" }
        },
        new StoreSchema()
        {
            Name = $"Customer{Globals.KeysSuffix}",
            PrimaryKey = "Id",
            PrimaryKeyAuto = true,
            Indexes = new List<string> { "Id" }
        }
    };
});

builder.Services.AddScoped<CustomerIndexedDBRepository>();
builder.Services.AddScoped<CustomerIndexedDBSyncRepository>();

await builder.Build().RunAsync();

TIP:

As you work with IndexedDB via BlazorDB, you may find a situation where after changing the schema of the database in Program.cs, the changes are not reflected in the browser tools Application tab. If that's the case, just bump up the hosting ports in the Server project's Properties/launchSettings.json file.

image-20240423114717798

Adding one to each of these ports, effectively changes the url, which means your IndexedDB database will be created anew.

To support offline syncing, our Home.razor page needs to handle the OnlineStatusChanged event, which will render it incompatible with the other repositories. Rather than do that, we're going to create a new page for the sync demo.

Create a new page in the Pages folder called Syncdemo.razor:

@page "/syncdemo"
@implements IDisposable
@inject CustomerIndexedDBSyncRepository CustomerManager

<h1>Offline Repository Sync Demo</h1>

<div style="background-color:lightgray;padding:20px;">
    This demo:
    <ul>
        <li>Downloads all data from server when first run online, and saves to IndexedDB</li>
        <li>Keeps IndexedDB in sync with online source as you make changes</li>
        <li>Keeps a list map of items with both local and online primary keys</li>
        <li>When offline, keeps a list of transactions (insert, update, and delete)</li>
        <li>When you go back online, it executes those transactions</li>
    </ul>
    What it does NOT do (yet):
    <ul>
        <li>Check for collisions and deal with the consequences</li>
        <li>Efficiently sync local db from online source when you go back online after syncing</li>
    </ul>
</div>

<br/>@foreach (var customer in Customers)
{
    <p>(@customer.Id) @customer.Name, @customer.Email</p>
}

<button @onclick="AddCustomer">Add Customer</button>
<button @onclick="UpdateIsadora">Update Isadora</button>
<button @onclick="DeleteRocky">Delete Rocky</button>
<button @onclick="DeleteHugh">Delete Hugh</button>
<button @onclick="GetJenny">GetJenny</button>
<button @onclick="ResetData">Reset Data</button>
<br />
<br />
<p>
    Search by Name: <input @bind=@SearchFilter />
    <button @onclick="Search">Search</button>
    <br />
    <br />
    <input style="zoom:1.5;" type="checkbox" @bind="CaseSensitive" /> Case Sensitive
    <br />
    <input style="zoom:1.5;" type="checkbox" @bind="Descending" /> Descending Order
    <br />
    <br />
    Options:<br />
    <select @onchange="SelectedOptionChanged" size=3 style="padding:10px;">
        <option selected value="StartsWith">Starts With</option>
        <option value="EndsWith">Ends With</option>
        <option value="Contains">Contains</option>
    </select>

</p>

<br />
<br />
<p>@JennyMessage</p>

@code
{
    List<Customer> Customers = new List<Customer>();
    string JennyMessage = "";
    string SearchFilter = "";
    bool CaseSensitive = false;
    bool Descending = false;
    FilterOperator filterOption = FilterOperator.StartsWith;

    bool Initialized = false;

    void SelectedOptionChanged(ChangeEventArgs args)
    {
        switch (args.Value.ToString())
        {
            case "StartsWith":
                filterOption = FilterOperator.StartsWith;
                break;
            case "EndsWith":
                filterOption = FilterOperator.EndsWith;
                break;
            case "Contains":
                filterOption = FilterOperator.Contains;
                break;
        }
    }

    async Task Search()
    {
        try
        {
            var expression = new QueryFilter<Customer>();

            expression.FilterProperties.Add(new FilterProperty { Name = "Name", Value = SearchFilter, Operator = filterOption, CaseSensitive = CaseSensitive });
            expression.OrderByPropertyName = "Name";
            expression.OrderByDescending = Descending;

            //// Example, return where Id = 2
            //expression.FilterProperties.Add(new FilterProperty { Name = "Id", Value = "2", Operator = FilterOperator.Equals });
            //expression.OrderByPropertyName = "Name";
            //expression.OrderByDescending = Descending;

            //// Example, return where Id > 1
            //expression.FilterProperties.Add(new FilterProperty { Name = "Id", Value = "1", Operator = FilterOperator.GreaterThan });
            //expression.OrderByPropertyName = "Name";
            //expression.OrderByDescending = Descending;

            var list = await CustomerManager.GetAsync(expression);
            Customers = list.ToList();

        }
        catch (Exception ex)
        {
            var msg = ex.Message;
        }
    }

    async Task DeleteRocky()
    {
        var rocky = (from x in Customers
                     where x.Email == "rocky@rhodes.com"
                     select x).FirstOrDefault();
        if (rocky != null)
        {
            await CustomerManager.DeleteAsync(rocky);
            await Reload();
        }
    }

    async Task DeleteHugh()
    {
        var hugh = (from x in Customers
                    where x.Email == "hugh@jass.com"
                    select x).FirstOrDefault();
        if (hugh != null)
        {
            await CustomerManager.DeleteByIdAsync(hugh.Id);
            await Reload();
        }
    }

    async Task AddCustomer()
    {
        var customer = new Customer
        {
            Name = "New Customer",
            Email = "new@customer.com"
        };
        customer = await CustomerManager.InsertAsync(customer);
        Customers.Add(customer);
    }

    async Task UpdateIsadora()
    {
        var isadora = (from x in Customers
                       where x.Email == "isadora@jarr.com"
                       select x).FirstOrDefault();
        if (isadora != null)
        {
            isadora.Email = "isadora@isadorajarr.com";
            await CustomerManager.UpdateAsync(isadora);
            await Reload();
        }
    }

    async Task GetJenny()
    {
        JennyMessage = "";
        var jenny = (from x in Customers
                     where x.Email == "jenny@jones.com"
                     select x).FirstOrDefault();
        if (jenny != null)
        {
            var jennyDb = await CustomerManager.GetByIdAsync(jenny.Id);
            if (jennyDb != null)
            {
                JennyMessage = $"Retrieved Jenny via Id {jennyDb.Id}";
            }
        }
        await InvokeAsync(StateHasChanged);
    }

    protected async void OnlineStatusChanged(object sender, OnlineStatusEventArgs args)
    {
        if (args.IsOnline == false)
        {
            // reload from IndexedDB
            Customers = (await CustomerManager.GetAllOfflineAsync()).ToList();
        }
        else
        {
            if (Initialized)
                // reload from API
                await Reload();
            else
                Initialized = true;
        }
        await InvokeAsync(StateHasChanged);
    }

    protected override async Task OnInitializedAsync()
    {
        CustomerManager.OnlineStatusChanged += OnlineStatusChanged;
        await AddCustomers();
    }

    async Task ResetData()
    {
        await CustomerManager.DeleteAllAsync();
        await AddCustomers();
    }

    async Task Reload()
    {
        JennyMessage = "";
        var list = await CustomerManager.GetAllAsync();
        if (list != null)
        {
            Customers = list.ToList();
            await InvokeAsync(StateHasChanged);
        }
    }

    async Task AddCustomers()
    {
        // Added these lines to not clobber the existing data
        var all = await CustomerManager.GetAllAsync();
        if (all.Count() > 0)
        {
            await Reload();
            return;
        }

        Customers.Clear();

        await CustomerManager.InsertAsync(new Customer
            {
                Id = 1,
                Name = "Isadora Jarr",
                Email = "isadora@jarr.com"
            });

        await CustomerManager.InsertAsync(new Customer
            {
                Id = 2,
                Name = "Rocky Rhodes",
                Email = "rocky@rhodes.com"
            });

        await CustomerManager.InsertAsync(new Customer
            {
                Id = 3,
                Name = "Jenny Jones",
                Email = "jenny@jones.com"
            });

        await CustomerManager.InsertAsync(new Customer
            {
                Id = 4,
                Name = "Hugh Jass",
                Email = "hugh@jass.com"
            });

        await Reload();
    }

    void IDisposable.Dispose()
    {
        CustomerManager.OnlineStatusChanged -= OnlineStatusChanged;
    }
}

Update Home.razor with a button to navigate to the Syncdemo page:

Home.razor:

@page "/"
@inject NavigationManager NavigationManager
@inject CustomerRepository CustomerManager

<h1>Repository Demo</h1>
<button @onclick="GoToSyncDemo">Go to offline sync demo</button>
<br/><br/>

@foreach (var customer in Customers)
{
    <p>(@customer.Id) @customer.Name, @customer.Email</p>
}

<button @onclick="UpdateIsadora">Update Isadora</button>
<button @onclick="DeleteRocky">Delete Rocky</button>
<button @onclick="DeleteHugh">Delete Hugh</button>
<button @onclick="GetJenny">GetJenny</button>
<button @onclick="ResetData">Reset Data</button>
<br />
<br />
<p>
    Search by Name: <input @bind=@SearchFilter />
    <button @onclick="Search">Search</button>
    <br />
    <br />
    <input style="zoom:1.5;" type="checkbox" @bind="CaseSensitive" /> Case Sensitive
    <br />
    <input style="zoom:1.5;" type="checkbox" @bind="Descending" /> Descending Order
    <br />
    <br />
    Options:<br />
    <select @onchange="SelectedOptionChanged" size=3 style="padding:10px;">
        <option selected value="StartsWith">Starts With</option>
        <option value="EndsWith">Ends With</option>
        <option value="Contains">Contains</option>
    </select>

</p>

<br />
<br />
<p>@JennyMessage</p>

@code
{
    List<Customer> Customers = new List<Customer>();
    string JennyMessage = "";
    string SearchFilter = "";
    bool CaseSensitive = false;
    bool Descending = false;
    FilterOperator filterOption = FilterOperator.StartsWith;

    void SelectedOptionChanged(ChangeEventArgs args)
    {
        switch (args.Value.ToString())
        {
            case "StartsWith":
                filterOption = FilterOperator.StartsWith;
                break;
            case "EndsWith":
                filterOption = FilterOperator.EndsWith;
                break;
            case "Contains":
                filterOption = FilterOperator.Contains;
                break;
        }
    }
    void GoToSyncDemo()
    {
        NavigationManager.NavigateTo("syncdemo");
    }

    async Task Search()
    {
        try
        {
            var expression = new QueryFilter<Customer>();

            expression.FilterProperties.Add(new FilterProperty { Name = "Name", Value = SearchFilter, Operator = filterOption, CaseSensitive = CaseSensitive });
            expression.OrderByPropertyName = "Name";
            expression.OrderByDescending = Descending;

            //// Example, return where Id = 2
            //expression.FilterProperties.Add(new FilterProperty { Name = "Id", Value = "2", Operator = FilterOperator.Equals });
            //expression.OrderByPropertyName = "Name";
            //expression.OrderByDescending = Descending;

            //// Example, return where Id > 1
            //expression.FilterProperties.Add(new FilterProperty { Name = "Id", Value = "1", Operator = FilterOperator.GreaterThan });
            //expression.OrderByPropertyName = "Name";
            //expression.OrderByDescending = Descending;

            var list = await CustomerManager.GetAsync(expression);
            Customers = list.ToList();

        }
        catch (Exception ex)
        {
            var msg = ex.Message;
        }
    }

    async Task DeleteRocky()
    {
        var rocky = (from x in Customers
                     where x.Email == "rocky@rhodes.com"
                     select x).FirstOrDefault();
        if (rocky != null)
        {
            await CustomerManager.DeleteAsync(rocky);
            await Reload();
        }
    }

    async Task DeleteHugh()
    {
        var hugh = (from x in Customers
                    where x.Email == "hugh@jass.com"
                    select x).FirstOrDefault();
        if (hugh != null)
        {
            await CustomerManager.DeleteByIdAsync(hugh.Id);
            await Reload();
        }
    }

    async Task UpdateIsadora()
    {
        var isadora = (from x in Customers
                       where x.Email == "isadora@jarr.com"
                       select x).FirstOrDefault();
        if (isadora != null)
        {
            isadora.Email = "isadora@isadorajarr.com";
            await CustomerManager.UpdateAsync(isadora);
            await Reload();
        }
    }

    async Task GetJenny()
    {
        JennyMessage = "";
        var jenny = (from x in Customers
                     where x.Email == "jenny@jones.com"
                     select x).FirstOrDefault();
        if (jenny != null)
        {
            var jennyDb = await CustomerManager.GetByIdAsync(jenny.Id);
            if (jennyDb != null)
            {
                JennyMessage = $"Retrieved Jenny via Id {jennyDb.Id}";
            }
        }
        await InvokeAsync(StateHasChanged);
    }

    protected override async Task OnInitializedAsync()
    {
        await AddCustomers();
    }

    async Task ResetData()
    {
        await CustomerManager.DeleteAllAsync();
        await AddCustomers();
    }

    async Task Reload()
    {
        JennyMessage = "";
        var list = await CustomerManager.GetAllAsync();
        if (list != null)
        {
            Customers = list.ToList();
            await InvokeAsync(StateHasChanged);
        }
    }

    async Task AddCustomers()
    {

        // Added these lines to not clobber the existing data
        var all = await CustomerManager.GetAllAsync();
        if (all.Count() > 0)
        {
            await Reload();
            return;
        }

        Customers.Clear();

        await CustomerManager.InsertAsync(new Customer
            {
                Id = 1,
                Name = "Isadora Jarr",
                Email = "isadora@jarr.com"
            });

        await CustomerManager.InsertAsync(new Customer
            {
                Id = 2,
                Name = "Rocky Rhodes",
                Email = "rocky@rhodes.com"
            });

        await CustomerManager.InsertAsync(new Customer
            {
                Id = 3,
                Name = "Jenny Jones",
                Email = "jenny@jones.com"
            });

        await CustomerManager.InsertAsync(new Customer
            {
                Id = 4,
                Name = "Hugh Jass",
                Email = "hugh@jass.com"
            });

        await Reload();
    }
}

Now run the app and go to the Syncdemo page.

Try this test, run the application offline, and perform the following actions:

  1. Reset Data
  2. Delete Rocky
  3. Delete Hugh
  4. Update Isadora

Notice the IndexedDB shows just two customers:

image-20240423120105891

Also noticed that there is a new Customers_transactions table with all the transaction data we recorded.

image-20240423120221785

Now, enable back Network connectivity, and the database will sync.

If you refresh the IndexedDB, you will notice it will still show two customers, and if you display the data in the RepositoryDemo SQL database, you will see two customers only.

image-20220609211836651

The finished code for this leg of the demo can be found in the 5-Added IndexedDB Sync Repository folder

Using SignalR to sync database actions when online

If we're going to keep our local database in sync, we might as well go all the way toward syncing in real time when other users modify data. This approach can save time and minimize going back to the database to refresh entire tables.

Taking a nod from BlazorTrain episode #30 (Synchronizing Data with SignalR), we're going to automatically sync CRUD actions from other users while online, and whenever we come back online from being offline.

In this demo we will use SignalR, but in production, I would use a more robust message broker like RabbitMQ.

We will start on in the Server project. Add the following to Program.cs:

First, a global using statement at the top:

global using Microsoft.AspNetCore.SignalR;

Add services just before the line var app = builder.Build();:

builder.Services.AddSignalR();
builder.Services.AddResponseCompression(opts =>
{
    opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(
        new[] { "application/octet-stream" });
});

add this after the line app.UseAntiforgery();:

app.UseResponseCompression();

finally, add this before the line app.Run(); :

app.MapHub<DataSyncHub>("/DataSyncHub");

The complete Program.cs should look like this:

global using Microsoft.AspNetCore.SignalR;
global using System.Reflection;
global using AvnRepository;
global using Microsoft.EntityFrameworkCore;
global using RepositoryDemo.Client.Models;
global using System.Data.SqlClient;
global using Dapper;
global using Dapper.Contrib.Extensions;
global using System.Data;
using RepositoryDemo.Components;
using RepositoryDemo.Data;
using Microsoft.AspNetCore.ResponseCompression;
using RepositoryDemo;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddRazorComponents()
    .AddInteractiveWebAssemblyComponents();

builder.Services.AddSingleton<MemoryRepository<Customer>>(x =>
    new MemoryRepository<Customer>("Id"));

builder.Services.AddTransient<RepositoryDemoContext, RepositoryDemoContext>();
builder.Services.AddTransient<EFRepository<Customer, RepositoryDemoContext>>();

builder.Services.AddTransient<DapperRepository<Customer>>(s =>
    new DapperRepository<Customer>(
        builder.Configuration.GetConnectionString("RepositoryDemoConnectionString")));

builder.Services.AddControllers();

builder.Services.AddSignalR();
builder.Services.AddResponseCompression(opts =>
{
    opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(
        new[] { "application/octet-stream" });
});

var app = builder.Build();

app.MapControllers();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseWebAssemblyDebugging();
}
else
{
    app.UseExceptionHandler("/Error", createScopeForErrors: true);
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

app.UseHttpsRedirection();

app.UseStaticFiles();
app.UseAntiforgery();
app.UseResponseCompression();

app.MapRazorComponents<App>()
    .AddInteractiveWebAssemblyRenderMode()
    .AddAdditionalAssemblies(typeof(RepositoryDemo.Client._Imports).Assembly);

app.MapHub<DataSyncHub>("/DataSyncHub");

app.Run();

Next, add DataSyncHub.cs to the Server Project:

namespace RepositoryDemo;

public class DataSyncHub : Hub
{
    public async Task SyncRecord(string Table, string Action, string Id)
    {
        await Clients.Others.SendAsync("ReceiveSyncRecord",
              Table, Action, Id);
    }
}

This hub will be used to send messages to other clients after we've inserted, updated, or deleted a record.

Now, on the client side we need to add the latest version of the Microsoft.AspNetCore.SignalR.Client NuGet package. You can add the following to your RepositoryDemo.Client.csproj file:

<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.4" />

In the Client's Program.cs we need the following:

Global using:

global using Microsoft.AspNetCore.SignalR.Client;

The entire Program.cs should look like this:

global using Microsoft.AspNetCore.SignalR.Client;
global using BlazorDB;
global using System.Net.Http.Json;
global using Newtonsoft.Json;
global using System.Net;
global using System.Linq.Expressions;
global using AvnRepository;
global using RepositoryDemo.Client.Models;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using RepositoryDemo.Client.Services;
using RepositoryDemo.Client;

var builder = WebAssemblyHostBuilder.CreateDefault(args);

builder.Services.AddScoped(sp => new HttpClient
{ BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });

builder.Services.AddScoped<CustomerRepository>();

builder.Services.AddBlazorDB(options =>
{
    options.Name = "RepositoryDemo";
    options.Version = 1;

    // List all your entities here, but as StoreSchema objects
    options.StoreSchemas = new List<StoreSchema>()
    {
        new StoreSchema()
        {
            Name = "Customer",      // Name of entity
            PrimaryKey = "Id",      // Primary Key of entity
            PrimaryKeyAuto = true,  // Whether or not the Primary key is generated
            Indexes = new List<string> { "Id" }
        },
        new StoreSchema()
        {
            Name = $"Customer{Globals.LocalTransactionsSuffix}",
            PrimaryKey = "Id",
            PrimaryKeyAuto = true,
            Indexes = new List<string> { "Id" }
        },
        new StoreSchema()
        {
            Name = $"Customer{Globals.KeysSuffix}",
            PrimaryKey = "Id",
            PrimaryKeyAuto = true,
            Indexes = new List<string> { "Id" }
        }
    };
});

builder.Services.AddScoped<CustomerIndexedDBRepository>();
builder.Services.AddScoped<CustomerIndexedDBSyncRepository>();

await builder.Build().RunAsync();

We're also going to dispose an event in the IndesedDBSyncRepository.cs file that will be fired whenever a data change message is received.

To the Services folder, add DataChangedEventArgs.cs :

namespace RepositoryDemo.Client.Services;

public class DataChangedEventArgs : EventArgs
{
    public string Table { get; set; }
    public string Action { get; set; }
    public string Id { get; set; }

    public DataChangedEventArgs(string table, string action, string id)
    {
        Table = table;
        Action = action;
        Id = id;
    }
}

We've made a few changes to IndexedDBSyncRepository.cs Here's the completed new version to support SignalR:

using Microsoft.JSInterop;
using RepositoryDemo.Client;
using System.Reflection;
namespace RepositoryDemo.Client.Services;

public class IndexedDBSyncRepository<TEntity> : IRepository<TEntity>
    where TEntity : class
{
    // injected
    IBlazorDbFactory _dbFactory;
    private readonly APIRepository<TEntity> _apiRepository;
    private readonly IJSRuntime _jsRuntime;
    string _dbName = "";
    string _primaryKeyName = "";
    bool _autoGenerateKey;
    HttpClient _httpClient;

    protected HubConnection hubConnection;
    IndexedDbManager manager;
    string storeName = "";
    string keyStoreName = "";
    Type entityType;
    PropertyInfo primaryKey;
    public bool IsOnline { get; set; } = true;

    public delegate void OnlineStatusEventHandler(object sender,
        OnlineStatusEventArgs e);
    public event OnlineStatusEventHandler OnlineStatusChanged;

    public delegate void DataChangedEventHandler(object sender,
        DataChangedEventArgs e);
    public event DataChangedEventHandler DataChanged;

    public IndexedDBSyncRepository(string dbName,
        string primaryKeyName,
        bool autoGenerateKey,
        IBlazorDbFactory dbFactory,
        APIRepository<TEntity> apiRepository,
        IJSRuntime jsRuntime,
        HttpClient httpClient)
    {
        _dbName = dbName;
        _dbFactory = dbFactory;
        _apiRepository = apiRepository;
        _jsRuntime = jsRuntime;
        _primaryKeyName = primaryKeyName;
        _autoGenerateKey = autoGenerateKey;
        _httpClient = httpClient;

        entityType = typeof(TEntity);
        storeName = entityType.Name;
        keyStoreName = $"{storeName}{Globals.KeysSuffix}";
        primaryKey = entityType.GetProperty(primaryKeyName);

        _jsRuntime.InvokeVoidAsync("connectivity.initialize",
            DotNetObjectReference.Create(this));

        hubConnection = new HubConnectionBuilder()
           .WithUrl($"{_httpClient.BaseAddress}DataSyncHub")
           .Build();

        Task.Run(async () => await AsyncConstructor());

    }

    async Task AsyncConstructor()
    {
        hubConnection.On<string, string, string>("ReceiveSyncRecord", async (Table, Action, Id) =>
        {
            // SignalR may not be the BEST way to send and receive messages.
            // If this were a production system, I would use a cloud-based queue or messaging system,
            // but SignalR makes for a good simple demonstration of how to keep client-side data in sync.

            await EnsureManager();

            try
            {
                // only interested in our table
                if (Table == storeName)
                {
                    if (Action == "insert")
                    {
                        // an item was inserted
                        // fetch it
                        var item = await _apiRepository.GetByIdAsync(Id);
                        if (item != null)
                        {
                            // add to the local database
                            var localItem = await InsertOfflineAsync(item);
                        }
                    }
                    else if (Action == "update")
                    {
                        // an item was updated
                        // update the item in the local database
                        var item = await _apiRepository.GetByIdAsync(Id);
                        if (item != null)
                        {
                            var localItem = await UpdateKeyToLocal(item);
                            await UpdateOfflineAsync(localItem);
                        }
                    }
                    else if (Action == "delete")
                    {
                        // an item was deleted. 
                        // delete the item in the local database
                        var localId = await GetLocalId(Id);
                        await DeleteByIdOfflineAsync(localId);
                    }
                    else if (Action == "delete-all")
                    {
                        // clear local database
                        await DeleteAllOfflineAsync();
                    }
                }
            }
            catch (Exception ex)
            {
                // ignore errors
            }

            // raise DataChanged event
            var args = new DataChangedEventArgs(Table, Action, Id);
            DataChanged?.Invoke(this, args);
        });

        if (IsOnline)
        {
            try
            {
                await hubConnection.StartAsync();
            }
            catch (Exception ex)
            {

            }
        }
    }

    public string LocalStoreName
    {
        get { return $"{storeName}{Globals.LocalTransactionsSuffix}"; }
    }

    [JSInvokable("ConnectivityChanged")]
    public async void OnConnectivityChanged(bool isOnline)
    {
        IsOnline = isOnline;

        if (!isOnline)
        {
            OnlineStatusChanged?.Invoke(this,
                new OnlineStatusEventArgs { IsOnline = false });
        }
        else
        {
            await SyncLocalToServer();
            OnlineStatusChanged?.Invoke(this,
                new OnlineStatusEventArgs { IsOnline = true });
        }
    }

    private async Task EnsureManager()
    {
        if (manager == null)
        {
            manager = await _dbFactory.GetDbManager(_dbName);
            await manager.OpenDb();
        }
    }
    public async Task DeleteAllAsync()
    {
        if (IsOnline)
        {
            await _apiRepository.DeleteAllAsync();
            await hubConnection.InvokeAsync("SyncRecord", storeName, "delete-all", "");
        }

        await DeleteAllOfflineAsync();
    }

    private async Task DeleteAllOfflineAsync()
    {
        await EnsureManager();

        // clear the keys table
        await manager.ClearTableAsync(keyStoreName);

        // clear the data table
        await manager.ClearTableAsync(storeName);

        RecordDeleteAllAsync();
    }

    public async void RecordDeleteAllAsync()
    {
        if (IsOnline) return;

        var action = LocalTransactionTypes.DeleteAll;
        var record = new StoreRecord<LocalTransaction<TEntity>>()
        {
            StoreName = LocalStoreName,
            Record = new LocalTransaction<TEntity>
            {
                Entity = null,
                Action = action,
                ActionName = action.ToString()
            }
        };

        await manager.AddRecordAsync(record);
    }

    public async Task<bool> DeleteAsync(TEntity EntityToDelete)
    {
        bool deleted = false;

        if (IsOnline)
        {
            var onlineId = primaryKey.GetValue(EntityToDelete);
            deleted = await _apiRepository.DeleteAsync(EntityToDelete);
            var localEntity = await UpdateKeyToLocal(EntityToDelete);
            await DeleteOfflineAsync(localEntity);
            await hubConnection.InvokeAsync("SyncRecord", storeName, "delete", onlineId.ToString());
        }
        else
        {
            deleted = await DeleteOfflineAsync(EntityToDelete);
        }

        return deleted;
    }

    public async Task<bool> DeleteOfflineAsync(TEntity EntityToDelete)
    {
        await EnsureManager();
        var Id = primaryKey.GetValue(EntityToDelete);
        return await DeleteByIdOfflineAsync(Id);
    }

    public async Task<bool> DeleteByIdAsync(object Id)
    {
        bool deleted = false;

        if (IsOnline)
        {
            var localId = await GetLocalId(Id);
            await DeleteByIdOfflineAsync(localId);
            deleted = await _apiRepository.DeleteByIdAsync(Id);
            await hubConnection.InvokeAsync("SyncRecord", storeName, "delete", Id.ToString());
        }
        else
        {
            deleted = await DeleteByIdOfflineAsync(Id);
        }

        return deleted;
    }

    public async Task<bool> DeleteByIdOfflineAsync(object Id)
    {
        await EnsureManager();
        try
        {
            RecordDeleteByIdAsync(Id);
            var result = await manager.DeleteRecordAsync(storeName, Id);
            if (result.Failed) return false;

            if (IsOnline)
            {
                // delete key map only if we're online.
                var keys = await GetKeys();
                if (keys.Count > 0)
                {
                    var key = (from x in keys
                               where x.LocalId.ToString() == Id.ToString()
                               select x).FirstOrDefault();
                    if (key != null)
                        await manager.DeleteRecordAsync(keyStoreName, key.Id);
                }
            }

            return true;
        }
        catch (Exception ex)
        {
            // log exception
            return false;
        }
    }

    public async void RecordDeleteByIdAsync(object id)
    {
        if (IsOnline) return;
        var action = LocalTransactionTypes.Delete;

        var entity = await GetByIdAsync(id);

        var record = new StoreRecord<LocalTransaction<TEntity>>()
        {
            StoreName = LocalStoreName,
            Record = new LocalTransaction<TEntity>
            {
                Entity = entity,
                Action = action,
                ActionName = action.ToString(),
                Id = int.Parse(id.ToString())
            }
        };

        await manager.AddRecordAsync(record);
    }

    /// <summary>
    /// just to satisfy the contract
    /// </summary>
    /// <returns></returns>
    public async Task<IEnumerable<TEntity>> GetAllAsync()
    {
        return await GetAllAsync(false);
    }

    public async Task<IEnumerable<TEntity>> GetAllAsync(bool dontSync = false)
    {
        if (IsOnline)
        {
            // retrieve all the data
            var list = await _apiRepository.GetAllAsync();
            if (list != null)
            {
                var allData = list.ToList();
                if (!dontSync)
                {
                    // clear the local db
                    await DeleteAllOfflineAsync();
                    // write the values into IndexedDB
                    var result = await manager.BulkAddRecordAsync<TEntity>
                        (storeName, allData);
                    // get all the local data
                    var localList = await GetAllOfflineAsync();
                    var localData = (localList).ToList();
                    // record the primary keys
                    var keys = new List<OnlineOfflineKey>();
                    for (int i = 0; i < allData.Count(); i++)
                    {
                        var localId = primaryKey.GetValue(localData[i]);
                        var key = new OnlineOfflineKey()
                        {
                            Id = Convert.ToInt32(localId),
                            OnlineId = primaryKey.GetValue(allData[i]),
                            LocalId = localId,
                        };
                        keys.Add(key);
                    };
                    // remove all the keys
                    await manager.ClearTableAsync(keyStoreName);
                    // store all of the keys
                    result = await manager.BulkAddRecordAsync<OnlineOfflineKey>
                        (keyStoreName, keys);
                }
                // return the data
                return allData;
            }
            else
                return null;
        }
        else
            return await GetAllOfflineAsync();
    }

    public async Task<IEnumerable<TEntity>> GetAllOfflineAsync()
    {
        await EnsureManager();
        var array = await manager.ToArray<TEntity>(storeName);
        if (array == null)
            return new List<TEntity>();
        else
            return array.ToList();
    }

    public async Task<IEnumerable<TEntity>> GetAsync(QueryFilter<TEntity> Filter)
    {
        // We have to load all items and use LINQ to filter them. :(
        var allitems = await GetAllAsync(true);
        return Filter.GetFilteredList(allitems);
    }

    public async Task<TEntity> GetByIdAsync(object Id)
    {
        if (IsOnline)
            return await _apiRepository.GetByIdAsync(Id);
        else
            return await GetByIdOfflineAsync(Id);
    }

    public async Task<TEntity> GetByIdOfflineAsync(object Id)
    {
        await EnsureManager();
        var items = await manager.Where<TEntity>(storeName, _primaryKeyName, Id);
        if (items.Any())
            return items.First();
        else
            return null;
    }

    public async Task<TEntity> InsertAsync(TEntity Entity)
    {
        TEntity returnValue;

        if (IsOnline)
        {
            returnValue = await _apiRepository.InsertAsync(Entity);
            var Id = primaryKey.GetValue(returnValue);
            await InsertOfflineAsync(returnValue);
            await hubConnection.InvokeAsync("SyncRecord", storeName, "insert", Id.ToString());
        }
        else
        {
            returnValue = await InsertOfflineAsync(Entity);
        }
        return returnValue;

    }

    public async Task<TEntity> InsertOfflineAsync(TEntity Entity)
    {
        await EnsureManager();

        try
        {
            var onlineId = primaryKey.GetValue(Entity);

            var record = new StoreRecord<TEntity>()
            {
                StoreName = storeName,
                Record = Entity
            };
            var result = await manager.AddRecordAsync<TEntity>(record);
            var allItems = await GetAllOfflineAsync();
            var last = allItems.Last();
            var localId = primaryKey.GetValue(last);

            // record in the keys database
            var key = new OnlineOfflineKey()
            {
                Id = Convert.ToInt32(localId),
                OnlineId = onlineId,
                LocalId = localId
            };
            var storeRecord = new StoreRecord<OnlineOfflineKey>
            {
                DbName = _dbName,
                StoreName = keyStoreName,
                Record = key
            };
            await manager.AddRecordAsync(storeRecord);

            RecordInsertAsync(last);

            return last;
        }
        catch (Exception ex)
        {
            // log exception
            return null;
        }
    }

    public async void RecordInsertAsync(TEntity Entity)
    {
        if (IsOnline) return;
        try
        {
            var action = LocalTransactionTypes.Insert;

            var record = new StoreRecord<LocalTransaction<TEntity>>()
            {
                StoreName = LocalStoreName,
                Record = new LocalTransaction<TEntity>
                {
                    Entity = Entity,
                    Action = action,
                    ActionName = action.ToString()
                }
            };

            await manager.AddRecordAsync(record);
        }
        catch (Exception ex)
        {
            // log exception
        }
    }

    public async Task<TEntity> UpdateAsync(TEntity EntityToUpdate)
    {
        TEntity returnValue;

        if (IsOnline)
        {
            returnValue = await _apiRepository.UpdateAsync(EntityToUpdate);
            var Id = primaryKey.GetValue(returnValue);
            returnValue = await UpdateKeyToLocal(returnValue);
            await UpdateOfflineAsync(returnValue);
            await hubConnection.InvokeAsync("SyncRecord", storeName, "update", Id.ToString());
        }
        else
        {
            returnValue = await UpdateOfflineAsync(EntityToUpdate);
        }
        return returnValue;
    }

    public async Task<TEntity> UpdateOfflineAsync(TEntity EntityToUpdate)
    {
        await EnsureManager();
        object Id = primaryKey.GetValue(EntityToUpdate);
        try
        {
            await manager.UpdateRecord(new UpdateRecord<TEntity>()
            {
                StoreName = storeName,
                Record = EntityToUpdate,
                Key = Id
            });

            RecordUpdateAsync(EntityToUpdate);

            return EntityToUpdate;
        }
        catch (Exception ex)
        {
            // log exception
            return null;
        }
    }

    public async void RecordUpdateAsync(TEntity Entity)
    {
        if (IsOnline) return;
        try
        {
            var action = LocalTransactionTypes.Update;

            var record = new StoreRecord<LocalTransaction<TEntity>>()
            {
                StoreName = LocalStoreName,
                Record = new LocalTransaction<TEntity>
                {
                    Entity = Entity,
                    Action = action,
                    ActionName = action.ToString()
                }
            };

            await manager.AddRecordAsync(record);
        }
        catch (Exception ex)
        {
            // log exception
        }
    }

    private async Task<object> GetLocalId(object OnlineId)
    {
        var keys = await GetKeys();
        var item = (from x in keys
                    where x.OnlineId.ToString() == OnlineId.ToString()
                    select x).FirstOrDefault();
        var localId = item.LocalId;
        localId = JsonConvert.DeserializeObject<object>(localId.ToString());
        return localId;
    }

    private async Task<object> GetOnlineId(object LocalId)
    {
        var keys = await GetKeys();
        var item = (from x in keys
                    where x.LocalId.ToString() == LocalId.ToString()
                    select x).FirstOrDefault();
        var onlineId = item.OnlineId;
        onlineId = JsonConvert.DeserializeObject<object>(onlineId.ToString());
        return onlineId;
    }

    private async Task<List<OnlineOfflineKey>> GetKeys()
    {
        await EnsureManager();
        var returnList = new List<OnlineOfflineKey>();

        var array = await manager.ToArray<OnlineOfflineKey>(keyStoreName);
        if (array == null) return null;

        foreach (var key in array)
        {
            var onlineId = key.OnlineId;
            key.OnlineId = JsonConvert.DeserializeObject<object>(onlineId.ToString());

            var localId = key.LocalId;
            key.LocalId = JsonConvert.DeserializeObject<object>(localId.ToString());

            returnList.Add(key);
        }

        return returnList;
    }

    private async Task<TEntity> UpdateKeyToLocal(TEntity Entity)
    {
        var OnlineId = primaryKey.GetValue(Entity);
        OnlineId = JsonConvert.DeserializeObject<object>(OnlineId.ToString());

        var keys = await GetKeys();
        if (keys == null) return null;

        var item = (from x in keys
                    where x.OnlineId.ToString() == OnlineId.ToString()
                    select x).FirstOrDefault();

        if (item == null) return null;

        var key = item.LocalId;

        var typeName = key.GetType().Name;

        if (typeName == nameof(Int64))
        {
            if (primaryKey.PropertyType.Name == nameof(Int32))
                key = Convert.ToInt32(key);
        }
        else if (typeName == "string")
        {
            if (primaryKey.PropertyType.Name != "string")
                key = key.ToString();
        }

        primaryKey.SetValue(Entity, key);

        return Entity;
    }

    private async Task<TEntity> UpdateKeyFromLocal(TEntity Entity)
    {
        var LocalId = primaryKey.GetValue(Entity);
        LocalId = JsonConvert.DeserializeObject<object>(LocalId.ToString());

        var keys = await GetKeys();
        if (keys == null) return null;

        var item = (from x in keys
                    where x.LocalId.ToString() == LocalId.ToString()
                    select x).FirstOrDefault();

        if (item == null) return null;

        var key = item.OnlineId;

        var typeName = key.GetType().Name;

        if (typeName == nameof(Int64))
        {
            if (primaryKey.PropertyType.Name == nameof(Int32))
                key = Convert.ToInt32(key);
        }
        else if (typeName == "string")
        {
            if (primaryKey.PropertyType.Name != "string")
                key = key.ToString();
        }

        primaryKey.SetValue(Entity, key);

        return Entity;
    }

    public async Task<bool> SyncLocalToServer()
    {
        if (!IsOnline) return false;

        await EnsureManager();

        var array = await manager.ToArray<LocalTransaction<TEntity>>(LocalStoreName);
        if (array == null || array.Count == 0)
            return true;
        else
        {
            foreach (var localTransaction in array.ToList())
            {
                try
                {
                    switch (localTransaction.Action)
                    {
                        case LocalTransactionTypes.Insert:
                            var insertedEntity = await
                                _apiRepository.InsertAsync(localTransaction.Entity);
                            // update the keys table
                            var localId = primaryKey.GetValue(localTransaction.Entity);
                            var onlineId = primaryKey.GetValue(insertedEntity);
                            var key = new OnlineOfflineKey()
                            {
                                Id = Convert.ToInt32(localId),
                                OnlineId = onlineId,
                                LocalId = localId
                            };
                            await manager.AddRecordAsync<OnlineOfflineKey>
                                (new StoreRecord<OnlineOfflineKey>
                                {
                                    StoreName = keyStoreName,
                                    Record = key
                                });

                            // send a sync message 
                            await hubConnection.InvokeAsync("SyncRecord", storeName, "insert", onlineId.ToString());

                            break;

                        case LocalTransactionTypes.Update:
                            localTransaction.Entity = await UpdateKeyFromLocal
                                (localTransaction.Entity);
                            await _apiRepository.UpdateAsync(localTransaction.Entity);
                            onlineId = primaryKey.GetValue(localTransaction.Entity);
                            // send a sync message 
                            await hubConnection.InvokeAsync("SyncRecord", storeName, "update", onlineId.ToString());

                            break;

                        case LocalTransactionTypes.Delete:
                            localTransaction.Entity = await UpdateKeyFromLocal
                                (localTransaction.Entity);
                            onlineId = primaryKey.GetValue(localTransaction.Entity);
                            await _apiRepository.DeleteAsync(localTransaction.Entity);
                            // send a sync message 
                            await hubConnection.InvokeAsync("SyncRecord", storeName, "delete", onlineId.ToString());
                            break;

                        case LocalTransactionTypes.DeleteAll:
                            await _apiRepository.DeleteAllAsync();
                            // send a sync message 
                            await hubConnection.InvokeAsync("SyncRecord", storeName, "delete-all", "");
                            break;

                        default:
                            break;
                    }
                }
                catch (Exception ex)
                {
                }
            }

            await DeleteAllTransactionsAsync();

            // TODO: Get all new records since last online
            // Get last record id
            // ask for new records since that id was recorded in the database
            // may require a time stamp field in the data record (invasive!)

            return true;
        }
    }

    private async Task DeleteAllTransactionsAsync()
    {
        await EnsureManager();
        await manager.ClearTableAsync(LocalStoreName);
    }

    public async Task<LocalTransaction<TEntity>>
        UpdateOfflineAsync(LocalTransaction<TEntity> entityToUpdate,
        TEntity onlineEntity)
    {
        await EnsureManager();

        object Id = primaryKey.GetValue(entityToUpdate.Entity);

        entityToUpdate.Entity = onlineEntity;

        try
        {
            await manager.UpdateRecord(new UpdateRecord<LocalTransaction<TEntity>>()
            {
                StoreName = LocalStoreName,
                Record = entityToUpdate,
                Key = Id
            });

            return entityToUpdate;
        }
        catch (Exception ex)
        {
            // log exception
            return null;
        }
    }

    public async ValueTask DisposeAsync()
    {
        await _jsRuntime.InvokeVoidAsync("connectivity.dispose");
    }
}

Since we are now injecting an HttpClient, we need to change CustomerIndexedDBSyncRepository.cs :

using Microsoft.JSInterop;
namespace RepositoryDemo.Client.Services;
public class CustomerIndexedDBSyncRepository : IndexedDBSyncRepository<Customer>
{
    public CustomerIndexedDBSyncRepository(IBlazorDbFactory dbFactory,
                                            CustomerRepository customerRepository,
                                            IJSRuntime jsRuntime,
                                            HttpClient httpClient)
        : base("RepositoryDemo",
            "Id",
            true,
            dbFactory,
            customerRepository,
            jsRuntime,
            httpClient)
    { }
}

Finally, change SyncDemo.razor to support these new features:

@page "/syncdemo"
@implements IDisposable
@inject CustomerIndexedDBSyncRepository CustomerManager

<h1>Offline Repository Sync Demo</h1>

<div style="background-color:lightgray;padding:20px;">
    This demo:
    <ul>
        <li>Downloads all data from server when first run online, and saves to IndexedDB</li>
        <li>Keeps IndexedDB in sync with online source as you make changes</li>
        <li>Keeps a list map of items with both local and online primary keys</li>
        <li>When offline, keeps a list of transactions (insert, update, and delete)</li>
        <li>When you go back online, it executes those transactions</li>
    </ul>
    What it does NOT do (yet):
    <ul>
        <li>Check for collisions and deal with the consequences</li>
        <li>Efficiently sync local db from online source when you go back online after syncing</li>
    </ul>
</div>
<br/>
@foreach (var customer in Customers)
{
            <p>(@customer.Id) @customer.Name, @customer.Email</p>
}

<button @onclick="AddCustomer">Add Customer</button>
<button @onclick="UpdateIsadora">Update Isadora</button>
<button @onclick="DeleteRocky">Delete Rocky</button>
<button @onclick="DeleteHugh">Delete Hugh</button>
<button @onclick="GetJenny">GetJenny</button>
<button @onclick="ResetData">Reset Data</button>
<br />
<br />
<p>
    Search by Name: <input @bind=@SearchFilter />
    <button @onclick="Search">Search</button>
    <br />
    <br />
    <input style="zoom:1.5;" type="checkbox" @bind="CaseSensitive" /> Case Sensitive
    <br />
    <input style="zoom:1.5;" type="checkbox" @bind="Descending" /> Descending Order
    <br />
    <br />
    Options:<br />
    <select @onchange="SelectedOptionChanged" size=3 style="padding:10px;">
        <option selected value="StartsWith">Starts With</option>
        <option value="EndsWith">Ends With</option>
        <option value="Contains">Contains</option>
    </select>

</p>

<br />
<br />
<p>@JennyMessage</p>

@code
{
    List<Customer> Customers = new List<Customer>();
    string JennyMessage = "";
    string SearchFilter = "";
    bool CaseSensitive = false;
    bool Descending = false;
    FilterOperator filterOption = FilterOperator.StartsWith;

    bool Initialized = false;

    void SelectedOptionChanged(ChangeEventArgs args)
    {
        switch (args.Value.ToString())
        {
            case "StartsWith":
                filterOption = FilterOperator.StartsWith;
                break;
            case "EndsWith":
                filterOption = FilterOperator.EndsWith;
                break;
            case "Contains":
                filterOption = FilterOperator.Contains;
                break;
        }
    }

    async Task Search()
    {
        try
        {
            var expression = new QueryFilter<Customer>();

            expression.FilterProperties.Add(new FilterProperty { Name = "Name", Value = SearchFilter, Operator = filterOption, CaseSensitive = CaseSensitive });
            expression.OrderByPropertyName = "Name";
            expression.OrderByDescending = Descending;

            //// Example, return where Id = 2
            //expression.FilterProperties.Add(new FilterProperty { Name = "Id", Value = "2", Operator = FilterOperator.Equals });
            //expression.OrderByPropertyName = "Name";
            //expression.OrderByDescending = Descending;

            //// Example, return where Id > 1
            //expression.FilterProperties.Add(new FilterProperty { Name = "Id", Value = "1", Operator = FilterOperator.GreaterThan });
            //expression.OrderByPropertyName = "Name";
            //expression.OrderByDescending = Descending;

            var list = await CustomerManager.GetAsync(expression);
            Customers = list.ToList();

        }
        catch (Exception ex)
        {
            var msg = ex.Message;
        }
    }

    async Task DeleteRocky()
    {
        var rocky = (from x in Customers
                     where x.Email == "rocky@rhodes.com"
                     select x).FirstOrDefault();
        if (rocky != null)
        {
            var index = Customers.IndexOf(rocky);
            await CustomerManager.DeleteAsync(rocky);
            Customers.RemoveAt(index);
        }
    }

    async Task DeleteHugh()
    {
        var hugh = (from x in Customers
                    where x.Email == "hugh@jass.com"
                    select x).FirstOrDefault();
        if (hugh != null)
        {
            var index = Customers.IndexOf(hugh);
            await CustomerManager.DeleteByIdAsync(hugh.Id);
            Customers.RemoveAt(index);
        }
    }

    async Task AddCustomer()
    {
        var customer = new Customer
            {
                Name = "New Customer",
                Email = "new@customer.com"
            };
        customer = await CustomerManager.InsertAsync(customer);
        Customers.Add(customer);
    }

    async Task UpdateIsadora()
    {
        var isadora = (from x in Customers
                       where x.Email == "isadora@jarr.com"
                       select x).FirstOrDefault();
        if (isadora != null)
        {
            isadora.Email = "isadora@isadorajarr.com";
            await CustomerManager.UpdateAsync(isadora);
        }
    }

    async Task GetJenny()
    {
        JennyMessage = "";
        var jenny = (from x in Customers
                     where x.Email == "jenny@jones.com"
                     select x).FirstOrDefault();
        if (jenny != null)
        {
            var jennyDb = await CustomerManager.GetByIdAsync(jenny.Id);
            if (jennyDb != null)
            {
                JennyMessage = $"Retrieved Jenny via Id {jennyDb.Id}";
            }
        }
        await InvokeAsync(StateHasChanged);
    }

    protected async void OnlineStatusChanged(object sender, OnlineStatusEventArgs args)
    {
        if (args.IsOnline == false)
        {
            // reload from IndexedDB
            Customers = (await CustomerManager.GetAllOfflineAsync()).ToList();
        }
        else
        {
            if (Initialized)
                // reload from API
                await Reload();
            else
                Initialized = true;
        }
        await InvokeAsync(StateHasChanged);
    }

    protected async void OnDataChanged(object sender, DataChangedEventArgs args)
    {
        // We only care about changes to the Customer table
        if (args.Table == "Customer")
        {
            if (args.Action == "delete-all")
            {
                // delete all?? How rude!
                Customers.Clear();
            }
            else if (args.Action == "insert")
            {
                // an item was added. 
                var customer = await CustomerManager.GetByIdAsync(args.Id);
                Customers.Add(customer);
                // add to 
            }
            else if (args.Action == "update")
            {
                // an item was updated
                var customer = await CustomerManager.GetByIdAsync(args.Id);
                if (customer != null)
                {
                    var localCustomer = (from x in Customers where x.Id == customer.Id select x).FirstOrDefault();
                    if (localCustomer != null)
                    {
                        var index = Customers.IndexOf(localCustomer);
                        Customers[index] = customer;
                    }
                }
            }
            else if (args.Action == "delete")
            {
                var localCustomer = (from x in Customers where x.Id.ToString() == args.Id select x).FirstOrDefault();
                if (localCustomer != null)
                {
                    var index = Customers.IndexOf(localCustomer);
                    Customers.RemoveAt(index);
                }
            }
        }
        await InvokeAsync(StateHasChanged);
    }

    protected override async Task OnInitializedAsync()
    {
        CustomerManager.OnlineStatusChanged += OnlineStatusChanged;
        CustomerManager.DataChanged += OnDataChanged;
        await AddCustomers();
    }

    async Task ResetData()
    {
        await CustomerManager.DeleteAllAsync();
        await AddCustomers();
    }

    async Task Reload()
    {
        JennyMessage = "";
        var list = await CustomerManager.GetAllAsync();
        if (list != null)
        {
            Customers = list.ToList();
            await InvokeAsync(StateHasChanged);
        }
    }

    async Task AddCustomers()
    {
        // Added these lines to not clobber the existing data
        var all = await CustomerManager.GetAllAsync();
        if (all.Count() > 0)
        {
            await Reload();
            return;
        }

        Customers.Clear();

        await CustomerManager.InsertAsync(new Customer
            {
                Id = 1,
                Name = "Isadora Jarr",
                Email = "isadora@jarr.com"
            });

        await CustomerManager.InsertAsync(new Customer
            {
                Id = 2,
                Name = "Rocky Rhodes",
                Email = "rocky@rhodes.com"
            });

        await CustomerManager.InsertAsync(new Customer
            {
                Id = 3,
                Name = "Jenny Jones",
                Email = "jenny@jones.com"
            });

        await CustomerManager.InsertAsync(new Customer
            {
                Id = 4,
                Name = "Hugh Jass",
                Email = "hugh@jass.com"
            });

        await Reload();
    }

    void IDisposable.Dispose()
    {
        CustomerManager.OnlineStatusChanged -= OnlineStatusChanged;
        CustomerManager.DataChanged -= OnDataChanged;
    }
}

To test the new sync features, run the app in two different browsers. I use Edge and Chrome. That way, each will have its own copy of IndexedDB. Bring up the developer tools in each, and go to the Application tab. There you will be able to see how the local data changes in response to messages from other users.