Browse Source

Merge branch 'master' of github.com:Squidex/squidex

pull/531/head 4.4.0-rc
Sebastian 6 years ago
parent
commit
cc3e739d7c
  1. 7
      backend/extensions/Squidex.Extensions/Actions/Algolia/AlgoliaActionHandler.cs
  2. 4
      backend/extensions/Squidex.Extensions/Actions/AzureQueue/AzureQueueActionHandler.cs
  3. 4
      backend/extensions/Squidex.Extensions/Actions/Comment/CommentActionHandler.cs
  4. 6
      backend/extensions/Squidex.Extensions/Actions/Discourse/DiscourseActionHandler.cs
  5. 4
      backend/extensions/Squidex.Extensions/Actions/ElasticSearch/ElasticSearchActionHandler.cs
  6. 12
      backend/extensions/Squidex.Extensions/Actions/Email/EmailActionHandler.cs
  7. 19
      backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaAction.cs
  8. 80
      backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaActionHandler.cs
  9. 108
      backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaProducer.cs
  10. 11
      backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaProducerOptions.cs
  11. 14
      backend/extensions/Squidex.Extensions/Actions/Medium/MediumActionHandler.cs
  12. 4
      backend/extensions/Squidex.Extensions/Actions/Notification/NotificationActionHandler.cs
  13. 4
      backend/extensions/Squidex.Extensions/Actions/Prerender/PrerenderActionHandler.cs
  14. 4
      backend/extensions/Squidex.Extensions/Actions/Slack/SlackActionHandler.cs
  15. 4
      backend/extensions/Squidex.Extensions/Actions/Twitter/TweetActionHandler.cs
  16. 8
      backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookActionHandler.cs
  17. 2
      backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj
  18. 12
      backend/src/Migrations/Migrations/PopulateGrainIndexes.cs
  19. 3
      backend/src/Squidex.Domain.Apps.Core.Model/Contents/IdContentData.cs
  20. 3
      backend/src/Squidex.Domain.Apps.Core.Model/Contents/NamedContentData.cs
  21. 25
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleEventFormatter.cs
  22. 197
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/PredefinedPatternsFormatter.cs
  23. 8
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionHandler.cs
  24. 418
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs
  25. 101
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleVariable.cs
  26. 32
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptContext.cs
  27. 11
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs
  28. 72
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryContentsByQuery.cs
  29. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageGate.cs
  30. 44
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs
  31. 18
      backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs
  32. 2
      backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTrackerGrain.cs
  33. 4
      backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj
  34. 4
      backend/src/Squidex.Infrastructure.Azure/Assets/AzureBlobAssetStore.cs
  35. 2
      backend/src/Squidex.Infrastructure.Azure/Squidex.Infrastructure.Azure.csproj
  36. 2
      backend/src/Squidex.Infrastructure.GetEventStore/Squidex.Infrastructure.GetEventStore.csproj
  37. 25
      backend/src/Squidex.Infrastructure.MongoDb/MongoDb/Queries/LimitExtensions.cs
  38. 4
      backend/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs
  39. 2
      backend/src/Squidex.Infrastructure/Json/Objects/JsonObject.cs
  40. 8
      backend/src/Squidex.Infrastructure/Net/IPAddressComparer.cs
  41. 2
      backend/src/Squidex.Infrastructure/Orleans/Indexes/UniqueNameIndexGrain.cs
  42. 7
      backend/src/Squidex.Infrastructure/Queries/CompareFilter.cs
  43. 4
      backend/src/Squidex.Infrastructure/Queries/FilterNode.cs
  44. 8
      backend/src/Squidex.Infrastructure/Queries/LogicalFilter.cs
  45. 7
      backend/src/Squidex.Infrastructure/Queries/NegateFilter.cs
  46. 17
      backend/src/Squidex.Infrastructure/Queries/Query.cs
  47. 3
      backend/src/Squidex.Infrastructure/Reflection/Equality/DeepEqualityComparer.cs
  48. 3
      backend/src/Squidex.Infrastructure/Security/Permission.cs
  49. 6
      backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj
  50. 11
      backend/src/Squidex.Infrastructure/UsageTracking/ApiUsageTracker.cs
  51. 4
      backend/src/Squidex.Infrastructure/UsageTracking/IApiUsageTracker.cs
  52. 5
      backend/src/Squidex/Config/Domain/RuleServices.cs
  53. 9
      backend/src/Squidex/Squidex.csproj
  54. 271
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterTests.cs
  55. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Plans/UsageGateTests.cs
  56. 106
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs
  57. 10
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryFixture.cs
  58. 50
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryTests.cs
  59. 6
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs
  60. 1
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj
  61. 18
      backend/tests/Squidex.Infrastructure.Tests/Orleans/JsonExternalSerializerTests.cs
  62. 64
      backend/tests/Squidex.Infrastructure.Tests/Queries/QueryTests.cs
  63. 20
      backend/tests/Squidex.Infrastructure.Tests/UsageTracking/ApiUsageTrackerTests.cs

7
backend/extensions/Squidex.Extensions/Actions/Algolia/AlgoliaActionHandler.cs

@ -33,7 +33,7 @@ namespace Squidex.Extensions.Actions.Algolia
}); });
} }
protected override (string Description, AlgoliaJob Data) CreateJob(EnrichedEvent @event, AlgoliaAction action) protected override async Task<(string Description, AlgoliaJob Data)> CreateJobAsync(EnrichedEvent @event, AlgoliaAction action)
{ {
if (@event is EnrichedContentEvent contentEvent) if (@event is EnrichedContentEvent contentEvent)
{ {
@ -45,7 +45,7 @@ namespace Squidex.Extensions.Actions.Algolia
AppId = action.AppId, AppId = action.AppId,
ApiKey = action.ApiKey, ApiKey = action.ApiKey,
ContentId = contentId, ContentId = contentId,
IndexName = Format(action.IndexName, @event) IndexName = await FormatAsync(action.IndexName, @event)
}; };
if (contentEvent.Type == EnrichedContentEventType.Deleted || if (contentEvent.Type == EnrichedContentEventType.Deleted ||
@ -64,7 +64,8 @@ namespace Squidex.Extensions.Actions.Algolia
if (!string.IsNullOrEmpty(action.Document)) if (!string.IsNullOrEmpty(action.Document))
{ {
jsonString = Format(action.Document, @event)?.Trim(); jsonString = await FormatAsync(action.Document, @event);
jsonString = jsonString?.Trim();
} }
else else
{ {

4
backend/extensions/Squidex.Extensions/Actions/AzureQueue/AzureQueueActionHandler.cs

@ -32,9 +32,9 @@ namespace Squidex.Extensions.Actions.AzureQueue
}); });
} }
protected override (string Description, AzureQueueJob Data) CreateJob(EnrichedEvent @event, AzureQueueAction action) protected override async Task<(string Description, AzureQueueJob Data)> CreateJobAsync(EnrichedEvent @event, AzureQueueAction action)
{ {
var queueName = Format(action.Queue, @event); var queueName = await FormatAsync(action.Queue, @event);
var ruleDescription = $"Send AzureQueueJob to azure queue '{queueName}'"; var ruleDescription = $"Send AzureQueueJob to azure queue '{queueName}'";
var ruleJob = new AzureQueueJob var ruleJob = new AzureQueueJob

4
backend/extensions/Squidex.Extensions/Actions/Comment/CommentActionHandler.cs

@ -30,11 +30,11 @@ namespace Squidex.Extensions.Actions.Comment
this.commandBus = commandBus; this.commandBus = commandBus;
} }
protected override (string Description, CommentJob Data) CreateJob(EnrichedEvent @event, CommentAction action) protected override async Task<(string Description, CommentJob Data)> CreateJobAsync(EnrichedEvent @event, CommentAction action)
{ {
if (@event is EnrichedContentEvent contentEvent) if (@event is EnrichedContentEvent contentEvent)
{ {
var text = Format(action.Text, @event); var text = await FormatAsync(action.Text, @event);
var actor = contentEvent.Actor; var actor = contentEvent.Actor;

6
backend/extensions/Squidex.Extensions/Actions/Discourse/DiscourseActionHandler.cs

@ -28,13 +28,13 @@ namespace Squidex.Extensions.Actions.Discourse
this.httpClientFactory = httpClientFactory; this.httpClientFactory = httpClientFactory;
} }
protected override (string Description, DiscourseJob Data) CreateJob(EnrichedEvent @event, DiscourseAction action) protected override async Task<(string Description, DiscourseJob Data)> CreateJobAsync(EnrichedEvent @event, DiscourseAction action)
{ {
var url = $"{action.Url.ToString().TrimEnd('/')}/posts.json?api_key={action.ApiKey}&api_username={action.ApiUsername}"; var url = $"{action.Url.ToString().TrimEnd('/')}/posts.json?api_key={action.ApiKey}&api_username={action.ApiUsername}";
var json = new Dictionary<string, object> var json = new Dictionary<string, object>
{ {
["title"] = Format(action.Title, @event) ["title"] = await FormatAsync(action.Title, @event)
}; };
if (action.Topic.HasValue) if (action.Topic.HasValue)
@ -47,7 +47,7 @@ namespace Squidex.Extensions.Actions.Discourse
json.Add("category", action.Category.Value); json.Add("category", action.Category.Value);
} }
json["raw"] = Format(action.Text, @event); json["raw"] = await FormatAsync(action.Text, @event);
var requestBody = ToJson(json); var requestBody = ToJson(json);

4
backend/extensions/Squidex.Extensions/Actions/ElasticSearch/ElasticSearchActionHandler.cs

@ -36,7 +36,7 @@ namespace Squidex.Extensions.Actions.ElasticSearch
}); });
} }
protected override (string Description, ElasticSearchJob Data) CreateJob(EnrichedEvent @event, ElasticSearchAction action) protected override async Task<(string Description, ElasticSearchJob Data)> CreateJobAsync(EnrichedEvent @event, ElasticSearchAction action)
{ {
if (@event is EnrichedContentEvent contentEvent) if (@event is EnrichedContentEvent contentEvent)
{ {
@ -46,7 +46,7 @@ namespace Squidex.Extensions.Actions.ElasticSearch
var ruleJob = new ElasticSearchJob var ruleJob = new ElasticSearchJob
{ {
IndexName = Format(action.IndexName, @event), IndexName = await FormatAsync(action.IndexName, @event),
ServerHost = action.Host.ToString(), ServerHost = action.Host.ToString(),
ServerUser = action.Username, ServerUser = action.Username,
ServerPassword = action.Password, ServerPassword = action.Password,

12
backend/extensions/Squidex.Extensions/Actions/Email/EmailActionHandler.cs

@ -23,7 +23,7 @@ namespace Squidex.Extensions.Actions.Email
{ {
} }
protected override (string Description, EmailJob Data) CreateJob(EnrichedEvent @event, EmailAction action) protected override async Task<(string Description, EmailJob Data)> CreateJobAsync(EnrichedEvent @event, EmailAction action)
{ {
var ruleJob = new EmailJob var ruleJob = new EmailJob
{ {
@ -31,11 +31,11 @@ namespace Squidex.Extensions.Actions.Email
ServerUseSsl = action.ServerUseSsl, ServerUseSsl = action.ServerUseSsl,
ServerPassword = action.ServerPassword, ServerPassword = action.ServerPassword,
ServerPort = action.ServerPort, ServerPort = action.ServerPort,
ServerUsername = Format(action.ServerUsername, @event), ServerUsername = await FormatAsync(action.ServerUsername, @event),
MessageFrom = Format(action.MessageFrom, @event), MessageFrom = await FormatAsync(action.MessageFrom, @event),
MessageTo = Format(action.MessageTo, @event), MessageTo = await FormatAsync(action.MessageTo, @event),
MessageSubject = Format(action.MessageSubject, @event), MessageSubject = await FormatAsync(action.MessageSubject, @event),
MessageBody = Format(action.MessageBody, @event) MessageBody = await FormatAsync(action.MessageBody, @event)
}; };
var description = $"Send an email to {action.MessageTo}"; var description = $"Send an email to {action.MessageTo}";

19
backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaAction.cs

@ -25,5 +25,24 @@ namespace Squidex.Extensions.Actions.Kafka
[DataType(DataType.Text)] [DataType(DataType.Text)]
[Formattable] [Formattable]
public string TopicName { get; set; } public string TopicName { get; set; }
[Display(Name = "Payload (Optional)", Description = "Leave it empty to use the full event as body.")]
[DataType(DataType.MultilineText)]
[Formattable]
public string Payload { get; set; }
[Display(Name = "Key", Description = "The message key, commonly used for partitioning.")]
[DataType(DataType.Text)]
[Formattable]
public string Key { get; set; }
[Display(Name = "Headers (Optional)", Description = "The message headers in the format '[Key]=[Value]', one entry per line.")]
[DataType(DataType.MultilineText)]
[Formattable]
public string Headers { get; set; }
[Display(Name = "Schema (Optional)", Description = "Define a specific AVRO schema in JSON format.")]
[DataType(DataType.MultilineText)]
public string Schema { get; set; }
} }
} }

80
backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaActionHandler.cs

@ -6,8 +6,11 @@
// ========================================================================== // ==========================================================================
using System; using System;
using System.Collections.Generic;
using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Confluent.Kafka;
using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
@ -24,29 +27,92 @@ namespace Squidex.Extensions.Actions.Kafka
this.kafkaProducer = kafkaProducer; this.kafkaProducer = kafkaProducer;
} }
protected override (string Description, KafkaJob Data) CreateJob(EnrichedEvent @event, KafkaAction action) protected override async Task<(string Description, KafkaJob Data)> CreateJobAsync(EnrichedEvent @event, KafkaAction action)
{ {
string value, key;
if (!string.IsNullOrEmpty(action.Payload))
{
value = await FormatAsync(action.Payload, @event);
}
else
{
value = ToEnvelopeJson(@event);
}
if (!string.IsNullOrEmpty(action.Key))
{
key = await FormatAsync(action.Key, @event);
}
else
{
key = @event.Name;
}
var ruleJob = new KafkaJob var ruleJob = new KafkaJob
{ {
TopicName = action.TopicName, TopicName = action.TopicName,
MessageKey = @event.Name, MessageKey = key,
MessageValue = ToEnvelopeJson(@event) MessageValue = value,
Headers = await ParseHeadersAsync(action.Headers, @event),
Schema = action.Schema
}; };
return (Description, ruleJob); return (Description, ruleJob);
} }
private async Task<Dictionary<string, string>> ParseHeadersAsync(string headers, EnrichedEvent @event)
{
if (string.IsNullOrWhiteSpace(headers))
{
return null;
}
var headersDictionary = new Dictionary<string, string>();
var lines = headers.Split('\n');
foreach (var line in lines)
{
var indexEqual = line.IndexOf('=');
if (indexEqual > 0 && indexEqual < line.Length - 1)
{
var key = line.Substring(0, indexEqual);
var val = line.Substring(indexEqual + 1);
val = await FormatAsync(val, @event);
headersDictionary[key] = val;
}
}
return headersDictionary;
}
protected override async Task<Result> ExecuteJobAsync(KafkaJob job, CancellationToken ct = default) protected override async Task<Result> ExecuteJobAsync(KafkaJob job, CancellationToken ct = default)
{ {
try try
{ {
await kafkaProducer.Send(job.TopicName, job.MessageKey, job.MessageValue); var message = new Message<string, string> { Key = job.MessageKey, Value = job.MessageValue };
if (job.Headers?.Count > 0)
{
message.Headers = new Headers();
foreach (var header in job.Headers)
{
message.Headers.Add(header.Key, Encoding.UTF8.GetBytes(header.Value));
}
}
await kafkaProducer.Send(job.TopicName, message, job.Schema);
return Result.Success($"Event pushed to {job.TopicName} kafka topic."); return Result.Success($"Event pushed to {job.TopicName} kafka topic.");
} }
catch (Exception ex) catch (Exception ex)
{ {
return Result.Failed(ex, "Push to Kafka failed."); return Result.Failed(ex, $"Push to Kafka failed: {ex}");
} }
} }
} }
@ -58,5 +124,9 @@ namespace Squidex.Extensions.Actions.Kafka
public string MessageKey { get; set; } public string MessageKey { get; set; }
public string MessageValue { get; set; } public string MessageValue { get; set; }
public Dictionary<string, string> Headers { get; set; }
public string Schema { get; set; }
} }
} }

108
backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaProducer.cs

