diff --git a/zero.Core/Blueprints/Blueprint.cs b/zero.Core/Blueprints/Blueprint.cs new file mode 100644 index 00000000..d01645c4 --- /dev/null +++ b/zero.Core/Blueprints/Blueprint.cs @@ -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 +{ + /// + /// + /// + public class Blueprint : Blueprint where T : ZeroEntity, new() + { + public List> 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); + } + + + /// + /// Merges blueprint data into a model based on the configuration + /// + 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 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; + } + + + /// + public override ZeroEntity Apply(ZeroEntity blueprint, ZeroEntity model) => Apply(blueprint as T, model as T); + + + /// + /// Updates meta data for an entity which should always be synchronized + /// + 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; + } + + + /// + /// Synchronize a field + /// + public BlueprintField Sync(Expression> selector) + { + BlueprintField field = Field(selector); + field.IsSynced = true; + return field; + } + + + /// + /// Lock a field so it always get synchronized no and cannot be changed + /// + public BlueprintField Lock(Expression> selector) + { + BlueprintField field = Field(selector); + field.IsLocked = true; + return field; + } + + + /// + /// Remove a field from synchronisation + /// + public void Remove(Expression> selector) + { + BlueprintField field = new(selector); + Fields.RemoveAll(x => x.FieldName.Equals(field.FieldName, StringComparison.InvariantCultureIgnoreCase)); + } + + + /// + /// Get existing field or create a new one + /// + protected BlueprintField Field(Expression> selector) + { + BlueprintField tempField = new(selector); + BlueprintField storedField = Fields.FirstOrDefault(x => x.FieldName.Equals(tempField.FieldName, StringComparison.InvariantCultureIgnoreCase)); + + if (storedField != null) + { + return storedField; + } + + Fields.Add(tempField); + return tempField; + } + } + + + /// + /// + /// + public abstract class Blueprint + { + /// + /// Type of the associated entity + /// + public Type ContentType { get; private set; } + + /// + /// String comparer for property name comparison + /// + protected readonly StringComparer StringComparer = StringComparer.InvariantCultureIgnoreCase; + + + public Blueprint(Type type) + { + ContentType = type; + } + + /// + /// Merges blueprint data into a model based on the configuration + /// + public abstract ZeroEntity Apply(ZeroEntity blueprint, ZeroEntity model); + } + + + public class DefaultBlueprint : Blueprint where T : ZeroEntity, new() + { + public DefaultBlueprint(Action> expression) + { + expression(this); + } + } +} diff --git a/zero.Core/Blueprints/BlueprintField.cs b/zero.Core/Blueprints/BlueprintField.cs new file mode 100644 index 00000000..0150abad --- /dev/null +++ b/zero.Core/Blueprints/BlueprintField.cs @@ -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 where T : ZeroEntity + { + public Expression> Expression { get; private set; } + + public PropertyInfo PropertyInfo { get; private set; } + + public string FieldName { get; private set; } + + /// + /// This field is synced as long as it's not desynced for a certain entity + /// + public bool IsSynced { get; set; } + + /// + /// This field is synced and cannot be desynced + /// + public bool IsLocked { get; set; } + + Action _applyHook { get; set; } + + Func _selectorFunc = null; + + + internal BlueprintField(Expression> 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 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; + } + } +} diff --git a/zero.Core/Blueprints/BlueprintInterceptor.cs b/zero.Core/Blueprints/BlueprintInterceptor.cs new file mode 100644 index 00000000..096eed8b --- /dev/null +++ b/zero.Core/Blueprints/BlueprintInterceptor.cs @@ -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 Logger { get; set; } + + protected IBlueprintService BlueprintService { get; set; } + + IList Apps { get; set; } + + + public BlueprintInterceptor(IZeroContext context, IZeroStore store, ILogger logger, IBlueprintService blueprintService) + { + Context = context; + Store = store; + Logger = logger; + BlueprintService = blueprintService; + } + + /// + 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; + } + + + /// + 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(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); + } + + + /// + 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); + } + + + /// + /// Get all applications to choose from + /// + async Task> GetApplications() + { + if (Apps != null) + { + return Apps; + } + + IAsyncDocumentSession session = Store.Session(global: true); + Apps = await session.Query().ToListAsync(); + return Apps; + } + + + + + /// + //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); + //} + } +} diff --git a/zero.Core/Blueprints/BlueprintService.cs b/zero.Core/Blueprints/BlueprintService.cs new file mode 100644 index 00000000..1d260676 --- /dev/null +++ b/zero.Core/Blueprints/BlueprintService.cs @@ -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 Blueprints { get; set; } + + + public BlueprintService(IZeroOptions options) + { + Options = options; + Blueprints = Options.Blueprints.GetAllItems(); + } + + + /// + public bool IsEnabled(T model) where T : ZeroEntity => IsEnabled(model.GetType()); + + + /// + public bool IsEnabled() where T : ZeroEntity => IsEnabled(typeof(T)); + + + /// + public bool IsEnabled(Type type) => Blueprints.Any(x => x.ContentType.IsAssignableFrom(type)); + + + /// + public bool TryGetBlueprint(T model, out Blueprint blueprint) where T : ZeroEntity => TryGetBlueprint(model.GetType(), out blueprint); + + + /// + public bool TryGetBlueprint(out Blueprint blueprint) where T : ZeroEntity => TryGetBlueprint(typeof(T), out blueprint); + + + /// + public bool TryGetBlueprint(Type type, out Blueprint blueprint) + { + blueprint = Blueprints.FirstOrDefault(x => x.ContentType.IsAssignableFrom(type)); + return blueprint != null; + } + } + + + public interface IBlueprintService + { + /// + /// Check whether blueprinting functionality is enabled for a certain entity + /// + bool IsEnabled() where T : ZeroEntity; + + /// + /// Check whether blueprinting functionality is enabled for a certain entity + /// + bool IsEnabled(T model) where T : ZeroEntity; + + bool IsEnabled(Type type); + + bool TryGetBlueprint(T model, out Blueprint blueprint) where T : ZeroEntity; + + bool TryGetBlueprint(out Blueprint blueprint) where T : ZeroEntity; + + bool TryGetBlueprint(Type type, out Blueprint blueprint); + } +} diff --git a/zero.Core/Blueprints/Implementations/CountryBlueprint.cs b/zero.Core/Blueprints/Implementations/CountryBlueprint.cs new file mode 100644 index 00000000..47853b2b --- /dev/null +++ b/zero.Core/Blueprints/Implementations/CountryBlueprint.cs @@ -0,0 +1,13 @@ +using zero.Core.Entities; + +namespace zero.Core.Blueprints +{ + public class CountryBlueprint : Blueprint + { + public CountryBlueprint() + { + Sync(x => x.Code); + Sync(x => x.IsPreferred); + } + } +} \ No newline at end of file diff --git a/zero.Core/Collections/Interceptors/CollectionInterceptor.cs b/zero.Core/Collections/Interceptors/CollectionInterceptor.cs index 76805979..215d9c2e 100644 --- a/zero.Core/Collections/Interceptors/CollectionInterceptor.cs +++ b/zero.Core/Collections/Interceptors/CollectionInterceptor.cs @@ -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 : ICollectionInterceptor where T : ZeroEntity { + /// + public virtual bool CanRun(Parameters args) => true; + /// public virtual Task> Creating(CreateParameters args) => Task.FromResult>(default); @@ -38,12 +40,21 @@ namespace zero.Core.Collections } - public abstract partial class CollectionInterceptor : CollectionInterceptor, ICollectionInterceptor { } + public abstract partial class CollectionInterceptor : CollectionInterceptor, ICollectionInterceptor + { + /// + public override bool CanRun(Parameters args) => base.CanRun(args); + } public interface ICollectionInterceptor : ICollectionInterceptor { } public interface ICollectionInterceptor where T : ZeroEntity { + /// + /// Whether any of the interceptor methods is allowed to run based on the parameters + /// + bool CanRun(CollectionInterceptor.Parameters args); + /// /// Called after an entity has been stored but before the session has saved its changes /// diff --git a/zero.Core/Collections/Interceptors/CollectionInterceptorHandler.cs b/zero.Core/Collections/Interceptors/CollectionInterceptorHandler.cs index a3ce376e..c22c89cd 100644 --- a/zero.Core/Collections/Interceptors/CollectionInterceptorHandler.cs +++ b/zero.Core/Collections/Interceptors/CollectionInterceptorHandler.cs @@ -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 beforeResult = instruction?.Results.FirstOrDefault(res => res.InterceptorHash == registration.Hash); if (func == default) diff --git a/zero.Core/Collections/Interceptors/CollectionInterceptorParameters.cs b/zero.Core/Collections/Interceptors/CollectionInterceptorParameters.cs index 986dc03b..ecf6f155 100644 --- a/zero.Core/Collections/Interceptors/CollectionInterceptorParameters.cs +++ b/zero.Core/Collections/Interceptors/CollectionInterceptorParameters.cs @@ -31,6 +31,11 @@ namespace zero.Core.Collections /// public IValidator Validator { get; set; } + /// + /// Access to the collection + /// + public ICollectionBase Collection { get; set; } + /// /// Parameters from the interceptor which ran on before the operation (only available for completed operations) /// diff --git a/zero.Core/Collections/Interceptors/CollectionInterceptorShim.cs b/zero.Core/Collections/Interceptors/CollectionInterceptorShim.cs index ff14d2be..d61b3a0b 100644 --- a/zero.Core/Collections/Interceptors/CollectionInterceptorShim.cs +++ b/zero.Core/Collections/Interceptors/CollectionInterceptorShim.cs @@ -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 }; } + /// + public override bool CanRun(Parameters args) + { + if (args is ParametersWithModel) + { + return _base.CanRun(Args.ParametersWithModel>(args as ParametersWithModel)); + } + return _base.CanRun(Args.Parameters>(args)); + } + /// public override async Task> Creating(CreateParameters args) => Result(await _base.Creating(Args.CreateParameters>(args))); diff --git a/zero.Core/Collections/Interceptors/InterceptorHelper.cs b/zero.Core/Collections/Interceptors/InterceptorHelper.cs new file mode 100644 index 00000000..61cf1b40 --- /dev/null +++ b/zero.Core/Collections/Interceptors/InterceptorHelper.cs @@ -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.UpdateParameters>> Updating(T model) where T : ZeroEntity + { + CollectionInterceptor.UpdateParameters parameters = new(); + InterceptorInstruction.UpdateParameters> instruction = Handler.Create.UpdateParameters>("update", parameters) ?? new(); + + await instruction.HandleBefore(x => x.Updating(instruction.Parameters)); + + return instruction; + } + } +} diff --git a/zero.Core/Options/BlueprintOptions.cs b/zero.Core/Options/BlueprintOptions.cs new file mode 100644 index 00000000..b16c1ed4 --- /dev/null +++ b/zero.Core/Options/BlueprintOptions.cs @@ -0,0 +1,32 @@ +using System; +using zero.Core.Blueprints; +using zero.Core.Entities; + +namespace zero.Core.Options +{ + public class BlueprintOptions : ZeroBackofficeCollection, IZeroCollectionOptions + { + public BlueprintOptions() + { + Add(); + } + + + public void Add() where T : Blueprint, new() + { + Items.Add(new T()); + } + + + public void Add(Blueprint implementation) where T : ZeroEntity, new() + { + Items.Add(implementation); + } + + + public void Add(Action> createExpression) where T : ZeroEntity, new() + { + Items.Add(new DefaultBlueprint(createExpression)); + } + } +} diff --git a/zero.Core/Options/ZeroOptions.cs b/zero.Core/Options/ZeroOptions.cs index d6878ca3..593ebbbc 100644 --- a/zero.Core/Options/ZeroOptions.cs +++ b/zero.Core/Options/ZeroOptions.cs @@ -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 /// public ServiceOptions Services { get; private set; } + /// + public BlueprintOptions Blueprints { get; private set; } + /// public RoutingOptions Routing { get; private set; } @@ -225,6 +229,11 @@ namespace zero.Core.Options /// ServiceOptions Services { get; } + /// + /// + /// + BlueprintOptions Blueprints { get; } + /// /// /// diff --git a/zero.Web.UI/vite.config.js b/zero.Web.UI/vite.config.js index 35c6eae3..e6bb9b36 100644 --- a/zero.Web.UI/vite.config.js +++ b/zero.Web.UI/vite.config.js @@ -79,7 +79,7 @@ const myPlugin = () => ({ */ let config = { server: { - port: process.env.PORT, + port: process.env.PORT || 3399, cors: true }, plugins: [createVuePlugin(), ...zeroPlugins, myPlugin()], diff --git a/zero.Web/Defaults/ZeroBackofficePlugin.cs b/zero.Web/Defaults/ZeroBackofficePlugin.cs index 971854d3..0ee4f7b6 100644 --- a/zero.Web/Defaults/ZeroBackofficePlugin.cs +++ b/zero.Web/Defaults/ZeroBackofficePlugin.cs @@ -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(Constants.Pages.FolderAlias, "@page.folder.name", "@page.folder.description", "fth-folder"); - zero.Interceptors.Add(gravity: 1000); + zero.Interceptors.Add(gravity: 100); + zero.Interceptors.Add(gravity: -1); } @@ -134,6 +136,9 @@ namespace zero.Web.Defaults services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } } diff --git a/zero.Web/DevServer/ZeroDevOptions.cs b/zero.Web/DevServer/ZeroDevOptions.cs index a7cc1631..bb566ca1 100644 --- a/zero.Web/DevServer/ZeroDevOptions.cs +++ b/zero.Web/DevServer/ZeroDevOptions.cs @@ -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; } } diff --git a/zero.Web/DevServer/ZeroDevService.cs b/zero.Web/DevServer/ZeroDevService.cs index 7e633975..2741ce12 100644 --- a/zero.Web/DevServer/ZeroDevService.cs +++ b/zero.Web/DevServer/ZeroDevService.cs @@ -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; }