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 Blazor Web App called RepositoryDemo. This will create two projects: RepositoryDemo (server) and RepositoryDemo.Client (client).
Make sure you select the following options:
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.
Browse for "AvnRepository" and install in both the Client and Server projects.
AvnRepository
contains all the plumbing code for building repositories.
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.
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.
IncludedPropertyNames
defines the columns to return, ala the SELECT clauseFilterProperties
defines the properties to compare, ala the WHERE clauseOrderByPropertyName
defines the sort column, ala the ORDER BY clauseOrderByDescending
defines the direction of the sort, ala DESCGetFilteredList
method applies the current filter settings given a list of all items. While it's true that all of the items need to be loaded, what you give up in memory efficiency you gain in convenience. This method currently handles properties of type string
, int32
and DateTime
.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:
Name
is the Name of the propertyValue
is a string representation of the value of the propertyCaseSensitive
is a flag to determine whether case-sensitivity should be appliedFilterOerator
defines how to compare the column values (StartsWith, etc.)FilterOperator.cs:
/// <summary>
/// Specify the compare operator
/// </summary>
public enum FilterOperator
{
Equals,
NotEquals,
StartsWith,
EndsWith,
Contains,
LessThan,
GreaterThan,
LessThanOrEqual,
GreaterThanOrEqual
}
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;
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 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."
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>>>
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 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;
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;
}
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.
To the client project's Program.cs file, add the following:
builder.Services.AddScoped<CustomerRepository>();
@using RepositoryDemo.Client.Models
@using RepositoryDemo.Client.Services
@using AvnRepository
Adding these ensures we can access classes in these namespaces from .razor components.
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:
The app displays four customers, their Ids, names, and email addresses.
If you press then Isadora's email address changes to isadora@isadorajarr.com:
If you press then Rocky will be deleted by entity:
If you press then Hugh will be deleted by Id:
If you press then Jenny's record will be retrieved using GetById
If you press or refresh the page the data will be reset to it's original state.
Try out the search functionality.
The finished code for this leg of the demo can be found in the 1-In Memory Only folder
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.
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.
Next, right-click on the RepositoryDemo database and select New Query...
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
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" />
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
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.
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");
}
}
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 the following lines to the Program.cs file:
builder.Services.AddTransient<RepositoryDemoContext, RepositoryDemoContext>();
builder.Services.AddTransient<EFRepository<Customer, RepositoryDemoContext>>();
The
RepositoryDemoContext
andEFRepository
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.
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;
}
}
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:
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:
The finished code for this leg of the demo can be found in the 2-Added EF Repository folder
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
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 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
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;
}
}
}
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;
}
}
These issues shouldn't cause too much concern unless client memory constraints are an issue.
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."
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.
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
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.
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.
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:
Running offline:
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; }
}
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:
APIRepository
(Online) or the IndexedDB methods (Offline) to record transactions on the server, or locally.*_transaction
table named after the original table.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();
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.
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:
Notice the IndexedDB shows just two customers:
Also noticed that there is a new Customers_transactions
table with all the transaction data we recorded.
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.
The finished code for this leg of the demo can be found in the 5-Added IndexedDB Sync Repository folder
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.