@ -5,20 +5,32 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avro;
using Avro.Generic;
using Confluent.Kafka; using Confluent.Kafka;
using Confluent.SchemaRegistry;
using Confluent.SchemaRegistry.Serdes;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.Json.Objects;
using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Log;
namespace Squidex.Extensions.Actions.Kafka namespace Squidex.Extensions.Actions.Kafka
{ {
public sealed class KafkaProducer public sealed class KafkaProducer
{ {
private readonly IProducer<string, string> producer; private readonly IProducer<string, string> textProducer;
private readonly IProducer<string, GenericRecord> avroProducer;
private readonly ISchemaRegistryClient schemaRegistry;
private readonly IJsonSerializer jsonSerializer;
public KafkaProducer(IOptions<KafkaProducerOptions> options, ISemanticLog log) public KafkaProducer(IOptions<KafkaProducerOptions> options, ISemanticLog log, IJsonSerializer jsonSerializer)
{ {
producer = new ProducerBuilder<string, string>(options.Value) this.jsonSerializer = jsonSerializer;
textProducer = new ProducerBuilder<string, string>(options.Value)
.SetErrorHandler((p, error) => .SetErrorHandler((p, error) =>
{ {
LogError(log, error); LogError(log, error);
@ -30,6 +42,24 @@ namespace Squidex.Extensions.Actions.Kafka
.SetKeySerializer(Serializers.Utf8) .SetKeySerializer(Serializers.Utf8)
.SetValueSerializer(Serializers.Utf8) .SetValueSerializer(Serializers.Utf8)
.Build(); .Build();
if (options.Value.IsSchemaRegistryConfigured())
{
schemaRegistry = new CachedSchemaRegistryClient(options.Value.SchemaRegistry);
avroProducer = new ProducerBuilder<string, GenericRecord>(options.Value)
.SetErrorHandler((p, error) =>
{
LogError(log, error);
})
.SetLogHandler((p, message) =>
{
LogMessage(log, message);
})
.SetKeySerializer(Serializers.Utf8)
.SetValueSerializer(new AvroSerializer<GenericRecord>(schemaRegistry, options.Value.AvroSerializer))
.Build();
}
} }
private static void LogMessage(ISemanticLog log, LogMessage message) private static void LogMessage(ISemanticLog log, LogMessage message)
@ -77,16 +107,80 @@ namespace Squidex.Extensions.Actions.Kafka
.WriteProperty("reason", error.Reason)); .WriteProperty("reason", error.Reason));
} }
public async Task<DeliveryResult<string, string>> Send(string topicName, string key, string value) public async Task<DeliveryResult<string, string>> Send(string topicName, Message<string, string> message, string schema)
{
if (!string.IsNullOrWhiteSpace(schema))
{
var value = CreateAvroRecord(message.Value, schema);
var avroMessage = new Message<string, GenericRecord> { Key = message.Key, Headers = message.Headers, Value = value };
await avroProducer.ProduceAsync(topicName, avroMessage);
}
return await textProducer.ProduceAsync(topicName, message);
}
private GenericRecord CreateAvroRecord(string json, string avroSchema)
{ {
var message = new Message<string, string> { Key = key, Value = value }; var schema = (RecordSchema)Avro.Schema.Parse(avroSchema);
var jsonObject = jsonSerializer.Deserialize<JsonObject>(json);
return await producer.ProduceAsync(topicName, message); var result = (GenericRecord)GetValue(jsonObject, schema);
return result;
} }
public void Dispose() public void Dispose()
{ {
producer?.Dispose(); textProducer?.Dispose();
avroProducer?.Dispose();
}
private object GetValue(IJsonValue value, Avro.Schema schema)
{
switch (value)
{
case JsonString s:
return s.Value;
case JsonNumber n:
return n.Value;
case JsonBoolean b:
return b.Value;
case JsonObject o:
{
var recordSchema = (RecordSchema)schema;
var result = new GenericRecord(recordSchema);
foreach (var (key, childValue) in o)
{
if (recordSchema.TryGetField(key, out var field))
{
result.Add(key, GetValue(childValue, field.Schema));
}
}
return result;
}
case JsonArray a:
{
var arraySchema = (ArraySchema)schema;
var result = new List<object>();
foreach (var item in a)
{
result.Add(GetValue(item, arraySchema.ItemSchema));
}
return result.ToArray();
}
}
return null;
} }
} }
} }

11
backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaProducerOptions.cs

@ -6,14 +6,25 @@
// ========================================================================== // ==========================================================================
using Confluent.Kafka; using Confluent.Kafka;
using Confluent.SchemaRegistry;
using Confluent.SchemaRegistry.Serdes;
namespace Squidex.Extensions.Actions.Kafka namespace Squidex.Extensions.Actions.Kafka
{ {
public class KafkaProducerOptions : ProducerConfig public class KafkaProducerOptions : ProducerConfig
{ {
public SchemaRegistryConfig SchemaRegistry { get; set; }
public AvroSerializerConfig AvroSerializer { get; set; }
public bool IsProducerConfigured() public bool IsProducerConfigured()
{ {
return !string.IsNullOrWhiteSpace(BootstrapServers); return !string.IsNullOrWhiteSpace(BootstrapServers);
} }
public bool IsSchemaRegistryConfigured()
{
return !string.IsNullOrWhiteSpace(SchemaRegistry?.Url);
}
} }
} }

14
backend/extensions/Squidex.Extensions/Actions/Medium/MediumActionHandler.cs

