diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/DateTimeScriptExtension.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/DateTimeScriptExtension.cs index 38bac0bb7..3f50c38c8 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/DateTimeScriptExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/DateTimeScriptExtension.cs @@ -14,12 +14,17 @@ namespace Squidex.Domain.Apps.Core.Scripting.Extensions { public sealed class DateTimeScriptExtension : IScriptExtension { - private delegate JsValue FormatDateDelegate(DateTime date, string format); + private readonly Func formatDate; + + public DateTimeScriptExtension() + { + formatDate = new Func(FormatDate); + } public void Extend(Engine engine) { - engine.SetValue("formatTime", new FormatDateDelegate(FormatDate)); - engine.SetValue("formatDate", new FormatDateDelegate(FormatDate)); + engine.SetValue("formatTime", formatDate); + engine.SetValue("formatDate", formatDate); } private static JsValue FormatDate(DateTime date, string format) diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/StringScriptExtension.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/StringScriptExtension.cs index 1ef1bae0a..e582456ef 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/StringScriptExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/StringScriptExtension.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using Jint; using Jint.Native; using Squidex.Infrastructure; @@ -14,14 +15,24 @@ namespace Squidex.Domain.Apps.Core.Scripting.Extensions public sealed class StringScriptExtension : IScriptExtension { private delegate JsValue StringSlugifyDelegate(string text, bool single = false); - private delegate JsValue StringFormatDelegate(string text); + private readonly StringSlugifyDelegate slugify; + private readonly Func toCamelCase; + private readonly Func toPascalCase; + + public StringScriptExtension() + { + slugify = new StringSlugifyDelegate(Slugify); + + toCamelCase = new Func(ToCamelCase); + toPascalCase = new Func(ToPascalCase); + } public void Extend(Engine engine) { - engine.SetValue("slugify", new StringSlugifyDelegate(Slugify)); + engine.SetValue("slugify", slugify); - engine.SetValue("toCamelCase", new StringFormatDelegate(ToCamelCase)); - engine.SetValue("toPascalCase", new StringFormatDelegate(ToPascalCase)); + engine.SetValue("toCamelCase", toCamelCase); + engine.SetValue("toPascalCase", toPascalCase); } private static JsValue Slugify(string text, bool single = false) diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs index 43e5bc308..82d7e9d6b 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs @@ -273,13 +273,13 @@ namespace Squidex.Domain.Apps.Core.Scripting var executionContext = new ExecutionContext(engine, cancellationToken, exceptionHandler); + context.Add(executionContext, nested); + foreach (var extension in extensions) { extension.Extend(executionContext, async); } - context.Add(executionContext, nested); - return executionContext.Engine; } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptContext.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptContext.cs index 174d9bafd..dba6b2db5 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptContext.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptContext.cs @@ -29,6 +29,12 @@ namespace Squidex.Domain.Apps.Core.Scripting set => SetValue(value); } + public Guid AppId + { + get => GetValue(); + set => SetValue(value); + } + public Guid ContentId { get => GetValue(); @@ -59,6 +65,12 @@ namespace Squidex.Domain.Apps.Core.Scripting set => SetValue(value); } + public string? AppName + { + get => GetValue(); + set => SetValue(value); + } + public string? Operation { get => GetValue(); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs index a3fe5ce01..dbd4ce806 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs @@ -17,15 +17,15 @@ namespace Squidex.Domain.Apps.Entities.Apps { public sealed class AppUISettingsGrain : GrainOfString, IAppUISettingsGrain { - private readonly IGrainState state; + private readonly IGrainState state; [CollectionName("UISettings")] - public sealed class GrainState + public sealed class State { public JsonObject Settings { get; set; } = JsonValue.Object(); } - public AppUISettingsGrain(IGrainState state) + public AppUISettingsGrain(IGrainState state) { Guard.NotNull(state); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs index bf87d3845..937bf9e8e 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs @@ -127,7 +127,8 @@ namespace Squidex.Domain.Apps.Entities.Contents private void Enrich(ScriptContext context) { context.ContentId = command.ContentId; - + context.AppId = appEntity.Id; + context.AppName = appEntity.Name; context.User = command.User; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Counter/CounterGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Counter/CounterGrain.cs new file mode 100644 index 000000000..586de51ed --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Counter/CounterGrain.cs @@ -0,0 +1,49 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Threading.Tasks; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Orleans; +using Squidex.Infrastructure.States; + +namespace Squidex.Domain.Apps.Entities.Contents.Counter +{ + public sealed class CounterGrain : GrainOfGuid, ICounterGrain + { + private readonly IGrainState state; + + [CollectionName("Counters")] + public sealed class State + { + public Dictionary Counters { get; set; } = new Dictionary(); + } + + public CounterGrain(IGrainState state) + { + Guard.NotNull(state); + + this.state = state; + } + + public Task IncrementAsync(string name) + { + state.Value.Counters.TryGetValue(name, out var value); + + return ResetAsync(name, value + 1); + } + + public async Task ResetAsync(string name, long value) + { + state.Value.Counters[name] = value; + + await state.WriteAsync(); + + return value; + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Counter/CounterScriptExtension.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Counter/CounterScriptExtension.cs new file mode 100644 index 000000000..cc0ac0c21 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Counter/CounterScriptExtension.cs @@ -0,0 +1,59 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Orleans; +using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Contents.Counter +{ + public sealed class CounterScriptExtension : IScriptExtension + { + private readonly IGrainFactory grainFactory; + + public CounterScriptExtension(IGrainFactory grainFactory) + { + Guard.NotNull(grainFactory); + + this.grainFactory = grainFactory; + } + + public void Extend(ExecutionContext context, bool async) + { + if (context.TryGetValue("appId", out var temp) && temp is Guid appId) + { + var engine = context.Engine; + + engine.SetValue("incrementCounter", new Func(name => + { + return Increment(appId, name); + })); + + engine.SetValue("resetCounter", new Func((name, value) => + { + return Reset(appId, name, value); + })); + } + } + + private long Increment(Guid appId, string name) + { + var grain = grainFactory.GetGrain(appId); + + return Task.Run(() => grain.IncrementAsync(name)).Result; + } + + private long Reset(Guid appId, string name, long value) + { + var grain = grainFactory.GetGrain(appId); + + return Task.Run(() => grain.ResetAsync(name, value)).Result; + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Counter/ICounterGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Counter/ICounterGrain.cs new file mode 100644 index 000000000..a2d302495 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Counter/ICounterGrain.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using Orleans; + +namespace Squidex.Domain.Apps.Entities.Contents.Counter +{ + public interface ICounterGrain : IGrainWithGuidKey + { + Task IncrementAsync(string name); + + Task ResetAsync(string name, long value); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs index 2a04a6475..721e9f7d0 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs @@ -46,10 +46,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps private async Task TransformAsync(Context context, string script, ContentEntity content) { - var scriptContext = new ScriptContext { User = context.User }; - - scriptContext.Data = content.Data; - scriptContext.ContentId = content.Id; + var scriptContext = new ScriptContext + { + ContentId = content.Id, + Data = content.Data, + AppId = context.App.Id, + AppName = context.App.Name, + User = context.User + }; content.Data = await scriptEngine.TransformAsync(scriptContext, script); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTrackerGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTrackerGrain.cs index 6aeeaa976..21a59fabc 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTrackerGrain.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTrackerGrain.cs @@ -23,7 +23,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.UsageTracking [Reentrant] public sealed class UsageTrackerGrain : GrainOfString, IRemindable, IUsageTrackerGrain { - private readonly IGrainState state; + private readonly IGrainState state; private readonly IApiUsageTracker usageTracker; public sealed class Target @@ -38,12 +38,12 @@ namespace Squidex.Domain.Apps.Entities.Rules.UsageTracking } [CollectionName("UsageTracker")] - public sealed class GrainState + public sealed class State { public Dictionary Targets { get; set; } = new Dictionary(); } - public UsageTrackerGrain(IGrainState state, IApiUsageTracker usageTracker) + public UsageTrackerGrain(IGrainState state, IApiUsageTracker usageTracker) { Guard.NotNull(state); Guard.NotNull(usageTracker); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs index 64e16c355..794ebda20 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs @@ -18,15 +18,15 @@ namespace Squidex.Domain.Apps.Entities.Tags { public sealed class TagGrain : GrainOfString, ITagGrain { - private readonly IGrainState state; + private readonly IGrainState state; [CollectionName("Index_Tags")] - public sealed class GrainState + public sealed class State { public TagsExport Tags { get; set; } = new TagsExport(); } - public TagGrain(IGrainState state) + public TagGrain(IGrainState state) { Guard.NotNull(state); diff --git a/backend/src/Squidex/Config/Domain/InfrastructureServices.cs b/backend/src/Squidex/Config/Domain/InfrastructureServices.cs index c72717e2c..e03de5779 100644 --- a/backend/src/Squidex/Config/Domain/InfrastructureServices.cs +++ b/backend/src/Squidex/Config/Domain/InfrastructureServices.cs @@ -18,6 +18,7 @@ using Squidex.Areas.Api.Controllers.UI; using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting.Extensions; using Squidex.Domain.Apps.Core.Tags; +using Squidex.Domain.Apps.Entities.Contents.Counter; using Squidex.Domain.Apps.Entities.Rules.UsageTracking; using Squidex.Domain.Apps.Entities.Tags; using Squidex.Infrastructure; @@ -59,6 +60,9 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .AsOptional(); + services.AddSingletonAs() + .As(); + services.AddSingletonAs() .As(); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsGrainTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsGrainTests.cs index a5455e43b..44784445b 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsGrainTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsGrainTests.cs @@ -16,7 +16,7 @@ namespace Squidex.Domain.Apps.Entities.Apps { public sealed class AppUISettingsGrainTests { - private readonly IGrainState grainState = A.Fake>(); + private readonly IGrainState grainState = A.Fake>(); private readonly AppUISettingsGrain sut; public AppUISettingsGrainTests() diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Counter/CounterGrainTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Counter/CounterGrainTests.cs new file mode 100644 index 000000000..dc3aba2ba --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Counter/CounterGrainTests.cs @@ -0,0 +1,52 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using FakeItEasy; +using Squidex.Infrastructure.Orleans; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Contents.Counter +{ + public class CounterGrainTests + { + private readonly IGrainState grainState = A.Fake>(); + private readonly CounterGrain sut; + + public CounterGrainTests() + { + sut = new CounterGrain(grainState); + } + + [Fact] + public async Task Should_increment_counters() + { + Assert.Equal(1, await sut.IncrementAsync("Counter1")); + Assert.Equal(2, await sut.IncrementAsync("Counter1")); + + Assert.Equal(1, await sut.IncrementAsync("Counter2")); + Assert.Equal(2, await sut.IncrementAsync("Counter2")); + + A.CallTo(() => grainState.WriteAsync()) + .MustHaveHappened(4, Times.Exactly); + } + + [Fact] + public async Task Should_reset_counter() + { + Assert.Equal(1, await sut.IncrementAsync("Counter1")); + Assert.Equal(2, await sut.IncrementAsync("Counter1")); + + Assert.Equal(1, await sut.ResetAsync("Counter1", 1)); + + Assert.Equal(2, await sut.IncrementAsync("Counter1")); + + A.CallTo(() => grainState.WriteAsync()) + .MustHaveHappened(4, Times.Exactly); + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Counter/CounterScriptExtensionTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Counter/CounterScriptExtensionTests.cs new file mode 100644 index 000000000..54af6a08a --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Counter/CounterScriptExtensionTests.cs @@ -0,0 +1,89 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using FakeItEasy; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using Orleans; +using Squidex.Domain.Apps.Core.Scripting; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Contents.Counter +{ + public class CounterScriptExtensionTests + { + private readonly IGrainFactory grainFactory = A.Fake(); + private readonly ICounterGrain counter = A.Fake(); + private readonly JintScriptEngine sut; + + public CounterScriptExtensionTests() + { + var extensions = new IScriptExtension[] + { + new CounterScriptExtension(grainFactory) + }; + + var cache = new MemoryCache(Options.Create(new MemoryCacheOptions())); + + sut = new JintScriptEngine(cache, extensions) + { + Timeout = TimeSpan.FromSeconds(1) + }; + } + + [Fact] + public void Should_reset_counter() + { + var appId = Guid.NewGuid(); + + A.CallTo(() => grainFactory.GetGrain(appId, null)) + .Returns(counter); + + A.CallTo(() => counter.ResetAsync("my", 4)) + .Returns(3); + + const string script = @" + return resetCounter('my', 4); + "; + + var context = new ScriptContext + { + ["appId"] = appId + }; + + var result = sut.Interpolate(context, script); + + Assert.Equal("3", result); + } + + [Fact] + public void Should_increment_counter() + { + var appId = Guid.NewGuid(); + + A.CallTo(() => grainFactory.GetGrain(appId, null)) + .Returns(counter); + + A.CallTo(() => counter.IncrementAsync("my")) + .Returns(3); + + const string script = @" + return incrementCounter('my'); + "; + + var context = new ScriptContext + { + ["appId"] = appId + }; + + var result = sut.Interpolate(context, script); + + Assert.Equal("3", result); + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Tags/TagGrainTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Tags/TagGrainTests.cs index 9b6d9b907..47b111a77 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Tags/TagGrainTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Tags/TagGrainTests.cs @@ -20,14 +20,14 @@ namespace Squidex.Domain.Apps.Entities.Tags { public class TagGrainTests { - private readonly IGrainState grainState = A.Fake>(); + private readonly IGrainState grainState = A.Fake>(); private readonly string id = Guid.NewGuid().ToString(); private readonly TagGrain sut; public TagGrainTests() { A.CallTo(() => grainState.ClearAsync()) - .Invokes(() => grainState.Value = new TagGrain.GrainState()); + .Invokes(() => grainState.Value = new TagGrain.State()); sut = new TagGrain(grainState); sut.ActivateAsync(id).Wait();