This the second article in a series on Building Blazor Database Applications. It describes the Data and Core Domain boilerplate code used to make deploying application specific data services simple. It is a total rewrite from earlier releases.
The articles in the series are:
The repository for the articles has moved to Blazor.Database Repository. All previous repos are obselete and will be removed shortly.
There's a SQL script in /SQL in the repository for building the database.
The demo site has changed now the Server and WASM have been combined. The site starts in Server mode - https://cec-blazor-database.azurewebsites.net/.
Our goal: build library code so declaring a standard UI View service is as simple as this:
public class WeatherForecastViewService : BaseModelViewService<WeatherForecast>, IModelViewService<WeatherForecast> { public WeatherForecastViewService(IDataServiceConnector dataServiceConnector) : base(dataServiceConnector) { } }
And declaring a database DbContext
that looks like:
public class MSSQLWeatherDbContext : DbContext { public MSSQLWeatherDbContext(DbContextOptions<MSSQLWeatherDbContext> options) : base(options) {} public DbSet<WeatherForecast> WeatherForecast { get; set; } }
Our process for adding a new database entity is:
DbSet
in the DbContext
.There will be complications with certain entities, but that doesn't invalidate the approach - 80%+ of the code in the library.
Blazor uses DI [Dependency Injection] and IOC [Inversion of Control] principles. If you're unfamiliar with these concepts, do a little backgound reading before diving into Blazor. It'll save you time in the long run!
Blazor Singleton and Transient services are relatively straight forward. You can read more about them in the Microsoft Documentation. Scoped are a little more complicated.
OwningComponentBase
component class restricts the life of a scoped service to the lifetime of that component.Services
is the Blazor IOC [Inversion of Control] container. Service instances are declared as follows:
Startup.cs
method ConfigureServices
Program.cs
.This solution uses a set of Service Collection extension methods such as AddInMemoryApplicationServices
to logically organise the solution services.
// Blazr.Database.Web/startup.cs public void ConfigureServices(IServiceCollection services) { services.AddRazorPages(); services.AddServerSideBlazor(); // the local application Services defined in ServiceCollectionExtensions.cs // services.AddApplicationServices(this.Configuration); services.AddInMemoryApplicationServices(this.Configuration); }
Extensions are declared as a static extension methods in a static class. Various methods are shown below.
//Blazr.Database/Extensions/ServiceCollectionExtensions.cs public static class ServiceCollectionExtensions { public static IServiceCollection AddWASMApplicationServices(this IServiceCollection services) { services.AddScoped<IDataBroker, APIDataBroker>(); AddCommonServices(services); return services; } public static IServiceCollection AddSQLServerApplicationServices(this IServiceCollection services, IConfiguration configuration) { // Local MS SQL DB Setup var dbContext = configuration.GetValue<string>("Configuration:DBContext"); services.AddDbContextFactory<MSSQLWeatherDbContext>(options => options.UseSqlServer(dbContext), ServiceLifetime.Singleton); services.AddSingleton<IDataBroker, WeatherSQLDataBroker>(); AddCommonServices(services); return services; } public static IServiceCollection AddSQLiteServerApplicationServices(this IServiceCollection services, IConfiguration configuration) { // In Memory SQLite DB Setup var memdbContext = "Data Source=:memory:"; services.AddDbContextFactory<SQLiteWeatherDbContext>(options => options.UseSqlite(memdbContext), ServiceLifetime.Singleton); services.AddSingleton<IDataBroker, WeatherSQLiteDataBroker>(); AddCommonServices(services); return services; } public static IServiceCollection AddInMemoryServerApplicationServices(this IServiceCollection services, IConfiguration configuration) { // In Memory Datastore Setup services.AddSingleton<IInMemoryDataStore, InMemoryWeatherDataStore>(); services.AddSingleton<IDataBroker, WeatherInMemoryDataBroker>(); AddCommonServices(services); return services; } private static void AddCommonServices(this IServiceCollection services) { services.AddBlazorSPA(); services.AddScoped<ILogger, Logger<LoggingBroker>>(); services.AddScoped<ILoggingBroker, LoggingBroker>(); services.AddScoped<IDateTimeBroker, DateTimeBroker>(); services.AddScoped<IDataServiceConnector, WeatherDataServiceConnector>(); services.AddScoped<WeatherForecastViewService>(); } }
The WASM project program.cs
setup looks like this:
// program.cs public static async Task Main(string[] args) { ..... // Added here as we don't have access to builder in AddApplicationServices builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); // the Services for the Application builder.Services.AddWASMApplicationServices(); ..... }
Points:
IServiceCollection
extension method for each project/library to encapsulate the specific services needed for the project.DbContextFactory
and manage DbContext
instances as they are used. The WASM version uses HttpClient
to make calls to the API.TRecord
defines which dataset is retrieved and returned.The boilerplate library code relies heavily on Generics. Two generic entities are defined:
TRecord
represents a model record class. It must be a class, implement IDbRecord
and define an empty new()
. TRecord
is used at the method level.TEditRecord
represent a model edit class. It must be a class, implement IEditRecord
and define an empty new()
. TEditRecord
is used at the method level.TDbContext
is the database context. It must inherit from the DbContext
class.Class declarations look like this:
public class ServerDataBroker<TDbContext> : BaseDataBroker, IDataBroker where TDbContext : DbContext ...... // example method template public virtual ValueTask<TRecord> SelectRecordAsync<TRecord>(Guid id) where TRecord : class, IDbRecord<TRecord>, new() => ValueTask.FromResult(new TRecord());
Before diving into the detail, let's look at the main CRUD methods we implement:
Keep these in mind as we work through this article.
Data layer CUD operations return a DbTaskResult
object. Most properties are self-evident. It's designed to be consumed by the UI to build CSS Framework entities such as Alerts and Toasts.
public class DbTaskResult { public string Message { get; set; } = null; public MessageType Type { get; set; } = MessageType.None; public bool IsOK { get; set; } = true; public object Data { get; set; } = null; }
Data classes implement IDbRecord
. All reside in the Core Domain - Blazr.Database.Core.
ID
is a Guid
, a unique Identity field for the record.DisplayName
provides a generic name for the record. We use this in titles, lookup lists and other UI components.GetDbSetName()
provides a method of defining a DbSet
name other that the record name. By default it gets the set name.public interface IDbRecord<TRecord> where TRecord : class, IDbRecord<TRecord>, new() { public Guid ID { get; } public string DisplayName { get; } public string GetDbSetName() => new TRecord().GetType().Name; }
Edit classes implement IEditRecord
and IValidation
ID
is a Guid
, a unique Identity field for the record.Populate()
populates the instance from the provided TRecord
.GetRecord()
gets a new TRecord
from the class instance.public interface IEditRecord<TRecord> where TRecord : class, IDbRecord<TRecord>, new() { public Guid ID { get; } public void Populate(IDbRecord<TRecord> dbRecord); public TRecord GetRecord(); }
More about Validation later.
public interface IValidation { public bool Validate(ValidationMessageStore validationMessageStore, string fieldname, object model = null); }
Here's the dataclass for a WeatherForecast data entity.
Points:
IDbRecord
.record
with immutable properties.public record WeatherForecast : IDbRecord<WeatherForecast> { [Key] public Guid ID { get; init; } = Guid.Empty; public DateTimeOffset Date { get; init; } = DateTimeOffset.Now; public int TemperatureC { get; init; } = 0; public string Summary { get; init; } = string.Empty; [NotMapped] public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); [NotMapped] public string DisplayName => $"Weather Forecast for {this.Date.LocalDateTime.ToShortDateString()} "; // A long string field to demo using a max row in a data table [NotMapped] public string Description => $"The Weather Forecast for this {this.Date.DayOfWeek}, the {this.Date.Day} of the month {this.Date.Month} in the year of {this.Date.Year} is {this.Summary}. From the font of all knowledge!"; }
And the editable version - EditWeatherForecast
.
Points:
IEditRecord
and IValidation
.class
with setter propertiesGetRecord
returns a WeatherForecast
record.Populate
populates the current class with data from a WeatherForecast
.Validate
runs the configured validations on the class properties. More later.public class EditWeatherForecast : IValidation, IEditRecord<WeatherForecast> { public Guid ID { get; set; } = Guid.Empty; public DateTimeOffset Date { get; set; } = DateTimeOffset.Now; public int TemperatureC { get; set; } = 0; public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); public string Summary { get; set; } = string.Empty; public Guid GUID { get; init; } = Guid.NewGuid(); public string DisplayName => $"Weather Forecast for {this.Date.LocalDateTime.ToShortDateString()} "; public WeatherForecast GetRecord() => new WeatherForecast { ID = this.ID, Date = this.Date, TemperatureC = this.TemperatureC, Summary = this.Summary }; public void Populate(IDbRecord<WeatherForecast> dbRecord) { var rec = (WeatherForecast)dbRecord; this.ID = rec.ID; this.Date = rec.Date; this.TemperatureC = rec.TemperatureC; this.Summary = rec.Summary; } public bool Validate(ValidationMessageStore validationMessageStore, string fieldname, object model = null) { model = model ?? this; bool trip = false; this.Summary.Validation("Summary", model, validationMessageStore) .LongerThan(2, "Your description needs to be a little longer! 3 letters minimum") .Validate(ref trip, fieldname); this.Date.Validation("Date", model, validationMessageStore) .NotDefault("You must select a date") .LessThan(DateTime.Now.AddMonths(1), true, "Date can only be up to 1 month ahead") .Validate(ref trip, fieldname); this.TemperatureC.Validation("TemperatureC", model, validationMessageStore) .LessThan(70, "The temperature must be less than 70C") .GreaterThan(-60, "The temperature must be greater than -60C") .Validate(ref trip, fieldname); return !trip; } }
The application implements two Entity Framework DBContext
classes and one InMemoryDataStore
. All reside in the Data Domain - Blazr.Database.Data.
The class is basic, creating a DbSet
per dataclass. The DBSet either must be the same name as the dataclass, or the record GetDbSetName
must return the correct DbSet
name.
public class MSSQLWeatherDbContext : DbContext { private readonly Guid _id; public LocalWeatherDbContext(DbContextOptions<LocalWeatherDbContext> options) : base(options) => _id = Guid.NewGuid(); public DbSet<WeatherForecast> WeatherForecast { get; set; } }
The application implements a single Entity Framework DbContext
, and a builder to build out the datbase instance and populate with data.
public class SQLiteWeatherDbContext : DbContext { /// <summary> /// New Method - creates a guid in case we need to track it /// </summary> /// <param name="options"></param> public SQLiteWeatherDbContext(DbContextOptions<SQLiteWeatherDbContext> options) : base(options) => this.BuildInMemoryDatabase(); /// <summary> /// DbSet for the <see cref="DbWeatherForecast"/> record /// </summary> public DbSet<WeatherForecast> WeatherForecast { get; set; } private void BuildInMemoryDatabase() { var conn = this.Database.GetDbConnection(); conn.Open(); var cmd = conn.CreateCommand(); cmd.CommandText = "CREATE TABLE [WeatherForecast]([ID] UNIQUEIDENTIFIER PRIMARY KEY, [Date] [smalldatetime] NOT NULL, [TemperatureC] [int] NOT NULL, [Summary] [varchar](255) NULL)"; cmd.ExecuteNonQuery(); foreach (var forecast in this.NewForecasts) { cmd.CommandText = $"INSERT INTO WeatherForecast([ID], [Date], [TemperatureC], [Summary]) VALUES({Guid.NewGuid()} ,'{forecast.Date.LocalDateTime.ToLongDateString()}', {forecast.TemperatureC}, '{forecast.Summary}')"; cmd.ExecuteNonQuery(); } } private static readonly string[] Summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; private List<WeatherForecast> NewForecasts { get { var rng = new Random(); return Enumerable.Range(1, 80).Select(index => new WeatherForecast { //ID = index, Date = DateTime.Now.AddDays(index), TemperatureC = rng.Next(-20, 55), Summary = Summaries[rng.Next(Summaries.Length)] }).ToList(); } } }
public class InMemoryWeatherDataStore : IInMemoryDataStore { public InMemoryDataSet<WeatherForecast> WeatherForecast { get; set; } public InMemoryWeatherDataStore() { this.WeatherForecast = new InMemoryDataSet<WeatherForecast>(); WeatherForecast.LoadData(LoadWeatherForecastData()); } public List<WeatherForecast> LoadWeatherForecastData() { var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; var rng = new Random(); return Enumerable.Range(1, 80).Select(index => new WeatherForecast { ID = Guid.NewGuid(), Date = DateTime.Now.AddDays(index), TemperatureC = rng.Next(-20, 55), Summary = summaries[rng.Next(summaries.Length)] }).ToList(); } public InMemoryDataSet<TRecord> GetDataSet<TRecord>() where TRecord : class, IDbRecord<TRecord>, new() { var dbSetName = new TRecord().GetDbSetName(); // Get the property info object for the DbSet var pinfo = this.GetType().GetProperty(dbSetName); InMemoryDataSet<TRecord> dbSet = null; Debug.Assert(pinfo != null); // Get the property DbSet try { dbSet = (InMemoryDataSet<TRecord>)pinfo.GetValue(this); } catch { throw new InvalidOperationException($"{dbSetName} does not have a matching DBset "); } Debug.Assert(dbSet != null); return dbSet; } }
We're using generics, so our methods only know about TRecord
and the IDbRecord
interface. We therefore need a methodology to get the correct DbSet
. We use either a naming convention - the name of the record, the DbSet and the Table/View is the same - or declare the DbSet
name in the record class through GetDbSetName
. We implement the method to get the DbSet
as an extension method on DbContext
.
The method uses reflection to find the DbSet
for TRecord
.
public static DbSet<TRecord> GetDbSet<TRecord>(this DbContext context) where TRecord : class, IDbRecord<TRecord>, new() { var dbSetName = new TRecord().GetDbSetName(); // Get the property info object for the DbSet var pinfo = context.GetType().GetProperty(dbSetName); DbSet<TRecord> dbSet = null; Debug.Assert(pinfo != null); // Get the property DbSet try { dbSet = (DbSet<TRecord>)pinfo.GetValue(context); } catch { throw new InvalidOperationException($"{dbSetName} does not have a matching DBset "); } Debug.Assert(dbSet != null); return dbSet; }
IDataBroker
resides in the Core Domain - Blazr.SPA.Core - defining the interface the Core Domain Connectors use. The implementations all reside in the Data Domain - Blazr.SPA.Data.
IDataBroker
defines the base CRUD methods DataServices must implement. Brokers are services defined in the Services container using the interface and consumed through the interface. Note TRecord
with it's constraints is defined at method level. SelectPagedRecordsAsync
uses a RecordPagingData
object which we'll look at in article 5.
public interface IDataBroker { public ValueTask<List<TRecord>> SelectAllRecordsAsync<TRecord>() where TRecord : class, IDbRecord<TRecord>, new(); public ValueTask<List<TRecord>> SelectPagedRecordsAsync<TRecord>(RecordPagingData pagingData) where TRecord : class, IDbRecord<TRecord>, new(); public ValueTask<TRecord> SelectRecordAsync<TRecord>(Guid id) where TRecord : class, IDbRecord<TRecord>, new(); public ValueTask<int> SelectRecordListCountAsync<TRecord>() where TRecord : class, IDbRecord<TRecord>, new(); public ValueTask<DbTaskResult> UpdateRecordAsync<TRecord>(TRecord record) where TRecord : class, IDbRecord<TRecord>, new(); public ValueTask<DbTaskResult> InsertRecordAsync<TRecord>(TRecord record) where TRecord : class, IDbRecord<TRecord>, new(); public ValueTask<DbTaskResult> DeleteRecordAsync<TRecord>(TRecord record) where TRecord : class, IDbRecord<TRecord>, new(); }
BaseDataBroker
is abstract implementation of IDataBroker
. It provides default records, lists or not implemented DBTaskResult
messages.
public abstract class BaseDataBroker: IDataBroker { public virtual ValueTask<List<TRecord>> SelectAllRecordsAsync<TRecord>() where TRecord : class, IDbRecord<TRecord>, new() => ValueTask.FromResult(new List<TRecord>()); public virtual ValueTask<List<TRecord>> SelectPagedRecordsAsync<TRecord>(RecordPagingData paginatorData) where TRecord : class, IDbRecord<TRecord>, new() => ValueTask.FromResult(new List<TRecord>()); public virtual ValueTask<TRecord> SelectRecordAsync<TRecord>(Guid id) where TRecord : class, IDbRecord<TRecord>, new() => ValueTask.FromResult(new TRecord()); public virtual ValueTask<int> SelectRecordListCountAsync<TRecord>() where TRecord : class, IDbRecord<TRecord>, new() => ValueTask.FromResult(0); public virtual ValueTask<DbTaskResult> UpdateRecordAsync<TRecord>(TRecord record) where TRecord : class, IDbRecord<TRecord>, new() => ValueTask.FromResult(new DbTaskResult() { IsOK = false, Type = MessageType.NotImplemented, Message = "Method not implemented" }); public virtual ValueTask<DbTaskResult> InsertRecordAsync<TRecord>(TRecord record) where TRecord : class, IDbRecord<TRecord>, new() => ValueTask.FromResult(new DbTaskResult() { IsOK = false, Type = MessageType.NotImplemented, Message = "Method not implemented" }); public virtual ValueTask<DbTaskResult> DeleteRecordAsync<TRecord>(TRecord record) where TRecord : class, IDbRecord<TRecord>, new() => ValueTask.FromResult(new DbTaskResult() { IsOK = false, Type = MessageType.NotImplemented, Message = "Method not implemented" }); }
This is the concrete server-side implementation. Each database operation is implemented using separate DbContext
instances. Note GetDBSet
used to get the correct DBSet for TRecord
.
public class SQLServerDataBroker<TDbContext> : BaseDataBroker, IDataBroker where TDbContext : DbContext { protected virtual IDbContextFactory<TDbContext> DBContext { get; set; } = null; public SQLServerDataBroker(IConfiguration configuration, IDbContextFactory<TDbContext> dbContext) => this.DBContext = dbContext; public override async ValueTask<List<TRecord>> SelectAllRecordsAsync<TRecord>() { using var dbContext = this.DBContext.CreateDbContext(); var list = await dbContext .GetDbSet<TRecord>() .ToListAsync() ?? new List<TRecord>(); return list; } public override async ValueTask<List<TRecord>> SelectPagedRecordsAsync<TRecord>(RecordPagingData pagingData) { using var dbContext = this.DBContext.CreateDbContext(); var dbset = dbContext .GetDbSet<TRecord>(); var isSortable = typeof(TRecord).GetProperty(pagingData.SortColumn) != null; List<TRecord> list; if (pagingData.Sort && isSortable) { list = await dbset .OrderBy(pagingData.SortDescending ? $"{pagingData.SortColumn} descending" : pagingData.SortColumn) .Skip(pagingData.StartRecord) .Take(pagingData.PageSize) .ToListAsync() ?? new List<TRecord>(); } else { list = await dbset .Skip(pagingData.StartRecord) .Take(pagingData.PageSize) .ToListAsync() ?? new List<TRecord>(); } return list; } public override async ValueTask<TRecord> SelectRecordAsync<TRecord>(Guid id) { using var dbContext = this.DBContext.CreateDbContext(); var list = await dbContext .GetDbSet<TRecord>() .FirstOrDefaultAsync(item => ((IDbRecord<TRecord>)item).ID == id) ?? default; return list; } public override async ValueTask<int> SelectRecordListCountAsync<TRecord>() { using var dbContext = this.DBContext.CreateDbContext(); var count = await dbContext .GetDbSet<TRecord>() .CountAsync(); return count; } public override async ValueTask<DbTaskResult> UpdateRecordAsync<TRecord>(TRecord record) { using var context = this.DBContext.CreateDbContext(); context.Entry(record).State = EntityState.Modified; var result = await this.UpdateContext(context); return result; } public override async ValueTask<DbTaskResult> InsertRecordAsync<TRecord>(TRecord record) { using var context = this.DBContext.CreateDbContext(); context.GetDbSet<TRecord>().Add(record); var result = await this.UpdateContext(context); return result; } public override async ValueTask<DbTaskResult> DeleteRecordAsync<TRecord>(TRecord record) { using var context = this.DBContext.CreateDbContext(); context.Entry(record).State = EntityState.Deleted; var result = await this.UpdateContext(context); return result; } /// Helper method to update the context and return a DBTaskResult protected async Task<DbTaskResult> UpdateContext(DbContext context) => await context.SaveChangesAsync() > 0 ? DbTaskResult.OK() : DbTaskResult.NotOK(); }
This is the concrete server-side implementation. All database operations use a single DbContext
instances. Note GetDBSet
gets the correct DBSet for TRecord
.
public class SQLiteDataBroker<TDbContext> : BaseDataBroker, IDataBroker where TDbContext : DbContext { protected virtual IDbContextFactory<TDbContext> DBContext { get; set; } = null; private DbContext _dbContext; public SQLiteDataBroker(IConfiguration configuration, IDbContextFactory<TDbContext> dbContext) { this.DBContext = dbContext; _dbContext = this.DBContext.CreateDbContext(); // Debug.WriteLine($"==> New Instance {this.ToString()} ID:{this.ServiceID.ToString()} "); } public override async ValueTask<List<TRecord>> SelectAllRecordsAsync<TRecord>() { var dbset = _dbContext.GetDbSet<TRecord>(); return await dbset.ToListAsync() ?? new List<TRecord>(); } public override async ValueTask<List<TRecord>> SelectPagedRecordsAsync<TRecord>(RecordPagingData paginatorData) { var dbset = _dbContext.GetDbSet<TRecord>(); var isSortable = typeof(TRecord).GetProperty(pagingData.SortColumn) != null; if (pagingData.Sort && isSortable) { var list = await dbset .OrderBy(pagingData.SortDescending ? $"{pagingData.SortColumn} descending" : pagingData.SortColumn) .Skip(pagingData.StartRecord) .Take(pagingData.PageSize).ToListAsync() ?? new List<TRecord>(); return list; } else { var list = await dbset .Skip(pagingData.StartRecord) .Take(pagingData.PageSize).ToListAsync() ?? new List<TRecord>(); return list; } } public override async ValueTask<TRecord> SelectRecordAsync<TRecord>(Guid id) { var dbset = _dbContext.GetDbSet<TRecord>(); return await dbset.FirstOrDefaultAsync(item => ((IDbRecord<TRecord>)item).ID == id) ?? default; } public override async ValueTask<int> SelectRecordListCountAsync<TRecord>() { var dbset = _dbContext.GetDbSet<TRecord>(); return await dbset.CountAsync(); } public override async ValueTask<DbTaskResult> UpdateRecordAsync<TRecord>(TRecord record) { _dbContext.Entry(record).State = EntityState.Modified; var x = await _dbContext.SaveChangesAsync(); return new DbTaskResult() { IsOK = true, Type = MessageType.Success }; } public override async ValueTask<DbTaskResult> InsertRecordAsync<TRecord>(TRecord record) { var dbset = _dbContext.GetDbSet<TRecord>(); dbset.Add(record); var x = await _dbContext.SaveChangesAsync(); return new DbTaskResult() { IsOK = true, Type = MessageType.Success }; } public override async ValueTask<DbTaskResult> DeleteRecordAsync<TRecord>(TRecord record) { _dbContext.Entry(record).State = EntityState.Deleted; var x = await _dbContext.SaveChangesAsync(); return new DbTaskResult() { IsOK = true, Type = MessageType.Success }; } }
This provides a datastore for demo sites and testing.
public class InMemoryDataStoreBroker<TContext> : BaseDataBroker, IDataBroker where TContext : IInMemoryDataStore { protected virtual IInMemoryDataStore DataContext { get; set; } = null; public InMemoryDataStoreBroker(IInMemoryDataStore dataContext) => this.DataContext = dataContext; public override ValueTask<List<TRecord>> SelectAllRecordsAsync<TRecord>() => ValueTask.FromResult<List<TRecord>>(DataContext .GetDataSet<TRecord>() .ToList()); public override ValueTask<List<TRecord>> SelectPagedRecordsAsync<TRecord>(RecordPagingData paginatorData) { var dbSet = DataContext .GetDataSet<TRecord>() .ToList(); if (pagingData.Sort) { dbSet = dbSet .AsQueryable() .OrderBy(pagingData.SortDescending ? $"{pagingData.SortColumn} descending" : pagingData.SortColumn) .ToList(); } return ValueTask.FromResult ( dbSet .Skip(pagingData.StartRecord) .Take(pagingData.PageSize) .ToList() ); } public override ValueTask<TRecord> SelectRecordAsync<TRecord>(Guid id) => ValueTask.FromResult<TRecord>(DataContext .GetDataSet<TRecord>() .FirstOrDefault(item => ((IDbRecord<TRecord>)item).ID == id)); public override ValueTask<int> SelectRecordListCountAsync<TRecord>() => ValueTask.FromResult<int>(DataContext .GetDataSet<TRecord>() .Count()); public override ValueTask<DbTaskResult> UpdateRecordAsync<TRecord>(TRecord record) { var result = this.DataContext.GetDataSet<TRecord>().Update(record); var dbResult = new DbTaskResult() { IsOK = result, Message = "Record Updated" }; return ValueTask.FromResult<DbTaskResult>(dbResult); } public override ValueTask<DbTaskResult> InsertRecordAsync<TRecord>(TRecord record) { var result = this.DataContext.GetDataSet<TRecord>().Insert(record); var dbResult = new DbTaskResult() { IsOK = result, Message = "Record Added" }; return ValueTask.FromResult<DbTaskResult>(dbResult); } public override ValueTask<DbTaskResult> DeleteRecordAsync<TRecord>(TRecord record) { var result = this.DataContext.GetDataSet<TRecord>().Delete(record); var dbResult = new DbTaskResult() { IsOK = result, Message = "Record Deleted" }; return ValueTask.FromResult<DbTaskResult>(dbResult); } }
The APIDataBroker
looks a little different. It implements the interface, but uses HttpClient
to get/post requests to the API on the server.
The service map looks like this:
View Service => APIDataBroker => API Controller => ServerDataBroker => DBContext
public class APIDataBroker : BaseDataBroker, IDataBroker { protected HttpClient HttpClient { get; set; } public APIDataBroker(IConfiguration configuration, HttpClient httpClient) => this.HttpClient = httpClient; public override async ValueTask<List<TRecord>> SelectAllRecordsAsync<TRecord>() => await this.HttpClient.GetFromJsonAsync<List<TRecord>>($"/api/{GetRecordName<TRecord>()}/list"); public override async ValueTask<List<TRecord>> SelectPagedRecordsAsync<TRecord>(RecordPagingData paginatorData) { var response = await this.HttpClient.PostAsJsonAsync($"/api/{GetRecordName<TRecord>()}/listpaged", paginatorData); return await response.Content.ReadFromJsonAsync<List<TRecord>>(); } public override async ValueTask<TRecord> SelectRecordAsync<TRecord>(Guid id) { var response = await this.HttpClient.PostAsJsonAsync($"/api/{GetRecordName<TRecord>()}/read", id); var result = await response.Content.ReadFromJsonAsync<TRecord>(); return result; } public override async ValueTask<int> SelectRecordListCountAsync<TRecord>() => await this.HttpClient.GetFromJsonAsync<int>($"/api/{GetRecordName<TRecord>()}/count"); public override async ValueTask<DbTaskResult> UpdateRecordAsync<TRecord>(TRecord record) { var response = await this.HttpClient.PostAsJsonAsync<TRecord>($"/api/{GetRecordName<TRecord>()}/update", record); var result = await response.Content.ReadFromJsonAsync<DbTaskResult>(); return result; } public override async ValueTask<DbTaskResult> InsertRecordAsync<TRecord>(TRecord record) { var response = await this.HttpClient.PostAsJsonAsync<TRecord>($"/api/{GetRecordName<TRecord>()}/create", record); var result = await response.Content.ReadFromJsonAsync<DbTaskResult>(); return result; } public override async ValueTask<DbTaskResult> DeleteRecordAsync<TRecord>(TRecord record) { var response = await this.HttpClient.PostAsJsonAsync<TRecord>($"/api/{GetRecordName<TRecord>()}/update", record); var result = await response.Content.ReadFromJsonAsync<DbTaskResult>(); return result; } protected string GetRecordName<TRecord>() where TRecord : class, new() => new TRecord().GetType().Name; }
Controllers are implemented in a specific project - Blazr.Database.Controllers, one per DataClass. They reside in the Data Domain, but can't reside in the project used to to build the WASM executables as they require the Microsoft.AsnNetCore.App
framework.
The WeatherForecast Controller is shown below. It routes requests through the IDataBroker
registered service.
[ApiController] public class WeatherForecastController : ControllerBase { protected IDataBroker DataService { get; set; } private readonly ILogger<WeatherForecastController> logger; public WeatherForecastController(ILogger<WeatherForecastController> logger, IDataBroker dataService) { this.DataService = dataService; this.logger = logger; } [MVC.Route("/api/weatherforecast/list")] [HttpGet] public async Task<List<WeatherForecast>> GetList() => await DataService.SelectAllRecordsAsync<WeatherForecast>(); [MVC.Route("/api/weatherforecast/listpaged")] [HttpPost] public async Task<List<WeatherForecast>> Read([FromBody] RecordPagingData data) => await DataService.SelectPagedRecordsAsync<WeatherForecast>(data); [MVC.Route("/api/weatherforecast/count")] [HttpGet] public async Task<int> Count() => await DataService.SelectRecordListCountAsync<WeatherForecast>(); [MVC.Route("/api/weatherforecast/get")] [HttpGet] public async Task<WeatherForecast> GetRec(Guid id) => await DataService.SelectRecordAsync<WeatherForecast>(id); [MVC.Route("/api/weatherforecast/read")] [HttpPost] public async Task<WeatherForecast> Read([FromBody] Guid id) => await DataService.SelectRecordAsync<WeatherForecast>(id); [MVC.Route("weatherforecast/update")] [HttpPost] public async Task<DbTaskResult> Update([FromBody]WeatherForecast record) => await DataService.UpdateRecordAsync<WeatherForecast>(record); [MVC.Route("weatherforecast/create")] [HttpPost] public async Task<DbTaskResult> Create([FromBody]WeatherForecast record) => await DataService.CreateRecordAsync<WeatherForecast>(record); [MVC.Route("weatherforecast/delete")] [HttpPost] public async Task<DbTaskResult> Delete([FromBody] WeatherForecast record) => await DataService.DeleteRecordAsync<WeatherForecast>(record); }
Connectors are the Core Domain interface to the Data Domain. They talk to Brokers. All the code resides in ModelDataServiceConnector
.
IDataServiceConnector
defines the common interface.
public interface IDataServiceConnector { ValueTask<DbTaskResult> AddRecordAsync<TModel>(TModel model) where TModel : class, IDbRecord<TModel>, new(); ValueTask<TModel> GetRecordByIdAsync<TModel>(Guid ModelId) where TModel : class, IDbRecord<TModel>, new(); ValueTask<DbTaskResult> ModifyRecordAsync<TModel>(TModel model) where TModel : class, IDbRecord<TModel>, new(); ValueTask<DbTaskResult> RemoveRecordAsync<TModel>(TModel model) where TModel : class, IDbRecord<TModel>, new(); ValueTask<int> GetRecordCountAsync<TModel>() where TModel : class, IDbRecord<TModel>, new(); ValueTask<List<TModel>> GetAllRecordsAsync<TModel>() where TModel : class, IDbRecord<TModel>, new(); ValueTask<List<TModel>> GetPagedRecordsAsync<TModel>(RecordPagingData paginatorData) where TModel : class, IDbRecord<TModel>, new(); }
ModelDataServiceConnector
implements the interface and connects to the defined IDataBroker
defined service.
public abstract class ModelDataServiceConnector : IDataServiceConnector { private readonly IDataBroker dataBroker; private readonly ILoggingBroker loggingBroker; public ModelDataServiceConnector(IDataBroker dataBroker, ILoggingBroker loggingBroker) { this.dataBroker = dataBroker; this.loggingBroker = loggingBroker; } public async ValueTask<DbTaskResult> AddRecordAsync<TModel>(TModel model) where TModel : class, IDbRecord<TModel>, new() => await this.dataBroker.InsertRecordAsync<TModel>(model); public async ValueTask<List<TModel>> GetAllRecordsAsync<TModel>() where TModel : class, IDbRecord<TModel>, new() => await this.dataBroker.SelectAllRecordsAsync<TModel>(); public async ValueTask<List<TModel>> GetPagedRecordsAsync<TModel>(RecordPagingData pagingData) where TModel : class, IDbRecord<TModel>, new() => await this.dataBroker.SelectPagedRecordsAsync<TModel>(pagingData); public async ValueTask<TModel> GetRecordByIdAsync<TModel>(Guid modelId) where TModel : class, IDbRecord<TModel>, new() => await this.dataBroker.SelectRecordAsync<TModel>(modelId); public async ValueTask<DbTaskResult> ModifyRecordAsync<TModel>(TModel model) where TModel : class, IDbRecord<TModel>, new() => await this.dataBroker.UpdateRecordAsync<TModel>(model); public async ValueTask<DbTaskResult> RemoveRecordAsync<TModel>(TModel model) where TModel : class, IDbRecord<TModel>, new() => await this.dataBroker.DeleteRecordAsync<TModel>(model); public async ValueTask<int> GetRecordCountAsync<TModel>() where TModel : class, IDbRecord<TModel>, new() => await this.dataBroker.SelectRecordListCountAsync<TModel>(); }
The actual project implementation.
public class WeatherDataServiceConnector : ModelDataServiceConnector { public WeatherDataServiceConnector(IDataBroker dataBroker, ILoggingBroker loggingBroker) : base(dataBroker, loggingBroker) { } }
View Services are the classes that represent the application core logic. The UI view uses view services to interact with data. The key mindset is no data structures in the UI. If the UI refers to some data, the data resides in a View Service, and the UI accesses that data through a view.
In data driven applications View Services come in three basic flavours:
IModelViewService
defines the common base interface for a Model View Service.
Note:
TRecord
.public interface IModelViewService<TRecord> where TRecord : class, new() { public Guid Id { get; } public TRecord Record { get; } public List<TRecord> Records { get; } public int RecordCount { get; } public DbTaskResult DbResult { get; } public RecordPager RecordPager { get; } public bool IsRecord { get; } public bool HasRecords { get; } public bool IsNewRecord { get; } public event EventHandler RecordHasChanged; public event EventHandler ListHasChanged; public ValueTask ResetServiceAsync(); public ValueTask ResetRecordAsync(); public ValueTask ResetListAsync(); public ValueTask GetRecordsAsync(); public ValueTask<bool> SaveRecordAsync(TRecord record); public ValueTask<bool> GetRecordAsync(Guid id); public ValueTask<bool> NewRecordAsync(); public ValueTask<bool> DeleteRecordAsync(); }
BaseModelViewService
implements IModelViewService
. It contains all the boilerplate code.
public abstract class BaseModelViewService<TRecord> : IDisposable, IModelViewService<TRecord> where TRecord : class, IDbRecord<TRecord>, new() { public Guid Id { get; } = Guid.NewGuid(); public TRecord Record { get => _record; private set { this._record = value; this.RecordHasChanged?.Invoke(value, EventArgs.Empty); } } private TRecord _record = null; public List<TRecord> Records { get => _records; private set { this._records = value; this.ListHasChanged?.Invoke(value, EventArgs.Empty); } } private List<TRecord> _records = null; public DbTaskResult DbResult { get; set; } = new DbTaskResult(); public RecordPager RecordPager { get; private set; } public bool HasRecord => this.Record != null; public bool HasRecords => this.Records != null && this.Records.Count > 0; public bool IsNewRecord { get; protected set; } = true; protected IDataServiceConnector DataServiceConnector { get; set; } public int RecordCount => throw new NotImplementedException(); public event EventHandler RecordHasChanged; public event EventHandler ListHasChanged; public BaseModelViewService(IDataServiceConnector dataServiceConnector) { this.DataServiceConnector = dataServiceConnector; this.RecordPager = new RecordPager(10, 5); this.RecordPager.PageChanged += this.OnPageChanged; } public void SetRecord(TRecord record) { this.Record = record; } public async ValueTask ResetServiceAsync() { await this.ResetListAsync(); await this.ResetRecordAsync(); } public ValueTask ResetListAsync() { this.Records = null; return ValueTask.CompletedTask; } public ValueTask ResetRecordAsync() { this.Record = null; this.IsNewRecord = false; return ValueTask.CompletedTask; } public async ValueTask GetRecordsAsync() { this.Records = await DataServiceConnector.GetPagedRecordsAsync<TRecord>(this.RecordPager.GetData); this.RecordPager.RecordCount = await GetRecordListCountAsync(); this.ListHasChanged?.Invoke(null, EventArgs.Empty); } public async ValueTask<bool> GetRecordAsync(Guid id) { if (!id.Equals(Guid.Empty)) { this.IsNewRecord = false; this.Record = await DataServiceConnector.GetRecordByIdAsync<TRecord>(id); } else { this.Record = new TRecord(); this.IsNewRecord = true; } return this.IsRecord; } public async ValueTask<int> GetRecordListCountAsync() => await DataServiceConnector.GetRecordCountAsync<TRecord>(); public async ValueTask<bool> SaveRecordAsync(TRecord record) { if (this.IsNewRecord) this.DbResult = await DataServiceConnector.AddRecordAsync<TRecord>(record); else this.DbResult = await DataServiceConnector.ModifyRecordAsync(record); await this.GetRecordsAsync(); this.IsNewRecord = false; return this.DbResult.IsOK; } public async ValueTask<bool> DeleteRecordAsync() { this.DbResult = await DataServiceConnector.RemoveRecordAsync<TRecord>(this.Record); return this.DbResult.IsOK; } protected async void OnPageChanged(object sender, EventArgs e) => await this.GetRecordsAsync(); protected void NotifyRecordChanged(object sender, EventArgs e) => this.RecordHasChanged?.Invoke(sender, e); protected void NotifyListChanged(object sender, EventArgs e) => this.ListHasChanged?.Invoke(sender, e); public ValueTask<bool> NewRecordAsync() { this.Record = new TRecord(); this.IsNewRecord = true; return ValueTask.FromResult(false); } public virtual void Dispose() { } }
The boilerplating payback comes in the declaration of WeatherForecastViewService
:
public class WeatherForecastViewService : BaseModelViewService<WeatherForecast>, IModelViewService<WeatherForecast> { public WeatherForecastViewService(IDataServiceConnector dataServiceConnector) : base(dataServiceConnector) { } }
This article shows how the data services can be built using a set of abstract classes implementing boilerplate code for CRUDL operations. I've purposely kept error checking in the code to a minimum, to make it much more readable. You can implement as little or as much as you like.
Some key points to note:
If you're reading this article in the future, check the readme in the repository for the latest version of this article set.