@ -42,17 +42,17 @@ namespace Squidex.Extensions.Actions.Medium
this.serializer = serializer; this.serializer = serializer;
} }
protected override (string Description, MediumJob Data) CreateJob(EnrichedEvent @event, MediumAction action) protected override async Task<(string Description, MediumJob Data)> CreateJobAsync(EnrichedEvent @event, MediumAction action)
{ {
var ruleJob = new MediumJob { AccessToken = action.AccessToken, PublicationId = action.PublicationId }; var ruleJob = new MediumJob { AccessToken = action.AccessToken, PublicationId = action.PublicationId };
var requestBody = new var requestBody = new
{ {
title = Format(action.Title, @event), title = await FormatAsync(action.Title, @event),
contentFormat = action.IsHtml ? "html" : "markdown", contentFormat = action.IsHtml ? "html" : "markdown",
content = Format(action.Content, @event), content = await FormatAsync(action.Content, @event),
canonicalUrl = Format(action.CanonicalUrl, @event), canonicalUrl = await FormatAsync(action.CanonicalUrl, @event),
tags = ParseTags(@event, action) tags = await ParseTagsAsync(@event, action)
}; };
ruleJob.RequestBody = ToJson(requestBody); ruleJob.RequestBody = ToJson(requestBody);
@ -60,7 +60,7 @@ namespace Squidex.Extensions.Actions.Medium
return (Description, ruleJob); return (Description, ruleJob);
} }
private string[] ParseTags(EnrichedEvent @event, MediumAction action) private async Task<string[]> ParseTagsAsync(EnrichedEvent @event, MediumAction action)
{ {
if (string.IsNullOrWhiteSpace(action.Tags)) if (string.IsNullOrWhiteSpace(action.Tags))
{ {
@ -69,7 +69,7 @@ namespace Squidex.Extensions.Actions.Medium
try try
{ {
var jsonTags = Format(action.Tags, @event); var jsonTags = await FormatAsync(action.Tags, @event);
return serializer.Deserialize<string[]>(jsonTags); return serializer.Deserialize<string[]>(jsonTags);
} }

4
backend/extensions/Squidex.Extensions/Actions/Notification/NotificationActionHandler.cs

@ -40,7 +40,7 @@ namespace Squidex.Extensions.Actions.Notification
{ {
if (@event is EnrichedUserEventBase userEvent) if (@event is EnrichedUserEventBase userEvent)
{ {
var text = Format(action.Text, @event); var text = await FormatAsync(action.Text, @event);
var actor = userEvent.Actor; var actor = userEvent.Actor;
@ -60,7 +60,7 @@ namespace Squidex.Extensions.Actions.Notification
if (!string.IsNullOrWhiteSpace(action.Url)) if (!string.IsNullOrWhiteSpace(action.Url))
{ {
var url = Format(action.Url, @event); var url = await FormatAsync(action.Url, @event);
if (Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri)) if (Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri))
{ {

4
backend/extensions/Squidex.Extensions/Actions/Prerender/PrerenderActionHandler.cs

@ -24,9 +24,9 @@ namespace Squidex.Extensions.Actions.Prerender
this.httpClientFactory = httpClientFactory; this.httpClientFactory = httpClientFactory;
} }
protected override (string Description, PrerenderJob Data) CreateJob(EnrichedEvent @event, PrerenderAction action) protected override async Task<(string Description, PrerenderJob Data)> CreateJobAsync(EnrichedEvent @event, PrerenderAction action)
{ {
var url = Format(action.Url, @event); var url = await FormatAsync(action.Url, @event);
var request = new { prerenderToken = action.Token, url }; var request = new { prerenderToken = action.Token, url };
var requestBody = ToJson(request); var requestBody = ToJson(request);

4
backend/extensions/Squidex.Extensions/Actions/Slack/SlackActionHandler.cs

@ -30,9 +30,9 @@ namespace Squidex.Extensions.Actions.Slack
this.httpClientFactory = httpClientFactory; this.httpClientFactory = httpClientFactory;
} }
protected override (string Description, SlackJob Data) CreateJob(EnrichedEvent @event, SlackAction action) protected override async Task<(string Description, SlackJob Data)> CreateJobAsync(EnrichedEvent @event, SlackAction action)
{ {
var body = new { text = Format(action.Text, @event) }; var body = new { text = await FormatAsync(action.Text, @event) };
var ruleJob = new SlackJob var ruleJob = new SlackJob
{ {

4
backend/extensions/Squidex.Extensions/Actions/Twitter/TweetActionHandler.cs

@ -30,11 +30,11 @@ namespace Squidex.Extensions.Actions.Twitter
this.twitterOptions = twitterOptions.Value; this.twitterOptions = twitterOptions.Value;
} }
protected override (string Description, TweetJob Data) CreateJob(EnrichedEvent @event, TweetAction action) protected override async Task<(string Description, TweetJob Data)> CreateJobAsync(EnrichedEvent @event, TweetAction action)
{ {
var ruleJob = new TweetJob var ruleJob = new TweetJob
{ {
Text = Format(action.Text, @event), Text = await FormatAsync(action.Text, @event),
AccessToken = action.AccessToken, AccessToken = action.AccessToken,
AccessSecret = action.AccessSecret AccessSecret = action.AccessSecret
}; };

8
backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookActionHandler.cs

@ -27,25 +27,25 @@ namespace Squidex.Extensions.Actions.Webhook
this.httpClientFactory = httpClientFactory; this.httpClientFactory = httpClientFactory;
} }
protected override (string Description, WebhookJob Data) CreateJob(EnrichedEvent @event, WebhookAction action) protected override async Task<(string Description, WebhookJob Data)> CreateJobAsync(EnrichedEvent @event, WebhookAction action)
{ {
string requestBody; string requestBody;
if (!string.IsNullOrEmpty(action.Payload)) if (!string.IsNullOrEmpty(action.Payload))
{ {
requestBody = Format(action.Payload, @event); requestBody = await FormatAsync(action.Payload, @event);
} }
else else
{ {
requestBody = ToEnvelopeJson(@event); requestBody = ToEnvelopeJson(@event);
} }
var requestUrl = Format(action.Url, @event); var requestUrl = await FormatAsync(action.Url, @event);
var ruleDescription = $"Send event to webhook '{requestUrl}'"; var ruleDescription = $"Send event to webhook '{requestUrl}'";
var ruleJob = new WebhookJob var ruleJob = new WebhookJob
{ {
RequestUrl = Format(action.Url.ToString(), @event), RequestUrl = await FormatAsync(action.Url.ToString(), @event),
RequestSignature = $"{requestBody}{action.SharedSecret}".Sha256Base64(), RequestSignature = $"{requestBody}{action.SharedSecret}".Sha256Base64(),
RequestBody = requestBody RequestBody = requestBody
}; };

2
backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj

@ -9,7 +9,9 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Algolia.Search" Version="6.5.1" /> <PackageReference Include="Algolia.Search" Version="6.5.1" />
<PackageReference Include="Confluent.Apache.Avro" Version="1.7.7.7" />
<PackageReference Include="Confluent.Kafka" Version="1.3.0" /> <PackageReference Include="Confluent.Kafka" Version="1.3.0" />
<PackageReference Include="Confluent.SchemaRegistry.Serdes" Version="1.3.0" />
<PackageReference Include="CoreTweet" Version="1.0.0.483" /> <PackageReference Include="CoreTweet" Version="1.0.0.483" />
<PackageReference Include="Datadog.Trace" Version="1.14.2" /> <PackageReference Include="Datadog.Trace" Version="1.14.2" />
<PackageReference Include="Elasticsearch.Net" Version="7.6.1" /> <PackageReference Include="Elasticsearch.Net" Version="7.6.1" />

12
backend/src/Migrations/Migrations/PopulateGrainIndexes.cs

@ -55,24 +55,24 @@ namespace Migrations.Migrations
bool HasApp(NamedId<Guid> appId, bool consistent, out Guid id) bool HasApp(NamedId<Guid> appId, bool consistent, out Guid id)
{ {
return appsByName.TryGetValue(appId.Name, out id) && (!consistent || id == appId.Id); return appsByName!.TryGetValue(appId.Name, out id) && (!consistent || id == appId.Id);
} }
HashSet<Guid> Index(string contributorId) HashSet<Guid> Index(string contributorId)
{ {
return appsByUser.GetOrAddNew(contributorId); return appsByUser!.GetOrAddNew(contributorId);
} }
void RemoveApp(NamedId<Guid> appId, bool consistent) void RemoveApp(NamedId<Guid> appId, bool consistent)
{ {
if (HasApp(appId, consistent, out var id)) if (HasApp(appId, consistent, out var id))
{ {
foreach (var apps in appsByUser.Values) foreach (var apps in appsByUser!.Values)
{ {
apps.Remove(id); apps.Remove(id);
} }
appsByName.Remove(appId.Name); appsByName!.Remove(appId.Name);
} }
} }
@ -125,7 +125,7 @@ namespace Migrations.Migrations
HashSet<Guid> Index(RuleEvent @event) HashSet<Guid> Index(RuleEvent @event)
{ {
return rulesByApp.GetOrAddNew(@event.AppId.Id); return rulesByApp!.GetOrAddNew(@event.AppId.Id);
} }
await eventStore.QueryAsync(storedEvent => await eventStore.QueryAsync(storedEvent =>
@ -157,7 +157,7 @@ namespace Migrations.Migrations
Dictionary<string, Guid> Index(SchemaEvent @event) Dictionary<string, Guid> Index(SchemaEvent @event)
{ {
return schemasByApp.GetOrAddNew(@event.AppId.Id); return schemasByApp!.GetOrAddNew(@event.AppId.Id);
} }
await eventStore.QueryAsync(storedEvent => await eventStore.QueryAsync(storedEvent =>

3
backend/src/Squidex.Domain.Apps.Core.Model/Contents/IdContentData.cs

@ -7,6 +7,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Squidex.Infrastructure; using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Core.Contents namespace Squidex.Domain.Apps.Core.Contents
@ -47,7 +48,7 @@ namespace Squidex.Domain.Apps.Core.Contents
return this; return this;
} }
public bool Equals(IdContentData other) public bool Equals([AllowNull] IdContentData other)
{ {
return base.Equals(other); return base.Equals(other);
} }

3
backend/src/Squidex.Domain.Apps.Core.Model/Contents/NamedContentData.cs

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using System; using System;
using System.Diagnostics.CodeAnalysis;
using Squidex.Infrastructure; using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Core.Contents namespace Squidex.Domain.Apps.Core.Contents
@ -63,7 +64,7 @@ namespace Squidex.Domain.Apps.Core.Contents
return clone; return clone;
} }
public bool Equals(NamedContentData other) public bool Equals([AllowNull] NamedContentData other)
{ {
return base.Equals(other); return base.Equals(other);
} }

25
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleEventFormatter.cs

@ -0,0 +1,25 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
namespace Squidex.Domain.Apps.Core.HandleRules
{
public interface IRuleEventFormatter
{
(bool Match, string?, int ReplacedLength) Format(EnrichedEvent @event, string text)
{
return default;
}
(bool Match, ValueTask<string?>) Format(EnrichedEvent @event, object value, string[] path)
{
return default;
}
}
}

197
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/PredefinedPatternsFormatter.cs

@ -0,0 +1,197 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Globalization;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Infrastructure;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Core.HandleRules
{
public sealed class PredefinedPatternsFormatter : IRuleEventFormatter
{
private readonly List<(string Pattern, Func<EnrichedEvent, string?> Replacer)> patterns = new List<(string Pattern, Func<EnrichedEvent, string?> Replacer)>();
private readonly IUrlGenerator urlGenerator;
public PredefinedPatternsFormatter(IUrlGenerator urlGenerator)
{
Guard.NotNull(urlGenerator, nameof(urlGenerator));
this.urlGenerator = urlGenerator;
AddPattern("APP_ID", AppId);
AddPattern("APP_NAME", AppName);
AddPattern("ASSET_CONTENT_URL", AssetContentUrl);
AddPattern("CONTENT_ACTION", ContentAction);
AddPattern("CONTENT_URL", ContentUrl);
AddPattern("MENTIONED_ID", MentionedId);
AddPattern("MENTIONED_NAME", MentionedName);
AddPattern("MENTIONED_EMAIL", MentionedEmail);
AddPattern("SCHEMA_ID", SchemaId);
AddPattern("SCHEMA_NAME", SchemaName);
AddPattern("TIMESTAMP_DATETIME", TimestampTime);
AddPattern("TIMESTAMP_DATE", TimestampDate);
AddPattern("USER_ID", UserId);
AddPattern("USER_NAME", UserName);
AddPattern("USER_EMAIL", UserEmail);
}
private void AddPattern(string placeholder, Func<EnrichedEvent, string?> generator)
{
patterns.Add((placeholder, generator));
}
public (bool Match, string?, int ReplacedLength) Format(EnrichedEvent @event, string text)
{
for (var j = 0; j < patterns.Count; j++)
{
var (pattern, replacer) = patterns[j];
if (text.StartsWith(pattern, StringComparison.OrdinalIgnoreCase))
{
var result = replacer(@event);
return (true, result, pattern.Length);
}
}
return default;
}
private static string TimestampDate(EnrichedEvent @event)
{
return @event.Timestamp.ToDateTimeUtc().ToString("yyy-MM-dd", CultureInfo.InvariantCulture);
}
private static string TimestampTime(EnrichedEvent @event)
{
return @event.Timestamp.ToDateTimeUtc().ToString("yyy-MM-dd-hh-mm-ss", CultureInfo.InvariantCulture);
}
private static string AppId(EnrichedEvent @event)
{
return @event.AppId.Id.ToString();
}
private static string AppName(EnrichedEvent @event)
{
return @event.AppId.Name;
}
private static string? SchemaId(EnrichedEvent @event)
{
if (@event is EnrichedSchemaEventBase schemaEvent)
{
return schemaEvent.SchemaId.Id.ToString();
}
return null;
}
private static string? SchemaName(EnrichedEvent @event)
{
if (@event is EnrichedSchemaEventBase schemaEvent)
{
return schemaEvent.SchemaId.Name;
}
return null;
}
private static string? ContentAction(EnrichedEvent @event)
{
if (@event is EnrichedContentEvent contentEvent)
{
return contentEvent.Type.ToString();
}
return null;
}
private string? AssetContentUrl(EnrichedEvent @event)
{
if (@event is EnrichedAssetEvent assetEvent)
{
return urlGenerator.AssetContent(assetEvent.Id);
}
return null;
}
private string? ContentUrl(EnrichedEvent @event)
{
if (@event is EnrichedContentEvent contentEvent)
{
return urlGenerator.ContentUI(contentEvent.AppId, contentEvent.SchemaId, contentEvent.Id);
}
return null;
}
private static string? UserName(EnrichedEvent @event)
{
if (@event is EnrichedUserEventBase userEvent)
{
return userEvent.User?.DisplayName();
}
return null;
}
private static string? UserId(EnrichedEvent @event)
{
if (@event is EnrichedUserEventBase userEvent)
{
return userEvent.User?.Id;
}
return null;
}
private static string? UserEmail(EnrichedEvent @event)
{
if (@event is EnrichedUserEventBase userEvent)
{
return userEvent.User?.Email;
}
return null;
}
private static string? MentionedName(EnrichedEvent @event)
{
if (@event is EnrichedCommentEvent commentEvent)
{
return commentEvent.MentionedUser.DisplayName();
}
return null;
}
private static string? MentionedId(EnrichedEvent @event)
{
if (@event is EnrichedCommentEvent commentEvent)
{
return commentEvent.MentionedUser.Id;
}
return null;
}
private static string? MentionedEmail(EnrichedEvent @event)
{
if (@event is EnrichedCommentEvent commentEvent)
{
return commentEvent.MentionedUser.Email;
}
return null;
}
}
}

8
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionHandler.cs

@ -47,14 +47,14 @@ namespace Squidex.Domain.Apps.Core.HandleRules
return formatter.ToEnvelope(@event); return formatter.ToEnvelope(@event);
} }
protected string? Format(Uri uri, EnrichedEvent @event) protected ValueTask<string?> FormatAsync(Uri uri, EnrichedEvent @event)
{ {
return formatter.Format(uri.ToString(), @event); return formatter.FormatAsync(uri.ToString(), @event);
} }
protected string? Format(string text, EnrichedEvent @event) protected ValueTask<string?> FormatAsync(string text, EnrichedEvent @event)
{ {
return formatter.Format(text, @event); return formatter.FormatAsync(text, @event);
} }
async Task<(string Description, object Data)> IRuleActionHandler.CreateJobAsync(EnrichedEvent @event, RuleAction action) async Task<(string Description, object Data)> IRuleActionHandler.CreateJobAsync(EnrichedEvent @event, RuleAction action)

418
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs

@ -7,63 +7,72 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.Linq; using System.Linq;
using System.Reflection;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents; using Newtonsoft.Json;
using NodaTime.Text;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Json; using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.Json.Objects;
using Squidex.Shared.Identity;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Core.HandleRules namespace Squidex.Domain.Apps.Core.HandleRules
{ {
public class RuleEventFormatter public class RuleEventFormatter
{ {
private const string Fallback = "null"; private const string GlobalFallback = "null";
private static readonly Regex RegexPatternOld = new Regex(@"^(?<Type>[^_]*)_(?<Path>[^\s]*)", RegexOptions.Compiled); private static readonly Regex RegexPatternOld = new Regex(@"^(?<FullPath>(?<Type>[^_]*)_(?<Path>[^\s]*))", RegexOptions.Compiled);
private static readonly Regex RegexPatternNew = new Regex(@"^\{(?<Type>[^_]*)_(?<Path>[^\s]*)\}", RegexOptions.Compiled); private static readonly Regex RegexPatternNew = new Regex(@"^\{(?<FullPath>(?<Type>[\w]+)_(?<Path>[\w\.\-]+))[\s]*(\|[\s]*(?<Transform>[^\?}]+))?(\?[\s]*(?<Fallback>[^\}\s]+))?[\s]*\}", RegexOptions.Compiled);
private readonly List<(char[] Pattern, Func<EnrichedEvent, string?> Replacer)> patterns = new List<(char[] Pattern, Func<EnrichedEvent, string?> Replacer)>();
private readonly IJsonSerializer jsonSerializer; private readonly IJsonSerializer jsonSerializer;
private readonly IUrlGenerator urlGenerator; private readonly IEnumerable<IRuleEventFormatter> formatters;
private readonly IScriptEngine scriptEngine; private readonly IScriptEngine scriptEngine;
public RuleEventFormatter(IJsonSerializer jsonSerializer, IUrlGenerator urlGenerator, IScriptEngine scriptEngine) private struct TextPart
{
public bool IsText;
public int Length;
public int Offset;
public string Fallback;
public string Transform;
public ValueTask<string?> Replacement;
public static TextPart Text(int offset, int length)
{
var result = default(TextPart);
result.Offset = offset;
result.Length = length;
result.IsText = true;
return result;
}
public static TextPart Variable(ValueTask<string?> replacement, string fallback, string transform)
{
var result = default(TextPart);
result.Replacement = replacement;
result.Fallback = fallback;
result.Transform = transform;
return result;
}
}
public RuleEventFormatter(IJsonSerializer jsonSerializer, IEnumerable<IRuleEventFormatter> formatters, IScriptEngine scriptEngine)
{ {
Guard.NotNull(jsonSerializer, nameof(jsonSerializer)); Guard.NotNull(jsonSerializer, nameof(jsonSerializer));
Guard.NotNull(scriptEngine, nameof(scriptEngine)); Guard.NotNull(scriptEngine, nameof(scriptEngine));
Guard.NotNull(urlGenerator, nameof(urlGenerator)); Guard.NotNull(formatters, nameof(formatters));
this.jsonSerializer = jsonSerializer; this.jsonSerializer = jsonSerializer;
this.formatters = formatters;
this.scriptEngine = scriptEngine; this.scriptEngine = scriptEngine;
this.urlGenerator = urlGenerator;
AddPattern("APP_ID", AppId);
AddPattern("APP_NAME", AppName);
AddPattern("ASSET_CONTENT_URL", AssetContentUrl);
AddPattern("CONTENT_ACTION", ContentAction);
AddPattern("CONTENT_URL", ContentUrl);
AddPattern("MENTIONED_ID", MentionedId);
AddPattern("MENTIONED_NAME", MentionedName);
AddPattern("MENTIONED_EMAIL", MentionedEmail);
AddPattern("SCHEMA_ID", SchemaId);
AddPattern("SCHEMA_NAME", SchemaName);
AddPattern("TIMESTAMP_DATETIME", TimestampTime);
AddPattern("TIMESTAMP_DATE", TimestampDate);
AddPattern("USER_ID", UserId);
AddPattern("USER_NAME", UserName);
AddPattern("USER_EMAIL", UserEmail);
}
private void AddPattern(string placeholder, Func<EnrichedEvent, string?> generator)
{
patterns.Add((placeholder.ToCharArray(), generator));
} }
public virtual string ToPayload<T>(T @event) public virtual string ToPayload<T>(T @event)
@ -76,7 +85,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules
return jsonSerializer.Serialize(new { type = @event.Name, payload = @event, timestamp = @event.Timestamp }); return jsonSerializer.Serialize(new { type = @event.Name, payload = @event, timestamp = @event.Timestamp });
} }
public string? Format(string text, EnrichedEvent @event) public async ValueTask<string?> FormatAsync(string text, EnrichedEvent @event)
{ {
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))
{ {
@ -93,288 +102,215 @@ namespace Squidex.Domain.Apps.Core.HandleRules
return scriptEngine.Interpolate(context, script); return scriptEngine.Interpolate(context, script);
} }
var span = text.AsSpan(); var parts = BuildParts(text, @event);
var currentOffset = 0; await Task.WhenAll(parts.Select(x => x.Replacement.AsTask()));
var parts = new List<(int Offset, int Length, ValueTask<string?> Task)>(); return CombineParts(text, parts);
}
for (var i = 0; i < text.Length; i++) private string CombineParts(string text, List<TextPart> parts)
{ {
var c = text[i]; var span = text.AsSpan();
if (c == '$') var sb = new StringBuilder();
foreach (var part in parts)
{
if (!part.IsText)
{ {
parts.Add((currentOffset, i - currentOffset, default)); var result = part.Replacement.Result;
var (replacement, length) = GetReplacement(span.Slice(i + 1), @event); result = TransformText(result, part.Transform);
if (length > 0) if (result == null)
{ {
parts.Add((0, 0, new ValueTask<string?>(replacement))); result = part.Fallback;
i += length + 1;
} }
currentOffset = i; if (string.IsNullOrEmpty(result))
} {
} result = GlobalFallback;
}
parts.Add((currentOffset, text.Length - currentOffset, default));
var sb = new StringBuilder();
foreach (var (offset, length, task) in parts) sb.Append(result);
{
if (task.Result != null)
{
sb.Append(task.Result);
} }
else else
{ {
sb.Append(span.Slice(offset, length)); sb.Append(span.Slice(part.Offset, part.Length));
} }
} }
return sb.ToString(); return sb.ToString();
} }
private (string Result, int Length) GetReplacement(ReadOnlySpan<char> test, EnrichedEvent @event) private List<TextPart> BuildParts(string text, EnrichedEvent @event)
{ {
for (var j = 0; j < patterns.Count; j++) var parts = new List<TextPart>();
{
var (pattern, replacer) = patterns[j];
if (test.StartsWith(pattern, StringComparison.OrdinalIgnoreCase)) var span = text.AsSpan();
{
return (replacer(@event) ?? Fallback, pattern.Length);
}
}
var currentString = test.ToString();
var match = RegexPatternNew.Match(currentString);
if (!match.Success) var currentOffset = 0;
{
match = RegexPatternOld.Match(currentString);
}
if (match.Success) for (var i = 0; i < text.Length; i++)
{ {
var path = match.Groups["Path"].Value.Split('.', StringSplitOptions.RemoveEmptyEntries); var c = text[i];
return (CalculateData(@event, path) ?? Fallback, match.Length);
}
return (Fallback, 0);
}
private static string TimestampDate(EnrichedEvent @event)
{
return @event.Timestamp.ToDateTimeUtc().ToString("yyy-MM-dd", CultureInfo.InvariantCulture);
}
private static string TimestampTime(EnrichedEvent @event)
{
return @event.Timestamp.ToDateTimeUtc().ToString("yyy-MM-dd-hh-mm-ss", CultureInfo.InvariantCulture);
}
private static string AppId(EnrichedEvent @event) if (c == '$')
{ {
return @event.AppId.Id.ToString(); parts.Add(TextPart.Text(currentOffset, i - currentOffset));
}
private static string AppName(EnrichedEvent @event) var (length, part) = GetReplacement(span.Slice(i + 1).ToString(), @event);
{
return @event.AppId.Name;
}
private static string? SchemaId(EnrichedEvent @event) if (length > 0)
{ {
if (@event is EnrichedSchemaEventBase schemaEvent) parts.Add(part);
{
return schemaEvent.SchemaId.Id.ToString();
}
return null; i += length + 1;
} }
private static string? SchemaName(EnrichedEvent @event) currentOffset = i;
{ }
if (@event is EnrichedSchemaEventBase schemaEvent)
{
return schemaEvent.SchemaId.Name;
} }
return null; parts.Add(TextPart.Text(currentOffset, text.Length - currentOffset));
}
private static string? ContentAction(EnrichedEvent @event) return parts;
{
if (@event is EnrichedContentEvent contentEvent)
{
return contentEvent.Type.ToString();
}
return null;
} }
private string? AssetContentUrl(EnrichedEvent @event) private (int Length, TextPart Part) GetReplacement(string test, EnrichedEvent @event)
{ {
if (@event is EnrichedAssetEvent assetEvent) var (isNewRegex, match) = Match(test);
{
return urlGenerator.AssetContent(assetEvent.Id);
}
return null; if (match.Success)
}
private string? ContentUrl(EnrichedEvent @event)
{
if (@event is EnrichedContentEvent contentEvent)
{ {
return urlGenerator.ContentUI(contentEvent.AppId, contentEvent.SchemaId, contentEvent.Id); var (length, replacement) = ResolveOldPatterns(match, isNewRegex, @event);
}
return null; if (length == 0)
} {
(length, replacement) = ResolveFromPath(match, @event);
}
private static string? UserName(EnrichedEvent @event) return (length, TextPart.Variable(replacement, match.Groups["Fallback"].Value, match.Groups["Transform"].Value));
{
if (@event is EnrichedUserEventBase userEvent)
{
return userEvent.User?.DisplayName();
} }
return null; return default;
} }
private static string? UserId(EnrichedEvent @event) private (bool IsNew, Match) Match(string test)
{ {
if (@event is EnrichedUserEventBase userEvent) var match = RegexPatternNew.Match(test);
{
return userEvent.User?.Id;
}
return null; if (match.Success)
}
private static string? UserEmail(EnrichedEvent @event)
{
if (@event is EnrichedUserEventBase userEvent)
{ {
return userEvent.User?.Email; return (true, match);
} }
return null; return (false, RegexPatternOld.Match(test));
} }
private static string? MentionedName(EnrichedEvent @event) private (int Length, ValueTask<string?> Result) ResolveOldPatterns(Match match, bool isNewRegex, EnrichedEvent @event)
{ {
if (@event is EnrichedCommentEvent commentEvent) var fullPath = match.Groups["FullPath"].Value;
{
return commentEvent.MentionedUser.DisplayName();
}
return null; foreach (var formatter in formatters)
}
private static string? MentionedId(EnrichedEvent @event)
{
if (@event is EnrichedCommentEvent commentEvent)
{ {
return commentEvent.MentionedUser.Id; var (replaced, result, replacedLength) = formatter.Format(@event, fullPath);
}
return null; if (replaced)
} {
if (isNewRegex)
{
replacedLength = match.Length;
}
private static string? MentionedEmail(EnrichedEvent @event) return (replacedLength, new ValueTask<string?>(result));
{ }
if (@event is EnrichedCommentEvent commentEvent)
{
return commentEvent.MentionedUser.Email;
} }
return null; return default;
} }
private static string? CalculateData(object @event, string[] path) private static string? TransformText(string? text, string? transform)
{ {
object? current = @event; if (text != null && !string.IsNullOrWhiteSpace(transform))
foreach (var segment in path)
{ {
if (current is NamedContentData data) var transformations = transform.Split("|", StringSplitOptions.RemoveEmptyEntries);
{
if (!data.TryGetValue(segment, out var temp) || temp == null)
{
return null;
}
current = temp;
}
else if (current is ContentFieldData field)
{
if (!field.TryGetValue(segment, out var temp) || temp == null)
{
return null;
}
current = temp; foreach (var transformation in transformations)
}
else if (current is IJsonValue json)
{ {
if (!json.TryGet(segment, out var temp) || temp == null || temp.Type == JsonValueType.Null) switch (transformation.Trim().ToLowerInvariant())
{ {
return null; case "lower":
text = text.ToLowerInvariant();
break;
case "upper":
text = text.ToUpperInvariant();
break;
case "escape":
text = JsonConvert.ToString(text);
text = text[1..^1];
break;
case "slugify":
text = text.Slugify();
break;
case "trim":
text = text.Trim();
break;
case "timestamp_ms":
{
var instant = InstantPattern.General.Parse(text);
if (instant.Success)
{
text = instant.Value.ToUnixTimeMilliseconds().ToString();
}
break;
}
case "timestamp_seconds":
{
var instant = InstantPattern.General.Parse(text);
if (instant.Success)
{
text = instant.Value.ToUnixTimeSeconds().ToString();
}
break;
}
} }
current = temp;
} }
else if (current != null) }
{
if (current is IUser user)
{
var type = segment;
if (string.Equals(type, "Name", StringComparison.OrdinalIgnoreCase))
{
type = SquidexClaimTypes.DisplayName;
}
var claim = user.Claims.FirstOrDefault(x => string.Equals(x.Type, type, StringComparison.OrdinalIgnoreCase)); return text;
}
if (claim != null) private (int Length, ValueTask<string?> Result) ResolveFromPath(Match match, EnrichedEvent @event)
{ {
current = claim.Value; var path = match.Groups["Path"].Value.Split('.', StringSplitOptions.RemoveEmptyEntries);
continue;
}
}
const BindingFlags bindingFlags = var (result, remaining) = RuleVariable.GetValue(@event, path);
BindingFlags.FlattenHierarchy |
BindingFlags.Public |
BindingFlags.Instance;
var properties = current.GetType().GetProperties(bindingFlags); if (remaining.Length > 0 && result != null)
var property = properties.FirstOrDefault(x => x.CanRead && string.Equals(x.Name, segment, StringComparison.OrdinalIgnoreCase)); {
foreach (var formatter in formatters)
{
var (replaced, result2) = formatter.Format(@event, result, remaining);
if (property == null) if (replaced)
{ {
return null; return (match.Length, result2);
} }
current = property.GetValue(current);
}
else
{
return null;
} }
} }
else if (remaining.Length == 0)
{
return (match.Length, new ValueTask<string?>(result?.ToString()));
}
return current?.ToString(); return (match.Length, default);
} }
private static bool TryGetScript(string text, out string script) private static bool TryGetScript(string text, out string script)

101
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleVariable.cs

@ -0,0 +1,101 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Linq;
using System.Reflection;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure.Json.Objects;
using Squidex.Shared.Identity;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Core.HandleRules
{
public static class RuleVariable
{
public static (object? Result, string[] Remaining) GetValue(object @event, string[] path)
{
object? current = @event;
var i = 0;
for (; i < path.Length; i++)
{
var segment = path[i];
if (current is NamedContentData data)
{
if (!data.TryGetValue(segment, out var temp) || temp == null)
{
break;
}
current = temp;
}
else if (current is ContentFieldData field)
{
if (!field.TryGetValue(segment, out var temp) || temp == null)
{
break;
}
current = temp;
}
else if (current is IJsonValue json)
{
if (!json.TryGet(segment, out var temp) || temp == null || temp.Type == JsonValueType.Null)
{
break;
}
current = temp;
}
else if (current != null)
{
if (current is IUser user)
{
var type = segment;
if (string.Equals(type, "Name", StringComparison.OrdinalIgnoreCase))
{
type = SquidexClaimTypes.DisplayName;
}
var claim = user.Claims.FirstOrDefault(x => string.Equals(x.Type, type, StringComparison.OrdinalIgnoreCase));
if (claim != null)
{
current = claim.Value;
continue;
}
}
const BindingFlags bindingFlags =
BindingFlags.FlattenHierarchy |
BindingFlags.Public |
BindingFlags.Instance;
var properties = current.GetType().GetProperties(bindingFlags);
var property = properties.FirstOrDefault(x => x.CanRead && string.Equals(x.Name, segment, StringComparison.OrdinalIgnoreCase));
if (property == null)
{
break;
}
current = property.GetValue(current);
}
else
{
break;
}
}
return (current, path.Skip(i).ToArray());
}
}
}

32
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptContext.cs

@ -47,24 +47,12 @@ namespace Squidex.Domain.Apps.Core.Scripting
set => SetValue(value); set => SetValue(value);
} }
public NamedContentData? DataOld
{
get => GetValue<NamedContentData?>("oldData");
set => SetValue(value, "oldData");
}
public Status Status public Status Status
{ {
get => GetValue<Status>(); get => GetValue<Status>();
set => SetValue(value); set => SetValue(value);
} }
public Status StatusOld
{
get => GetValue<Status>();
set => SetValue(value);
}
public string? AppName public string? AppName
{ {
get => GetValue<string?>(); get => GetValue<string?>();
@ -77,6 +65,26 @@ namespace Squidex.Domain.Apps.Core.Scripting
set => SetValue(value); set => SetValue(value);
} }
public NamedContentData? DataOld
{
get => GetValue<NamedContentData?>();
set
{
SetValue(value, "oldData");
SetValue(value);
}
}
public Status StatusOld
{
get => GetValue<Status>();
set
{
SetValue(value, "oldStatus");
SetValue(value);
}
}
public void SetValue(object? value, [CallerMemberNameAttribute] string? key = null) public void SetValue(object? value, [CallerMemberNameAttribute] string? key = null)
{ {
if (key != null) if (key != null)

11
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs

@ -85,16 +85,9 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
return ResultList.Create<IAssetEntity>(assetCount.Result, assetItems.Result); return ResultList.Create<IAssetEntity>(assetCount.Result, assetItems.Result);
} }
catch (MongoQueryException ex) catch (MongoQueryException ex) when (ex.Message.Contains("17406"))
{ {
if (ex.Message.Contains("17406")) throw new DomainException("Result set is too large to be retrieved. Use $take parameter to reduce the number of items.");
{
throw new DomainException("Result set is too large to be retrieved. Use $top parameter to reduce the number of items.");
}
else
{
throw;
}
} }
} }
} }

72
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryContentsByQuery.cs

@ -7,8 +7,11 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver; using MongoDB.Driver;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Contents;
@ -22,9 +25,21 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
{ {
internal sealed class QueryContentsByQuery : OperationBase internal sealed class QueryContentsByQuery : OperationBase
{ {
private static readonly PropertyPath DefaultOrderField = "mt";
private readonly DataConverter converter; private readonly DataConverter converter;
private readonly ITextIndex indexer; private readonly ITextIndex indexer;
[BsonIgnoreExtraElements]
internal sealed class IdOnly
{
[BsonId]
[BsonElement("_id")]
[BsonRepresentation(BsonType.String)]
public Guid Id { get; set; }
public MongoContentEntity[] Joined { get; set; }
}
public QueryContentsByQuery(DataConverter converter, ITextIndex indexer) public QueryContentsByQuery(DataConverter converter, ITextIndex indexer)
{ {
this.converter = converter; this.converter = converter;
@ -71,12 +86,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
var filter = CreateFilter(schema.Id, fullTextIds, query); var filter = CreateFilter(schema.Id, fullTextIds, query);
var contentCount = Collection.Find(filter).CountDocumentsAsync(); var contentCount = Collection.Find(filter).CountDocumentsAsync();
var contentItems = var contentItems = FindContentsAsync(query, filter);
Collection.Find(filter)
.QueryLimit(query)
.QuerySkip(query)
.QuerySort(query)
.ToListAsync();
await Task.WhenAll(contentItems, contentCount); await Task.WhenAll(contentItems, contentCount);
@ -87,17 +97,53 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
return ResultList.Create<IContentEntity>(contentCount.Result, contentItems.Result); return ResultList.Create<IContentEntity>(contentCount.Result, contentItems.Result);
} }
catch (MongoQueryException ex) catch (MongoCommandException ex) when (ex.Code == 96)
{ {
if (ex.Message.Contains("17406")) throw new DomainException("Result set is too large to be retrieved. Use $take parameter to reduce the number of items.");
{ }
throw new DomainException("Result set is too large to be retrieved. Use $top parameter to reduce the number of items."); catch (MongoQueryException ex) when (ex.Message.Contains("17406"))
} {
else throw new DomainException("Result set is too large to be retrieved. Use $take parameter to reduce the number of items.");
}
}
private async Task<List<MongoContentEntity>> FindContentsAsync(ClrQuery query, FilterDefinition<MongoContentEntity> filter)
{
if (query.Skip > 0 && !IsSatisfiedByIndex(query))
{
var projection = Projection.Include("_id");
foreach (var field in query.GetAllFields())
{ {
throw; projection = Projection.Include(field);
} }
var joined =
await Collection.Aggregate()
.Match(filter)
.Project<IdOnly>(projection)
.QuerySort(query)
.QuerySkip(query)
.QueryLimit(query)
.Lookup<IdOnly, MongoContentEntity, IdOnly>(Collection, x => x.Id, x => x.Id, x => x.Joined)
.ToListAsync();
return joined.Select(x => x.Joined[0]).ToList();
} }
var result =
Collection.Find(filter)
.QuerySort(query)
.QueryLimit(query)
.QuerySkip(query)
.ToListAsync();
return await result;
}
private static bool IsSatisfiedByIndex(ClrQuery query)
{
return query.Sort?.Any(x => x.Path == DefaultOrderField && x.Order == SortOrder.Descending) == true;
} }
private static FilterDefinition<MongoContentEntity> CreateFilter(Guid schemaId, ICollection<Guid>? ids, ClrQuery? query) private static FilterDefinition<MongoContentEntity> CreateFilter(Guid schemaId, ICollection<Guid>? ids, ClrQuery? query)

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageGate.cs

@ -50,7 +50,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans
{ {
var appId = app.Id; var appId = app.Id;
var usage = await apiUsageTracker.GetMonthCostsAsync(appId.ToString(), today); var usage = await apiUsageTracker.GetMonthCallsAsync(appId.ToString(), today);
if (IsAboutToBeLocked(today, plan.MaxApiCalls, usage) && !HasNotifiedBefore(app.Id)) if (IsAboutToBeLocked(today, plan.MaxApiCalls, usage) && !HasNotifiedBefore(app.Id))
{ {

44
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs

@ -14,24 +14,62 @@ using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Events.Contents; using Squidex.Domain.Apps.Events.Contents;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Json.Objects;
using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Reflection;
namespace Squidex.Domain.Apps.Entities.Contents namespace Squidex.Domain.Apps.Entities.Contents
{ {
public sealed class ContentChangedTriggerHandler : RuleTriggerHandler<ContentChangedTriggerV2, ContentEvent, EnrichedContentEvent> public sealed class ContentChangedTriggerHandler : RuleTriggerHandler<ContentChangedTriggerV2, ContentEvent, EnrichedContentEvent>, IRuleEventFormatter
{ {
private readonly IScriptEngine scriptEngine; private readonly IScriptEngine scriptEngine;
private readonly IContentLoader contentLoader; private readonly IContentLoader contentLoader;
private readonly ILocalCache localCache;
public ContentChangedTriggerHandler(IScriptEngine scriptEngine, IContentLoader contentLoader) public ContentChangedTriggerHandler(IScriptEngine scriptEngine, IContentLoader contentLoader, ILocalCache localCache)
{ {
Guard.NotNull(scriptEngine, nameof(scriptEngine)); Guard.NotNull(scriptEngine, nameof(scriptEngine));
Guard.NotNull(contentLoader, nameof(contentLoader)); Guard.NotNull(contentLoader, nameof(contentLoader));
Guard.NotNull(localCache, nameof(localCache));
this.scriptEngine = scriptEngine; this.scriptEngine = scriptEngine;
this.contentLoader = contentLoader; this.contentLoader = contentLoader;
this.localCache = localCache;
}
public (bool Match, ValueTask<string?>) Format(EnrichedEvent @event, object value, string[] path)
{
if (value is JsonArray array &&
array.Count > 0 &&
array[0] is JsonString s &&
Guid.TryParse(s.Value, out var referenceId))
{
return (true, GetReferenceValueAsync(referenceId, path));
}
return default;
}
private async ValueTask<string?> GetReferenceValueAsync(Guid referenceId, string[] path)
{
var reference = await GetContentFromCacheAsync(referenceId);
var (result, remaining) = RuleVariable.GetValue(reference, path);
if (remaining.Length == 0)
{
return result?.ToString();
}
return default;
}
private Task<IContentEntity> GetContentFromCacheAsync(Guid referenceId)
{
var cacheKey = $"FORMAT_REFERENCE_{referenceId}";
return localCache.GetOrCreate(cacheKey, () => contentLoader.GetAsync(referenceId));
} }
protected override async Task<EnrichedContentEvent?> CreateEnrichedEventAsync(Envelope<ContentEvent> @event) protected override async Task<EnrichedContentEvent?> CreateEnrichedEventAsync(Envelope<ContentEvent> @event)

18
backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs

@ -14,6 +14,7 @@ using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Entities.Rules.Repositories; using Squidex.Domain.Apps.Entities.Rules.Repositories;
using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Entities.Rules namespace Squidex.Domain.Apps.Entities.Rules
@ -24,6 +25,7 @@ namespace Squidex.Domain.Apps.Entities.Rules
private readonly IRuleEventRepository ruleEventRepository; private readonly IRuleEventRepository ruleEventRepository;
private readonly IAppProvider appProvider; private readonly IAppProvider appProvider;
private readonly IMemoryCache cache; private readonly IMemoryCache cache;
private readonly ILocalCache localCache;
private readonly RuleService ruleService; private readonly RuleService ruleService;
public string Name public string Name
@ -36,18 +38,19 @@ namespace Squidex.Domain.Apps.Entities.Rules
get { return ".*"; } get { return ".*"; }
} }
public RuleEnqueuer(IAppProvider appProvider, IMemoryCache cache, IRuleEventRepository ruleEventRepository, public RuleEnqueuer(IAppProvider appProvider, IMemoryCache cache, ILocalCache localCache, IRuleEventRepository ruleEventRepository,
RuleService ruleService) RuleService ruleService)
{ {
Guard.NotNull(appProvider, nameof(appProvider)); Guard.NotNull(appProvider, nameof(appProvider));
Guard.NotNull(cache, nameof(cache)); Guard.NotNull(cache, nameof(cache));
Guard.NotNull(localCache, nameof(localCache));
Guard.NotNull(ruleEventRepository, nameof(ruleEventRepository)); Guard.NotNull(ruleEventRepository, nameof(ruleEventRepository));
Guard.NotNull(ruleService, nameof(ruleService)); Guard.NotNull(ruleService, nameof(ruleService));
this.appProvider = appProvider; this.appProvider = appProvider;
this.cache = cache; this.cache = cache;
this.localCache = localCache;
this.ruleEventRepository = ruleEventRepository; this.ruleEventRepository = ruleEventRepository;
this.ruleService = ruleService; this.ruleService = ruleService;
} }
@ -67,11 +70,14 @@ namespace Squidex.Domain.Apps.Entities.Rules
Guard.NotNull(rule, nameof(rule)); Guard.NotNull(rule, nameof(rule));
Guard.NotNull(@event, nameof(@event)); Guard.NotNull(@event, nameof(@event));
var jobs = await ruleService.CreateJobsAsync(rule, ruleId, @event); using (localCache.StartContext())
foreach (var job in jobs)
{ {
await ruleEventRepository.EnqueueAsync(job, job.Created); var jobs = await ruleService.CreateJobsAsync(rule, ruleId, @event);
foreach (var job in jobs)
{
await ruleEventRepository.EnqueueAsync(job, job.Created);
}
} }
} }

2
backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTrackerGrain.cs

@ -83,7 +83,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.UsageTracking
if (!target.Triggered.HasValue || target.Triggered < from) if (!target.Triggered.HasValue || target.Triggered < from)
{ {
var costs = await usageTracker.GetMonthCostsAsync(target.AppId.Id.ToString(), today); var costs = await usageTracker.GetMonthCallsAsync(target.AppId.Id.ToString(), today);
var limit = target.Limits; var limit = target.Limits;

4
backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj

@ -29,11 +29,11 @@
<PackageReference Include="Lucene.Net.Queries" Version="4.8.0-beta00005" /> <PackageReference Include="Lucene.Net.Queries" Version="4.8.0-beta00005" />
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00005" /> <PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00005" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="3.1.2" /> <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="3.1.2" />
<PackageReference Include="Microsoft.Orleans.CodeGenerator.MSBuild" Version="3.1.3"> <PackageReference Include="Microsoft.Orleans.CodeGenerator.MSBuild" Version="3.1.7">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.Orleans.Core" Version="3.1.3" /> <PackageReference Include="Microsoft.Orleans.Core" Version="3.1.7" />
<PackageReference Include="NodaTime" Version="2.4.7" /> <PackageReference Include="NodaTime" Version="2.4.7" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />

4
backend/src/Squidex.Infrastructure.Azure/Assets/AzureBlobAssetStore.cs

@ -9,8 +9,8 @@ using System;
using System.IO; using System.IO;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.WindowsAzure.Storage; using Microsoft.Azure.Storage;
using Microsoft.WindowsAzure.Storage.Blob; using Microsoft.Azure.Storage.Blob;
namespace Squidex.Infrastructure.Assets namespace Squidex.Infrastructure.Assets
{ {

2
backend/src/Squidex.Infrastructure.Azure/Squidex.Infrastructure.Azure.csproj

@ -10,7 +10,7 @@
<PackageReference Include="Microsoft.Azure.DocumentDB.Core" Version="2.10.1" /> <PackageReference Include="Microsoft.Azure.DocumentDB.Core" Version="2.10.1" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="WindowsAzure.Storage" Version="9.3.3" /> <PackageReference Include="Microsoft.Azure.Storage.Blob" Version="11.1.5" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Squidex.Infrastructure\Squidex.Infrastructure.csproj" /> <ProjectReference Include="..\Squidex.Infrastructure\Squidex.Infrastructure.csproj" />

2
backend/src/Squidex.Infrastructure.GetEventStore/Squidex.Infrastructure.GetEventStore.csproj

@ -10,7 +10,7 @@
<DebugSymbols>True</DebugSymbols> <DebugSymbols>True</DebugSymbols>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="EventStore.ClientAPI.NetCore" Version="4.1.0.23" /> <PackageReference Include="EventStore.Client" Version="5.0.8" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
</ItemGroup> </ItemGroup>

25
backend/src/Squidex.Infrastructure.MongoDb/MongoDb/Queries/LimitExtensions.cs

@ -12,6 +12,16 @@ namespace Squidex.Infrastructure.MongoDb.Queries
{ {
public static class LimitExtensions public static class LimitExtensions
{ {
public static IAggregateFluent<T> QueryLimit<T>(this IAggregateFluent<T> cursor, ClrQuery query)
{
if (query.Take < long.MaxValue)
{
cursor = cursor.Limit((int)query.Take);
}
return cursor;
}
public static IFindFluent<T, T> QueryLimit<T>(this IFindFluent<T, T> cursor, ClrQuery query) public static IFindFluent<T, T> QueryLimit<T>(this IFindFluent<T, T> cursor, ClrQuery query)
{ {
if (query.Take < long.MaxValue) if (query.Take < long.MaxValue)
@ -22,6 +32,16 @@ namespace Squidex.Infrastructure.MongoDb.Queries
return cursor; return cursor;
} }
public static IAggregateFluent<T> QuerySkip<T>(this IAggregateFluent<T> cursor, ClrQuery query)
{
if (query.Skip > 0)
{
cursor = cursor.Skip((int)query.Skip);
}
return cursor;
}
public static IFindFluent<T, T> QuerySkip<T>(this IFindFluent<T, T> cursor, ClrQuery query) public static IFindFluent<T, T> QuerySkip<T>(this IFindFluent<T, T> cursor, ClrQuery query)
{ {
if (query.Skip > 0) if (query.Skip > 0)
@ -36,5 +56,10 @@ namespace Squidex.Infrastructure.MongoDb.Queries
{ {
return cursor.Sort(query.BuildSort<T>()); return cursor.Sort(query.BuildSort<T>());
} }
public static IAggregateFluent<T> QuerySort<T>(this IAggregateFluent<T> cursor, ClrQuery query)
{
return cursor.Sort(query.BuildSort<T>());
}
} }
} }

4
backend/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs

@ -201,7 +201,7 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
private async Task ClearAsync() private async Task ClearAsync()
{ {
var logContext = (actionId: Guid.NewGuid().ToString(), consumer: eventConsumer.Name); var logContext = (actionId: Guid.NewGuid().ToString(), consumer: eventConsumer!.Name);
log.LogDebug(logContext, (ctx, w) => w log.LogDebug(logContext, (ctx, w) => w
.WriteProperty("action", "EventConsumerReset") .WriteProperty("action", "EventConsumerReset")
@ -224,7 +224,7 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
var eventId = @event.Headers.EventId().ToString(); var eventId = @event.Headers.EventId().ToString();
var eventType = @event.Payload.GetType().Name; var eventType = @event.Payload.GetType().Name;
var logContext = (eventId, eventType, consumer: eventConsumer.Name); var logContext = (eventId, eventType, consumer: eventConsumer!.Name);
log.LogDebug(logContext, (ctx, w) => w log.LogDebug(logContext, (ctx, w) => w
.WriteProperty("action", "HandleEvent") .WriteProperty("action", "HandleEvent")

2
backend/src/Squidex.Infrastructure/Json/Objects/JsonObject.cs

@ -113,7 +113,7 @@ namespace Squidex.Infrastructure.Json.Objects
return Equals(obj as JsonObject); return Equals(obj as JsonObject);
} }
public bool Equals(IJsonValue other) public bool Equals([AllowNull] IJsonValue other)
{ {
return Equals(other as JsonObject); return Equals(other as JsonObject);
} }

8
backend/src/Squidex.Infrastructure/Net/IPAddressComparer.cs

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Net; using System.Net;
namespace Squidex.Infrastructure.Net namespace Squidex.Infrastructure.Net
@ -18,8 +19,13 @@ namespace Squidex.Infrastructure.Net
{ {
} }
public int Compare(IPAddress x, IPAddress y) public int Compare([AllowNull] IPAddress x, [AllowNull] IPAddress y)
{ {
if (x == null || y == null)
{
return 0;
}
var lbytes = x.GetAddressBytes(); var lbytes = x.GetAddressBytes();
var rbytes = y.GetAddressBytes(); var rbytes = y.GetAddressBytes();

2
backend/src/Squidex.Infrastructure/Orleans/Indexes/UniqueNameIndexGrain.cs

@ -116,7 +116,7 @@ namespace Squidex.Infrastructure.Orleans.Indexes
{ {
state.Value.Names.TryGetValue(name, out var id); state.Value.Names.TryGetValue(name, out var id);
return Task.FromResult(id); return Task.FromResult(id!);
} }
public Task<List<T>> GetIdsAsync() public Task<List<T>> GetIdsAsync()

7
backend/src/Squidex.Infrastructure/Queries/CompareFilter.cs

@ -5,6 +5,8 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.Generic;
namespace Squidex.Infrastructure.Queries namespace Squidex.Infrastructure.Queries
{ {
public sealed class CompareFilter<TValue> : FilterNode<TValue> public sealed class CompareFilter<TValue> : FilterNode<TValue>
@ -28,6 +30,11 @@ namespace Squidex.Infrastructure.Queries
Value = value; Value = value;
} }
public override void AddFields(HashSet<string> fields)
{
fields.Add(Path.ToString());
}
public override T Accept<T>(FilterNodeVisitor<T, TValue> visitor) public override T Accept<T>(FilterNodeVisitor<T, TValue> visitor)
{ {
return visitor.Visit(this); return visitor.Visit(this);

4
backend/src/Squidex.Infrastructure/Queries/FilterNode.cs

@ -5,12 +5,16 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.Generic;
namespace Squidex.Infrastructure.Queries namespace Squidex.Infrastructure.Queries
{ {
public abstract class FilterNode<TValue> public abstract class FilterNode<TValue>
{ {
public abstract T Accept<T>(FilterNodeVisitor<T, TValue> visitor); public abstract T Accept<T>(FilterNodeVisitor<T, TValue> visitor);
public abstract void AddFields(HashSet<string> fields);
public abstract override string ToString(); public abstract override string ToString();
} }
} }

8
backend/src/Squidex.Infrastructure/Queries/LogicalFilter.cs

@ -25,6 +25,14 @@ namespace Squidex.Infrastructure.Queries
Type = type; Type = type;
} }
public override void AddFields(HashSet<string> fields)
{
foreach (var filter in Filters)
{
filter.AddFields(fields);
}
}
public override T Accept<T>(FilterNodeVisitor<T, TValue> visitor) public override T Accept<T>(FilterNodeVisitor<T, TValue> visitor)
{ {
return visitor.Visit(this); return visitor.Visit(this);

7
backend/src/Squidex.Infrastructure/Queries/NegateFilter.cs

@ -5,6 +5,8 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.Generic;
namespace Squidex.Infrastructure.Queries namespace Squidex.Infrastructure.Queries
{ {
public sealed class NegateFilter<TValue> : FilterNode<TValue> public sealed class NegateFilter<TValue> : FilterNode<TValue>
@ -18,6 +20,11 @@ namespace Squidex.Infrastructure.Queries
Filter = filter; Filter = filter;
} }
public override void AddFields(HashSet<string> fields)
{
Filter.AddFields(fields);
}
public override T Accept<T>(FilterNodeVisitor<T, TValue> visitor) public override T Accept<T>(FilterNodeVisitor<T, TValue> visitor)
{ {
return visitor.Visit(this); return visitor.Visit(this);

17
backend/src/Squidex.Infrastructure/Queries/Query.cs

@ -26,6 +26,23 @@ namespace Squidex.Infrastructure.Queries
public List<SortNode> Sort { get; set; } = new List<SortNode>(); public List<SortNode> Sort { get; set; } = new List<SortNode>();
public HashSet<string> GetAllFields()
{
var result = new HashSet<string>();
if (Sort != null)
{
foreach (var sorting in Sort)
{
result.Add(sorting.Path.ToString());
}
}
Filter?.AddFields(result);
return result;
}
public override string ToString() public override string ToString()
{ {
var parts = new List<string>(); var parts = new List<string>();

3
backend/src/Squidex.Infrastructure/Reflection/Equality/DeepEqualityComparer.cs

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
namespace Squidex.Infrastructure.Reflection.Equality namespace Squidex.Infrastructure.Reflection.Equality
{ {
@ -19,7 +20,7 @@ namespace Squidex.Infrastructure.Reflection.Equality
this.comparer = comparer ?? SimpleEquals.Build(typeof(T)); this.comparer = comparer ?? SimpleEquals.Build(typeof(T));
} }
public bool Equals(T x, T y) public bool Equals([AllowNull] T x, [AllowNull] T y)
{ {
return comparer.IsEquals(x, y); return comparer.IsEquals(x, y);
} }

3
backend/src/Squidex.Infrastructure/Security/Permission.cs

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using System; using System;
using System.Diagnostics.CodeAnalysis;
namespace Squidex.Infrastructure.Security namespace Squidex.Infrastructure.Security
{ {
@ -112,7 +113,7 @@ namespace Squidex.Infrastructure.Security
return id; return id;
} }
public int CompareTo(Permission other) public int CompareTo([AllowNull] Permission other)
{ {
return other == null ? -1 : string.Compare(id, other.id, StringComparison.Ordinal); return other == null ? -1 : string.Compare(id, other.id, StringComparison.Ordinal);
} }

6
backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj

@ -21,12 +21,12 @@
<PackageReference Include="Microsoft.Extensions.Logging" Version="3.1.2" /> <PackageReference Include="Microsoft.Extensions.Logging" Version="3.1.2" />
<PackageReference Include="Microsoft.Extensions.ObjectPool" Version="3.1.2" /> <PackageReference Include="Microsoft.Extensions.ObjectPool" Version="3.1.2" />
<PackageReference Include="Microsoft.OData.Core" Version="7.6.3" /> <PackageReference Include="Microsoft.OData.Core" Version="7.6.3" />
<PackageReference Include="Microsoft.Orleans.CodeGenerator.MSBuild" Version="3.1.3"> <PackageReference Include="Microsoft.Orleans.CodeGenerator.MSBuild" Version="3.1.7">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.Orleans.Core" Version="3.1.3" /> <PackageReference Include="Microsoft.Orleans.Core" Version="3.1.7" />
<PackageReference Include="Microsoft.Orleans.OrleansRuntime" Version="3.1.3" /> <PackageReference Include="Microsoft.Orleans.OrleansRuntime" Version="3.1.7" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="NJsonSchema" Version="10.1.8" /> <PackageReference Include="NJsonSchema" Version="10.1.8" />
<PackageReference Include="NodaTime" Version="2.4.7" /> <PackageReference Include="NodaTime" Version="2.4.7" />

11
backend/src/Squidex.Infrastructure/UsageTracking/ApiUsageTracker.cs

@ -23,7 +23,7 @@ namespace Squidex.Infrastructure.UsageTracking
this.usageTracker = usageTracker; this.usageTracker = usageTracker;
} }
public async Task<long> GetMonthCostsAsync(string key, DateTime date) public async Task<long> GetMonthCallsAsync(string key, DateTime date)
{ {
var apiKey = GetKey(key); var apiKey = GetKey(key);
@ -32,6 +32,15 @@ namespace Squidex.Infrastructure.UsageTracking
return counters.GetInt64(CounterTotalCalls); return counters.GetInt64(CounterTotalCalls);
} }
public async Task<long> GetMonthBytesAsync(string key, DateTime date)
{
var apiKey = GetKey(key);
var counters = await usageTracker.GetForMonthAsync(apiKey, date);
return counters.GetInt64(CounterTotalBytes);
}
public Task TrackAsync(DateTime date, string key, string? category, double weight, long elapsedMs, long bytes) public Task TrackAsync(DateTime date, string key, string? category, double weight, long elapsedMs, long bytes)
{ {
var apiKey = GetKey(key); var apiKey = GetKey(key);

4
backend/src/Squidex.Infrastructure/UsageTracking/IApiUsageTracker.cs

@ -15,7 +15,9 @@ namespace Squidex.Infrastructure.UsageTracking
{ {
Task TrackAsync(DateTime date, string key, string? category, double weight, long elapsedMs, long bytes); Task TrackAsync(DateTime date, string key, string? category, double weight, long elapsedMs, long bytes);
Task<long> GetMonthCostsAsync(string key, DateTime date); Task<long> GetMonthCallsAsync(string key, DateTime date);
Task<long> GetMonthBytesAsync(string key, DateTime date);
Task<(ApiStatsSummary, Dictionary<string, List<ApiStats>> Details)> QueryAsync(string key, DateTime fromDate, DateTime toDate); Task<(ApiStatsSummary, Dictionary<string, List<ApiStats>> Details)> QueryAsync(string key, DateTime fromDate, DateTime toDate);
} }

5
backend/src/Squidex/Config/Domain/RuleServices.cs

@ -44,7 +44,7 @@ namespace Squidex.Config.Domain
.As<IRuleTriggerHandler>(); .As<IRuleTriggerHandler>();
services.AddSingletonAs<ContentChangedTriggerHandler>() services.AddSingletonAs<ContentChangedTriggerHandler>()
.As<IRuleTriggerHandler>(); .As<IRuleTriggerHandler>().As<IRuleEventFormatter>();
services.AddSingletonAs<ManualTriggerHandler>() services.AddSingletonAs<ManualTriggerHandler>()
.As<IRuleTriggerHandler>(); .As<IRuleTriggerHandler>();
@ -73,6 +73,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<EventScriptExtension>() services.AddSingletonAs<EventScriptExtension>()
.As<IScriptExtension>(); .As<IScriptExtension>();
services.AddSingletonAs<PredefinedPatternsFormatter>()
.As<IRuleEventFormatter>();
services.AddSingletonAs<RuleEventFormatter>() services.AddSingletonAs<RuleEventFormatter>()
.AsSelf(); .AsSelf();

9
backend/src/Squidex/Squidex.csproj

@ -35,7 +35,6 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AspNet.Security.OAuth.GitHub" Version="3.0.0" /> <PackageReference Include="AspNet.Security.OAuth.GitHub" Version="3.0.0" />
<PackageReference Include="EventStore.ClientAPI.NetCore" Version="4.1.0.23" />
<PackageReference Include="IdentityServer4" Version="3.1.2" /> <PackageReference Include="IdentityServer4" Version="3.1.2" />
<PackageReference Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" /> <PackageReference Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" />
<PackageReference Include="IdentityServer4.AspNetIdentity" Version="3.1.2" /> <PackageReference Include="IdentityServer4.AspNetIdentity" Version="3.1.2" />
@ -46,9 +45,9 @@
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="1.1.0" /> <PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="1.1.0" />
<PackageReference Include="Microsoft.Data.Edm" Version="5.8.4" /> <PackageReference Include="Microsoft.Data.Edm" Version="5.8.4" />
<PackageReference Include="Microsoft.OData.Core" Version="7.6.3" /> <PackageReference Include="Microsoft.OData.Core" Version="7.6.3" />
<PackageReference Include="Microsoft.Orleans.Core" Version="3.1.3" /> <PackageReference Include="Microsoft.Orleans.Core" Version="3.1.7" />
<PackageReference Include="Microsoft.Orleans.Core.Abstractions" Version="3.1.3" /> <PackageReference Include="Microsoft.Orleans.Core.Abstractions" Version="3.1.7" />
<PackageReference Include="Microsoft.Orleans.OrleansRuntime" Version="3.1.3" /> <PackageReference Include="Microsoft.Orleans.OrleansRuntime" Version="3.1.7" />
<PackageReference Include="MongoDB.Driver" Version="2.10.3" /> <PackageReference Include="MongoDB.Driver" Version="2.10.3" />
<PackageReference Include="Namotion.Reflection" Version="1.0.10" /> <PackageReference Include="Namotion.Reflection" Version="1.0.10" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
@ -56,7 +55,7 @@
<PackageReference Include="NSwag.AspNetCore" Version="13.2.5" /> <PackageReference Include="NSwag.AspNetCore" Version="13.2.5" />
<PackageReference Include="OpenCover" Version="4.7.922" PrivateAssets="all" /> <PackageReference Include="OpenCover" Version="4.7.922" PrivateAssets="all" />
<PackageReference Include="Orleans.Providers.MongoDB" Version="3.1.1" /> <PackageReference Include="Orleans.Providers.MongoDB" Version="3.1.1" />
<PackageReference Include="OrleansDashboard" Version="3.0.8" /> <PackageReference Include="OrleansDashboard" Version="3.1.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="ReportGenerator" Version="4.5.2" PrivateAssets="all" /> <PackageReference Include="ReportGenerator" Version="4.5.2" PrivateAssets="all" />
<PackageReference Include="Squidex.ClientLibrary" Version="4.2.0" /> <PackageReference Include="Squidex.ClientLibrary" Version="4.2.0" />

271
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterTests.cs

@ -7,7 +7,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Security.Claims; using System.Security.Claims;
using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@ -38,6 +40,26 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
private readonly Guid assetId = Guid.NewGuid(); private readonly Guid assetId = Guid.NewGuid();
private readonly RuleEventFormatter sut; private readonly RuleEventFormatter sut;
private class FakeContentResolver : IRuleEventFormatter
{
public (bool Match, ValueTask<string?>) Format(EnrichedEvent @event, object value, string[] path)
{
if (path[0] == "data" && value is JsonArray _)
{
return (true, GetValueAsync());
}
return default;
}
private async ValueTask<string?> GetValueAsync()
{
await Task.Delay(5);
return "Reference";
}
}
public RuleEventFormatterTests() public RuleEventFormatterTests()
{ {
A.CallTo(() => urlGenerator.ContentUI(appId, schemaId, contentId)) A.CallTo(() => urlGenerator.ContentUI(appId, schemaId, contentId))
@ -64,7 +86,13 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
var cache = new MemoryCache(Options.Create(new MemoryCacheOptions())); var cache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
sut = new RuleEventFormatter(TestUtils.DefaultSerializer, urlGenerator, new JintScriptEngine(cache, extensions)); var formatters = new IRuleEventFormatter[]
{
new PredefinedPatternsFormatter(urlGenerator),
new FakeContentResolver()
};
sut = new RuleEventFormatter(TestUtils.DefaultSerializer, formatters, new JintScriptEngine(cache, extensions));
} }
[Fact] [Fact]
@ -97,13 +125,13 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Theory] [Theory]
[InlineData("Name $APP_NAME has id $APP_ID")] [InlineData("Name $APP_NAME has id $APP_ID")]
[InlineData("Name ${$EVENT_APPID.NAME} has id ${EVENT_APPID.ID}")] [InlineData("Name ${EVENT_APPID.NAME} has id ${EVENT_APPID.ID}")]
[InlineData("Script(`Name ${event.appId.name} has id ${event.appId.id}`)")] [InlineData("Script(`Name ${event.appId.name} has id ${event.appId.id}`)")]
public void Should_format_app_information_from_event(string script) public async Task Should_format_app_information_from_event(string script)
{ {
var @event = new EnrichedContentEvent { AppId = appId }; var @event = new EnrichedContentEvent { AppId = appId };
var result = sut.Format(script, @event); var result = await sut.FormatAsync(script, @event);
Assert.Equal($"Name my-app has id {appId.Id}", result); Assert.Equal($"Name my-app has id {appId.Id}", result);
} }
@ -111,11 +139,11 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Theory] [Theory]
[InlineData("Name $SCHEMA_NAME has id $SCHEMA_ID")] [InlineData("Name $SCHEMA_NAME has id $SCHEMA_ID")]
[InlineData("Script(`Name ${event.schemaId.name} has id ${event.schemaId.id}`)")] [InlineData("Script(`Name ${event.schemaId.name} has id ${event.schemaId.id}`)")]
public void Should_format_schema_information_from_event(string script) public async Task Should_format_schema_information_from_event(string script)
{ {
var @event = new EnrichedContentEvent { SchemaId = schemaId }; var @event = new EnrichedContentEvent { SchemaId = schemaId };
var result = sut.Format(script, @event); var result = await sut.FormatAsync(script, @event);
Assert.Equal($"Name my-schema has id {schemaId.Id}", result); Assert.Equal($"Name my-schema has id {schemaId.Id}", result);
} }
@ -123,11 +151,11 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Theory] [Theory]
[InlineData("Full: $TIMESTAMP_DATETIME")] [InlineData("Full: $TIMESTAMP_DATETIME")]
[InlineData("Script(`Full: ${formatDate(event.timestamp, 'yyyy-MM-dd-hh-mm-ss')}`)")] [InlineData("Script(`Full: ${formatDate(event.timestamp, 'yyyy-MM-dd-hh-mm-ss')}`)")]
public void Should_format_timestamp_information_from_event(string script) public async Task Should_format_timestamp_information_from_event(string script)
{ {
var @event = new EnrichedContentEvent { Timestamp = now }; var @event = new EnrichedContentEvent { Timestamp = now };
var result = sut.Format(script, @event); var result = await sut.FormatAsync(script, @event);
Assert.Equal($"Full: {now:yyyy-MM-dd-hh-mm-ss}", result); Assert.Equal($"Full: {now:yyyy-MM-dd-hh-mm-ss}", result);
} }
@ -135,11 +163,11 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Theory] [Theory]
[InlineData("Date: $TIMESTAMP_DATE")] [InlineData("Date: $TIMESTAMP_DATE")]
[InlineData("Script(`Date: ${formatDate(event.timestamp, 'yyyy-MM-dd')}`)")] [InlineData("Script(`Date: ${formatDate(event.timestamp, 'yyyy-MM-dd')}`)")]
public void Should_format_timestamp_date_information_from_event(string script) public async Task Should_format_timestamp_date_information_from_event(string script)
{ {
var @event = new EnrichedContentEvent { Timestamp = now }; var @event = new EnrichedContentEvent { Timestamp = now };
var result = sut.Format(script, @event); var result = await sut.FormatAsync(script, @event);
Assert.Equal($"Date: {now:yyyy-MM-dd}", result); Assert.Equal($"Date: {now:yyyy-MM-dd}", result);
} }
@ -148,11 +176,11 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[InlineData("From $MENTIONED_NAME ($MENTIONED_EMAIL, $MENTIONED_ID)")] [InlineData("From $MENTIONED_NAME ($MENTIONED_EMAIL, $MENTIONED_ID)")]
[InlineData("From ${COMMENT_MENTIONEDUSER.NAME} (${COMMENT_MENTIONEDUSER.EMAIL}, ${COMMENT_MENTIONEDUSER.ID})")] [InlineData("From ${COMMENT_MENTIONEDUSER.NAME} (${COMMENT_MENTIONEDUSER.EMAIL}, ${COMMENT_MENTIONEDUSER.ID})")]
[InlineData("Script(`From ${event.mentionedUser.name} (${event.mentionedUser.email}, ${event.mentionedUser.id})`)")] [InlineData("Script(`From ${event.mentionedUser.name} (${event.mentionedUser.email}, ${event.mentionedUser.id})`)")]
public void Should_format_email_and_display_name_from_mentioned_user(string script) public async Task Should_format_email_and_display_name_from_mentioned_user(string script)
{ {
var @event = new EnrichedCommentEvent { MentionedUser = user }; var @event = new EnrichedCommentEvent { MentionedUser = user };
var result = sut.Format(script, @event); var result = await sut.FormatAsync(script, @event);
Assert.Equal("From me (me@email.com, user123)", result); Assert.Equal("From me (me@email.com, user123)", result);
} }
@ -160,11 +188,11 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Theory] [Theory]
[InlineData("From $USER_NAME ($USER_EMAIL, $USER_ID)")] [InlineData("From $USER_NAME ($USER_EMAIL, $USER_ID)")]
[InlineData("Script(`From ${event.user.name} (${event.user.email}, ${event.user.id})`)")] [InlineData("Script(`From ${event.user.name} (${event.user.email}, ${event.user.id})`)")]
public void Should_format_email_and_display_name_from_user(string script) public async Task Should_format_email_and_display_name_from_user(string script)
{ {
var @event = new EnrichedContentEvent { User = user }; var @event = new EnrichedContentEvent { User = user };
var result = sut.Format(script, @event); var result = await sut.FormatAsync(script, @event);
Assert.Equal("From me (me@email.com, user123)", result); Assert.Equal("From me (me@email.com, user123)", result);
} }
@ -172,11 +200,11 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Theory] [Theory]
[InlineData("From $USER_NAME ($USER_EMAIL, $USER_ID)")] [InlineData("From $USER_NAME ($USER_EMAIL, $USER_ID)")]
[InlineData("Script(`From ${event.user.name} (${event.user.email}, ${event.user.id})`)")] [InlineData("Script(`From ${event.user.name} (${event.user.email}, ${event.user.id})`)")]
public void Should_return_null_if_user_is_not_found(string script) public async Task Should_return_null_if_user_is_not_found(string script)
{ {
var @event = new EnrichedContentEvent(); var @event = new EnrichedContentEvent();
var result = sut.Format(script, @event); var result = await sut.FormatAsync(script, @event);
Assert.Equal("From null (null, null)", result); Assert.Equal("From null (null, null)", result);
} }
@ -184,11 +212,11 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Theory] [Theory]
[InlineData("From $USER_NAME ($USER_EMAIL, $USER_ID)")] [InlineData("From $USER_NAME ($USER_EMAIL, $USER_ID)")]
[InlineData("Script(`From ${event.user.name} (${event.user.email}, ${event.user.id})`)")] [InlineData("Script(`From ${event.user.name} (${event.user.email}, ${event.user.id})`)")]
public void Should_format_email_and_display_name_from_client(string script) public async Task Should_format_email_and_display_name_from_client(string script)
{ {
var @event = new EnrichedContentEvent { User = new ClientUser(new RefToken(RefTokenType.Client, "android")) }; var @event = new EnrichedContentEvent { User = new ClientUser(new RefToken(RefTokenType.Client, "android")) };
var result = sut.Format(script, @event); var result = await sut.FormatAsync(script, @event);
Assert.Equal("From client:android (client:android, android)", result); Assert.Equal("From client:android (client:android, android)", result);
} }
@ -196,11 +224,11 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Theory] [Theory]
[InlineData("Version: $ASSET_VERSION")] [InlineData("Version: $ASSET_VERSION")]
[InlineData("Script(`Version: ${event.version}`)")] [InlineData("Script(`Version: ${event.version}`)")]
public void Should_format_base_property(string script) public async Task Should_format_base_property(string script)
{ {
var @event = new EnrichedAssetEvent { Version = 13 }; var @event = new EnrichedAssetEvent { Version = 13 };
var result = sut.Format(script, @event); var result = await sut.FormatAsync(script, @event);
Assert.Equal("Version: 13", result); Assert.Equal("Version: 13", result);
} }
@ -208,11 +236,11 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Theory] [Theory]
[InlineData("File: $ASSET_FILENAME")] [InlineData("File: $ASSET_FILENAME")]
[InlineData("Script(`File: ${event.fileName}`)")] [InlineData("Script(`File: ${event.fileName}`)")]
public void Should_format_asset_file_name_from_event(string script) public async Task Should_format_asset_file_name_from_event(string script)
{ {
var @event = new EnrichedAssetEvent { FileName = "my-file.png" }; var @event = new EnrichedAssetEvent { FileName = "my-file.png" };
var result = sut.Format(script, @event); var result = await sut.FormatAsync(script, @event);
Assert.Equal("File: my-file.png", result); Assert.Equal("File: my-file.png", result);
} }
@ -220,11 +248,11 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Theory] [Theory]
[InlineData("Type: $ASSET_ASSETTYPE")] [InlineData("Type: $ASSET_ASSETTYPE")]
[InlineData("Script(`Type: ${event.assetType}`)")] [InlineData("Script(`Type: ${event.assetType}`)")]
public void Should_format_asset_asset_type_from_event(string script) public async Task Should_format_asset_asset_type_from_event(string script)
{ {
var @event = new EnrichedAssetEvent { AssetType = AssetType.Audio }; var @event = new EnrichedAssetEvent { AssetType = AssetType.Audio };
var result = sut.Format(script, @event); var result = await sut.FormatAsync(script, @event);
Assert.Equal("Type: Audio", result); Assert.Equal("Type: Audio", result);
} }
@ -232,11 +260,11 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Theory] [Theory]
[InlineData("Download at $ASSET_CONTENT_URL")] [InlineData("Download at $ASSET_CONTENT_URL")]
[InlineData("Script(`Download at ${assetContentUrl()}`)")] [InlineData("Script(`Download at ${assetContentUrl()}`)")]
public void Should_format_asset_content_url_from_event(string script) public async Task Should_format_asset_content_url_from_event(string script)
{ {
var @event = new EnrichedAssetEvent { Id = assetId }; var @event = new EnrichedAssetEvent { Id = assetId };
var result = sut.Format(script, @event); var result = await sut.FormatAsync(script, @event);
Assert.Equal("Download at asset-content-url", result); Assert.Equal("Download at asset-content-url", result);
} }
@ -244,11 +272,11 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Theory] [Theory]
[InlineData("Download at $ASSET_CONTENT_URL")] [InlineData("Download at $ASSET_CONTENT_URL")]
[InlineData("Script(`Download at ${assetContentUrl()}`)")] [InlineData("Script(`Download at ${assetContentUrl()}`)")]
public void Should_return_null_when_asset_content_url_not_found(string script) public async Task Should_return_null_when_asset_content_url_not_found(string script)
{ {
var @event = new EnrichedContentEvent(); var @event = new EnrichedContentEvent();
var result = sut.Format(script, @event); var result = await sut.FormatAsync(script, @event);
Assert.Equal("Download at null", result); Assert.Equal("Download at null", result);
} }
@ -256,11 +284,11 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Theory] [Theory]
[InlineData("Go to $CONTENT_URL")] [InlineData("Go to $CONTENT_URL")]
[InlineData("Script(`Go to ${contentUrl()}`)")] [InlineData("Script(`Go to ${contentUrl()}`)")]
public void Should_format_content_url_from_event(string script) public async Task Should_format_content_url_from_event(string script)
{ {
var @event = new EnrichedContentEvent { AppId = appId, Id = contentId, SchemaId = schemaId }; var @event = new EnrichedContentEvent { AppId = appId, Id = contentId, SchemaId = schemaId };
var result = sut.Format(script, @event); var result = await sut.FormatAsync(script, @event);
Assert.Equal("Go to content-url", result); Assert.Equal("Go to content-url", result);
} }
@ -268,11 +296,11 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Theory] [Theory]
[InlineData("Go to $CONTENT_URL")] [InlineData("Go to $CONTENT_URL")]
[InlineData("Script(`Go to ${contentUrl()}`)")] [InlineData("Script(`Go to ${contentUrl()}`)")]
public void Should_return_null_when_content_url_when_not_found(string script) public async Task Should_return_null_when_content_url_when_not_found(string script)
{ {
var @event = new EnrichedAssetEvent(); var @event = new EnrichedAssetEvent();
var result = sut.Format(script, @event); var result = await sut.FormatAsync(script, @event);
Assert.Equal("Go to null", result); Assert.Equal("Go to null", result);
} }
@ -281,11 +309,11 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[InlineData("$CONTENT_STATUS")] [InlineData("$CONTENT_STATUS")]
[InlineData("Script(contentAction())")] [InlineData("Script(contentAction())")]
[InlineData("Script(`${event.status}`)")] [InlineData("Script(`${event.status}`)")]
public void Should_format_content_status_when_found(string script) public async Task Should_format_content_status_when_found(string script)
{ {
var @event = new EnrichedContentEvent { Status = Status.Published }; var @event = new EnrichedContentEvent { Status = Status.Published };
var result = sut.Format(script, @event); var result = await sut.FormatAsync(script, @event);
Assert.Equal("Published", result); Assert.Equal("Published", result);
} }
@ -293,11 +321,11 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Theory] [Theory]
[InlineData("$CONTENT_ACTION")] [InlineData("$CONTENT_ACTION")]
[InlineData("Script(contentAction())")] [InlineData("Script(contentAction())")]
public void Should_return_null_when_content_status_not_found(string script) public async Task Should_return_null_when_content_status_not_found(string script)
{ {
var @event = new EnrichedAssetEvent(); var @event = new EnrichedAssetEvent();
var result = sut.Format(script, @event); var result = await sut.FormatAsync(script, @event);
Assert.Equal("null", result); Assert.Equal("null", result);
} }
@ -305,11 +333,11 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Theory] [Theory]
[InlineData("$CONTENT_ACTION")] [InlineData("$CONTENT_ACTION")]
[InlineData("Script(`${event.type}`)")] [InlineData("Script(`${event.type}`)")]
public void Should_format_content_actions_when_found(string script) public async Task Should_format_content_actions_when_found(string script)
{ {
var @event = new EnrichedContentEvent { Type = EnrichedContentEventType.Created }; var @event = new EnrichedContentEvent { Type = EnrichedContentEventType.Created };
var result = sut.Format(script, @event); var result = await sut.FormatAsync(script, @event);
Assert.Equal("Created", result); Assert.Equal("Created", result);
} }
@ -317,11 +345,11 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Theory] [Theory]
[InlineData("$CONTENT_ACTION")] [InlineData("$CONTENT_ACTION")]
[InlineData("Script(contentAction())")] [InlineData("Script(contentAction())")]
public void Should_return_null_when_content_action_not_found(string script) public async Task Should_return_null_when_content_action_not_found(string script)
{ {
var @event = new EnrichedAssetEvent(); var @event = new EnrichedAssetEvent();
var result = sut.Format(script, @event); var result = await sut.FormatAsync(script, @event);
Assert.Equal("null", result); Assert.Equal("null", result);
} }
@ -329,7 +357,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Theory] [Theory]
[InlineData("$CONTENT_DATA.country.iv")] [InlineData("$CONTENT_DATA.country.iv")]
[InlineData("Script(`${event.data.country.iv}`)")] [InlineData("Script(`${event.data.country.iv}`)")]
public void Should_return_null_when_field_not_found(string script) public async Task Should_return_null_when_field_not_found(string script)
{ {
var @event = new EnrichedContentEvent var @event = new EnrichedContentEvent
{ {
@ -340,7 +368,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
.AddValue("iv", "Berlin")) .AddValue("iv", "Berlin"))
}; };
var result = sut.Format(script, @event); var result = await sut.FormatAsync(script, @event);
Assert.Equal("null", result); Assert.Equal("null", result);
} }
@ -348,7 +376,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Theory] [Theory]
[InlineData("$CONTENT_DATA.city.de")] [InlineData("$CONTENT_DATA.city.de")]
[InlineData("Script(`${event.data.country.iv}`)")] [InlineData("Script(`${event.data.country.iv}`)")]
public void Should_return_null_when_partition_not_found(string script) public async Task Should_return_null_when_partition_not_found(string script)
{ {
var @event = new EnrichedContentEvent var @event = new EnrichedContentEvent
{ {
@ -359,7 +387,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
.AddValue("iv", "Berlin")) .AddValue("iv", "Berlin"))
}; };
var result = sut.Format(script, @event); var result = await sut.FormatAsync(script, @event);
Assert.Equal("null", result); Assert.Equal("null", result);
} }
@ -367,7 +395,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Theory] [Theory]
[InlineData("$CONTENT_DATA.city.iv.10")] [InlineData("$CONTENT_DATA.city.iv.10")]
[InlineData("Script(`${event.data.country.de[10]}`)")] [InlineData("Script(`${event.data.country.de[10]}`)")]
public void Should_return_null_when_array_item_not_found(string script) public async Task Should_return_null_when_array_item_not_found(string script)
{ {
var @event = new EnrichedContentEvent var @event = new EnrichedContentEvent
{ {
@ -378,7 +406,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
.AddJsonValue(JsonValue.Array())) .AddJsonValue(JsonValue.Array()))
}; };
var result = sut.Format(script, @event); var result = await sut.FormatAsync(script, @event);
Assert.Equal("null", result); Assert.Equal("null", result);
} }
@ -386,7 +414,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Theory] [Theory]
[InlineData("$CONTENT_DATA.city.de.Name")] [InlineData("$CONTENT_DATA.city.de.Name")]
[InlineData("Script(`${event.data.city.de.Location}`)")] [InlineData("Script(`${event.data.city.de.Location}`)")]
public void Should_return_null_when_property_not_found(string script) public async Task Should_return_null_when_property_not_found(string script)
{ {
var @event = new EnrichedContentEvent var @event = new EnrichedContentEvent
{ {
@ -397,7 +425,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
.AddJsonValue(JsonValue.Object().Add("name", "Berlin"))) .AddJsonValue(JsonValue.Object().Add("name", "Berlin")))
}; };
var result = sut.Format(script, @event); var result = await sut.FormatAsync(script, @event);
Assert.Equal("null", result); Assert.Equal("null", result);
} }
@ -405,7 +433,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Theory] [Theory]
[InlineData("$CONTENT_DATA.city.iv")] [InlineData("$CONTENT_DATA.city.iv")]
[InlineData("Script(`${event.data.city.iv}`)")] [InlineData("Script(`${event.data.city.iv}`)")]
public void Should_return_plain_value_when_found(string script) public async Task Should_return_plain_value_when_found(string script)
{ {
var @event = new EnrichedContentEvent var @event = new EnrichedContentEvent
{ {
@ -416,7 +444,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
.AddValue("iv", "Berlin")) .AddValue("iv", "Berlin"))
}; };
var result = sut.Format(script, @event); var result = await sut.FormatAsync(script, @event);
Assert.Equal("Berlin", result); Assert.Equal("Berlin", result);
} }
@ -424,7 +452,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Theory] [Theory]
[InlineData("$CONTENT_DATA.city.iv.0")] [InlineData("$CONTENT_DATA.city.iv.0")]
[InlineData("Script(`${event.data.city.iv[0]}`)")] [InlineData("Script(`${event.data.city.iv[0]}`)")]
public void Should_return_plain_value_from_array_when_found(string script) public async Task Should_return_plain_value_from_array_when_found(string script)
{ {
var @event = new EnrichedContentEvent var @event = new EnrichedContentEvent
{ {
@ -435,7 +463,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
.AddJsonValue(JsonValue.Array("Berlin"))) .AddJsonValue(JsonValue.Array("Berlin")))
}; };
var result = sut.Format(script, @event); var result = await sut.FormatAsync(script, @event);
Assert.Equal("Berlin", result); Assert.Equal("Berlin", result);
} }
@ -443,7 +471,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Theory] [Theory]
[InlineData("$CONTENT_DATA.city.iv.name")] [InlineData("$CONTENT_DATA.city.iv.name")]
[InlineData("Script(`${event.data.city.iv.name}`)")] [InlineData("Script(`${event.data.city.iv.name}`)")]
public void Should_return_plain_value_from_object_when_found(string script) public async Task Should_return_plain_value_from_object_when_found(string script)
{ {
var @event = new EnrichedContentEvent var @event = new EnrichedContentEvent
{ {
@ -454,7 +482,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
.AddJsonValue(JsonValue.Object().Add("name", "Berlin"))) .AddJsonValue(JsonValue.Object().Add("name", "Berlin")))
}; };
var result = sut.Format(script, @event); var result = await sut.FormatAsync(script, @event);
Assert.Equal("Berlin", result); Assert.Equal("Berlin", result);
} }
@ -462,7 +490,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Theory] [Theory]
[InlineData("$CONTENT_DATA.city.iv")] [InlineData("$CONTENT_DATA.city.iv")]
[InlineData("Script(`${JSON.stringify(event.data.city.iv)}`)")] [InlineData("Script(`${JSON.stringify(event.data.city.iv)}`)")]
public void Should_return_json_string_when_object(string script) public async Task Should_return_json_string_when_object(string script)
{ {
var @event = new EnrichedContentEvent var @event = new EnrichedContentEvent
{ {
@ -473,58 +501,169 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
.AddJsonValue(JsonValue.Object().Add("name", "Berlin"))) .AddJsonValue(JsonValue.Object().Add("name", "Berlin")))
}; };
var result = sut.Format(script, @event); var result = await sut.FormatAsync(script, @event);
Assert.Equal("{\"name\":\"Berlin\"}", result); Assert.Equal("{\"name\":\"Berlin\"}", result);
} }
[Fact]
public async Task Should_resolve_reference()
{
var @event = new EnrichedContentEvent
{
Data =
new NamedContentData()
.AddField("city",
new ContentFieldData()
.AddJsonValue(JsonValue.Array()))
};
var result = await sut.FormatAsync("${CONTENT_DATA.city.iv.data.name}", @event);
Assert.Equal("Reference", result);
}
[Theory] [Theory]
[InlineData("Script(`From ${event.actor}`)")] [InlineData("Script(`From ${event.actor}`)")]
public void Should_format_actor(string script) public async Task Should_format_actor(string script)
{ {
var @event = new EnrichedContentEvent { Actor = new RefToken(RefTokenType.Client, "android") }; var @event = new EnrichedContentEvent { Actor = new RefToken(RefTokenType.Client, "android") };
var result = sut.Format(script, @event); var result = await sut.FormatAsync(script, @event);
Assert.Equal("From client:android", result); Assert.Equal("From client:android", result);
} }
[Theory]
[InlineData("${EVENT_INVALID ? file}", "file")]
public async Task Should_provide_fallback_if_path_is_invalid(string script, string expect)
{
var @event = new EnrichedAssetEvent { FileName = null! };
var result = await sut.FormatAsync(script, @event);
Assert.Equal(expect, result);
}
[Theory]
[InlineData("${ASSET_FILENAME ? file}", "file")]
public async Task Should_provide_fallback_if_value_is_null(string script, string expect)
{
var @event = new EnrichedAssetEvent { FileName = null! };
var result = await sut.FormatAsync(script, @event);
Assert.Equal(expect, result);
}
[Theory]
[InlineData("Found in ${ASSET_FILENAME | Upper}.docx", "Found in DONALD DUCK.docx")]
[InlineData("Found in ${ASSET_FILENAME| Upper }.docx", "Found in DONALD DUCK.docx")]
[InlineData("Found in ${ASSET_FILENAME|Upper }.docx", "Found in DONALD DUCK.docx")]
public async Task Should_transform_replacements_and_igore_whitepsaces(string script, string expect)
{
var @event = new EnrichedAssetEvent { FileName = "Donald Duck" };
var result = await sut.FormatAsync(script, @event);
Assert.Equal(expect, result);
}
[Theory]
[InlineData("Found in ${ASSET_FILENAME | Escape | Upper}.docx", "Found in DONALD\\\"DUCK.docx", "Donald\"Duck")]
[InlineData("Found in ${ASSET_FILENAME | Escape}.docx", "Found in Donald\\\"Duck.docx", "Donald\"Duck")]
[InlineData("Found in ${ASSET_FILENAME | Upper}.docx", "Found in DONALD DUCK.docx", "Donald Duck")]
[InlineData("Found in ${ASSET_FILENAME | Lower}.docx", "Found in donald duck.docx", "Donald Duck")]
[InlineData("Found in ${ASSET_FILENAME | Slugify}.docx", "Found in donald-duck.docx", "Donald Duck")]
[InlineData("Found in ${ASSET_FILENAME | Trim}.docx", "Found in Donald Duck.docx", "Donald Duck ")]
public async Task Should_transform_replacements(string script, string expect, string name)
{
var @event = new EnrichedAssetEvent { FileName = name };
var result = await sut.FormatAsync(script, @event);
Assert.Equal(expect, result);
}
[Theory]
[InlineData("From ${USER_NAME | Escape | Upper}", "From DONALD\\\"DUCK", "Donald\"Duck")]
[InlineData("From ${USER_NAME | Escape}", "From Donald\\\"Duck", "Donald\"Duck")]
[InlineData("From ${USER_NAME | Upper}", "From DONALD DUCK", "Donald Duck")]
[InlineData("From ${USER_NAME | Lower}", "From donald duck", "Donald Duck")]
[InlineData("From ${USER_NAME | Slugify}", "From donald-duck", "Donald Duck")]
[InlineData("From ${USER_NAME | Trim}", "From Donald Duck", "Donald Duck ")]
public async Task Should_transform_replacements_with_simple_pattern(string script, string expect, string name)
{
var @event = new EnrichedContentEvent { User = user };
A.CallTo(() => user.Claims)
.Returns(new List<Claim> { new Claim(SquidexClaimTypes.DisplayName, name) });
var result = await sut.FormatAsync(script, @event);
Assert.Equal(expect, result);
}
[Theory]
[InlineData("{'Key':'${ASSET_FILENAME | Upper}'}", "{'Key':'DONALD DUCK'}")]
[InlineData("{'Key':'${ASSET_FILENAME}'}", "{'Key':'Donald Duck'}")]
public async Task Should_transform_json_examples(string script, string expect)
{
var @event = new EnrichedAssetEvent { FileName = "Donald Duck" };
var result = await sut.FormatAsync(script, @event);
Assert.Equal(expect, result);
}
[Theory]
[InlineData("${ASSET_LASTMODIFIED | timestamp_seconds}", "1590769584")]
[InlineData("${ASSET_LASTMODIFIED | timestamp_ms}", "1590769584000")]
public async Task Should_transform_timestamp(string script, string expect)
{
var @event = new EnrichedAssetEvent { LastModified = Instant.FromUnixTimeSeconds(1590769584) };
var result = await sut.FormatAsync(script, @event);
Assert.Equal(expect, result);
}
[Fact] [Fact]
public void Should_format_json() public async Task Should_format_json()
{ {
var @event = new EnrichedContentEvent { Actor = new RefToken(RefTokenType.Client, "android") }; var @event = new EnrichedContentEvent { Actor = new RefToken(RefTokenType.Client, "android") };
var result = sut.Format("Script(JSON.stringify({ actor: event.actor.toString() }))", @event); var result = await sut.FormatAsync("Script(JSON.stringify({ actor: event.actor.toString() }))", @event);
Assert.Equal("{\"actor\":\"client:android\"}", result); Assert.Equal("{\"actor\":\"client:android\"}", result);
} }
[Fact] [Fact]
public void Should_format_json_with_special_characters() public async Task Should_format_json_with_special_characters()
{ {
var @event = new EnrichedContentEvent { Actor = new RefToken(RefTokenType.Client, "mobile\"android") }; var @event = new EnrichedContentEvent { Actor = new RefToken(RefTokenType.Client, "mobile\"android") };
var result = sut.Format("Script(JSON.stringify({ actor: event.actor.toString() }))", @event); var result = await sut.FormatAsync("Script(JSON.stringify({ actor: event.actor.toString() }))", @event);
Assert.Equal("{\"actor\":\"client:mobile\\\"android\"}", result); Assert.Equal("{\"actor\":\"client:mobile\\\"android\"}", result);
} }
[Fact] [Fact]
public void Should_evaluate_script_if_starting_with_whitespace() public async Task Should_evaluate_script_if_starting_with_whitespace()
{ {
var @event = new EnrichedContentEvent { Type = EnrichedContentEventType.Created }; var @event = new EnrichedContentEvent { Type = EnrichedContentEventType.Created };
var result = sut.Format(" Script(`${event.type}`)", @event); var result = await sut.FormatAsync(" Script(`${event.type}`)", @event);
Assert.Equal("Created", result); Assert.Equal("Created", result);
} }
[Fact] [Fact]
public void Should_evaluate_script_if_ends_with_whitespace() public async Task Should_evaluate_script_if_ends_with_whitespace()
{ {
var @event = new EnrichedContentEvent { Type = EnrichedContentEventType.Created }; var @event = new EnrichedContentEvent { Type = EnrichedContentEventType.Created };
var result = sut.Format("Script(`${event.type}`) ", @event); var result = await sut.FormatAsync("Script(`${event.type}`) ", @event);
Assert.Equal("Created", result); Assert.Equal("Created", result);
} }

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Plans/UsageGateTests.cs

@ -51,7 +51,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans
A.CallTo(() => appPlan.BlockingApiCalls) A.CallTo(() => appPlan.BlockingApiCalls)
.ReturnsLazily(x => apiCallsBlocking); .ReturnsLazily(x => apiCallsBlocking);
A.CallTo(() => usageTracker.GetMonthCostsAsync(appId.Id.ToString(), today)) A.CallTo(() => usageTracker.GetMonthCallsAsync(appId.Id.ToString(), today))
.ReturnsLazily(x => Task.FromResult(apiCallsCurrent)); .ReturnsLazily(x => Task.FromResult(apiCallsCurrent));
sut = new UsageGate(appPlansProvider, usageTracker, grainFactory); sut = new UsageGate(appPlansProvider, usageTracker, grainFactory);

106
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs

@ -20,7 +20,9 @@ using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Assets; using Squidex.Domain.Apps.Events.Assets;
using Squidex.Domain.Apps.Events.Contents; using Squidex.Domain.Apps.Events.Contents;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Json.Objects;
using Xunit; using Xunit;
#pragma warning disable SA1401 // Fields must be private #pragma warning disable SA1401 // Fields must be private
@ -31,8 +33,10 @@ namespace Squidex.Domain.Apps.Entities.Contents
public class ContentChangedTriggerHandlerTests public class ContentChangedTriggerHandlerTests
{ {
private readonly IScriptEngine scriptEngine = A.Fake<IScriptEngine>(); private readonly IScriptEngine scriptEngine = A.Fake<IScriptEngine>();
private readonly ILocalCache localCache = new AsyncLocalCache();
private readonly IContentLoader contentLoader = A.Fake<IContentLoader>(); private readonly IContentLoader contentLoader = A.Fake<IContentLoader>();
private readonly IRuleTriggerHandler sut; private readonly ContentChangedTriggerHandler sut;
private readonly IRuleTriggerHandler handler;
private readonly Guid ruleId = Guid.NewGuid(); private readonly Guid ruleId = Guid.NewGuid();
private static readonly NamedId<Guid> SchemaMatch = NamedId.Of(Guid.NewGuid(), "my-schema1"); private static readonly NamedId<Guid> SchemaMatch = NamedId.Of(Guid.NewGuid(), "my-schema1");
private static readonly NamedId<Guid> SchemaNonMatch = NamedId.Of(Guid.NewGuid(), "my-schema2"); private static readonly NamedId<Guid> SchemaNonMatch = NamedId.Of(Guid.NewGuid(), "my-schema2");
@ -45,7 +49,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
A.CallTo(() => scriptEngine.Evaluate(A<ScriptContext>._, "false")) A.CallTo(() => scriptEngine.Evaluate(A<ScriptContext>._, "false"))
.Returns(false); .Returns(false);
sut = new ContentChangedTriggerHandler(scriptEngine, contentLoader); sut = new ContentChangedTriggerHandler(scriptEngine, contentLoader, localCache);
handler = sut;
} }
public static IEnumerable<object[]> TestEvents() public static IEnumerable<object[]> TestEvents()
@ -58,6 +64,58 @@ namespace Squidex.Domain.Apps.Entities.Contents
yield return new object[] { new ContentStatusChanged { Change = StatusChange.Unpublished }, EnrichedContentEventType.Unpublished }; yield return new object[] { new ContentStatusChanged { Change = StatusChange.Unpublished }, EnrichedContentEventType.Unpublished };
} }
[Fact]
public async Task Should_resolve_reference_if_value_from_content_loader()
{
var referenceId = Guid.NewGuid();
var referenceValue = JsonValue.Array(referenceId);
SetupReference(referenceId);
var (handled, result) = sut.Format(null!, referenceValue, new[] { "data", "field1", "iv" });
Assert.True(handled);
Assert.Equal("Hello", await result);
}
[Fact]
public async Task Should_resolve_reference_only_once()
{
using (localCache.StartContext())
{
var referenceId = Guid.NewGuid();
var referenceValue = JsonValue.Array(referenceId);
SetupReference(referenceId);
var (handled1, result1) = sut.Format(null!, referenceValue, new[] { "data", "field1", "iv" });
var (handled2, result2) = sut.Format(null!, referenceValue, new[] { "data", "field2", "iv" });
Assert.True(handled1);
Assert.Equal("Hello", await result1);
Assert.True(handled2);
Assert.Equal("World", await result2);
A.CallTo(() => contentLoader.GetAsync(A<Guid>._, A<long>._))
.MustHaveHappenedOnceExactly();
}
}
[Fact]
public async Task Should_not_return_value_if_path_not_found_in_reference()
{
var referenceId = Guid.NewGuid();
var referenceValue = JsonValue.Array(referenceId);
SetupReference(referenceId);
var (handled, result) = sut.Format(null!, referenceValue, new[] { "data", "invalid", "iv" });
Assert.True(handled);
Assert.Null(await result);
}
[Theory] [Theory]
[MemberData(nameof(TestEvents))] [MemberData(nameof(TestEvents))]
public async Task Should_create_enriched_events(ContentEvent @event, EnrichedContentEventType type) public async Task Should_create_enriched_events(ContentEvent @event, EnrichedContentEventType type)
@ -67,7 +125,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
A.CallTo(() => contentLoader.GetAsync(@event.ContentId, 12)) A.CallTo(() => contentLoader.GetAsync(@event.ContentId, 12))
.Returns(new ContentEntity { SchemaId = SchemaMatch }); .Returns(new ContentEntity { SchemaId = SchemaMatch });
var result = await sut.CreateEnrichedEventsAsync(envelope); var result = await handler.CreateEnrichedEventsAsync(envelope);
var enrichedEvent = result.Single() as EnrichedContentEvent; var enrichedEvent = result.Single() as EnrichedContentEvent;
@ -90,7 +148,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
A.CallTo(() => contentLoader.GetAsync(@event.ContentId, 11)) A.CallTo(() => contentLoader.GetAsync(@event.ContentId, 11))
.Returns(new ContentEntity { SchemaId = SchemaMatch, Version = 11, Data = dataOld }); .Returns(new ContentEntity { SchemaId = SchemaMatch, Version = 11, Data = dataOld });
var result = await sut.CreateEnrichedEventsAsync(envelope); var result = await handler.CreateEnrichedEventsAsync(envelope);
var enrichedEvent = result.Single() as EnrichedContentEvent; var enrichedEvent = result.Single() as EnrichedContentEvent;
@ -103,7 +161,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
TestForTrigger(handleAll: true, schemaId: null, condition: null, action: trigger => TestForTrigger(handleAll: true, schemaId: null, condition: null, action: trigger =>
{ {
var result = sut.Trigger(new AssetCreated(), trigger, ruleId); var result = handler.Trigger(new AssetCreated(), trigger, ruleId);
Assert.False(result); Assert.False(result);
}); });
@ -114,7 +172,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
TestForTrigger(handleAll: false, schemaId: null, condition: null, action: trigger => TestForTrigger(handleAll: false, schemaId: null, condition: null, action: trigger =>
{ {
var result = sut.Trigger(new ContentCreated { SchemaId = SchemaMatch }, trigger, ruleId); var result = handler.Trigger(new ContentCreated { SchemaId = SchemaMatch }, trigger, ruleId);
Assert.False(result); Assert.False(result);
}); });
@ -125,7 +183,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
TestForTrigger(handleAll: true, schemaId: SchemaMatch, condition: null, action: trigger => TestForTrigger(handleAll: true, schemaId: SchemaMatch, condition: null, action: trigger =>
{ {
var result = sut.Trigger(new ContentCreated { SchemaId = SchemaMatch }, trigger, ruleId); var result = handler.Trigger(new ContentCreated { SchemaId = SchemaMatch }, trigger, ruleId);
Assert.True(result); Assert.True(result);
}); });
@ -136,7 +194,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
TestForTrigger(handleAll: false, schemaId: SchemaMatch, condition: string.Empty, action: trigger => TestForTrigger(handleAll: false, schemaId: SchemaMatch, condition: string.Empty, action: trigger =>
{ {
var result = sut.Trigger(new ContentCreated { SchemaId = SchemaMatch }, trigger, ruleId); var result = handler.Trigger(new ContentCreated { SchemaId = SchemaMatch }, trigger, ruleId);
Assert.True(result); Assert.True(result);
}); });
@ -147,7 +205,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
TestForTrigger(handleAll: false, schemaId: SchemaNonMatch, condition: null, action: trigger => TestForTrigger(handleAll: false, schemaId: SchemaNonMatch, condition: null, action: trigger =>
{ {
var result = sut.Trigger(new ContentCreated { SchemaId = SchemaMatch }, trigger, ruleId); var result = handler.Trigger(new ContentCreated { SchemaId = SchemaMatch }, trigger, ruleId);
Assert.False(result); Assert.False(result);
}); });
@ -158,7 +216,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
TestForTrigger(handleAll: true, schemaId: null, condition: null, action: trigger => TestForTrigger(handleAll: true, schemaId: null, condition: null, action: trigger =>
{ {
var result = sut.Trigger(new EnrichedAssetEvent(), trigger); var result = handler.Trigger(new EnrichedAssetEvent(), trigger);
Assert.False(result); Assert.False(result);
}); });
@ -169,7 +227,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
TestForTrigger(handleAll: false, schemaId: null, condition: null, action: trigger => TestForTrigger(handleAll: false, schemaId: null, condition: null, action: trigger =>
{ {
var result = sut.Trigger(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger); var result = handler.Trigger(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger);
Assert.False(result); Assert.False(result);
}); });
@ -180,7 +238,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
TestForTrigger(handleAll: true, schemaId: SchemaMatch, condition: null, action: trigger => TestForTrigger(handleAll: true, schemaId: SchemaMatch, condition: null, action: trigger =>
{ {
var result = sut.Trigger(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger); var result = handler.Trigger(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger);
Assert.True(result); Assert.True(result);
}); });
@ -191,7 +249,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
TestForTrigger(handleAll: false, schemaId: SchemaMatch, condition: string.Empty, action: trigger => TestForTrigger(handleAll: false, schemaId: SchemaMatch, condition: string.Empty, action: trigger =>
{ {
var result = sut.Trigger(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger); var result = handler.Trigger(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger);
Assert.True(result); Assert.True(result);
}); });
@ -202,7 +260,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
TestForTrigger(handleAll: false, schemaId: SchemaMatch, condition: "true", action: trigger => TestForTrigger(handleAll: false, schemaId: SchemaMatch, condition: "true", action: trigger =>
{ {
var result = sut.Trigger(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger); var result = handler.Trigger(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger);
Assert.True(result); Assert.True(result);
}); });
@ -213,7 +271,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
TestForTrigger(handleAll: false, schemaId: SchemaNonMatch, condition: null, action: trigger => TestForTrigger(handleAll: false, schemaId: SchemaNonMatch, condition: null, action: trigger =>
{ {
var result = sut.Trigger(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger); var result = handler.Trigger(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger);
Assert.False(result); Assert.False(result);
}); });
@ -224,7 +282,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
TestForTrigger(handleAll: false, schemaId: SchemaMatch, condition: "false", action: trigger => TestForTrigger(handleAll: false, schemaId: SchemaMatch, condition: "false", action: trigger =>
{ {
var result = sut.Trigger(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger); var result = handler.Trigger(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger);
Assert.False(result); Assert.False(result);
}); });
@ -258,5 +316,21 @@ namespace Squidex.Domain.Apps.Entities.Contents
.MustHaveHappened(); .MustHaveHappened();
} }
} }
private void SetupReference(Guid referenceId)
{
A.CallTo(() => contentLoader.GetAsync(referenceId, EtagVersion.Any))
.Returns(new ContentEntity
{
Data =
new NamedContentData()
.AddField("field1",
new ContentFieldData()
.AddJsonValue(JsonValue.Create("Hello")))
.AddField("field2",
new ContentFieldData()
.AddJsonValue(JsonValue.Create("World")))
});
}
} }
} }

10
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryFixture.cs

@ -9,6 +9,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
using LoremNET;
using MongoDB.Bson; using MongoDB.Bson;
using MongoDB.Driver; using MongoDB.Driver;
using Newtonsoft.Json; using Newtonsoft.Json;
@ -102,13 +103,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb
{ {
for (var i = 0; i < numValues; i++) for (var i = 0; i < numValues; i++)
{ {
var value = i.ToString();
var data = var data =
new IdContentData() new IdContentData()
.AddField(1, .AddField(1,
new ContentFieldData() new ContentFieldData()
.AddJsonValue(JsonValue.Create(value))); .AddJsonValue(JsonValue.Create(i)))
.AddField(2,
new ContentFieldData()
.AddJsonValue(JsonValue.Create(Lorem.Paragraph(200, 20))));
var content = new MongoContentEntity var content = new MongoContentEntity
{ {
@ -198,7 +200,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb
{ {
var schemaDef = var schemaDef =
new Schema("my-schema") new Schema("my-schema")
.AddField(Fields.String(1, "value", Partitioning.Invariant)); .AddField(Fields.Number(1, "value", Partitioning.Invariant));
var schema = var schema =
Mocks.Schema( Mocks.Schema(

50
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryTests.cs

@ -62,11 +62,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb
[Fact] [Fact]
public async Task Should_query_contents_by_filter() public async Task Should_query_contents_by_filter()
{ {
var filter = F.Eq("data.value.iv", _.RandomValue()); var filter = F.Eq("data.value.iv", 12);
var contents = await _.ContentRepository.QueryIdsAsync(_.RandomAppId(), _.RandomSchemaId(), filter); var contents = await _.ContentRepository.QueryIdsAsync(_.RandomAppId(), _.RandomSchemaId(), filter);
Assert.NotNull(contents); Assert.NotEmpty(contents);
} }
[Fact] [Fact]
@ -84,7 +84,23 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb
var contents = await QueryAsync(query); var contents = await QueryAsync(query);
Assert.NotNull(contents); Assert.NotEmpty(contents);
}
[Fact]
public async Task Should_query_contents_with_large_skip()
{
var query = new ClrQuery
{
Sort = new List<SortNode>
{
new SortNode("data.value.iv", SortOrder.Ascending)
}
};
var contents = await QueryAsync(query, 1000, 9000);
Assert.NotEmpty(contents);
} }
[Fact] [Fact]
@ -105,19 +121,33 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb
{ {
var query = new ClrQuery var query = new ClrQuery
{ {
Filter = F.Eq("data.value.iv", _.RandomValue()) Filter = F.Eq("data.value.iv", 200)
}; };
var contents = await QueryAsync(query); var contents = await QueryAsync(query, 1000, 0);
Assert.NotNull(contents); Assert.NotEmpty(contents);
} }
private async Task<IResultList<IContentEntity>> QueryAsync(ClrQuery clrQuery) private async Task<IResultList<IContentEntity>> QueryAsync(ClrQuery clrQuery, int take = 1000, int skip = 100)
{ {
clrQuery.Top = 1000; if (clrQuery.Take == long.MaxValue)
clrQuery.Skip = 100; {
clrQuery.Sort = new List<SortNode> { new SortNode("LastModified", SortOrder.Descending) }; clrQuery.Take = take;
}
if (clrQuery.Skip == 0)
{
clrQuery.Skip = skip;
}
if (clrQuery.Sort.Count == 0)
{
clrQuery.Sort = new List<SortNode>
{
new SortNode("LastModified", SortOrder.Descending)
};
}
var contents = await _.ContentRepository.QueryAsync(_.RandomApp(), _.RandomSchema(), clrQuery, SearchScope.All); var contents = await _.ContentRepository.QueryAsync(_.RandomApp(), _.RandomSchema(), clrQuery, SearchScope.All);

6
backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs

@ -18,6 +18,7 @@ using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Entities.Rules.Repositories; using Squidex.Domain.Apps.Entities.Rules.Repositories;
using Squidex.Domain.Apps.Events.Contents; using Squidex.Domain.Apps.Events.Contents;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
using Xunit; using Xunit;
@ -27,6 +28,7 @@ namespace Squidex.Domain.Apps.Entities.Rules
{ {
private readonly IAppProvider appProvider = A.Fake<IAppProvider>(); private readonly IAppProvider appProvider = A.Fake<IAppProvider>();
private readonly IMemoryCache cache = new MemoryCache(Options.Create(new MemoryCacheOptions())); private readonly IMemoryCache cache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
private readonly ILocalCache localCache = A.Fake<ILocalCache>();
private readonly IRuleEventRepository ruleEventRepository = A.Fake<IRuleEventRepository>(); private readonly IRuleEventRepository ruleEventRepository = A.Fake<IRuleEventRepository>();
private readonly Instant now = SystemClock.Instance.GetCurrentInstant(); private readonly Instant now = SystemClock.Instance.GetCurrentInstant();
private readonly NamedId<Guid> appId = NamedId.Of(Guid.NewGuid(), "my-app"); private readonly NamedId<Guid> appId = NamedId.Of(Guid.NewGuid(), "my-app");
@ -43,6 +45,7 @@ namespace Squidex.Domain.Apps.Entities.Rules
sut = new RuleEnqueuer( sut = new RuleEnqueuer(
appProvider, appProvider,
cache, cache,
localCache,
ruleEventRepository, ruleEventRepository,
ruleService); ruleService);
} }
@ -81,6 +84,9 @@ namespace Squidex.Domain.Apps.Entities.Rules
A.CallTo(() => ruleEventRepository.EnqueueAsync(job, now, default)) A.CallTo(() => ruleEventRepository.EnqueueAsync(job, now, default))
.MustHaveHappened(); .MustHaveHappened();
A.CallTo(() => localCache.StartContext())
.MustHaveHappened();
} }
[Fact] [Fact]

1
backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj

@ -20,6 +20,7 @@
<PackageReference Include="FakeItEasy" Version="6.0.0" /> <PackageReference Include="FakeItEasy" Version="6.0.0" />
<PackageReference Include="FluentAssertions" Version="5.10.2" /> <PackageReference Include="FluentAssertions" Version="5.10.2" />
<PackageReference Include="GraphQL" Version="2.4.0" /> <PackageReference Include="GraphQL" Version="2.4.0" />
<PackageReference Include="Lorem.Universal.Net" Version="3.0.64" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="3.1.2" /> <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="3.1.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.5.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.5.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />

18
backend/tests/Squidex.Infrastructure.Tests/Orleans/JsonExternalSerializerTests.cs

@ -78,7 +78,7 @@ namespace Squidex.Infrastructure.Orleans
} }
} }
private static DeserializationContext CreateReader(MemoryStream buffer) private static IDeserializationContext CreateReader(MemoryStream buffer)
{ {
var reader = A.Fake<IBinaryTokenStreamReader>(); var reader = A.Fake<IBinaryTokenStreamReader>();
@ -89,10 +89,15 @@ namespace Squidex.Infrastructure.Orleans
A.CallTo(() => reader.Length) A.CallTo(() => reader.Length)
.ReturnsLazily(x => (int)buffer.Length); .ReturnsLazily(x => (int)buffer.Length);
return new DeserializationContext(null) { StreamReader = reader }; var context = A.Fake<IDeserializationContext>();
A.CallTo(() => context.StreamReader)
.Returns(reader);
return context;
} }
private static SerializationContext CreateWriter(MemoryStream buffer) private static ISerializationContext CreateWriter(MemoryStream buffer)
{ {
var writer = A.Fake<IBinaryTokenStreamWriter>(); var writer = A.Fake<IBinaryTokenStreamWriter>();
@ -101,7 +106,12 @@ namespace Squidex.Infrastructure.Orleans
A.CallTo(() => writer.CurrentOffset) A.CallTo(() => writer.CurrentOffset)
.ReturnsLazily(x => (int)buffer.Position); .ReturnsLazily(x => (int)buffer.Position);
return new SerializationContext(null) { StreamWriter = writer }; var context = A.Fake<ISerializationContext>();
A.CallTo(() => context.StreamWriter)
.Returns(writer);
return context;
} }
private static List<int> ArrayOfLength(int length) private static List<int> ArrayOfLength(int length)

64
backend/tests/Squidex.Infrastructure.Tests/Queries/QueryTests.cs

@ -0,0 +1,64 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using Xunit;
namespace Squidex.Infrastructure.Queries
{
public class QueryTests
{
[Fact]
public void Should_add_fields_from_sorting()
{
var query = new ClrQuery
{
Sort = new List<SortNode>
{
new SortNode("field1", SortOrder.Ascending),
new SortNode("field1", SortOrder.Ascending),
new SortNode("field2", SortOrder.Ascending)
}
};
var fields = query.GetAllFields();
var expected = new HashSet<string>
{
"field1",
"field2"
};
Assert.Equal(expected, fields);
}
[Fact]
public void Should_add_fields_from_filters()
{
var query = new ClrQuery
{
Filter =
ClrFilter.And(
ClrFilter.Not(
ClrFilter.Eq("field1", 1)),
ClrFilter.Or(
ClrFilter.Eq("field2", 2),
ClrFilter.Eq("field2", 4)))
};
var fields = query.GetAllFields();
var expected = new HashSet<string>
{
"field1",
"field2"
};
Assert.Equal(expected, fields);
}
}
}

20
backend/tests/Squidex.Infrastructure.Tests/UsageTracking/ApiUsageTrackerTests.cs

@ -48,7 +48,7 @@ namespace Squidex.Infrastructure.UsageTracking
} }
[Fact] [Fact]
public async Task Should_query_from_tracker() public async Task Should_query_calls_from_tracker()
{ {
var counters = new Counters var counters = new Counters
{ {
@ -58,11 +58,27 @@ namespace Squidex.Infrastructure.UsageTracking
A.CallTo(() => usageTracker.GetForMonthAsync($"{key}_API", date)) A.CallTo(() => usageTracker.GetForMonthAsync($"{key}_API", date))
.Returns(counters); .Returns(counters);
var result = await sut.GetMonthCostsAsync(key, date); var result = await sut.GetMonthCallsAsync(key, date);
Assert.Equal(4, result); Assert.Equal(4, result);
} }
[Fact]
public async Task Should_query_bytes_from_tracker()
{
var counters = new Counters
{
[ApiUsageTracker.CounterTotalBytes] = 14
};
A.CallTo(() => usageTracker.GetForMonthAsync($"{key}_API", date))
.Returns(counters);
var result = await sut.GetMonthBytesAsync(key, date);
Assert.Equal(14, result);
}
[Fact] [Fact]
public async Task Should_query_stats_from_tracker() public async Task Should_query_stats_from_tracker()
{ {

Loading…
Cancel
Save