first draft of blueprint synchronization works

This commit is contained in:
2021-11-12 15:16:48 +01:00
parent 05a7b831bb
commit 0e043316ef
16 changed files with 629 additions and 13 deletions
+168
View File
@@ -0,0 +1,168 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using zero.Core.Entities;
namespace zero.Core.Blueprints
{
/// <summary>
///
/// </summary>
public class Blueprint<T> : Blueprint where T : ZeroEntity, new()
{
public List<BlueprintField<T>> Fields { get; private set; } = new();
public Blueprint() : base(typeof(T))
{
Sync(x => x.Name);
Sync(x => x.Alias);
Sync(x => x.Sort);
Sync(x => x.IsActive);
Sync(x => x.LanguageId);
Sync(x => x.Key);
}
/// <summary>
/// Merges blueprint data into a model based on the configuration
/// </summary>
public virtual T Apply(T blueprint, T model)
{
// reset blueprint options for model in case no blueprint was found
if (blueprint == null)
{
model.Blueprint = null;
return model;
}
foreach (BlueprintField<T> field in Fields)
{
// do not sync disabled fields
if (!field.IsSynced && !field.IsLocked)
{
continue;
}
// desynced fields are not synced with the blueprint
// (but only if they are not locked)
if (model.Blueprint.Desync.Contains(field.FieldName, StringComparer) && !field.IsLocked)
{
continue;
}
// apply new value to model property
field.Apply(blueprint, model);
}
ApplyDefaults(blueprint, model);
return model;
}
/// <inheritdoc />
public override ZeroEntity Apply(ZeroEntity blueprint, ZeroEntity model) => Apply(blueprint as T, model as T);
/// <summary>
/// Updates meta data for an entity which should always be synchronized
/// </summary>
protected virtual void ApplyDefaults(T blueprint, T model)
{
model.Hash = blueprint.Hash;
model.CreatedById = blueprint.CreatedById;
model.CreatedDate = blueprint.CreatedDate;
model.LastModifiedById = blueprint.LastModifiedById;
model.LastModifiedDate = blueprint.LastModifiedDate;
}
/// <summary>
/// Synchronize a field
/// </summary>
public BlueprintField<T> Sync(Expression<Func<T, object>> selector)
{
BlueprintField<T> field = Field(selector);
field.IsSynced = true;
return field;
}
/// <summary>
/// Lock a field so it always get synchronized no and cannot be changed
/// </summary>
public BlueprintField<T> Lock(Expression<Func<T, object>> selector)
{
BlueprintField<T> field = Field(selector);
field.IsLocked = true;
return field;
}
/// <summary>
/// Remove a field from synchronisation
/// </summary>
public void Remove(Expression<Func<T, object>> selector)
{
BlueprintField<T> field = new(selector);
Fields.RemoveAll(x => x.FieldName.Equals(field.FieldName, StringComparison.InvariantCultureIgnoreCase));
}
/// <summary>
/// Get existing field or create a new one
/// </summary>
protected BlueprintField<T> Field(Expression<Func<T, object>> selector)
{
BlueprintField<T> tempField = new(selector);
BlueprintField<T> storedField = Fields.FirstOrDefault(x => x.FieldName.Equals(tempField.FieldName, StringComparison.InvariantCultureIgnoreCase));
if (storedField != null)
{
return storedField;
}
Fields.Add(tempField);
return tempField;
}
}
/// <summary>
///
/// </summary>
public abstract class Blueprint
{
/// <summary>
/// Type of the associated entity
/// </summary>
public Type ContentType { get; private set; }
/// <summary>
/// String comparer for property name comparison
/// </summary>
protected readonly StringComparer StringComparer = StringComparer.InvariantCultureIgnoreCase;
public Blueprint(Type type)
{
ContentType = type;
}
/// <summary>
/// Merges blueprint data into a model based on the configuration
/// </summary>
public abstract ZeroEntity Apply(ZeroEntity blueprint, ZeroEntity model);
}
public class DefaultBlueprint<T> : Blueprint<T> where T : ZeroEntity, new()
{
public DefaultBlueprint(Action<Blueprint<T>> expression)
{
expression(this);
}
}
}
+112
View File
@@ -0,0 +1,112 @@
using System;
using System.Linq.Expressions;
using System.Reflection;
using System.Text;
using zero.Core.Entities;
using zero.Core.Extensions;
namespace zero.Core.Blueprints
{
public class BlueprintField<T> where T : ZeroEntity
{
public Expression<Func<T, object>> Expression { get; private set; }
public PropertyInfo PropertyInfo { get; private set; }
public string FieldName { get; private set; }
/// <summary>
/// This field is synced as long as it's not desynced for a certain entity
/// </summary>
public bool IsSynced { get; set; }
/// <summary>
/// This field is synced and cannot be desynced
/// </summary>
public bool IsLocked { get; set; }
Action<T, T> _applyHook { get; set; }
Func<T, object> _selectorFunc = null;
internal BlueprintField(Expression<Func<T, object>> selector)
{
Expression = selector;
FieldName = GetPropertyPath(selector, out MemberExpression member);
PropertyInfo = member?.Member as PropertyInfo;
if (PropertyInfo == null)
{
throw new ArgumentException("selector parameter has to be a property selector");
}
if (FieldName.IsNullOrEmpty())
{
throw new Exception("Could not find a matching field name");
}
}
public virtual void Apply(T blueprint, T model)
{
if (_applyHook != null)
{
_applyHook(blueprint, model);
return;
}
_selectorFunc ??= Expression.Compile();
PropertyInfo.SetValue(model, _selectorFunc(blueprint));
}
public virtual void OnUpdate(Action<T, T> onUpdate)
{
_applyHook = onUpdate;
}
static string GetPropertyPath(Expression expr, out MemberExpression member)
{
StringBuilder path = new();
MemberExpression memberExpression = GetMemberExpression(expr);
do
{
member = memberExpression;
if (path.Length > 0)
{
path.Insert(0, ".");
}
path.Insert(0, memberExpression.Member.Name);
memberExpression = GetMemberExpression(memberExpression.Expression);
}
while (memberExpression != null);
return path.ToString();
}
static MemberExpression GetMemberExpression(Expression expression)
{
if (expression is MemberExpression)
{
return (MemberExpression)expression;
}
else if (expression is LambdaExpression)
{
var lambdaExpression = expression as LambdaExpression;
if (lambdaExpression.Body is MemberExpression)
{
return (MemberExpression)lambdaExpression.Body;
}
else if (lambdaExpression.Body is UnaryExpression)
{
return ((MemberExpression)((UnaryExpression)lambdaExpression.Body).Operand);
}
}
return null;
}
}
}
@@ -0,0 +1,141 @@
using Microsoft.Extensions.Logging;
using Raven.Client.Documents;
using Raven.Client.Documents.Session;
using System.Collections.Generic;
using System.Threading.Tasks;
using zero.Core.Blueprints;
using zero.Core.Collections;
using zero.Core.Database;
using zero.Core.Entities;
using zero.Core.Extensions;
namespace zero.Core.Routing
{
public class BlueprintInterceptor : CollectionInterceptor
{
protected IZeroContext Context { get; set; }
protected IZeroStore Store { get; set; }
protected ILogger<BlueprintInterceptor> Logger { get; set; }
protected IBlueprintService BlueprintService { get; set; }
IList<Application> Apps { get; set; }
public BlueprintInterceptor(IZeroContext context, IZeroStore store, ILogger<BlueprintInterceptor> logger, IBlueprintService blueprintService)
{
Context = context;
Store = store;
Logger = logger;
BlueprintService = blueprintService;
}
/// <inheritdoc />
public override bool CanRun(Parameters args)
{
// we do only update children if we operate on the shared database
return (args.Session as AsyncDocumentSession)?.DatabaseName == Context.Options.Raven.Database;
}
/// <inheritdoc />
public override async Task Saved(SaveParameters args)
{
if (!BlueprintService.TryGetBlueprint(args.Model, out Blueprint blueprint))
{
return;
}
int count = 0;
foreach (Application app in await GetApplications())
{
using ZeroContextScope scope = Context.CreateScope(app);
IZeroDocumentSession session = scope.Store.Session(scope.Database);
ZeroEntity child = default;
if (args.IsUpdate)
{
child = await session.LoadAsync<ZeroEntity>(args.Model.Id);
}
if (child == null)
{
child = args.Model.Clone();
child.Blueprint = new() { Id = args.Model.Id };
}
else
{
blueprint.Apply(args.Model, child);
}
count += 1;
// now we have to store the child
// but this will not work with the session as we need to run the scoped interceptors,
// therefore we need access to the collection
await session.StoreAsync(child);
await session.SaveChangesAsync();
}
Logger.LogDebug("Blueprint: Synced {count} children for {name} ({id})", count, args.Model.Name, args.Id);
}
/// <inheritdoc />
public override async Task Deleted(DeleteParameters args)
{
if (!BlueprintService.TryGetBlueprint(args.Model, out Blueprint blueprint))
{
return;
}
int count = 0;
foreach (Application app in await GetApplications())
{
using ZeroContextScope scope = Context.CreateScope(app);
IZeroDocumentSession session = scope.Store.Session(scope.Database);
count += 1;
// now we have to delete the child
// but this will not work with the session as we need to run the scoped interceptors,
// therefore we need access to the collection
session.Delete(args.Model.Id);
await session.SaveChangesAsync();
}
Logger.LogDebug("Blueprint: Deleted {count} children for {name} ({id})", count, args.Model.Name, args.Id);
}
/// <summary>
/// Get all applications to choose from
/// </summary>
async Task<IList<Application>> GetApplications()
{
if (Apps != null)
{
return Apps;
}
IAsyncDocumentSession session = Store.Session(global: true);
Apps = await session.Query<Application>().ToListAsync();
return Apps;
}
/// <inheritdoc />
//public override async Task Saved(SaveParameters args)
//{
// //Logger.LogInformation("Route updates completed (+{added}/~{updated}/-{removed}) for {model} (id: {id})", countRoutes - countUpdatedRoutes, countUpdatedRoutes, obsoleteRoutes.Count, args.Model.Name, args.Model.Id);
//}
}
}
+73
View File
@@ -0,0 +1,73 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using zero.Core.Entities;
using zero.Core.Options;
namespace zero.Core.Blueprints
{
public class BlueprintService : IBlueprintService
{
protected IZeroOptions Options { get; set; }
protected IReadOnlyCollection<Blueprint> Blueprints { get; set; }
public BlueprintService(IZeroOptions options)
{
Options = options;
Blueprints = Options.Blueprints.GetAllItems();
}
/// <inheritdoc />
public bool IsEnabled<T>(T model) where T : ZeroEntity => IsEnabled(model.GetType());
/// <inheritdoc />
public bool IsEnabled<T>() where T : ZeroEntity => IsEnabled(typeof(T));
/// <inheritdoc />
public bool IsEnabled(Type type) => Blueprints.Any(x => x.ContentType.IsAssignableFrom(type));
/// <inheritdoc />
public bool TryGetBlueprint<T>(T model, out Blueprint blueprint) where T : ZeroEntity => TryGetBlueprint(model.GetType(), out blueprint);
/// <inheritdoc />
public bool TryGetBlueprint<T>(out Blueprint blueprint) where T : ZeroEntity => TryGetBlueprint(typeof(T), out blueprint);
/// <inheritdoc />
public bool TryGetBlueprint(Type type, out Blueprint blueprint)
{
blueprint = Blueprints.FirstOrDefault(x => x.ContentType.IsAssignableFrom(type));
return blueprint != null;
}
}
public interface IBlueprintService
{
/// <summary>
/// Check whether blueprinting functionality is enabled for a certain entity
/// </summary>
bool IsEnabled<T>() where T : ZeroEntity;
/// <summary>
/// Check whether blueprinting functionality is enabled for a certain entity
/// </summary>
bool IsEnabled<T>(T model) where T : ZeroEntity;
bool IsEnabled(Type type);
bool TryGetBlueprint<T>(T model, out Blueprint blueprint) where T : ZeroEntity;
bool TryGetBlueprint<T>(out Blueprint blueprint) where T : ZeroEntity;
bool TryGetBlueprint(Type type, out Blueprint blueprint);
}
}
@@ -0,0 +1,13 @@
using zero.Core.Entities;
namespace zero.Core.Blueprints
{
public class CountryBlueprint : Blueprint<Country>
{
public CountryBlueprint()
{
Sync(x => x.Code);
Sync(x => x.IsPreferred);
}
}
}
@@ -1,11 +1,13 @@
using System;
using System.Threading.Tasks;
using System.Threading.Tasks;
using zero.Core.Entities;
namespace zero.Core.Collections
{
public abstract partial class CollectionInterceptor<T> : ICollectionInterceptor<T> where T : ZeroEntity
{
/// <inheritdoc />
public virtual bool CanRun(Parameters args) => true;
/// <inheritdoc />
public virtual Task<InterceptorResult<T>> Creating(CreateParameters args) => Task.FromResult<InterceptorResult<T>>(default);
@@ -38,12 +40,21 @@ namespace zero.Core.Collections
}
public abstract partial class CollectionInterceptor : CollectionInterceptor<ZeroEntity>, ICollectionInterceptor { }
public abstract partial class CollectionInterceptor : CollectionInterceptor<ZeroEntity>, ICollectionInterceptor
{
/// <inheritdoc />
public override bool CanRun(Parameters args) => base.CanRun(args);
}
public interface ICollectionInterceptor : ICollectionInterceptor<ZeroEntity> { }
public interface ICollectionInterceptor<T> where T : ZeroEntity
{
/// <summary>
/// Whether any of the interceptor methods is allowed to run based on the parameters
/// </summary>
bool CanRun(CollectionInterceptor<T>.Parameters args);
/// <summary>
/// Called after an entity has been stored but before the session has saved its changes
/// </summary>
@@ -61,6 +61,11 @@ namespace zero.Core.Collections
continue;
}
if (!interceptor.CanRun(instruction.Parameters))
{
continue;
}
if (func == default)
{
func = expression.Compile();
@@ -113,6 +118,11 @@ namespace zero.Core.Collections
continue;
}
if (!interceptor.CanRun(instruction.Parameters))
{
continue;
}
InterceptorResult<T> beforeResult = instruction?.Results.FirstOrDefault(res => res.InterceptorHash == registration.Hash);
if (func == default)
@@ -31,6 +31,11 @@ namespace zero.Core.Collections
/// </summary>
public IValidator<T> Validator { get; set; }
/// <summary>
/// Access to the collection
/// </summary>
public ICollectionBase<T> Collection { get; set; }
/// <summary>
/// Parameters from the interceptor which ran on before the operation (only available for completed operations)
/// </summary>
@@ -20,7 +20,8 @@ namespace zero.Core.Collections
Context = args.Context,
Properties = args.Properties,
Session = args.Session,
Store = args.Store
Store = args.Store,
Collection = null, //args.Collection
//Validator = args.Validator
};
@@ -46,6 +47,16 @@ namespace zero.Core.Collections
};
}
/// <inheritdoc />
public override bool CanRun(Parameters args)
{
if (args is ParametersWithModel)
{
return _base.CanRun(Args<CollectionInterceptor<ZeroEntity>.ParametersWithModel>(args as ParametersWithModel));
}
return _base.CanRun(Args<CollectionInterceptor<ZeroEntity>.Parameters>(args));
}
/// <inheritdoc />
public override async Task<InterceptorResult<T>> Creating(CreateParameters args) => Result(await _base.Creating(Args<CollectionInterceptor<ZeroEntity>.CreateParameters>(args)));
@@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using zero.Core.Entities;
namespace zero.Core.Collections
{
public class InterceptorHelper
{
protected ICollectionInterceptorHandler Handler { get; set; }
public InterceptorHelper(ICollectionInterceptorHandler handler)
{
Handler = handler;
}
public async Task<InterceptorInstruction<T, CollectionInterceptor<T>.UpdateParameters>> Updating<T>(T model) where T : ZeroEntity
{
CollectionInterceptor<T>.UpdateParameters parameters = new();
InterceptorInstruction<T, CollectionInterceptor<T>.UpdateParameters> instruction = Handler.Create<T, CollectionInterceptor<T>.UpdateParameters>("update", parameters) ?? new();
await instruction.HandleBefore(x => x.Updating(instruction.Parameters));
return instruction;
}
}
}
+32
View File
@@ -0,0 +1,32 @@
using System;
using zero.Core.Blueprints;
using zero.Core.Entities;
namespace zero.Core.Options
{
public class BlueprintOptions : ZeroBackofficeCollection<Blueprint>, IZeroCollectionOptions
{
public BlueprintOptions()
{
Add<CountryBlueprint>();
}
public void Add<T>() where T : Blueprint, new()
{
Items.Add(new T());
}
public void Add<T>(Blueprint<T> implementation) where T : ZeroEntity, new()
{
Items.Add(implementation);
}
public void Add<T>(Action<Blueprint<T>> createExpression) where T : ZeroEntity, new()
{
Items.Add(new DefaultBlueprint<T>(createExpression));
}
}
}
+9
View File
@@ -28,6 +28,7 @@ namespace zero.Core.Options
Integrations = new();
Icons = new();
Services = new();
Blueprints = new();
Routing = new();
Interceptors = new();
Applications = new();
@@ -116,6 +117,9 @@ namespace zero.Core.Options
/// <inheritdoc />
public ServiceOptions Services { get; private set; }
/// <inheritdoc />
public BlueprintOptions Blueprints { get; private set; }
/// <inheritdoc />
public RoutingOptions Routing { get; private set; }
@@ -225,6 +229,11 @@ namespace zero.Core.Options
/// </summary>
ServiceOptions Services { get; }
/// <summary>
///
/// </summary>
BlueprintOptions Blueprints { get; }
/// <summary>
///
/// </summary>
+1 -1
View File
@@ -79,7 +79,7 @@ const myPlugin = () => ({
*/
let config = {
server: {
port: process.env.PORT,
port: process.env.PORT || 3399,
cors: true
},
plugins: [createVuePlugin(), ...zeroPlugins, myPlugin()],
+6 -1
View File
@@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using zero.Core;
using zero.Core.Api;
using zero.Core.Blueprints;
using zero.Core.Collections;
using zero.Core.Entities;
using zero.Core.Integrations;
@@ -50,7 +51,8 @@ namespace zero.Web.Defaults
zero.Icons.AddSet("feather", "Feather", "/assets/icons/feather.svg", "fth");
zero.Pages.Add<PageFolder>(Constants.Pages.FolderAlias, "@page.folder.name", "@page.folder.description", "fth-folder");
zero.Interceptors.Add<ZeroEntityRouteInterceptor>(gravity: 1000);
zero.Interceptors.Add<ZeroEntityRouteInterceptor>(gravity: 100);
zero.Interceptors.Add<BlueprintInterceptor>(gravity: -1);
}
@@ -134,6 +136,9 @@ namespace zero.Web.Defaults
services.AddScoped<IBackofficeSearchService, BackofficeSearchService>();
services.AddScoped<IBlueprintService, BlueprintService>();
services.AddScoped<BlueprintInterceptor>();
services.AddScoped<ZeroEntityRouteInterceptor>();
}
}
+3 -6
View File
@@ -1,9 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Zero.Web.DevServer
namespace Zero.Web.DevServer
{
public class ZeroDevOptions
{
@@ -12,5 +7,7 @@ namespace Zero.Web.DevServer
public bool ForwardLog { get; set; } = false;
public string WorkingDirectory { get; set; }
public bool Enabled { get; set; } = true;
}
}
+1 -1
View File
@@ -42,7 +42,7 @@ namespace Zero.Web.DevServer
{
// this is a development-time service,
// therefore no way to enable it in production
if (!env.IsDevelopment())
if (!env.IsDevelopment() || !options.Value.Enabled)
{
return;
}