mirror of https://github.com/Squidex/squidex.git
committed by
GitHub
407 changed files with 7167 additions and 3517 deletions
@ -0,0 +1,21 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Microsoft.Extensions.Configuration; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Squidex.Infrastructure.Plugins; |
|||
|
|||
namespace Squidex.Extensions.Actions.Algolia |
|||
{ |
|||
public sealed class AlgoliaPlugin : IPlugin |
|||
{ |
|||
public void ConfigureServices(IServiceCollection services, IConfiguration config) |
|||
{ |
|||
services.AddRuleAction<AlgoliaAction, AlgoliaActionHandler>(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Microsoft.Extensions.Configuration; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Squidex.Infrastructure.Plugins; |
|||
|
|||
namespace Squidex.Extensions.Actions.AzureQueue |
|||
{ |
|||
public sealed class AzureQueuePlugin : IPlugin |
|||
{ |
|||
public void ConfigureServices(IServiceCollection services, IConfiguration config) |
|||
{ |
|||
services.AddRuleAction<AzureQueueAction, AzureQueueActionHandler>(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Microsoft.Extensions.Configuration; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Squidex.Infrastructure.Plugins; |
|||
|
|||
namespace Squidex.Extensions.Actions.Discourse |
|||
{ |
|||
public sealed class DiscoursePlugin : IPlugin |
|||
{ |
|||
public void ConfigureServices(IServiceCollection services, IConfiguration config) |
|||
{ |
|||
services.AddRuleAction<DiscourseAction, DiscourseActionHandler>(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Microsoft.Extensions.Configuration; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Squidex.Infrastructure.Plugins; |
|||
|
|||
namespace Squidex.Extensions.Actions.ElasticSearch |
|||
{ |
|||
public sealed class ElasticSearchPlugin : IPlugin |
|||
{ |
|||
public void ConfigureServices(IServiceCollection services, IConfiguration config) |
|||
{ |
|||
services.AddRuleAction<ElasticSearchAction, ElasticSearchActionHandler>(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Microsoft.Extensions.Configuration; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Squidex.Infrastructure.Plugins; |
|||
|
|||
namespace Squidex.Extensions.Actions.Email |
|||
{ |
|||
public sealed class EmailPlugin : IPlugin |
|||
{ |
|||
public void ConfigureServices(IServiceCollection services, IConfiguration config) |
|||
{ |
|||
services.AddRuleAction<EmailAction, EmailActionHandler>(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Microsoft.Extensions.Configuration; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Squidex.Infrastructure.Plugins; |
|||
|
|||
namespace Squidex.Extensions.Actions.Fastly |
|||
{ |
|||
public sealed class FastlyPlugin : IPlugin |
|||
{ |
|||
public void ConfigureServices(IServiceCollection services, IConfiguration config) |
|||
{ |
|||
services.AddRuleAction<FastlyAction, FastlyActionHandler>(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Microsoft.Extensions.Configuration; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Squidex.Infrastructure.Plugins; |
|||
|
|||
namespace Squidex.Extensions.Actions.Medium |
|||
{ |
|||
public sealed class MediumPlugin : IPlugin |
|||
{ |
|||
public void ConfigureServices(IServiceCollection services, IConfiguration config) |
|||
{ |
|||
services.AddRuleAction<MediumAction, MediumActionHandler>(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Microsoft.Extensions.Configuration; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Squidex.Infrastructure.Plugins; |
|||
|
|||
namespace Squidex.Extensions.Actions.Prerender |
|||
{ |
|||
public sealed class PrerenderPlugin : IPlugin |
|||
{ |
|||
public void ConfigureServices(IServiceCollection services, IConfiguration config) |
|||
{ |
|||
services.AddRuleAction<PrerenderAction, PrerenderActionHandler>(); |
|||
} |
|||
} |
|||
} |
|||
@ -1,31 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using Squidex.Domain.Apps.Core.HandleRules; |
|||
using Squidex.Infrastructure; |
|||
|
|||
namespace Squidex.Extensions.Actions |
|||
{ |
|||
[AttributeUsage(AttributeTargets.Class, Inherited = false)] |
|||
public sealed class RuleActionHandlerAttribute : Attribute |
|||
{ |
|||
public Type HandlerType { get; } |
|||
|
|||
public RuleActionHandlerAttribute(Type handlerType) |
|||
{ |
|||
Guard.NotNull(handlerType, nameof(handlerType)); |
|||
|
|||
HandlerType = handlerType; |
|||
|
|||
if (!typeof(IRuleActionHandler).IsAssignableFrom(handlerType)) |
|||
{ |
|||
throw new ArgumentException($"Handler type must implement {typeof(IRuleActionHandler)}.", nameof(handlerType)); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -1,85 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using System.Reflection; |
|||
using Squidex.Domain.Apps.Core.Rules; |
|||
using Squidex.Infrastructure; |
|||
|
|||
namespace Squidex.Extensions.Actions |
|||
{ |
|||
public static class RuleElementRegistry |
|||
{ |
|||
private const string ActionSuffix = "Action"; |
|||
private const string ActionSuffixV2 = "Action"; |
|||
private static readonly HashSet<Type> ActionHandlerTypes = new HashSet<Type>(); |
|||
private static readonly Dictionary<string, RuleElement> ActionTypes = new Dictionary<string, RuleElement>(); |
|||
|
|||
public static IReadOnlyDictionary<string, RuleElement> Triggers |
|||
{ |
|||
get { return TriggerTypes.All; } |
|||
} |
|||
|
|||
public static IReadOnlyDictionary<string, RuleElement> Actions |
|||
{ |
|||
get { return ActionTypes; } |
|||
} |
|||
|
|||
public static IReadOnlyCollection<Type> ActionHandlers |
|||
{ |
|||
get { return ActionHandlerTypes; } |
|||
} |
|||
|
|||
static RuleElementRegistry() |
|||
{ |
|||
var actionTypes = |
|||
typeof(RuleElementRegistry).Assembly |
|||
.GetTypes() |
|||
.Where(x => typeof(RuleAction).IsAssignableFrom(x)) |
|||
.Where(x => x.GetCustomAttribute<RuleActionAttribute>() != null) |
|||
.Where(x => x.GetCustomAttribute<RuleActionHandlerAttribute>() != null) |
|||
.ToList(); |
|||
|
|||
foreach (var actionType in actionTypes) |
|||
{ |
|||
var name = GetActionName(actionType); |
|||
|
|||
var metadata = actionType.GetCustomAttribute<RuleActionAttribute>(); |
|||
|
|||
ActionTypes[name] = |
|||
new RuleElement |
|||
{ |
|||
Type = actionType, |
|||
Display = metadata.Display, |
|||
Description = metadata.Description, |
|||
IconColor = metadata.IconColor, |
|||
IconImage = metadata.IconImage, |
|||
ReadMore = metadata.ReadMore |
|||
}; |
|||
|
|||
ActionHandlerTypes.Add(actionType.GetCustomAttribute<RuleActionHandlerAttribute>().HandlerType); |
|||
} |
|||
} |
|||
|
|||
public static TypeNameRegistry MapRuleActions(this TypeNameRegistry typeNameRegistry) |
|||
{ |
|||
foreach (var actionType in ActionTypes.Values) |
|||
{ |
|||
typeNameRegistry.Map(actionType.Type, actionType.Type.Name); |
|||
} |
|||
|
|||
return typeNameRegistry; |
|||
} |
|||
|
|||
private static string GetActionName(Type type) |
|||
{ |
|||
return type.TypeName(false, ActionSuffix, ActionSuffixV2); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Microsoft.Extensions.Configuration; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Squidex.Infrastructure.Plugins; |
|||
|
|||
namespace Squidex.Extensions.Actions.Slack |
|||
{ |
|||
public sealed class SlackPlugin : IPlugin |
|||
{ |
|||
public void ConfigureServices(IServiceCollection services, IConfiguration config) |
|||
{ |
|||
services.AddRuleAction<SlackAction, SlackActionHandler>(); |
|||
} |
|||
} |
|||
} |
|||
@ -1,57 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using Squidex.Domain.Apps.Core.Rules.Triggers; |
|||
using Squidex.Infrastructure; |
|||
|
|||
namespace Squidex.Extensions.Actions |
|||
{ |
|||
public static class TriggerTypes |
|||
{ |
|||
private const string TriggerSuffix = "Trigger"; |
|||
private const string TriggerSuffixV2 = "TriggerV2"; |
|||
|
|||
public static readonly IReadOnlyDictionary<string, RuleElement> All = new Dictionary<string, RuleElement> |
|||
{ |
|||
[GetTriggerName(typeof(ContentChangedTriggerV2))] = new RuleElement |
|||
{ |
|||
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 28 28'><path d='M21.875 28H6.125A6.087 6.087 0 0 1 0 21.875V6.125A6.087 6.087 0 0 1 6.125 0h15.75A6.087 6.087 0 0 1 28 6.125v15.75A6.088 6.088 0 0 1 21.875 28zM6.125 1.75A4.333 4.333 0 0 0 1.75 6.125v15.75a4.333 4.333 0 0 0 4.375 4.375h15.75a4.333 4.333 0 0 0 4.375-4.375V6.125a4.333 4.333 0 0 0-4.375-4.375H6.125z'/><path d='M13.125 12.25H7.35c-1.575 0-2.888-1.313-2.888-2.888V7.349c0-1.575 1.313-2.888 2.888-2.888h5.775c1.575 0 2.887 1.313 2.887 2.888v2.013c0 1.575-1.312 2.888-2.887 2.888zM7.35 6.212c-.613 0-1.138.525-1.138 1.138v2.012A1.16 1.16 0 0 0 7.35 10.5h5.775a1.16 1.16 0 0 0 1.138-1.138V7.349a1.16 1.16 0 0 0-1.138-1.138H7.35zM22.662 16.713H5.337c-.525 0-.875-.35-.875-.875s.35-.875.875-.875h17.237c.525 0 .875.35.875.875s-.35.875-.787.875zM15.138 21.262h-9.8c-.525 0-.875-.35-.875-.875s.35-.875.875-.875h9.713c.525 0 .875.35.875.875s-.35.875-.787.875z'/></svg>", |
|||
IconColor = "#3389ff", |
|||
Display = "Content changed", |
|||
Description = "For content changes like created, updated, published, unpublished..." |
|||
}, |
|||
[GetTriggerName(typeof(AssetChangedTriggerV2))] = new RuleElement |
|||
{ |
|||
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 28 28'><path d='M21.875 28H6.125A6.087 6.087 0 0 1 0 21.875V6.125A6.087 6.087 0 0 1 6.125 0h15.75A6.087 6.087 0 0 1 28 6.125v15.75A6.088 6.088 0 0 1 21.875 28zM6.125 1.75A4.333 4.333 0 0 0 1.75 6.125v15.75a4.333 4.333 0 0 0 4.375 4.375h15.75a4.333 4.333 0 0 0 4.375-4.375V6.125a4.333 4.333 0 0 0-4.375-4.375H6.125z'/><path d='M21.088 23.537H9.1c-.35 0-.612-.175-.787-.525s-.088-.7.088-.962l8.225-9.713c.175-.175.438-.35.7-.35s.525.175.7.35l5.25 7.525c.088.087.088.175.088.262.438 1.225.087 2.012-.175 2.45-.613.875-1.925.963-2.1.963zm-10.063-1.75h10.15c.175 0 .612-.088.7-.262.088-.088.088-.35 0-.7l-4.55-6.475-6.3 7.438zM9.1 13.737c-2.1 0-3.85-1.75-3.85-3.85S7 6.037 9.1 6.037s3.85 1.75 3.85 3.85-1.663 3.85-3.85 3.85zm0-5.949c-1.138 0-2.1.875-2.1 2.1s.962 2.1 2.1 2.1 2.1-.962 2.1-2.1-.875-2.1-2.1-2.1z'/></svg>", |
|||
IconColor = "#3389ff", |
|||
Display = "Asset changed", |
|||
Description = "For asset changes like uploaded, updated (reuploaded), renamed, deleted..." |
|||
}, |
|||
[GetTriggerName(typeof(SchemaChangedTrigger))] = new RuleElement |
|||
{ |
|||
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path d='M9.6 0c-.6 0-1 .4-1 1s.4 1 1 1h12.8c.6 0 1.1-.4 1.1-1s-.4-1-1-1H9.6zM6.1 4.3c-.6 0-1 .4-1 1s.4 1 1 1h19.8c.5 0 .9-.4.9-1s-.4-1-1-1H6.1zM7 8.6c-3.9 0-7 3.1-7 7V25c0 3.9 3.1 7 7 7h18c3.9 0 7-3.1 7-7v-9.4c0-3.9-3.1-7-7-7H7zm0 2h18c2.8 0 5 2.2 5 5V25c0 2.8-2.2 5-5 5H7c-2.8 0-5-2.2-5-5v-9.4c0-2.8 2.2-5 5-5zM5.3 13v2c0 2.4 2 4.4 4.4 4.4h12.7c2.4 0 4.4-2 4.4-4.4v-2H25v2c0 1.5-1.2 2.6-2.6 2.6H9.6C8.2 17.7 7 16.5 7 15v-2H5.3z' id='path5869'/></svg>", |
|||
IconColor = "#3389ff", |
|||
Display = "Schema changed", |
|||
Description = "When a schema definition has been created, updated, published or deleted..." |
|||
}, |
|||
[GetTriggerName(typeof(UsageTrigger))] = new RuleElement |
|||
{ |
|||
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path d='M21.2 11.4c-.2 0-.4-.1-.6-.2-.5-.3-.6-.9-.3-1.4L22 7.2c.3-.5.9-.6 1.4-.3.6.4.7 1.1.4 1.5L22.1 11c-.2.3-.5.4-.9.4zM16 20.9h-.2c-1-.1-2-.6-2.5-1.5l-6-8.7c-.3-.3-.3-.8 0-1.2.3-.3.8-.4 1.2-.2l9.2 5.4c.9.5 1.5 1.4 1.6 2.4.1 1-.2 2-.9 2.8-.6.7-1.5 1-2.4 1zm-4.6-7.5l3.4 5c.2.3.6.6 1 .6s.8-.1 1.1-.4c.3-.3.4-.7.3-1.1-.1-.4-.3-.7-.6-1zM25.9 32H6.1C2.8 32 0 29.2 0 25.9v-10C0 7.1 7.1 0 15.8 0 24.8 0 32 7.2 32 16.2v9.7c0 3.3-2.8 6.1-6.1 6.1zM15.8 2C8.2 2 2 8.2 2 15.8v10C2 28.1 3.9 30 6.1 30h19.7c2.3 0 4.1-1.9 4.1-4.1v-9.7C30 8.4 23.6 2 15.8 2z'/></svg>", |
|||
IconColor = "#3389ff", |
|||
Display = "Usage exceeded", |
|||
Description = "When monthly API calls exceed a specified limit for one time a month..." |
|||
} |
|||
}; |
|||
|
|||
private static string GetTriggerName(Type type) |
|||
{ |
|||
return type.TypeName(false, TriggerSuffix, TriggerSuffixV2); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Microsoft.Extensions.Configuration; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Squidex.Infrastructure.Plugins; |
|||
|
|||
namespace Squidex.Extensions.Actions.Twitter |
|||
{ |
|||
public sealed class TwitterPlugin : IPlugin |
|||
{ |
|||
public void ConfigureServices(IServiceCollection services, IConfiguration config) |
|||
{ |
|||
services.Configure<TwitterOptions>( |
|||
config.GetSection("twitter")); |
|||
|
|||
services.AddRuleAction<TweetAction, TweetActionHandler>(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Microsoft.Extensions.Configuration; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Squidex.Infrastructure.Plugins; |
|||
|
|||
namespace Squidex.Extensions.Actions.Webhook |
|||
{ |
|||
public sealed class WebhookPlugin : IPlugin |
|||
{ |
|||
public void ConfigureServices(IServiceCollection services, IConfiguration config) |
|||
{ |
|||
services.AddRuleAction<WebhookAction, WebhookActionHandler>(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,29 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using Microsoft.Extensions.Configuration; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Squidex.Infrastructure.Assets; |
|||
using Squidex.Infrastructure.Plugins; |
|||
|
|||
namespace Squidex.Extensions.Samples |
|||
{ |
|||
public sealed class MemoryAssetStorePlugin : IPlugin |
|||
{ |
|||
public void ConfigureServices(IServiceCollection services, IConfiguration config) |
|||
{ |
|||
var storeType = config.GetValue<string>("assetStore:type"); |
|||
|
|||
if (string.Equals(storeType, "Memory", StringComparison.OrdinalIgnoreCase)) |
|||
{ |
|||
services.AddSingletonAs<MemoryAssetStore>() |
|||
.As<IAssetStore>(); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,25 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Core.HandleRules; |
|||
using Squidex.Domain.Apps.Core.Rules; |
|||
|
|||
namespace Microsoft.Extensions.DependencyInjection |
|||
{ |
|||
public static class DependencyInjectionExtensions |
|||
{ |
|||
public static IServiceCollection AddRuleAction<TAction, THandler>(this IServiceCollection services) where THandler : class, IRuleActionHandler where TAction : RuleAction |
|||
{ |
|||
services.AddSingletonAs<THandler>() |
|||
.As<IRuleActionHandler>(); |
|||
|
|||
services.AddSingleton(new RuleActionRegistration(typeof(TAction))); |
|||
|
|||
return services; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,16 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
|
|||
namespace Squidex.Domain.Apps.Core.HandleRules |
|||
{ |
|||
[AttributeUsage(AttributeTargets.Property)] |
|||
public sealed class FormattableAttribute : Attribute |
|||
{ |
|||
} |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Domain.Apps.Core.HandleRules |
|||
{ |
|||
public sealed class RuleActionProperty |
|||
{ |
|||
public RuleActionPropertyEditor Editor { get; set; } |
|||
|
|||
public string Name { get; set; } |
|||
|
|||
public string Display { get; set; } |
|||
|
|||
public string Description { get; set; } |
|||
|
|||
public bool IsFormattable { get; set; } |
|||
|
|||
public bool IsRequired { get; set; } |
|||
} |
|||
} |
|||
@ -0,0 +1,20 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Domain.Apps.Core.HandleRules |
|||
{ |
|||
public enum RuleActionPropertyEditor |
|||
{ |
|||
Checkbox, |
|||
Email, |
|||
Number, |
|||
Password, |
|||
Text, |
|||
TextArea, |
|||
Url |
|||
} |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using Squidex.Infrastructure; |
|||
|
|||
namespace Squidex.Domain.Apps.Core.HandleRules |
|||
{ |
|||
public sealed class RuleActionRegistration |
|||
{ |
|||
public Type ActionType { get; } |
|||
|
|||
internal RuleActionRegistration(Type actionType) |
|||
{ |
|||
Guard.NotNull(actionType, nameof(actionType)); |
|||
|
|||
ActionType = actionType; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,18 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Reflection; |
|||
|
|||
#pragma warning disable RECS0014 // If all fields, properties and methods members are static, the class can be made static.
|
|||
|
|||
namespace Squidex.Domain.Apps.Core |
|||
{ |
|||
public static class SquidexCoreOperations |
|||
{ |
|||
public static readonly Assembly Assembly = typeof(SquidexCoreOperations).Assembly; |
|||
} |
|||
} |
|||
@ -1,152 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
using MongoDB.Driver; |
|||
using NodaTime; |
|||
using Squidex.Domain.Apps.Core.ConvertContent; |
|||
using Squidex.Domain.Apps.Entities.Apps; |
|||
using Squidex.Domain.Apps.Entities.Contents; |
|||
using Squidex.Domain.Apps.Entities.Contents.State; |
|||
using Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors; |
|||
using Squidex.Domain.Apps.Entities.Schemas; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Json; |
|||
using Squidex.Infrastructure.MongoDb; |
|||
using Squidex.Infrastructure.Queries; |
|||
using Squidex.Infrastructure.Reflection; |
|||
using Squidex.Infrastructure.States; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.MongoDb.Contents |
|||
{ |
|||
internal sealed class MongoContentDraftCollection : MongoContentCollection |
|||
{ |
|||
public MongoContentDraftCollection(IMongoDatabase database, IJsonSerializer serializer, IAppProvider appProvider) |
|||
: base(database, serializer, appProvider, "State_Content_Draft") |
|||
{ |
|||
} |
|||
|
|||
protected override async Task SetupCollectionAsync(IMongoCollection<MongoContentEntity> collection, CancellationToken ct = default) |
|||
{ |
|||
await collection.Indexes.CreateManyAsync( |
|||
new[] |
|||
{ |
|||
new CreateIndexModel<MongoContentEntity>( |
|||
Index |
|||
.Ascending(x => x.IndexedAppId) |
|||
.Ascending(x => x.Id) |
|||
.Ascending(x => x.IsDeleted)), |
|||
new CreateIndexModel<MongoContentEntity>( |
|||
Index |
|||
.Ascending(x => x.IndexedSchemaId) |
|||
.Ascending(x => x.Id) |
|||
.Ascending(x => x.IsDeleted)), |
|||
new CreateIndexModel<MongoContentEntity>( |
|||
Index |
|||
.Text(x => x.DataText) |
|||
.Ascending(x => x.IndexedSchemaId) |
|||
.Ascending(x => x.IsDeleted) |
|||
.Ascending(x => x.Status)) |
|||
}, ct); |
|||
|
|||
await base.SetupCollectionAsync(collection, ct); |
|||
} |
|||
|
|||
public async Task<IReadOnlyList<Guid>> QueryIdsAsync(Guid appId, ISchemaEntity schema, FilterNode filterNode) |
|||
{ |
|||
var filter = filterNode.AdjustToModel(schema.SchemaDef, true).ToFilter(schema.Id); |
|||
|
|||
var contentEntities = |
|||
await Collection.Find(filter).Only(x => x.Id) |
|||
.ToListAsync(); |
|||
|
|||
return contentEntities.Select(x => Guid.Parse(x["_id"].AsString)).ToList(); |
|||
} |
|||
|
|||
public async Task<IReadOnlyList<Guid>> QueryIdsAsync(Guid appId) |
|||
{ |
|||
var contentEntities = |
|||
await Collection.Find(x => x.IndexedAppId == appId).Only(x => x.Id) |
|||
.ToListAsync(); |
|||
|
|||
return contentEntities.Select(x => Guid.Parse(x["_id"].AsString)).ToList(); |
|||
} |
|||
|
|||
public Task QueryScheduledWithoutDataAsync(Instant now, Func<IContentEntity, Task> callback) |
|||
{ |
|||
return Collection.Find(x => x.ScheduledAt < now && x.IsDeleted != true) |
|||
.Not(x => x.DataByIds) |
|||
.Not(x => x.DataDraftByIds) |
|||
.Not(x => x.DataText) |
|||
.ForEachAsync(c => |
|||
{ |
|||
callback(c); |
|||
}); |
|||
} |
|||
|
|||
public async Task<IContentEntity> FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id) |
|||
{ |
|||
var contentEntity = |
|||
await Collection.Find(x => x.IndexedSchemaId == schema.Id && x.Id == id && x.IsDeleted != true).Not(x => x.DataText) |
|||
.FirstOrDefaultAsync(); |
|||
|
|||
contentEntity?.ParseData(schema.SchemaDef, Serializer); |
|||
|
|||
return contentEntity; |
|||
} |
|||
|
|||
public async Task<(ContentState Value, long Version)> ReadAsync(Guid key, Func<Guid, Guid, Task<ISchemaEntity>> getSchema) |
|||
{ |
|||
var contentEntity = |
|||
await Collection.Find(x => x.Id == key).Not(x => x.DataText) |
|||
.FirstOrDefaultAsync(); |
|||
|
|||
if (contentEntity != null) |
|||
{ |
|||
var schema = await getSchema(contentEntity.IndexedAppId, contentEntity.IndexedSchemaId); |
|||
|
|||
contentEntity.ParseData(schema.SchemaDef, Serializer); |
|||
|
|||
return (SimpleMapper.Map(contentEntity, new ContentState()), contentEntity.Version); |
|||
} |
|||
|
|||
return (null, EtagVersion.NotFound); |
|||
} |
|||
|
|||
public async Task UpsertAsync(MongoContentEntity content, long oldVersion) |
|||
{ |
|||
try |
|||
{ |
|||
content.DataText = content.DataDraftByIds.ToFullText(); |
|||
|
|||
await Collection.ReplaceOneAsync(x => x.Id == content.Id && x.Version == oldVersion, content, Upsert); |
|||
} |
|||
catch (MongoWriteException ex) |
|||
{ |
|||
if (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) |
|||
{ |
|||
var existingVersion = |
|||
await Collection.Find(x => x.Id == content.Id).Only(x => x.Id, x => x.Version) |
|||
.FirstOrDefaultAsync(); |
|||
|
|||
if (existingVersion != null) |
|||
{ |
|||
throw new InconsistentStateException(existingVersion["vs"].AsInt64, oldVersion, ex); |
|||
} |
|||
} |
|||
else |
|||
{ |
|||
throw; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -1,71 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
using MongoDB.Driver; |
|||
using Squidex.Domain.Apps.Core.ConvertContent; |
|||
using Squidex.Domain.Apps.Entities.Apps; |
|||
using Squidex.Domain.Apps.Entities.Contents; |
|||
using Squidex.Domain.Apps.Entities.Schemas; |
|||
using Squidex.Infrastructure.Json; |
|||
using Squidex.Infrastructure.MongoDb; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.MongoDb.Contents |
|||
{ |
|||
internal sealed class MongoContentPublishedCollection : MongoContentCollection |
|||
{ |
|||
public MongoContentPublishedCollection(IMongoDatabase database, IJsonSerializer serializer, IAppProvider appProvider) |
|||
: base(database, serializer, appProvider, "State_Content_Published") |
|||
{ |
|||
} |
|||
|
|||
protected override async Task SetupCollectionAsync(IMongoCollection<MongoContentEntity> collection, CancellationToken ct = default) |
|||
{ |
|||
await collection.Indexes.CreateManyAsync( |
|||
new[] |
|||
{ |
|||
new CreateIndexModel<MongoContentEntity>( |
|||
Index |
|||
.Ascending(x => x.IndexedAppId) |
|||
.Ascending(x => x.Id)), |
|||
new CreateIndexModel<MongoContentEntity>( |
|||
Index |
|||
.Ascending(x => x.IndexedSchemaId) |
|||
.Ascending(x => x.Id)), |
|||
new CreateIndexModel<MongoContentEntity>( |
|||
Index |
|||
.Text(x => x.DataText) |
|||
.Ascending(x => x.IndexedSchemaId)) |
|||
}, ct); |
|||
|
|||
await base.SetupCollectionAsync(collection, ct); |
|||
} |
|||
|
|||
public async Task<IContentEntity> FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id) |
|||
{ |
|||
var contentEntity = |
|||
await Collection.Find(x => x.IndexedSchemaId == schema.Id && x.Id == id).Not(x => x.DataText) |
|||
.FirstOrDefaultAsync(); |
|||
|
|||
contentEntity?.ParseData(schema.SchemaDef, Serializer); |
|||
|
|||
return contentEntity; |
|||
} |
|||
|
|||
public Task UpsertAsync(MongoContentEntity content) |
|||
{ |
|||
content.DataText = content.DataByIds.ToFullText(); |
|||
content.DataDraftByIds = null; |
|||
content.ScheduleJob = null; |
|||
content.ScheduledAt = null; |
|||
|
|||
return Collection.ReplaceOneAsync(x => x.Id == content.Id, content, new UpdateOptions { IsUpsert = true }); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,105 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using System.Threading.Tasks; |
|||
using Orleans; |
|||
using Squidex.Domain.Apps.Core.Contents; |
|||
using Squidex.Domain.Apps.Entities.Apps; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Log; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text |
|||
{ |
|||
public sealed class GrainTextIndexer : ITextIndexer |
|||
{ |
|||
private readonly IGrainFactory grainFactory; |
|||
private readonly ISemanticLog log; |
|||
|
|||
public GrainTextIndexer(IGrainFactory grainFactory, ISemanticLog log) |
|||
{ |
|||
Guard.NotNull(grainFactory, nameof(grainFactory)); |
|||
Guard.NotNull(log, nameof(log)); |
|||
|
|||
this.grainFactory = grainFactory; |
|||
|
|||
this.log = log; |
|||
} |
|||
|
|||
public async Task DeleteAsync(Guid schemaId, Guid id) |
|||
{ |
|||
var index = grainFactory.GetGrain<ITextIndexerGrain>(schemaId); |
|||
|
|||
using (Profiler.TraceMethod<GrainTextIndexer>()) |
|||
{ |
|||
try |
|||
{ |
|||
await index.DeleteAsync(id); |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
log.LogError(ex, w => w |
|||
.WriteProperty("action", "DeleteTextEntry") |
|||
.WriteProperty("status", "Failed")); |
|||
} |
|||
} |
|||
} |
|||
|
|||
public async Task IndexAsync(Guid schemaId, Guid id, NamedContentData data, NamedContentData dataDraft) |
|||
{ |
|||
var index = grainFactory.GetGrain<ITextIndexerGrain>(schemaId); |
|||
|
|||
using (Profiler.TraceMethod<GrainTextIndexer>()) |
|||
{ |
|||
try |
|||
{ |
|||
if (data != null) |
|||
{ |
|||
await index.IndexAsync(id, new IndexData { Data = data }); |
|||
} |
|||
|
|||
if (dataDraft != null) |
|||
{ |
|||
await index.IndexAsync(id, new IndexData { Data = dataDraft, IsDraft = true }); |
|||
} |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
log.LogError(ex, w => w |
|||
.WriteProperty("action", "UpdateTextEntry") |
|||
.WriteProperty("status", "Failed")); |
|||
} |
|||
} |
|||
} |
|||
|
|||
public async Task<List<Guid>> SearchAsync(string queryText, IAppEntity app, Guid schemaId, bool useDraft = false) |
|||
{ |
|||
if (string.IsNullOrWhiteSpace(queryText)) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
var index = grainFactory.GetGrain<ITextIndexerGrain>(schemaId); |
|||
|
|||
using (Profiler.TraceMethod<GrainTextIndexer>()) |
|||
{ |
|||
var context = CreateContext(app, useDraft); |
|||
|
|||
return await index.SearchAsync(queryText, context); |
|||
} |
|||
} |
|||
|
|||
private static SearchContext CreateContext(IAppEntity app, bool useDraft) |
|||
{ |
|||
var languages = new HashSet<string>(app.LanguagesConfig.Select(x => x.Key)); |
|||
|
|||
return new SearchContext { Languages = languages, IsDraft = useDraft }; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Threading.Tasks; |
|||
using Squidex.Domain.Apps.Core.Contents; |
|||
using Squidex.Domain.Apps.Entities.Apps; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text |
|||
{ |
|||
public interface ITextIndexer |
|||
{ |
|||
Task DeleteAsync(Guid schemaId, Guid id); |
|||
|
|||
Task IndexAsync(Guid schemaId, Guid id, NamedContentData data, NamedContentData dataDraft); |
|||
|
|||
Task<List<Guid>> SearchAsync(string queryText, IAppEntity app, Guid schemaId, bool useDraft = false); |
|||
} |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Threading.Tasks; |
|||
using Orleans; |
|||
using Squidex.Infrastructure.Orleans; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text |
|||
{ |
|||
public interface ITextIndexerGrain : IGrainWithGuidKey |
|||
{ |
|||
Task DeleteAsync(Guid id); |
|||
|
|||
Task IndexAsync(Guid id, J<IndexData> data); |
|||
|
|||
Task<List<Guid>> SearchAsync(string queryText, SearchContext context); |
|||
} |
|||
} |
|||
@ -0,0 +1,18 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Core.Contents; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text |
|||
{ |
|||
public sealed class IndexData |
|||
{ |
|||
public NamedContentData Data { get; set; } |
|||
|
|||
public bool IsDraft { get; set; } |
|||
} |
|||
} |
|||
@ -0,0 +1,65 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using Lucene.Net.Analysis; |
|||
using Lucene.Net.Analysis.Standard; |
|||
using Lucene.Net.Util; |
|||
using Squidex.Infrastructure; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text |
|||
{ |
|||
public sealed class MultiLanguageAnalyzer : AnalyzerWrapper |
|||
{ |
|||
private readonly StandardAnalyzer fallbackAnalyzer; |
|||
private readonly Dictionary<string, Analyzer> analyzers = new Dictionary<string, Analyzer>(StringComparer.OrdinalIgnoreCase); |
|||
|
|||
public MultiLanguageAnalyzer(LuceneVersion version) |
|||
: base(PER_FIELD_REUSE_STRATEGY) |
|||
{ |
|||
fallbackAnalyzer = new StandardAnalyzer(version); |
|||
|
|||
foreach (var type in typeof(StandardAnalyzer).Assembly.GetTypes()) |
|||
{ |
|||
if (typeof(Analyzer).IsAssignableFrom(type)) |
|||
{ |
|||
var language = type.Namespace.Split('.').Last(); |
|||
|
|||
if (language.Length == 2) |
|||
{ |
|||
try |
|||
{ |
|||
var analyzer = Activator.CreateInstance(type, version); |
|||
|
|||
analyzers[language] = (Analyzer)analyzer; |
|||
} |
|||
catch (MissingMethodException) |
|||
{ |
|||
continue; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
protected override Analyzer GetWrappedAnalyzer(string fieldName) |
|||
{ |
|||
if (fieldName.Length > 0) |
|||
{ |
|||
var analyzer = analyzers.GetOrDefault(fieldName.Substring(0, 2)) ?? fallbackAnalyzer; |
|||
|
|||
return analyzer; |
|||
} |
|||
else |
|||
{ |
|||
return fallbackAnalyzer; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,94 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.IO; |
|||
using System.IO.Compression; |
|||
using System.Threading.Tasks; |
|||
using Lucene.Net.Index; |
|||
using Squidex.Infrastructure.Assets; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text |
|||
{ |
|||
public static class PersistenceHelper |
|||
{ |
|||
private const string ArchiveFile = "Archive.zip"; |
|||
private const string LockFile = "write.lock"; |
|||
|
|||
public static async Task UploadDirectoryAsync(this IAssetStore assetStore, DirectoryInfo directory, IndexCommit commit) |
|||
{ |
|||
using (var fileStream = new FileStream( |
|||
Path.Combine(directory.FullName, ArchiveFile), |
|||
FileMode.Create, |
|||
FileAccess.ReadWrite, |
|||
FileShare.None, |
|||
4096, |
|||
FileOptions.DeleteOnClose)) |
|||
{ |
|||
using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create, true)) |
|||
{ |
|||
foreach (var fileName in commit.FileNames) |
|||
{ |
|||
var file = new FileInfo(Path.Combine(directory.FullName, fileName)); |
|||
|
|||
try |
|||
{ |
|||
if (!file.Name.Equals(ArchiveFile, StringComparison.OrdinalIgnoreCase) && |
|||
!file.Name.Equals(LockFile, StringComparison.OrdinalIgnoreCase)) |
|||
{ |
|||
zipArchive.CreateEntryFromFile(file.FullName, file.Name); |
|||
} |
|||
} |
|||
catch (IOException) |
|||
{ |
|||
continue; |
|||
} |
|||
} |
|||
} |
|||
|
|||
fileStream.Position = 0; |
|||
|
|||
await assetStore.UploadAsync(directory.Name, 0, string.Empty, fileStream, true); |
|||
} |
|||
} |
|||
|
|||
public static async Task DownloadAsync(this IAssetStore assetStore, DirectoryInfo directory) |
|||
{ |
|||
if (directory.Exists) |
|||
{ |
|||
directory.Delete(true); |
|||
} |
|||
|
|||
directory.Create(); |
|||
|
|||
using (var fileStream = new FileStream( |
|||
Path.Combine(directory.FullName, ArchiveFile), |
|||
FileMode.Create, |
|||
FileAccess.ReadWrite, |
|||
FileShare.None, |
|||
4096, |
|||
FileOptions.DeleteOnClose)) |
|||
{ |
|||
try |
|||
{ |
|||
await assetStore.DownloadAsync(directory.Name, 0, string.Empty, fileStream); |
|||
|
|||
fileStream.Position = 0; |
|||
|
|||
using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Read, true)) |
|||
{ |
|||
zipArchive.ExtractToDirectory(directory.FullName); |
|||
} |
|||
} |
|||
catch (AssetNotFoundException) |
|||
{ |
|||
return; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,18 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Collections.Generic; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text |
|||
{ |
|||
public sealed class SearchContext |
|||
{ |
|||
public bool IsDraft { get; set; } |
|||
|
|||
public HashSet<string> Languages { get; set; } |
|||
} |
|||
} |
|||
@ -0,0 +1,297 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.IO; |
|||
using System.Linq; |
|||
using System.Text; |
|||
using System.Threading.Tasks; |
|||
using Lucene.Net.Analysis; |
|||
using Lucene.Net.Documents; |
|||
using Lucene.Net.Index; |
|||
using Lucene.Net.Queries; |
|||
using Lucene.Net.QueryParsers.Classic; |
|||
using Lucene.Net.Search; |
|||
using Lucene.Net.Store; |
|||
using Lucene.Net.Util; |
|||
using Squidex.Domain.Apps.Core; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Assets; |
|||
using Squidex.Infrastructure.Json.Objects; |
|||
using Squidex.Infrastructure.Orleans; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text |
|||
{ |
|||
public sealed class TextIndexerGrain : GrainOfGuid, ITextIndexerGrain |
|||
{ |
|||
private const LuceneVersion Version = LuceneVersion.LUCENE_48; |
|||
private const int MaxResults = 2000; |
|||
private const int MaxUpdates = 100; |
|||
private const string MetaId = "_id"; |
|||
private const string MetaKey = "_key"; |
|||
private const string MetaDraft = "_dd"; |
|||
private static readonly TimeSpan CommitDelay = TimeSpan.FromSeconds(30); |
|||
private static readonly Analyzer Analyzer = new MultiLanguageAnalyzer(Version); |
|||
private static readonly TermsFilter DraftFilter = new TermsFilter(new Term(MetaDraft, "1")); |
|||
private static readonly TermsFilter NoDraftFilter = new TermsFilter(new Term(MetaDraft, "0")); |
|||
private readonly SnapshotDeletionPolicy snapshotter = new SnapshotDeletionPolicy(new KeepOnlyLastCommitDeletionPolicy()); |
|||
private readonly IAssetStore assetStore; |
|||
private IDisposable timer; |
|||
private DirectoryInfo directory; |
|||
private IndexWriter indexWriter; |
|||
private IndexReader indexReader; |
|||
private IndexSearcher indexSearcher; |
|||
private QueryParser queryParser; |
|||
private HashSet<string> currentLanguages; |
|||
private long updates; |
|||
|
|||
public TextIndexerGrain(IAssetStore assetStore) |
|||
{ |
|||
Guard.NotNull(assetStore, nameof(assetStore)); |
|||
|
|||
this.assetStore = assetStore; |
|||
} |
|||
|
|||
public override async Task OnDeactivateAsync() |
|||
{ |
|||
await DeactivateAsync(true); |
|||
} |
|||
|
|||
protected override async Task OnActivateAsync(Guid key) |
|||
{ |
|||
directory = new DirectoryInfo(Path.Combine(Path.GetTempPath(), $"Index_{key}")); |
|||
|
|||
await assetStore.DownloadAsync(directory); |
|||
|
|||
var config = new IndexWriterConfig(Version, Analyzer) |
|||
{ |
|||
IndexDeletionPolicy = snapshotter |
|||
}; |
|||
|
|||
indexWriter = new IndexWriter(FSDirectory.Open(directory), config); |
|||
|
|||
if (indexWriter.NumDocs > 0) |
|||
{ |
|||
indexReader = indexWriter.GetReader(false); |
|||
indexSearcher = new IndexSearcher(indexReader); |
|||
} |
|||
} |
|||
|
|||
public Task DeleteAsync(Guid id) |
|||
{ |
|||
indexWriter.DeleteDocuments(new Term(MetaId, id.ToString())); |
|||
|
|||
return TryFlushAsync(); |
|||
} |
|||
|
|||
public Task IndexAsync(Guid id, J<IndexData> data) |
|||
{ |
|||
var docId = id.ToString(); |
|||
var docDraft = data.Value.IsDraft ? "1" : "0"; |
|||
var docKey = $"{docId}_{docDraft}"; |
|||
|
|||
indexWriter.DeleteDocuments(new Term(MetaKey, docKey)); |
|||
|
|||
var languages = new Dictionary<string, StringBuilder>(); |
|||
|
|||
void AppendText(string language, string text) |
|||
{ |
|||
if (!string.IsNullOrWhiteSpace(text)) |
|||
{ |
|||
var sb = languages.GetOrAddNew(language); |
|||
|
|||
if (sb.Length > 0) |
|||
{ |
|||
sb.Append(" "); |
|||
} |
|||
|
|||
sb.Append(text); |
|||
} |
|||
} |
|||
|
|||
foreach (var field in data.Value.Data) |
|||
{ |
|||
foreach (var fieldValue in field.Value) |
|||
{ |
|||
var appendText = new Action<string>(text => AppendText(fieldValue.Key, text)); |
|||
|
|||
AppendJsonText(fieldValue.Value, appendText); |
|||
} |
|||
} |
|||
|
|||
if (languages.Count > 0) |
|||
{ |
|||
var document = new Document(); |
|||
|
|||
document.AddStringField(MetaId, docId, Field.Store.YES); |
|||
document.AddStringField(MetaKey, docKey, Field.Store.YES); |
|||
document.AddStringField(MetaDraft, docDraft, Field.Store.YES); |
|||
|
|||
foreach (var field in languages) |
|||
{ |
|||
var fieldName = BuildFieldName(field.Key); |
|||
|
|||
document.AddTextField(fieldName, field.Value.ToString(), Field.Store.NO); |
|||
} |
|||
|
|||
indexWriter.AddDocument(document); |
|||
} |
|||
|
|||
return TryFlushAsync(); |
|||
} |
|||
|
|||
private static void AppendJsonText(IJsonValue value, Action<string> appendText) |
|||
{ |
|||
if (value.Type == JsonValueType.String) |
|||
{ |
|||
appendText(value.ToString()); |
|||
} |
|||
else if (value is JsonArray array) |
|||
{ |
|||
foreach (var item in array) |
|||
{ |
|||
AppendJsonText(item, appendText); |
|||
} |
|||
} |
|||
else if (value is JsonObject obj) |
|||
{ |
|||
foreach (var item in obj.Values) |
|||
{ |
|||
AppendJsonText(item, appendText); |
|||
} |
|||
} |
|||
} |
|||
|
|||
public Task<List<Guid>> SearchAsync(string queryText, SearchContext context) |
|||
{ |
|||
var result = new HashSet<Guid>(); |
|||
|
|||
if (!string.IsNullOrWhiteSpace(queryText)) |
|||
{ |
|||
var query = BuildQuery(queryText, context); |
|||
|
|||
if (indexReader != null) |
|||
{ |
|||
var filter = context.IsDraft ? DraftFilter : NoDraftFilter; |
|||
|
|||
var hits = indexSearcher.Search(query, filter, MaxResults).ScoreDocs; |
|||
|
|||
foreach (var hit in hits) |
|||
{ |
|||
var document = indexReader.Document(hit.Doc); |
|||
|
|||
var idField = document.GetField(MetaId)?.GetStringValue(); |
|||
|
|||
if (idField != null && Guid.TryParse(idField, out var guid)) |
|||
{ |
|||
result.Add(guid); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
return Task.FromResult(result.ToList()); |
|||
} |
|||
|
|||
private Query BuildQuery(string query, SearchContext context) |
|||
{ |
|||
if (queryParser == null || !currentLanguages.SetEquals(context.Languages)) |
|||
{ |
|||
var fields = |
|||
context.Languages.Select(BuildFieldName) |
|||
.Union(Enumerable.Repeat(BuildFieldName(InvariantPartitioning.Instance.Master.Key), 1)).ToArray(); |
|||
|
|||
queryParser = new MultiFieldQueryParser(Version, fields, Analyzer); |
|||
|
|||
currentLanguages = context.Languages; |
|||
} |
|||
|
|||
try |
|||
{ |
|||
return queryParser.Parse(query); |
|||
} |
|||
catch (ParseException ex) |
|||
{ |
|||
throw new ValidationException(ex.Message); |
|||
} |
|||
} |
|||
|
|||
private async Task TryFlushAsync() |
|||
{ |
|||
updates++; |
|||
|
|||
if (updates >= MaxUpdates) |
|||
{ |
|||
await FlushAsync(); |
|||
} |
|||
else |
|||
{ |
|||
timer?.Dispose(); |
|||
|
|||
try |
|||
{ |
|||
timer = RegisterTimer(_ => FlushAsync(), null, CommitDelay, CommitDelay); |
|||
} |
|||
catch (InvalidOperationException) |
|||
{ |
|||
return; |
|||
} |
|||
} |
|||
} |
|||
|
|||
public async Task FlushAsync() |
|||
{ |
|||
if (updates > 0 && indexWriter != null) |
|||
{ |
|||
indexWriter.Commit(); |
|||
indexWriter.Flush(true, true); |
|||
|
|||
indexReader?.Dispose(); |
|||
indexReader = indexWriter.GetReader(false); |
|||
indexSearcher = new IndexSearcher(indexReader); |
|||
|
|||
var commit = snapshotter.Snapshot(); |
|||
try |
|||
{ |
|||
await assetStore.UploadDirectoryAsync(directory, commit); |
|||
} |
|||
finally |
|||
{ |
|||
snapshotter.Release(commit); |
|||
} |
|||
|
|||
updates = 0; |
|||
} |
|||
else |
|||
{ |
|||
timer?.Dispose(); |
|||
} |
|||
} |
|||
|
|||
public async Task DeactivateAsync(bool deleteFolder = false) |
|||
{ |
|||
await TryFlushAsync(); |
|||
|
|||
indexWriter?.Dispose(); |
|||
indexWriter = null; |
|||
|
|||
indexReader?.Dispose(); |
|||
indexReader = null; |
|||
|
|||
if (deleteFolder && directory.Exists) |
|||
{ |
|||
directory.Delete(true); |
|||
} |
|||
} |
|||
|
|||
private static string BuildFieldName(string language) |
|||
{ |
|||
return language; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,32 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.Azure.Documents.Client; |
|||
using Microsoft.Extensions.Diagnostics.HealthChecks; |
|||
|
|||
namespace Squidex.Infrastructure.Diagnostics |
|||
{ |
|||
public sealed class CosmosDbHealthCheck : IHealthCheck |
|||
{ |
|||
private readonly DocumentClient documentClient; |
|||
|
|||
public CosmosDbHealthCheck(Uri uri, string masterKey) |
|||
{ |
|||
documentClient = new DocumentClient(uri, masterKey); |
|||
} |
|||
|
|||
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) |
|||
{ |
|||
await documentClient.ReadDatabaseFeedAsync(); |
|||
|
|||
return HealthCheckResult.Healthy("Application must query data from CosmosDB."); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,16 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Infrastructure.EventSourcing |
|||
{ |
|||
internal static class Constants |
|||
{ |
|||
public const string Collection = "Events"; |
|||
|
|||
public const string LeaseCollection = "Leases"; |
|||
} |
|||
} |
|||
@ -0,0 +1,33 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Newtonsoft.Json; |
|||
|
|||
namespace Squidex.Infrastructure.EventSourcing |
|||
{ |
|||
internal sealed class CosmosDbEvent |
|||
{ |
|||
[JsonProperty("type")] |
|||
public string Type { get; set; } |
|||
|
|||
[JsonProperty("payload")] |
|||
public string Payload { get; set; } |
|||
|
|||
[JsonProperty("header")] |
|||
public EnvelopeHeaders Headers { get; set; } |
|||
|
|||
public static CosmosDbEvent FromEventData(EventData data) |
|||
{ |
|||
return new CosmosDbEvent { Type = data.Type, Headers = data.Headers, Payload = data.Payload }; |
|||
} |
|||
|
|||
public EventData ToEventData() |
|||
{ |
|||
return new EventData(Type, Headers, Payload); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,33 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using Newtonsoft.Json; |
|||
|
|||
namespace Squidex.Infrastructure.EventSourcing |
|||
{ |
|||
internal sealed class CosmosDbEventCommit |
|||
{ |
|||
[JsonProperty("id")] |
|||
public Guid Id { get; set; } |
|||
|
|||
[JsonProperty("events")] |
|||
public CosmosDbEvent[] Events { get; set; } |
|||
|
|||
[JsonProperty("eventStreamOffset")] |
|||
public long EventStreamOffset { get; set; } |
|||
|
|||
[JsonProperty("eventsCount")] |
|||
public long EventsCount { get; set; } |
|||
|
|||
[JsonProperty("eventStream")] |
|||
public string EventStream { get; set; } |
|||
|
|||
[JsonProperty("timestamp")] |
|||
public long Timestamp { get; set; } |
|||
} |
|||
} |
|||
@ -0,0 +1,124 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.ObjectModel; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.Azure.Documents; |
|||
using Microsoft.Azure.Documents.Client; |
|||
using Newtonsoft.Json; |
|||
|
|||
namespace Squidex.Infrastructure.EventSourcing |
|||
{ |
|||
public sealed partial class CosmosDbEventStore : DisposableObjectBase, IEventStore, IInitializable |
|||
{ |
|||
private readonly DocumentClient documentClient; |
|||
private readonly Uri collectionUri; |
|||
private readonly Uri databaseUri; |
|||
private readonly string masterKey; |
|||
private readonly string databaseId; |
|||
private readonly JsonSerializerSettings serializerSettings; |
|||
|
|||
public JsonSerializerSettings SerializerSettings |
|||
{ |
|||
get { return serializerSettings; } |
|||
} |
|||
|
|||
public string DatabaseId |
|||
{ |
|||
get { return databaseId; } |
|||
} |
|||
|
|||
public string MasterKey |
|||
{ |
|||
get { return masterKey; } |
|||
} |
|||
|
|||
public Uri ServiceUri |
|||
{ |
|||
get { return documentClient.ServiceEndpoint; } |
|||
} |
|||
|
|||
public CosmosDbEventStore(DocumentClient documentClient, string masterKey, string database, JsonSerializerSettings serializerSettings) |
|||
{ |
|||
Guard.NotNull(documentClient, nameof(documentClient)); |
|||
Guard.NotNull(serializerSettings, nameof(serializerSettings)); |
|||
Guard.NotNullOrEmpty(masterKey, nameof(masterKey)); |
|||
Guard.NotNullOrEmpty(database, nameof(database)); |
|||
|
|||
this.documentClient = documentClient; |
|||
|
|||
databaseUri = UriFactory.CreateDatabaseUri(database); |
|||
databaseId = database; |
|||
|
|||
collectionUri = UriFactory.CreateDocumentCollectionUri(database, Constants.Collection); |
|||
|
|||
this.masterKey = masterKey; |
|||
|
|||
this.serializerSettings = serializerSettings; |
|||
} |
|||
|
|||
protected override void DisposeObject(bool disposing) |
|||
{ |
|||
if (disposing) |
|||
{ |
|||
documentClient.Dispose(); |
|||
} |
|||
} |
|||
|
|||
public async Task InitializeAsync(CancellationToken ct = default) |
|||
{ |
|||
await documentClient.CreateDatabaseIfNotExistsAsync(new Database { Id = databaseId }); |
|||
|
|||
await documentClient.CreateDocumentCollectionIfNotExistsAsync(databaseUri, |
|||
new DocumentCollection |
|||
{ |
|||
Id = Constants.LeaseCollection, |
|||
}); |
|||
|
|||
await documentClient.CreateDocumentCollectionIfNotExistsAsync(databaseUri, |
|||
new DocumentCollection |
|||
{ |
|||
IndexingPolicy = new IndexingPolicy |
|||
{ |
|||
IncludedPaths = new Collection<IncludedPath> |
|||
{ |
|||
new IncludedPath |
|||
{ |
|||
Path = "/*", |
|||
Indexes = new Collection<Index> |
|||
{ |
|||
Index.Range(DataType.Number), |
|||
Index.Range(DataType.String), |
|||
} |
|||
} |
|||
} |
|||
}, |
|||
UniqueKeyPolicy = new UniqueKeyPolicy |
|||
{ |
|||
UniqueKeys = new Collection<UniqueKey> |
|||
{ |
|||
new UniqueKey |
|||
{ |
|||
Paths = new Collection<string> |
|||
{ |
|||
$"/eventStream", |
|||
$"/eventStreamOffset" |
|||
} |
|||
} |
|||
} |
|||
}, |
|||
Id = Constants.Collection, |
|||
}, |
|||
new RequestOptions |
|||
{ |
|||
PartitionKey = new PartitionKey($"/eventStream") |
|||
}); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,142 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.Azure.Documents; |
|||
using Squidex.Infrastructure.Log; |
|||
using Squidex.Infrastructure.Tasks; |
|||
|
|||
namespace Squidex.Infrastructure.EventSourcing |
|||
{ |
|||
public delegate bool EventPredicate(EventData data); |
|||
|
|||
public partial class CosmosDbEventStore : IEventStore, IInitializable |
|||
{ |
|||
public IEventSubscription CreateSubscription(IEventSubscriber subscriber, string streamFilter = null, string position = null) |
|||
{ |
|||
Guard.NotNull(subscriber, nameof(subscriber)); |
|||
|
|||
ThrowIfDisposed(); |
|||
|
|||
return new CosmosDbSubscription(this, subscriber, streamFilter, position); |
|||
} |
|||
|
|||
public Task CreateIndexAsync(string property) |
|||
{ |
|||
Guard.NotNullOrEmpty(property, nameof(property)); |
|||
|
|||
ThrowIfDisposed(); |
|||
|
|||
return Task.CompletedTask; |
|||
} |
|||
|
|||
public async Task<IReadOnlyList<StoredEvent>> QueryAsync(string streamName, long streamPosition = 0) |
|||
{ |
|||
Guard.NotNullOrEmpty(streamName, nameof(streamName)); |
|||
|
|||
ThrowIfDisposed(); |
|||
|
|||
using (Profiler.TraceMethod<CosmosDbEventStore>()) |
|||
{ |
|||
var query = FilterBuilder.ByStreamName(streamName, streamPosition - MaxCommitSize); |
|||
|
|||
var result = new List<StoredEvent>(); |
|||
|
|||
await documentClient.QueryAsync(collectionUri, query, commit => |
|||
{ |
|||
var eventStreamOffset = (int)commit.EventStreamOffset; |
|||
|
|||
var commitTimestamp = commit.Timestamp; |
|||
var commitOffset = 0; |
|||
|
|||
foreach (var @event in commit.Events) |
|||
{ |
|||
eventStreamOffset++; |
|||
|
|||
if (eventStreamOffset >= streamPosition) |
|||
{ |
|||
var eventData = @event.ToEventData(); |
|||
var eventToken = new StreamPosition(commitTimestamp, commitOffset, commit.Events.Length); |
|||
|
|||
result.Add(new StoredEvent(streamName, eventToken, eventStreamOffset, eventData)); |
|||
} |
|||
} |
|||
|
|||
return TaskHelper.Done; |
|||
}); |
|||
|
|||
return result; |
|||
} |
|||
} |
|||
|
|||
public Task QueryAsync(Func<StoredEvent, Task> callback, string property, object value, string position = null, CancellationToken ct = default) |
|||
{ |
|||
Guard.NotNull(callback, nameof(callback)); |
|||
Guard.NotNullOrEmpty(property, nameof(property)); |
|||
Guard.NotNull(value, nameof(value)); |
|||
|
|||
ThrowIfDisposed(); |
|||
|
|||
StreamPosition lastPosition = position; |
|||
|
|||
var filterDefinition = FilterBuilder.CreateByProperty(property, value, lastPosition); |
|||
var filterExpression = FilterBuilder.CreateExpression(property, value); |
|||
|
|||
return QueryAsync(callback, lastPosition, filterDefinition, filterExpression, ct); |
|||
} |
|||
|
|||
public Task QueryAsync(Func<StoredEvent, Task> callback, string streamFilter = null, string position = null, CancellationToken ct = default) |
|||
{ |
|||
Guard.NotNull(callback, nameof(callback)); |
|||
|
|||
ThrowIfDisposed(); |
|||
|
|||
StreamPosition lastPosition = position; |
|||
|
|||
var filterDefinition = FilterBuilder.CreateByFilter(streamFilter, lastPosition); |
|||
var filterExpression = FilterBuilder.CreateExpression(null, null); |
|||
|
|||
return QueryAsync(callback, lastPosition, filterDefinition, filterExpression, ct); |
|||
} |
|||
|
|||
private async Task QueryAsync(Func<StoredEvent, Task> callback, StreamPosition lastPosition, SqlQuerySpec query, EventPredicate filterExpression, CancellationToken ct = default) |
|||
{ |
|||
using (Profiler.TraceMethod<CosmosDbEventStore>()) |
|||
{ |
|||
await documentClient.QueryAsync(collectionUri, query, async commit => |
|||
{ |
|||
var eventStreamOffset = (int)commit.EventStreamOffset; |
|||
|
|||
var commitTimestamp = commit.Timestamp; |
|||
var commitOffset = 0; |
|||
|
|||
foreach (var @event in commit.Events) |
|||
{ |
|||
eventStreamOffset++; |
|||
|
|||
if (commitOffset > lastPosition.CommitOffset || commitTimestamp > lastPosition.Timestamp) |
|||
{ |
|||
var eventData = @event.ToEventData(); |
|||
|
|||
if (filterExpression(eventData)) |
|||
{ |
|||
var eventToken = new StreamPosition(commitTimestamp, commitOffset, commit.Events.Length); |
|||
|
|||
await callback(new StoredEvent(commit.EventStream, eventToken, eventStreamOffset, eventData)); |
|||
} |
|||
} |
|||
|
|||
commitOffset++; |
|||
} |
|||
}, ct); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,149 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Net; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.Azure.Documents; |
|||
using Microsoft.Azure.Documents.Client; |
|||
using NodaTime; |
|||
using Squidex.Infrastructure.Log; |
|||
|
|||
namespace Squidex.Infrastructure.EventSourcing |
|||
{ |
|||
public partial class CosmosDbEventStore |
|||
{ |
|||
private const int MaxWriteAttempts = 20; |
|||
private const int MaxCommitSize = 10; |
|||
|
|||
public Task DeleteStreamAsync(string streamName) |
|||
{ |
|||
Guard.NotNullOrEmpty(streamName, nameof(streamName)); |
|||
|
|||
ThrowIfDisposed(); |
|||
|
|||
var query = FilterBuilder.AllIds(streamName); |
|||
|
|||
return documentClient.QueryAsync(collectionUri, query, commit => |
|||
{ |
|||
var documentUri = UriFactory.CreateDocumentUri(databaseId, Constants.Collection, commit.Id.ToString()); |
|||
|
|||
return documentClient.DeleteDocumentAsync(documentUri); |
|||
}); |
|||
} |
|||
|
|||
public Task AppendAsync(Guid commitId, string streamName, ICollection<EventData> events) |
|||
{ |
|||
return AppendAsync(commitId, streamName, EtagVersion.Any, events); |
|||
} |
|||
|
|||
public async Task AppendAsync(Guid commitId, string streamName, long expectedVersion, ICollection<EventData> events) |
|||
{ |
|||
Guard.NotEmpty(commitId, nameof(commitId)); |
|||
Guard.NotNullOrEmpty(streamName, nameof(streamName)); |
|||
Guard.NotNull(events, nameof(events)); |
|||
Guard.LessThan(events.Count, MaxCommitSize, "events.Count"); |
|||
|
|||
ThrowIfDisposed(); |
|||
|
|||
using (Profiler.TraceMethod<CosmosDbEventStore>()) |
|||
{ |
|||
if (events.Count == 0) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
var currentVersion = await GetEventStreamOffsetAsync(streamName); |
|||
|
|||
if (expectedVersion != EtagVersion.Any && expectedVersion != currentVersion) |
|||
{ |
|||
throw new WrongEventVersionException(currentVersion, expectedVersion); |
|||
} |
|||
|
|||
var commit = BuildCommit(commitId, streamName, expectedVersion >= -1 ? expectedVersion : currentVersion, events); |
|||
|
|||
for (var attempt = 0; attempt < MaxWriteAttempts; attempt++) |
|||
{ |
|||
try |
|||
{ |
|||
await documentClient.CreateDocumentAsync(collectionUri, commit); |
|||
|
|||
return; |
|||
} |
|||
catch (DocumentClientException ex) |
|||
{ |
|||
if (ex.StatusCode == HttpStatusCode.Conflict) |
|||
{ |
|||
currentVersion = await GetEventStreamOffsetAsync(streamName); |
|||
|
|||
if (expectedVersion != EtagVersion.Any) |
|||
{ |
|||
throw new WrongEventVersionException(currentVersion, expectedVersion); |
|||
} |
|||
|
|||
if (attempt < MaxWriteAttempts) |
|||
{ |
|||
expectedVersion = currentVersion; |
|||
} |
|||
else |
|||
{ |
|||
throw new TimeoutException("Could not acquire a free slot for the commit within the provided time."); |
|||
} |
|||
} |
|||
else |
|||
{ |
|||
throw; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
private async Task<long> GetEventStreamOffsetAsync(string streamName) |
|||
{ |
|||
var query = |
|||
documentClient.CreateDocumentQuery<CosmosDbEventCommit>(collectionUri, |
|||
FilterBuilder.LastPosition(streamName)); |
|||
|
|||
var document = await query.FirstOrDefaultAsync(); |
|||
|
|||
if (document != null) |
|||
{ |
|||
return document.EventStreamOffset + document.EventsCount; |
|||
} |
|||
|
|||
return EtagVersion.Empty; |
|||
} |
|||
|
|||
private static CosmosDbEventCommit BuildCommit(Guid commitId, string streamName, long expectedVersion, ICollection<EventData> events) |
|||
{ |
|||
var commitEvents = new CosmosDbEvent[events.Count]; |
|||
|
|||
var i = 0; |
|||
|
|||
foreach (var e in events) |
|||
{ |
|||
var mongoEvent = CosmosDbEvent.FromEventData(e); |
|||
|
|||
commitEvents[i++] = mongoEvent; |
|||
} |
|||
|
|||
var mongoCommit = new CosmosDbEventCommit |
|||
{ |
|||
Id = commitId, |
|||
Events = commitEvents, |
|||
EventsCount = events.Count, |
|||
EventStream = streamName, |
|||
EventStreamOffset = expectedVersion, |
|||
Timestamp = SystemClock.Instance.GetCurrentInstant().ToUnixTimeTicks() |
|||
}; |
|||
|
|||
return mongoCommit; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,150 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Text.RegularExpressions; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.Azure.Documents; |
|||
using Microsoft.Azure.Documents.ChangeFeedProcessor.FeedProcessing; |
|||
using Newtonsoft.Json; |
|||
using Builder = Microsoft.Azure.Documents.ChangeFeedProcessor.ChangeFeedProcessorBuilder; |
|||
using Collection = Microsoft.Azure.Documents.ChangeFeedProcessor.DocumentCollectionInfo; |
|||
using Options = Microsoft.Azure.Documents.ChangeFeedProcessor.ChangeFeedProcessorOptions; |
|||
|
|||
#pragma warning disable IDE0017 // Simplify object initialization
|
|||
|
|||
namespace Squidex.Infrastructure.EventSourcing |
|||
{ |
|||
internal sealed class CosmosDbSubscription : IEventSubscription, IChangeFeedObserverFactory, IChangeFeedObserver |
|||
{ |
|||
private readonly TaskCompletionSource<bool> processorStopRequested = new TaskCompletionSource<bool>(); |
|||
private readonly Task processorTask; |
|||
private readonly CosmosDbEventStore store; |
|||
private readonly Regex regex; |
|||
private readonly string hostName; |
|||
private readonly IEventSubscriber subscriber; |
|||
|
|||
public CosmosDbSubscription(CosmosDbEventStore store, IEventSubscriber subscriber, string streamFilter, string position = null) |
|||
{ |
|||
this.store = store; |
|||
|
|||
var fromBeginning = string.IsNullOrWhiteSpace(position); |
|||
|
|||
if (fromBeginning) |
|||
{ |
|||
hostName = $"squidex.{DateTime.UtcNow.Ticks.ToString()}"; |
|||
} |
|||
else |
|||
{ |
|||
hostName = position; |
|||
} |
|||
|
|||
if (!StreamFilter.IsAll(streamFilter)) |
|||
{ |
|||
regex = new Regex(streamFilter); |
|||
} |
|||
|
|||
this.subscriber = subscriber; |
|||
|
|||
processorTask = Task.Run(async () => |
|||
{ |
|||
try |
|||
{ |
|||
Collection CreateCollection(string name) |
|||
{ |
|||
var collection = new Collection(); |
|||
|
|||
collection.CollectionName = name; |
|||
collection.DatabaseName = store.DatabaseId; |
|||
collection.MasterKey = store.MasterKey; |
|||
collection.Uri = store.ServiceUri; |
|||
|
|||
return collection; |
|||
} |
|||
|
|||
var processor = |
|||
await new Builder() |
|||
.WithFeedCollection(CreateCollection(Constants.Collection)) |
|||
.WithLeaseCollection(CreateCollection(Constants.LeaseCollection)) |
|||
.WithHostName(hostName) |
|||
.WithProcessorOptions(new Options { StartFromBeginning = fromBeginning, LeasePrefix = hostName }) |
|||
.WithObserverFactory(this) |
|||
.BuildAsync(); |
|||
|
|||
await processor.StartAsync(); |
|||
await processorStopRequested.Task; |
|||
await processor.StopAsync(); |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
await subscriber.OnErrorAsync(this, ex); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public IChangeFeedObserver CreateObserver() |
|||
{ |
|||
return this; |
|||
} |
|||
|
|||
public async Task CloseAsync(IChangeFeedObserverContext context, ChangeFeedObserverCloseReason reason) |
|||
{ |
|||
if (reason == ChangeFeedObserverCloseReason.ObserverError) |
|||
{ |
|||
await subscriber.OnErrorAsync(this, new InvalidOperationException("Change feed observer failed.")); |
|||
} |
|||
} |
|||
|
|||
public Task OpenAsync(IChangeFeedObserverContext context) |
|||
{ |
|||
return Task.CompletedTask; |
|||
} |
|||
|
|||
public async Task ProcessChangesAsync(IChangeFeedObserverContext context, IReadOnlyList<Document> docs, CancellationToken cancellationToken) |
|||
{ |
|||
if (!processorStopRequested.Task.IsCompleted) |
|||
{ |
|||
foreach (var document in docs) |
|||
{ |
|||
if (!processorStopRequested.Task.IsCompleted) |
|||
{ |
|||
var streamName = document.GetPropertyValue<string>("eventStream"); |
|||
|
|||
if (regex == null || regex.IsMatch(streamName)) |
|||
{ |
|||
var commit = JsonConvert.DeserializeObject<CosmosDbEventCommit>(document.ToString(), store.SerializerSettings); |
|||
|
|||
var eventStreamOffset = (int)commit.EventStreamOffset; |
|||
|
|||
foreach (var @event in commit.Events) |
|||
{ |
|||
eventStreamOffset++; |
|||
|
|||
var eventData = @event.ToEventData(); |
|||
|
|||
await subscriber.OnEventAsync(this, new StoredEvent(commit.EventStream, hostName, eventStreamOffset, eventData)); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
public void WakeUp() |
|||
{ |
|||
} |
|||
|
|||
public Task StopAsync() |
|||
{ |
|||
processorStopRequested.SetResult(true); |
|||
|
|||
return processorTask; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,156 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Collections.Generic; |
|||
using Microsoft.Azure.Documents; |
|||
using Squidex.Infrastructure.Json.Objects; |
|||
|
|||
namespace Squidex.Infrastructure.EventSourcing |
|||
{ |
|||
internal static class FilterBuilder |
|||
{ |
|||
public static SqlQuerySpec AllIds(string streamName) |
|||
{ |
|||
var query = |
|||
$"SELECT TOP 1 " + |
|||
$" e.id," + |
|||
$" e.eventsCount " + |
|||
$"FROM {Constants.Collection} e " + |
|||
$"WHERE " + |
|||
$" e.eventStream = @name " + |
|||
$"ORDER BY e.eventStreamOffset DESC"; |
|||
|
|||
var parameters = new SqlParameterCollection |
|||
{ |
|||
new SqlParameter("@name", streamName) |
|||
}; |
|||
|
|||
return new SqlQuerySpec(query, parameters); |
|||
} |
|||
|
|||
public static SqlQuerySpec LastPosition(string streamName) |
|||
{ |
|||
var query = |
|||
$"SELECT TOP 1 " + |
|||
$" e.eventStreamOffset," + |
|||
$" e.eventsCount " + |
|||
$"FROM {Constants.Collection} e " + |
|||
$"WHERE " + |
|||
$" e.eventStream = @name " + |
|||
$"ORDER BY e.eventStreamOffset DESC"; |
|||
|
|||
var parameters = new SqlParameterCollection |
|||
{ |
|||
new SqlParameter("@name", streamName) |
|||
}; |
|||
|
|||
return new SqlQuerySpec(query, parameters); |
|||
} |
|||
|
|||
public static SqlQuerySpec ByStreamName(string streamName, long streamPosition = 0) |
|||
{ |
|||
var query = |
|||
$"SELECT * " + |
|||
$"FROM {Constants.Collection} e " + |
|||
$"WHERE " + |
|||
$" e.eventStream = @name " + |
|||
$"AND e.eventStreamOffset >= @position " + |
|||
$"ORDER BY e.eventStreamOffset ASC"; |
|||
|
|||
var parameters = new SqlParameterCollection |
|||
{ |
|||
new SqlParameter("@name", streamName), |
|||
new SqlParameter("@position", streamPosition) |
|||
}; |
|||
|
|||
return new SqlQuerySpec(query, parameters); |
|||
} |
|||
|
|||
public static SqlQuerySpec CreateByProperty(string property, object value, StreamPosition streamPosition) |
|||
{ |
|||
var filters = new List<string>(); |
|||
|
|||
var parameters = new SqlParameterCollection(); |
|||
|
|||
filters.ForPosition(parameters, streamPosition); |
|||
filters.ForProperty(parameters, property, value); |
|||
|
|||
return BuildQuery(filters, parameters); |
|||
} |
|||
|
|||
public static SqlQuerySpec CreateByFilter(string streamFilter, StreamPosition streamPosition) |
|||
{ |
|||
var filters = new List<string>(); |
|||
|
|||
var parameters = new SqlParameterCollection(); |
|||
|
|||
filters.ForPosition(parameters, streamPosition); |
|||
filters.ForRegex(parameters, streamFilter); |
|||
|
|||
return BuildQuery(filters, parameters); |
|||
} |
|||
|
|||
private static SqlQuerySpec BuildQuery(List<string> filters, SqlParameterCollection parameters) |
|||
{ |
|||
var query = $"SELECT * FROM {Constants.Collection} e WHERE {string.Join(" AND ", filters)} ORDER BY e.timestamp"; |
|||
|
|||
return new SqlQuerySpec(query, parameters); |
|||
} |
|||
|
|||
private static void ForProperty(this List<string> filters, SqlParameterCollection parameters, string property, object value) |
|||
{ |
|||
filters.Add($"ARRAY_CONTAINS(e.events, {{ \"header\": {{ \"{property}\": @value }} }}, true)"); |
|||
|
|||
parameters.Add(new SqlParameter("@value", value)); |
|||
} |
|||
|
|||
private static void ForRegex(this List<string> filters, SqlParameterCollection parameters, string streamFilter) |
|||
{ |
|||
if (!StreamFilter.IsAll(streamFilter)) |
|||
{ |
|||
if (streamFilter.Contains("^")) |
|||
{ |
|||
filters.Add($"STARTSWITH(e.eventStream, @filter)"); |
|||
} |
|||
else |
|||
{ |
|||
filters.Add($"e.eventStream = @filter"); |
|||
} |
|||
|
|||
parameters.Add(new SqlParameter("@filter", streamFilter)); |
|||
} |
|||
} |
|||
|
|||
private static void ForPosition(this List<string> filters, SqlParameterCollection parameters, StreamPosition streamPosition) |
|||
{ |
|||
if (streamPosition.IsEndOfCommit) |
|||
{ |
|||
filters.Add($"e.timestamp > @time"); |
|||
} |
|||
else |
|||
{ |
|||
filters.Add($"e.timestamp >= @time"); |
|||
} |
|||
|
|||
parameters.Add(new SqlParameter("@time", streamPosition.Timestamp)); |
|||
} |
|||
|
|||
public static EventPredicate CreateExpression(string property, object value) |
|||
{ |
|||
if (!string.IsNullOrWhiteSpace(property)) |
|||
{ |
|||
var jsonValue = JsonValue.Create(value); |
|||
|
|||
return x => x.Headers.TryGetValue(property, out var p) && p.Equals(jsonValue); |
|||
} |
|||
else |
|||
{ |
|||
return x => true; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,62 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Linq; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.Azure.Documents; |
|||
using Microsoft.Azure.Documents.Client; |
|||
using Microsoft.Azure.Documents.Linq; |
|||
|
|||
namespace Squidex.Infrastructure.EventSourcing |
|||
{ |
|||
internal static class FilterExtensions |
|||
{ |
|||
public static async Task<T> FirstOrDefaultAsync<T>(this IQueryable<T> queryable, CancellationToken ct = default) |
|||
{ |
|||
var documentQuery = queryable.AsDocumentQuery(); |
|||
|
|||
using (documentQuery) |
|||
{ |
|||
if (documentQuery.HasMoreResults) |
|||
{ |
|||
var results = await documentQuery.ExecuteNextAsync<T>(ct); |
|||
|
|||
return results.FirstOrDefault(); |
|||
} |
|||
} |
|||
|
|||
return default; |
|||
} |
|||
|
|||
public static Task QueryAsync(this DocumentClient documentClient, Uri collectionUri, SqlQuerySpec querySpec, Func<CosmosDbEventCommit, Task> handler, CancellationToken ct = default) |
|||
{ |
|||
var query = documentClient.CreateDocumentQuery<CosmosDbEventCommit>(collectionUri, querySpec); |
|||
|
|||
return query.QueryAsync(handler, ct); |
|||
} |
|||
|
|||
public static async Task QueryAsync<T>(this IQueryable<T> queryable, Func<T, Task> handler, CancellationToken ct = default) |
|||
{ |
|||
var documentQuery = queryable.AsDocumentQuery(); |
|||
|
|||
using (documentQuery) |
|||
{ |
|||
while (documentQuery.HasMoreResults && !ct.IsCancellationRequested) |
|||
{ |
|||
var items = await documentQuery.ExecuteNextAsync<T>(ct); |
|||
|
|||
foreach (var item in items) |
|||
{ |
|||
await handler(item); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue