Browse Source

Content enrichment improvements.

pull/478/head
Sebastian 6 years ago
parent
commit
ae5de75c3e
  1. 57
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/IFieldVisitor.cs
  2. 34
      backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/ContentConverter.cs
  3. 8
      backend/src/Squidex.Domain.Apps.Core.Operations/DefaultValues/DefaultValueExtensions.cs
  4. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/DefaultValues/DefaultValueFactory.cs
  5. 6
      backend/src/Squidex.Domain.Apps.Core.Operations/DefaultValues/DefaultValueGenerator.cs
  6. 82
      backend/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ContentReferencesExtensions.cs
  7. 63
      backend/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesCleaner.cs
  8. 41
      backend/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesExtensions.cs
  9. 90
      backend/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesExtractor.cs
  10. 15
      backend/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ValueReferencesConverter.cs
  11. 12
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonError.cs
  12. 12
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs
  13. 12
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs
  14. 3
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs
  15. 52
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_EventHandling.cs
  16. 5
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs
  17. 35
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/CleanupReferences.cs
  18. 15
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Extensions.cs
  19. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs
  20. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs
  21. 6
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs
  22. 3
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentEnricher.cs
  23. 70
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ConvertData.cs
  24. 6
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveAssets.cs
  25. 4
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveReferences.cs
  26. 5
      backend/src/Squidex.Infrastructure/Json/Objects/JsonArray.cs
  27. 7
      backend/src/Squidex.Infrastructure/Json/Objects/JsonValue.cs
  28. 36
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ConvertContent/FieldConvertersTests.cs
  29. 12
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/DefaultValues/DefaultValuesTests.cs
  30. 289
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ExtractReferenceIds/ReferenceExtractionTests.cs
  31. 11
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterTests.cs
  32. 12
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/ContentDataObjectTests.cs
  33. 15
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Tags/TagNormalizerTests.cs
  34. 4
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ContentValidationTests.cs
  35. 2
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/UIFieldTests.cs
  36. 31
      backend/tests/Squidex.Domain.Apps.Core.Tests/TestHelpers/AExtensions.cs
  37. 4
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetEnricherTests.cs
  38. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryServiceTests.cs
  39. 146
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ConvertDataTests.cs
  40. 6
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ResolveAssetsTests.cs
  41. 6
      backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/AExtensions.cs
  42. 4
      backend/tools/TestSuite/TestSuite.ApiTests/ContentCleanupTests.cs

57
backend/src/Squidex.Domain.Apps.Core.Model/Schemas/IFieldVisitor.cs

@ -5,30 +5,65 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
#pragma warning disable CS8653 // A default expression introduces a null value for a type parameter.
namespace Squidex.Domain.Apps.Core.Schemas namespace Squidex.Domain.Apps.Core.Schemas
{ {
public interface IFieldVisitor<out T> public interface IFieldVisitor<out T>
{ {
T Visit(IArrayField field); T Visit(IArrayField field)
{
return default;
}
T Visit(IField<AssetsFieldProperties> field); T Visit(IField<AssetsFieldProperties> field)
{
return default;
}
T Visit(IField<BooleanFieldProperties> field); T Visit(IField<BooleanFieldProperties> field)
{
return default;
}
T Visit(IField<DateTimeFieldProperties> field); T Visit(IField<DateTimeFieldProperties> field)
{
return default;
}
T Visit(IField<GeolocationFieldProperties> field); T Visit(IField<GeolocationFieldProperties> field)
{
return default;
}
T Visit(IField<JsonFieldProperties> field); T Visit(IField<JsonFieldProperties> field)
{
return default;
}
T Visit(IField<NumberFieldProperties> field); T Visit(IField<NumberFieldProperties> field)
{
return default;
}
T Visit(IField<ReferencesFieldProperties> field); T Visit(IField<ReferencesFieldProperties> field)
{
return default;
}
T Visit(IField<StringFieldProperties> field); T Visit(IField<StringFieldProperties> field)
{
return default;
}
T Visit(IField<TagsFieldProperties> field); T Visit(IField<TagsFieldProperties> field)
{
return default;
}
T Visit(IField<UIFieldProperties> field); T Visit(IField<UIFieldProperties> field)
{
return default;
}
} }
} }

34
backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/ContentConverter.cs

@ -7,11 +7,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Text;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Domain.Apps.Core.ConvertContent namespace Squidex.Domain.Apps.Core.ConvertContent
{ {
@ -20,38 +18,6 @@ namespace Squidex.Domain.Apps.Core.ConvertContent
private static readonly Func<IRootField, string> KeyNameResolver = f => f.Name; private static readonly Func<IRootField, string> KeyNameResolver = f => f.Name;
private static readonly Func<IRootField, long> KeyIdResolver = f => f.Id; private static readonly Func<IRootField, long> KeyIdResolver = f => f.Id;
private static void AppendText(IJsonValue value, StringBuilder stringBuilder, int maxFieldLength, string separator, bool allowObjects)
{
if (value.Type == JsonValueType.String)
{
var text = value.ToString();
if (text.Length <= maxFieldLength)
{
if (stringBuilder.Length > 0)
{
stringBuilder.Append(separator);
}
stringBuilder.Append(text);
}
}
else if (value is JsonArray array)
{
foreach (var item in array)
{
AppendText(item, stringBuilder, maxFieldLength, separator, true);
}
}
else if (value is JsonObject obj && allowObjects)
{
foreach (var item in obj.Values)
{
AppendText(item, stringBuilder, maxFieldLength, separator, true);
}
}
}
public static NamedContentData ConvertId2Name(this IdContentData content, Schema schema, params FieldConverter[] converters) public static NamedContentData ConvertId2Name(this IdContentData content, Schema schema, params FieldConverter[] converters)
{ {
Guard.NotNull(schema); Guard.NotNull(schema);

8
backend/src/Squidex.Domain.Apps.Core.Operations/EnrichContent/ContentEnrichmentExtensions.cs → backend/src/Squidex.Domain.Apps.Core.Operations/DefaultValues/DefaultValueExtensions.cs

@ -8,13 +8,13 @@
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Schemas;
namespace Squidex.Domain.Apps.Core.EnrichContent namespace Squidex.Domain.Apps.Core.DefaultValues
{ {
public static class ContentEnrichmentExtensions public static class DefaultValueExtensions
{ {
public static void Enrich(this NamedContentData data, Schema schema, PartitionResolver partitionResolver) public static void GenerateDefaultValues(this NamedContentData data, Schema schema, PartitionResolver partitionResolver)
{ {
var enricher = new ContentEnricher(schema, partitionResolver); var enricher = new DefaultValueGenerator(schema, partitionResolver);
enricher.Enrich(data); enricher.Enrich(data);
} }

2
backend/src/Squidex.Domain.Apps.Core.Operations/EnrichContent/DefaultValueFactory.cs → backend/src/Squidex.Domain.Apps.Core.Operations/DefaultValues/DefaultValueFactory.cs

@ -11,7 +11,7 @@ using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Domain.Apps.Core.EnrichContent namespace Squidex.Domain.Apps.Core.DefaultValues
{ {
public sealed class DefaultValueFactory : IFieldVisitor<IJsonValue> public sealed class DefaultValueFactory : IFieldVisitor<IJsonValue>
{ {

6
backend/src/Squidex.Domain.Apps.Core.Operations/EnrichContent/ContentEnricher.cs → backend/src/Squidex.Domain.Apps.Core.Operations/DefaultValues/DefaultValueGenerator.cs

@ -11,14 +11,14 @@ using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Domain.Apps.Core.EnrichContent namespace Squidex.Domain.Apps.Core.DefaultValues
{ {
public sealed class ContentEnricher public sealed class DefaultValueGenerator
{ {
private readonly Schema schema; private readonly Schema schema;
private readonly PartitionResolver partitionResolver; private readonly PartitionResolver partitionResolver;
public ContentEnricher(Schema schema, PartitionResolver partitionResolver) public DefaultValueGenerator(Schema schema, PartitionResolver partitionResolver)
{ {
Guard.NotNull(schema); Guard.NotNull(schema);
Guard.NotNull(partitionResolver); Guard.NotNull(partitionResolver);

82
backend/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ContentReferencesExtensions.cs

@ -17,77 +17,79 @@ namespace Squidex.Domain.Apps.Core.ExtractReferenceIds
{ {
public static class ContentReferencesExtensions public static class ContentReferencesExtensions
{ {
public static IEnumerable<Guid> GetReferencedIds(this IdContentData source, Schema schema, Ids strategy = Ids.All) public static HashSet<Guid> GetReferencedIds(this NamedContentData source, Schema schema)
{ {
Guard.NotNull(schema); Guard.NotNull(schema);
foreach (var field in schema.Fields) var extractor = new ReferencesExtractor(new HashSet<Guid>());
{
var ids = source.GetReferencedIds(field, strategy);
foreach (var id in ids) AddReferencedIds(source, schema.Fields, extractor);
{
yield return id; return extractor.Result;
}
}
} }
public static IEnumerable<Guid> GetReferencedIds(this IdContentData source, IField field, Ids strategy = Ids.All) public static void AddReferencedIds(this NamedContentData source, Schema schema, HashSet<Guid> result)
{ {
Guard.NotNull(field); Guard.NotNull(schema);
if (source.TryGetValue(field.Id, out var fieldData) && fieldData != null) var extractor = new ReferencesExtractor(result);
{
foreach (var partitionValue in fieldData)
{
var ids = field.GetReferencedIds(partitionValue.Value, strategy);
foreach (var id in ids) AddReferencedIds(source, schema.Fields, extractor);
{
yield return id;
}
}
}
} }
public static IEnumerable<Guid> GetReferencedIds(this NamedContentData source, Schema schema, Ids strategy = Ids.All) public static void AddReferencedIds(this NamedContentData source, IEnumerable<IField> fields, HashSet<Guid> result)
{ {
Guard.NotNull(schema); Guard.NotNull(fields);
var extractor = new ReferencesExtractor(result);
return GetReferencedIds(source, schema.Fields, strategy); AddReferencedIds(source, fields, extractor);
} }
public static IEnumerable<Guid> GetReferencedIds(this NamedContentData source, IEnumerable<IField> fields, Ids strategy = Ids.All) public static void AddReferencedIds(this NamedContentData source, IField field, HashSet<Guid> result)
{ {
Guard.NotNull(fields); Guard.NotNull(field);
foreach (var field in fields) var extractor = new ReferencesExtractor(result);
{
var ids = source.GetReferencedIds(field, strategy);
foreach (var id in ids) AddReferencedIds(source, field, extractor);
{
yield return id;
} }
private static void AddReferencedIds(NamedContentData source, IEnumerable<IField> fields, ReferencesExtractor extractor)
{
foreach (var field in fields)
{
AddReferencedIds(source, field, extractor);
} }
} }
public static IEnumerable<Guid> GetReferencedIds(this NamedContentData source, IField field, Ids strategy = Ids.All) private static void AddReferencedIds(NamedContentData source, IField field, ReferencesExtractor extractor)
{ {
Guard.NotNull(field);
if (source.TryGetValue(field.Name, out var fieldData) && fieldData != null) if (source.TryGetValue(field.Name, out var fieldData) && fieldData != null)
{ {
foreach (var partitionValue in fieldData) foreach (var partitionValue in fieldData)
{ {
var ids = field.GetReferencedIds(partitionValue.Value, strategy); extractor.SetValue(partitionValue.Value);
foreach (var id in ids) field.Accept(extractor);
{
yield return id;
} }
} }
} }
public static HashSet<Guid> GetReferencedIds(this IField field, IJsonValue? value)
{
var result = new HashSet<Guid>();
if (value != null)
{
var extractor = new ReferencesExtractor(result);
extractor.SetValue(value);
field.Accept(extractor);
}
return result;
} }
public static JsonObject FormatReferences(this NamedContentData data, Schema schema, IFieldPartitioning partitioning, string separator = ", ") public static JsonObject FormatReferences(this NamedContentData data, Schema schema, IFieldPartitioning partitioning, string separator = ", ")

63
backend/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesCleaner.cs

@ -8,25 +8,26 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Domain.Apps.Core.ExtractReferenceIds namespace Squidex.Domain.Apps.Core.ExtractReferenceIds
{ {
public sealed class ReferencesCleaner : IFieldVisitor<IJsonValue> public sealed class ReferencesCleaner : IFieldVisitor<IJsonValue>
{ {
private readonly IJsonValue value; private readonly HashSet<Guid> validIds;
private readonly ICollection<Guid>? oldReferences; private IJsonValue value;
private ReferencesCleaner(IJsonValue value, ICollection<Guid>? oldReferences) public ReferencesCleaner(HashSet<Guid> validIds)
{ {
this.value = value; Guard.NotNull(validIds);
this.oldReferences = oldReferences; this.validIds = validIds;
} }
public static IJsonValue CleanReferences(IField field, IJsonValue value, ICollection<Guid>? oldReferences) public void SetValue(IJsonValue newValue)
{ {
return field.Accept(new ReferencesCleaner(value, oldReferences)); value = newValue;
} }
public IJsonValue Visit(IField<AssetsFieldProperties> field) public IJsonValue Visit(IField<AssetsFieldProperties> field)
@ -36,31 +37,9 @@ namespace Squidex.Domain.Apps.Core.ExtractReferenceIds
public IJsonValue Visit(IField<ReferencesFieldProperties> field) public IJsonValue Visit(IField<ReferencesFieldProperties> field)
{ {
if (oldReferences?.Contains(field.Properties.SingleId()) == true)
{
return JsonValue.Array();
}
return CleanIds(); return CleanIds();
} }
private IJsonValue CleanIds()
{
var ids = value.ToGuidSet();
var isRemoved = false;
if (oldReferences != null)
{
foreach (var oldReference in oldReferences)
{
isRemoved |= ids.Remove(oldReference);
}
}
return isRemoved ? ids.ToJsonArray() : value;
}
public IJsonValue Visit(IField<BooleanFieldProperties> field) public IJsonValue Visit(IField<BooleanFieldProperties> field)
{ {
return value; return value;
@ -105,5 +84,31 @@ namespace Squidex.Domain.Apps.Core.ExtractReferenceIds
{ {
return value; return value;
} }
private IJsonValue CleanIds()
{
if (value is JsonArray array)
{
var result = new JsonArray(array);
for (var i = 0; i < result.Count; i++)
{
if (!IsValidReference(result[i]))
{
result.RemoveAt(i);
i--;
}
}
return result;
}
return value;
}
private bool IsValidReference(IJsonValue item)
{
return item is JsonString s && Guid.TryParse(s.Value, out var guid) && validIds.Contains(guid);
}
} }
} }

41
backend/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesExtensions.cs

@ -7,51 +7,16 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Domain.Apps.Core.ExtractReferenceIds namespace Squidex.Domain.Apps.Core.ExtractReferenceIds
{ {
public static class ReferencesExtensions public static class ReferencesExtensions
{ {
public static IEnumerable<Guid> GetReferencedIds(this IField field, IJsonValue? value, Ids strategy = Ids.All) public static void AddIds(this IJsonValue? value, HashSet<Guid> result)
{
return ReferencesExtractor.ExtractReferences(field, value, strategy);
}
public static IJsonValue CleanReferences(this IField field, IJsonValue value, ICollection<Guid>? oldReferences)
{
if (IsNull(value))
{
return value;
}
return ReferencesCleaner.CleanReferences(field, value, oldReferences);
}
private static bool IsNull(IJsonValue value)
{
return value == null || value.Type == JsonValueType.Null;
}
public static JsonArray ToJsonArray(this HashSet<Guid> ids)
{
var result = JsonValue.Array();
foreach (var id in ids)
{
result.Add(JsonValue.Create(id.ToString()));
}
return result;
}
public static HashSet<Guid> ToGuidSet(this IJsonValue? value)
{ {
if (value is JsonArray array) if (value is JsonArray array)
{ {
var result = new HashSet<Guid>();
foreach (var id in array) foreach (var id in array)
{ {
if (id.Type == JsonValueType.String && Guid.TryParse(id.ToString(), out var guid)) if (id.Type == JsonValueType.String && Guid.TryParse(id.ToString(), out var guid))
@ -59,11 +24,7 @@ namespace Squidex.Domain.Apps.Core.ExtractReferenceIds
result.Add(guid); result.Add(guid);
} }
} }
return result;
} }
return new HashSet<Guid>();
} }
} }
} }

90
backend/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesExtractor.cs

@ -9,31 +9,35 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Domain.Apps.Core.ExtractReferenceIds namespace Squidex.Domain.Apps.Core.ExtractReferenceIds
{ {
public sealed class ReferencesExtractor : IFieldVisitor<IEnumerable<Guid>> internal sealed class ReferencesExtractor : IFieldVisitor<None>
{ {
private readonly IJsonValue? value; private readonly HashSet<Guid> result;
private readonly Ids strategy; private IJsonValue? value;
private ReferencesExtractor(IJsonValue? value, Ids strategy) public HashSet<Guid> Result
{ {
this.value = value; get { return result; }
this.strategy = strategy;
} }
public static IEnumerable<Guid> ExtractReferences(IField field, IJsonValue? value, Ids strategy) public ReferencesExtractor(HashSet<Guid> result)
{ {
return field.Accept(new ReferencesExtractor(value, strategy)); Guard.NotNull(result);
this.result = result;
} }
public IEnumerable<Guid> Visit(IArrayField field) public void SetValue(IJsonValue? newValue)
{ {
var result = new List<Guid>(); value = newValue;
}
public None Visit(IArrayField field)
{
if (value is JsonArray array) if (value is JsonArray array)
{ {
foreach (var item in array.OfType<JsonObject>()) foreach (var item in array.OfType<JsonObject>())
@ -42,75 +46,29 @@ namespace Squidex.Domain.Apps.Core.ExtractReferenceIds
{ {
if (item.TryGetValue(nestedField.Name, out var nestedValue)) if (item.TryGetValue(nestedField.Name, out var nestedValue))
{ {
result.AddRange(nestedField.Accept(new ReferencesExtractor(nestedValue, strategy))); SetValue(nestedValue);
}
}
}
}
return result;
}
public IEnumerable<Guid> Visit(IField<AssetsFieldProperties> field)
{
var ids = value.ToGuidSet();
return ids;
}
public IEnumerable<Guid> Visit(IField<ReferencesFieldProperties> field)
{
var ids = value.ToGuidSet();
if (strategy == Ids.All && field.Properties.SchemaIds != null) nestedField.Accept(this);
{
foreach (var schemaId in field.Properties.SchemaIds)
{
ids.Add(schemaId);
}
}
return ids;
} }
public IEnumerable<Guid> Visit(IField<BooleanFieldProperties> field)
{
return Enumerable.Empty<Guid>();
} }
public IEnumerable<Guid> Visit(IField<DateTimeFieldProperties> field)
{
return Enumerable.Empty<Guid>();
} }
public IEnumerable<Guid> Visit(IField<GeolocationFieldProperties> field)
{
return Enumerable.Empty<Guid>();
} }
public IEnumerable<Guid> Visit(IField<JsonFieldProperties> field) return None.Value;
{
return Enumerable.Empty<Guid>();
} }
public IEnumerable<Guid> Visit(IField<NumberFieldProperties> field) public None Visit(IField<AssetsFieldProperties> field)
{ {
return Enumerable.Empty<Guid>(); value.AddIds(result);
}
public IEnumerable<Guid> Visit(IField<StringFieldProperties> field) return None.Value;
{
return Enumerable.Empty<Guid>();
} }
public IEnumerable<Guid> Visit(IField<TagsFieldProperties> field) public None Visit(IField<ReferencesFieldProperties> field)
{ {
return Enumerable.Empty<Guid>(); value.AddIds(result);
}
public IEnumerable<Guid> Visit(IField<UIFieldProperties> field) return None.Value;
{
return Enumerable.Empty<Guid>();
} }
} }
} }

15
backend/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ValueReferencesConverter.cs

@ -14,18 +14,25 @@ namespace Squidex.Domain.Apps.Core.ExtractReferenceIds
{ {
public static class ValueReferencesConverter public static class ValueReferencesConverter
{ {
public static ValueConverter CleanReferences(IEnumerable<Guid> deletedReferencedIds) public static ValueConverter CleanReferences(HashSet<Guid>? validIds = null)
{ {
var ids = new HashSet<Guid>(deletedReferencedIds); if (validIds == null || validIds.Count == 0)
{
return (value, field) => value;
}
var cleaner = new ReferencesCleaner(validIds);
return (value, field) => return (value, field) =>
{ {
if (value.Type == JsonValueType.Null) if (value.Type == JsonValueType.Null)
{ {
return value; return value!;
} }
return field.CleanReferences(value, ids); cleaner.SetValue(value);
return field.Accept(cleaner);
}; };
} }
} }

12
backend/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/Ids.cs → backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonError.cs

@ -5,11 +5,15 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
namespace Squidex.Domain.Apps.Core.ExtractReferenceIds namespace Squidex.Domain.Apps.Core.ValidateContent
{ {
public enum Ids public sealed class JsonError
{ {
All, public string Error { get; }
ContentOnly
public JsonError(string error)
{
Error = error;
}
} }
} }

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

@ -89,6 +89,18 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
} }
} }
public async Task<IReadOnlyList<Guid>> QueryIdsAsync(Guid appId, HashSet<Guid> ids)
{
using (Profiler.TraceMethod<MongoAssetRepository>("QueryAsyncByIds"))
{
var find = Collection.Find(x => ids.Contains(x.Id)).Only(x => x.Id);
var assetItems = await find.ToListAsync();
return assetItems.Select(x => Guid.Parse(x["_si"].AsString)).ToList();
}
}
public async Task<IResultList<IAssetEntity>> QueryAsync(Guid appId, HashSet<Guid> ids) public async Task<IResultList<IAssetEntity>> QueryAsync(Guid appId, HashSet<Guid> ids)
{ {
using (Profiler.TraceMethod<MongoAssetRepository>("QueryAsyncByIds")) using (Profiler.TraceMethod<MongoAssetRepository>("QueryAsyncByIds"))

12
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs

@ -19,6 +19,7 @@ using Squidex.Infrastructure.MongoDb;
namespace Squidex.Domain.Apps.Entities.MongoDb.Contents namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{ {
[BsonIgnoreExtraElements]
public sealed class MongoContentEntity : IContentEntity, IVersionedEntity<Guid> public sealed class MongoContentEntity : IContentEntity, IVersionedEntity<Guid>
{ {
private NamedContentData? data; private NamedContentData? data;
@ -42,12 +43,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
[BsonRequired] [BsonRequired]
[BsonElement("rf")] [BsonElement("rf")]
[BsonRepresentation(BsonType.String)] [BsonRepresentation(BsonType.String)]
public List<Guid>? ReferencedIds { get; set; } public HashSet<Guid>? ReferencedIds { get; set; }
[BsonRequired]
[BsonElement("rd")]
[BsonRepresentation(BsonType.String)]
public List<Guid> ReferencedIdsDeleted { get; set; } = new List<Guid>();
[BsonRequired] [BsonRequired]
[BsonElement("ss")] [BsonElement("ss")]
@ -122,11 +118,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
public void ParseData(Schema schema, IJsonSerializer serializer) public void ParseData(Schema schema, IJsonSerializer serializer)
{ {
data = DataByIds?.FromMongoModel(schema, ReferencedIdsDeleted, serializer); data = DataByIds?.FromMongoModel(schema, serializer);
if (DataDraftByIds != null) if (DataDraftByIds != null)
{ {
dataDraft = DataDraftByIds.FromMongoModel(schema, ReferencedIdsDeleted, serializer); dataDraft = DataDraftByIds.FromMongoModel(schema, serializer);
} }
} }
} }

3
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs

@ -36,7 +36,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
private readonly IJsonSerializer serializer; private readonly IJsonSerializer serializer;
private readonly string typeAssetDeleted; private readonly string typeAssetDeleted;
private readonly string typeContentDeleted; private readonly string typeContentDeleted;
private readonly CleanupReferences cleanupReferences;
private readonly QueryContent queryContentAsync; private readonly QueryContent queryContentAsync;
private readonly QueryContentsByIds queryContentsById; private readonly QueryContentsByIds queryContentsById;
private readonly QueryContentsByQuery queryContentsByQuery; private readonly QueryContentsByQuery queryContentsByQuery;
@ -59,7 +58,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
this.serializer = serializer; this.serializer = serializer;
cleanupReferences = new CleanupReferences();
queryContentAsync = new QueryContent(serializer); queryContentAsync = new QueryContent(serializer);
queryContentsById = new QueryContentsByIds(serializer, appProvider); queryContentsById = new QueryContentsByIds(serializer, appProvider);
queryContentsByQuery = new QueryContentsByQuery(serializer, indexer); queryContentsByQuery = new QueryContentsByQuery(serializer, indexer);
@ -72,7 +70,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
protected override async Task SetupCollectionAsync(IMongoCollection<MongoContentEntity> collection, CancellationToken ct = default) protected override async Task SetupCollectionAsync(IMongoCollection<MongoContentEntity> collection, CancellationToken ct = default)
{ {
await cleanupReferences.PrepareAsync(collection, ct);
await queryContentAsync.PrepareAsync(collection, ct); await queryContentAsync.PrepareAsync(collection, ct);
await queryContentsById.PrepareAsync(collection, ct); await queryContentsById.PrepareAsync(collection, ct);
await queryContentsByQuery.PrepareAsync(collection, ct); await queryContentsByQuery.PrepareAsync(collection, ct);

52
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_EventHandling.cs

@ -1,52 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Squidex.Domain.Apps.Events.Assets;
using Squidex.Domain.Apps.Events.Contents;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{
public partial class MongoContentRepository : IEventConsumer
{
public string Name
{
get { return GetType().Name; }
}
public string EventsFilter
{
get { return "^(content-)|(asset-)"; }
}
public bool Handles(StoredEvent @event)
{
return @event.Data.Type == typeAssetDeleted || @event.Data.Type == typeContentDeleted;
}
public Task On(Envelope<IEvent> @event)
{
switch (@event.Payload)
{
case AssetDeleted e:
return cleanupReferences.DoAsync(e.AssetId);
case ContentDeleted e:
return cleanupReferences.DoAsync(e.ContentId);
}
return TaskHelper.Done;
}
Task IEventConsumer.ClearAsync()
{
return TaskHelper.Done;
}
}
}

5
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs

@ -9,6 +9,7 @@ using System;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MongoDB.Driver; using MongoDB.Driver;
using Squidex.Domain.Apps.Core.ExtractReferenceIds;
using Squidex.Domain.Apps.Entities.Contents.State; using Squidex.Domain.Apps.Entities.Contents.State;
using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -66,7 +67,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
var schema = await GetSchemaAsync(value.AppId.Id, value.SchemaId.Id); var schema = await GetSchemaAsync(value.AppId.Id, value.SchemaId.Id);
var idData = value.Data!.ToMongoModel(schema.SchemaDef, serializer); var idData = value.Data.ToMongoModel(schema.SchemaDef, serializer);
var idDraftData = idData; var idDraftData = idData;
if (!ReferenceEquals(value.Data, value.DataDraft)) if (!ReferenceEquals(value.Data, value.DataDraft))
@ -81,7 +82,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
IsDeleted = value.IsDeleted, IsDeleted = value.IsDeleted,
IndexedAppId = value.AppId.Id, IndexedAppId = value.AppId.Id,
IndexedSchemaId = value.SchemaId.Id, IndexedSchemaId = value.SchemaId.Id,
ReferencedIds = idData.ToReferencedIds(schema.SchemaDef), ReferencedIds = value.Data.GetReferencedIds(schema.SchemaDef),
ScheduledAt = value.ScheduleJob?.DueTime, ScheduledAt = value.ScheduleJob?.DueTime,
Version = newVersion Version = newVersion
}); });

35
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/CleanupReferences.cs

@ -1,35 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
{
internal sealed class CleanupReferences : OperationBase
{
protected override Task PrepareAsync(CancellationToken ct = default)
{
var index =
new CreateIndexModel<MongoContentEntity>(
Index.Ascending(x => x.ReferencedIds));
return Collection.Indexes.CreateOneAsync(index, cancellationToken: ct);
}
public Task DoAsync(Guid id)
{
return Collection.UpdateManyAsync(
Filter.And(
Filter.AnyEq(x => x.ReferencedIds, id),
Filter.AnyNe(x => x.ReferencedIdsDeleted, id)),
Update.AddToSet(x => x.ReferencedIdsDeleted, id));
}
}
}

15
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Extensions.cs

@ -6,12 +6,10 @@
// ========================================================================== // ==========================================================================
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using MongoDB.Driver; using MongoDB.Driver;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.ConvertContent; using Squidex.Domain.Apps.Core.ConvertContent;
using Squidex.Domain.Apps.Core.ExtractReferenceIds;
using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Infrastructure.Json; using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.MongoDb; using Squidex.Infrastructure.MongoDb;
@ -20,20 +18,13 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{ {
public static class Extensions public static class Extensions
{ {
public static List<Guid> ToReferencedIds(this IdContentData data, Schema schema) public static NamedContentData FromMongoModel(this IdContentData result, Schema schema, IJsonSerializer serializer)
{
return data.GetReferencedIds(schema).Distinct().ToList();
}
public static NamedContentData FromMongoModel(this IdContentData result, Schema schema, List<Guid> deletedIds, IJsonSerializer serializer)
{ {
return result.ConvertId2Name(schema, return result.ConvertId2Name(schema,
FieldConverters.ForValues( FieldConverters.ForValues(
ValueConverters.DecodeJson(serializer), ValueConverters.DecodeJson(serializer)),
ValueReferencesConverter.CleanReferences(deletedIds)),
FieldConverters.ForNestedId2Name( FieldConverters.ForNestedId2Name(
ValueConverters.DecodeJson(serializer), ValueConverters.DecodeJson(serializer)));
ValueReferencesConverter.CleanReferences(deletedIds)));
} }
public static IdContentData ToMongoModel(this NamedContentData result, Schema schema, IJsonSerializer serializer) public static IdContentData ToMongoModel(this NamedContentData result, Schema schema, IJsonSerializer serializer)

2
backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs

@ -21,6 +21,8 @@ namespace Squidex.Domain.Apps.Entities.Assets.Repositories
Task<IResultList<IAssetEntity>> QueryAsync(Guid appId, HashSet<Guid> ids); Task<IResultList<IAssetEntity>> QueryAsync(Guid appId, HashSet<Guid> ids);
Task<IReadOnlyList<Guid>> QueryIdsAsync(Guid appId, HashSet<Guid> ids);
Task<IAssetEntity?> FindAssetAsync(Guid id); Task<IAssetEntity?> FindAssetAsync(Guid id);
Task<IAssetEntity?> FindAssetBySlugAsync(Guid appId, string slug); Task<IAssetEntity?> FindAssetBySlugAsync(Guid appId, string slug);

2
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs

@ -83,7 +83,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
}); });
} }
await ctx.EnrichAsync(c.Data); await ctx.GenerateDefaultValuesAsync(c.Data);
if (!c.DoNotValidate) if (!c.DoNotValidate)
{ {

6
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs

@ -9,7 +9,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.EnrichContent; using Squidex.Domain.Apps.Core.DefaultValues;
using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Core.ValidateContent; using Squidex.Domain.Apps.Core.ValidateContent;
@ -76,9 +76,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
return context; return context;
} }
public Task EnrichAsync(NamedContentData data) public Task GenerateDefaultValuesAsync(NamedContentData data)
{ {
data.Enrich(schemaEntity.SchemaDef, appEntity.PartitionResolver()); data.GenerateDefaultValues(schemaEntity.SchemaDef, appEntity.PartitionResolver());
return TaskHelper.Done; return TaskHelper.Done;
} }

3
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentEnricher.cs

@ -73,10 +73,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
} }
foreach (var step in steps) foreach (var step in steps)
{
using (Profiler.TraceMethod(step.ToString()!))
{ {
await step.EnrichAsync(context, results, GetSchema); await step.EnrichAsync(context, results, GetSchema);
} }
} }
}
return results; return results;
} }

70
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ConvertData.cs

@ -5,10 +5,14 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.ConvertContent; using Squidex.Domain.Apps.Core.ConvertContent;
using Squidex.Domain.Apps.Core.ExtractReferenceIds;
using Squidex.Domain.Apps.Entities.Assets.Repositories;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Infrastructure; using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
@ -16,20 +20,28 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
public sealed class ConvertData : IContentEnricherStep public sealed class ConvertData : IContentEnricherStep
{ {
private readonly IAssetUrlGenerator assetUrlGenerator; private readonly IAssetUrlGenerator assetUrlGenerator;
private readonly IAssetRepository assetRepository;
private readonly IContentRepository contentRepository;
public ConvertData(IAssetUrlGenerator assetUrlGenerator) public ConvertData(IAssetUrlGenerator assetUrlGenerator, IAssetRepository assetRepository, IContentRepository contentRepository)
{ {
Guard.NotNull(assetUrlGenerator); Guard.NotNull(assetUrlGenerator);
Guard.NotNull(assetRepository);
Guard.NotNull(contentRepository);
this.assetUrlGenerator = assetUrlGenerator; this.assetUrlGenerator = assetUrlGenerator;
this.assetRepository = assetRepository;
this.contentRepository = contentRepository;
} }
public async Task EnrichAsync(Context context, IEnumerable<ContentEntity> contents, ProvideSchema schemas) public async Task EnrichAsync(Context context, IEnumerable<ContentEntity> contents, ProvideSchema schemas)
{ {
var converters = GenerateConverters(context).ToArray();
var resolveDataDraft = context.IsUnpublished() || context.IsFrontendClient; var resolveDataDraft = context.IsUnpublished() || context.IsFrontendClient;
var referenceCleaner = await CleanReferencesAsync(context, contents, schemas);
var converters = GenerateConverters(context, referenceCleaner).ToArray();
foreach (var group in contents.GroupBy(x => x.SchemaId.Id)) foreach (var group in contents.GroupBy(x => x.SchemaId.Id))
{ {
var schema = await schemas(group.Key); var schema = await schemas(group.Key);
@ -53,7 +65,51 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
} }
} }
private IEnumerable<FieldConverter> GenerateConverters(Context context) private async Task<ValueConverter?> CleanReferencesAsync(Context context, IEnumerable<ContentEntity> contents, ProvideSchema schemas)
{
var ids = new HashSet<Guid>();
foreach (var group in contents.GroupBy(x => x.SchemaId.Id))
{
var schema = await schemas(group.Key);
foreach (var content in group)
{
content.Data?.AddReferencedIds(schema.SchemaDef, ids);
content.DataDraft?.AddReferencedIds(schema.SchemaDef, ids);
}
}
if (ids.Count > 0)
{
var taskForAssets = QueryAssetIdsAsync(context, ids);
var taskForContents = QueryContentIdsAsync(context, ids);
await Task.WhenAll(taskForAssets, taskForContents);
var foundIds = new HashSet<Guid>(taskForAssets.Result.Union(taskForContents.Result));
return ValueReferencesConverter.CleanReferences(foundIds);
}
return null;
}
private async Task<IEnumerable<Guid>> QueryContentIdsAsync(Context context, HashSet<Guid> ids)
{
var result = await contentRepository.QueryIdsAsync(context.App.Id, ids);
return result.Select(x => x.Id);
}
private async Task<IEnumerable<Guid>> QueryAssetIdsAsync(Context context, HashSet<Guid> ids)
{
var result = await assetRepository.QueryIdsAsync(context.App.Id, ids);
return result;
}
private IEnumerable<FieldConverter> GenerateConverters(Context context, ValueConverter? cleanReferences)
{ {
if (!context.IsFrontendClient) if (!context.IsFrontendClient)
{ {
@ -64,6 +120,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
yield return FieldConverters.ExcludeChangedTypes(); yield return FieldConverters.ExcludeChangedTypes();
yield return FieldConverters.ForNestedName2Name(ValueConverters.ExcludeChangedTypes()); yield return FieldConverters.ForNestedName2Name(ValueConverters.ExcludeChangedTypes());
if (cleanReferences != null)
{
yield return FieldConverters.ForValues(cleanReferences);
yield return FieldConverters.ForNestedName2Name(cleanReferences);
}
yield return FieldConverters.ResolveInvariant(context.App.LanguagesConfig); yield return FieldConverters.ResolveInvariant(context.App.LanguagesConfig);
yield return FieldConverters.ResolveLanguages(context.App.LanguagesConfig); yield return FieldConverters.ResolveLanguages(context.App.LanguagesConfig);

6
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveAssets.cs

@ -63,6 +63,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
private void ResolveAssetsUrls(ISchemaEntity schema, IGrouping<Guid, ContentEntity> contents, ILookup<Guid, IEnrichedAssetEntity> assets) private void ResolveAssetsUrls(ISchemaEntity schema, IGrouping<Guid, ContentEntity> contents, ILookup<Guid, IEnrichedAssetEntity> assets)
{ {
var temp = new HashSet<Guid>();
foreach (var field in schema.SchemaDef.ResolvingAssets()) foreach (var field in schema.SchemaDef.ResolvingAssets())
{ {
foreach (var content in contents) foreach (var content in contents)
@ -79,7 +81,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
foreach (var (partitionKey, partitionValue) in fieldData) foreach (var (partitionKey, partitionValue) in fieldData)
{ {
var referencedImage = var referencedImage =
field.GetReferencedIds(partitionValue, Ids.ContentOnly) field.GetReferencedIds(partitionValue)
.Select(x => assets[x]) .Select(x => assets[x])
.SelectMany(x => x) .SelectMany(x => x)
.FirstOrDefault(x => x.Type == AssetType.Image); .FirstOrDefault(x => x.Type == AssetType.Image);
@ -117,7 +119,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
{ {
foreach (var content in contents) foreach (var content in contents)
{ {
ids.AddRange(content.DataDraft.GetReferencedIds(schema.SchemaDef.ResolvingAssets(), Ids.ContentOnly)); content.DataDraft.AddReferencedIds(schema.SchemaDef.ResolvingAssets(), ids);
} }
} }

4
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveReferences.cs

@ -81,7 +81,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
foreach (var (partition, partitionValue) in fieldData) foreach (var (partition, partitionValue) in fieldData)
{ {
var referencedContents = var referencedContents =
field.GetReferencedIds(partitionValue, Ids.ContentOnly) field.GetReferencedIds(partitionValue)
.Select(x => references[x]) .Select(x => references[x])
.SelectMany(x => x) .SelectMany(x => x)
.ToList(); .ToList();
@ -143,7 +143,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
{ {
foreach (var content in contents) foreach (var content in contents)
{ {
ids.AddRange(content.DataDraft.GetReferencedIds(schema.SchemaDef.ResolvingReferences(), Ids.ContentOnly)); content.DataDraft.AddReferencedIds(schema.SchemaDef.ResolvingReferences(), ids);
} }
} }

5
backend/src/Squidex.Infrastructure/Json/Objects/JsonArray.cs

@ -25,6 +25,11 @@ namespace Squidex.Infrastructure.Json.Objects
{ {
} }
public JsonArray(JsonArray source)
: base(source)
{
}
internal JsonArray(params object?[] values) internal JsonArray(params object?[] values)
: base(ToList(values)) : base(ToList(values))
{ {

7
backend/src/Squidex.Infrastructure/Json/Objects/JsonValue.cs

@ -68,6 +68,8 @@ namespace Squidex.Infrastructure.Json.Objects
return Create(i); return Create(i);
case long l: case long l:
return Create(l); return Create(l);
case Guid g:
return Create(g);
case Instant i: case Instant i:
return Create(i); return Create(i);
} }
@ -75,6 +77,11 @@ namespace Squidex.Infrastructure.Json.Objects
throw new ArgumentException("Invalid json type"); throw new ArgumentException("Invalid json type");
} }
public static IJsonValue Create(Guid value)
{
return Create(value.ToString());
}
public static IJsonValue Create(bool value) public static IJsonValue Create(bool value)
{ {
return value ? True : False; return value ? True : False;

36
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ConvertContent/FieldConvertersTests.cs

@ -36,7 +36,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ConvertContent
var input = var input =
new ContentFieldData() new ContentFieldData()
.AddValue("iv", JsonValue.Object()); .AddJsonValue(JsonValue.Object());
var actual = FieldConverters.ForValues((f, i) => Value.Unset)(input, field); var actual = FieldConverters.ForValues((f, i) => Value.Unset)(input, field);
@ -52,7 +52,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ConvertContent
var input = var input =
new ContentFieldData() new ContentFieldData()
.AddValue("iv", JsonValue.Object()); .AddJsonValue(JsonValue.Object());
var actual = FieldConverters.ForValues(ValueConverters.EncodeJson(TestUtils.DefaultSerializer))(input, field); var actual = FieldConverters.ForValues(ValueConverters.EncodeJson(TestUtils.DefaultSerializer))(input, field);
@ -73,7 +73,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ConvertContent
var input = var input =
new ContentFieldData() new ContentFieldData()
.AddValue("iv", .AddJsonValue(
JsonValue.Array( JsonValue.Array(
JsonValue.Object() JsonValue.Object()
.Add("field1", 100) .Add("field1", 100)
@ -84,7 +84,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ConvertContent
var expected = var expected =
new ContentFieldData() new ContentFieldData()
.AddValue("iv", .AddJsonValue(
JsonValue.Array( JsonValue.Array(
JsonValue.Object() JsonValue.Object()
.Add("1", 100))); .Add("1", 100)));
@ -102,7 +102,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ConvertContent
var input = var input =
new ContentFieldData() new ContentFieldData()
.AddValue("iv", .AddJsonValue(
JsonValue.Array( JsonValue.Array(
JsonValue.Object() JsonValue.Object()
.Add("field1", 100) .Add("field1", 100)
@ -113,7 +113,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ConvertContent
var expected = var expected =
new ContentFieldData() new ContentFieldData()
.AddValue("iv", .AddJsonValue(
JsonValue.Array( JsonValue.Array(
JsonValue.Object() JsonValue.Object()
.Add("field1", 100))); .Add("field1", 100)));
@ -479,11 +479,11 @@ namespace Squidex.Domain.Apps.Core.Operations.ConvertContent
var source = var source =
new ContentFieldData() new ContentFieldData()
.AddValue("iv", JsonValue.Array("1", "2")); .AddJsonValue(JsonValue.Array("1", "2"));
var expected = var expected =
new ContentFieldData() new ContentFieldData()
.AddValue("iv", JsonValue.Array("url/to/1", "url/to/2")); .AddJsonValue(JsonValue.Array("url/to/1", "url/to/2"));
var result = FieldConverters.ResolveAssetUrls(new HashSet<string>(new[] { "assets" }), assetUrlGenerator)(source, field); var result = FieldConverters.ResolveAssetUrls(new HashSet<string>(new[] { "assets" }), assetUrlGenerator)(source, field);
@ -499,13 +499,13 @@ namespace Squidex.Domain.Apps.Core.Operations.ConvertContent
var source = var source =
new ContentFieldData() new ContentFieldData()
.AddValue("iv", JsonValue.Array( .AddJsonValue(JsonValue.Array(
JsonValue.Object() JsonValue.Object()
.Add("assets", JsonValue.Array("1", "2")))); .Add("assets", JsonValue.Array("1", "2"))));
var expected = var expected =
new ContentFieldData() new ContentFieldData()
.AddValue("iv", JsonValue.Array( .AddJsonValue(JsonValue.Array(
JsonValue.Object() JsonValue.Object()
.Add("assets", JsonValue.Array("url/to/1", "url/to/2")))); .Add("assets", JsonValue.Array("url/to/1", "url/to/2"))));
@ -521,11 +521,11 @@ namespace Squidex.Domain.Apps.Core.Operations.ConvertContent
var source = var source =
new ContentFieldData() new ContentFieldData()
.AddValue("iv", JsonValue.Array("1", "2")); .AddJsonValue(JsonValue.Array("1", "2"));
var expected = var expected =
new ContentFieldData() new ContentFieldData()
.AddValue("iv", JsonValue.Array("url/to/1", "url/to/2")); .AddJsonValue(JsonValue.Array("url/to/1", "url/to/2"));
var result = FieldConverters.ResolveAssetUrls(new HashSet<string>(new[] { "*" }), assetUrlGenerator)(source, field); var result = FieldConverters.ResolveAssetUrls(new HashSet<string>(new[] { "*" }), assetUrlGenerator)(source, field);
@ -541,13 +541,13 @@ namespace Squidex.Domain.Apps.Core.Operations.ConvertContent
var source = var source =
new ContentFieldData() new ContentFieldData()
.AddValue("iv", JsonValue.Array( .AddJsonValue(JsonValue.Array(
JsonValue.Object() JsonValue.Object()
.Add("assets", JsonValue.Array("1", "2")))); .Add("assets", JsonValue.Array("1", "2"))));
var expected = var expected =
new ContentFieldData() new ContentFieldData()
.AddValue("iv", JsonValue.Array( .AddJsonValue(JsonValue.Array(
JsonValue.Object() JsonValue.Object()
.Add("assets", JsonValue.Array("url/to/1", "url/to/2")))); .Add("assets", JsonValue.Array("url/to/1", "url/to/2"))));
@ -563,11 +563,11 @@ namespace Squidex.Domain.Apps.Core.Operations.ConvertContent
var source = var source =
new ContentFieldData() new ContentFieldData()
.AddValue("iv", JsonValue.Array("1", "2")); .AddJsonValue(JsonValue.Array("1", "2"));
var expected = var expected =
new ContentFieldData() new ContentFieldData()
.AddValue("iv", JsonValue.Array("1", "2")); .AddJsonValue(JsonValue.Array("1", "2"));
var result = FieldConverters.ResolveAssetUrls(new HashSet<string>(new[] { "other" }), assetUrlGenerator)(source, field); var result = FieldConverters.ResolveAssetUrls(new HashSet<string>(new[] { "other" }), assetUrlGenerator)(source, field);
@ -581,11 +581,11 @@ namespace Squidex.Domain.Apps.Core.Operations.ConvertContent
var source = var source =
new ContentFieldData() new ContentFieldData()
.AddValue("iv", JsonValue.Array("1", "2")); .AddJsonValue(JsonValue.Array("1", "2"));
var expected = var expected =
new ContentFieldData() new ContentFieldData()
.AddValue("iv", JsonValue.Array("1", "2")); .AddJsonValue(JsonValue.Array("1", "2"));
var result = FieldConverters.ResolveAssetUrls(null, assetUrlGenerator)(source, field); var result = FieldConverters.ResolveAssetUrls(null, assetUrlGenerator)(source, field);

12
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/EnrichContent/ContentEnrichmentTests.cs → backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/DefaultValues/DefaultValuesTests.cs

@ -8,7 +8,7 @@
using NodaTime; using NodaTime;
using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.EnrichContent; using Squidex.Domain.Apps.Core.DefaultValues;
using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Json.Objects;
@ -16,15 +16,15 @@ using Xunit;
#pragma warning disable xUnit2004 // Do not use equality check to test for boolean conditions #pragma warning disable xUnit2004 // Do not use equality check to test for boolean conditions
namespace Squidex.Domain.Apps.Core.Operations.EnrichContent namespace Squidex.Domain.Apps.Core.Operations.DefaultValues
{ {
public class ContentEnrichmentTests public class DefaultValuesTests
{ {
private readonly Instant now = Instant.FromUtc(2017, 10, 12, 16, 30, 10); private readonly Instant now = Instant.FromUtc(2017, 10, 12, 16, 30, 10);
private readonly LanguagesConfig languagesConfig = LanguagesConfig.English.Set(Language.DE); private readonly LanguagesConfig languagesConfig = LanguagesConfig.English.Set(Language.DE);
private readonly Schema schema; private readonly Schema schema;
public ContentEnrichmentTests() public DefaultValuesTests()
{ {
schema = schema =
new Schema("my-schema") new Schema("my-schema")
@ -50,7 +50,7 @@ namespace Squidex.Domain.Apps.Core.Operations.EnrichContent
new ContentFieldData() new ContentFieldData()
.AddValue("iv", 456)); .AddValue("iv", 456));
data.Enrich(schema, languagesConfig.ToResolver()); data.GenerateDefaultValues(schema, languagesConfig.ToResolver());
Assert.Equal(456, ((JsonScalar<double>)data["my-number"]!["iv"]).Value); Assert.Equal(456, ((JsonScalar<double>)data["my-number"]!["iv"]).Value);
@ -74,7 +74,7 @@ namespace Squidex.Domain.Apps.Core.Operations.EnrichContent
new ContentFieldData() new ContentFieldData()
.AddValue("iv", 456)); .AddValue("iv", 456));
data.Enrich(schema, languagesConfig.ToResolver()); data.GenerateDefaultValues(schema, languagesConfig.ToResolver());
Assert.Equal("en-string", data["my-string"]!["de"].ToString()); Assert.Equal("en-string", data["my-string"]!["de"].ToString());
Assert.Equal("en-string", data["my-string"]!["en"].ToString()); Assert.Equal("en-string", data["my-string"]!["en"].ToString());

289
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ExtractReferenceIds/ReferenceExtractionTests.cs

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.ConvertContent; using Squidex.Domain.Apps.Core.ConvertContent;
@ -21,39 +22,16 @@ namespace Squidex.Domain.Apps.Core.Operations.ExtractReferenceIds
{ {
public class ReferenceExtractionTests public class ReferenceExtractionTests
{ {
private readonly Guid schemaId = Guid.NewGuid();
private readonly Schema schema; private readonly Schema schema;
public ReferenceExtractionTests() public ReferenceExtractionTests()
{ {
schema = schema =
new Schema("my-schema") new Schema("my-schema")
.AddNumber(1, "field1", Partitioning.Language) .AddReferences(1, "references", Partitioning.Invariant)
.AddNumber(2, "field2", Partitioning.Invariant) .AddAssets(2, "assets", Partitioning.Invariant)
.AddNumber(3, "field3", Partitioning.Invariant) .AddArray(3, "array", Partitioning.Invariant, a => a
.AddAssets(5, "assets1", Partitioning.Invariant) .AddAssets(31, "nested"));
.AddAssets(6, "assets2", Partitioning.Invariant)
.AddArray(7, "array", Partitioning.Invariant, a => a
.AddAssets(71, "assets71"))
.AddJson(4, "json", Partitioning.Language)
.UpdateField(3, f => f.Hide());
}
[Fact]
public void Should_get_ids_from_id_data()
{
var id1 = Guid.NewGuid();
var id2 = Guid.NewGuid();
var input =
new IdContentData()
.AddField(5,
new ContentFieldData()
.AddValue("iv", JsonValue.Array(id1.ToString(), id2.ToString())));
var ids = input.GetReferencedIds(schema).ToArray();
Assert.Equal(new[] { id1, id2 }, ids);
} }
[Fact] [Fact]
@ -64,11 +42,13 @@ namespace Squidex.Domain.Apps.Core.Operations.ExtractReferenceIds
var input = var input =
new NamedContentData() new NamedContentData()
.AddField("assets1", .AddField("assets",
new ContentFieldData() new ContentFieldData()
.AddValue("iv", JsonValue.Array(id1.ToString(), id2.ToString()))); .AddJsonValue(JsonValue.Array(id1.ToString(), id2.ToString())));
var ids = input.GetReferencedIds(schema).ToArray(); var ids = new HashSet<Guid>();
input.AddReferencedIds(schema, ids);
Assert.Equal(new[] { id1, id2 }, ids); Assert.Equal(new[] { id1, id2 }, ids);
} }
@ -79,53 +59,44 @@ namespace Squidex.Domain.Apps.Core.Operations.ExtractReferenceIds
var id1 = Guid.NewGuid(); var id1 = Guid.NewGuid();
var id2 = Guid.NewGuid(); var id2 = Guid.NewGuid();
var input = var source =
new IdContentData() new NamedContentData()
.AddField(5, .AddField("references",
new ContentFieldData() new ContentFieldData()
.AddValue("iv", JsonValue.Array(id1.ToString(), id2.ToString()))); .AddJsonValue(JsonValue.Array(id1, id2)))
.AddField("assets",
var converter = FieldConverters.ForValues(ValueReferencesConverter.CleanReferences(new[] { id2 })); new ContentFieldData()
.AddJsonValue(JsonValue.Array(id1)))
var actual = input.ConvertId2Id(schema, converter); .AddField("array",
new ContentFieldData()
var cleanedValue = (JsonArray)actual[5]!["iv"]; .AddJsonValue(
JsonValue.Array(
Assert.Equal(1, cleanedValue.Count); JsonValue.Object()
Assert.Equal(id1.ToString(), cleanedValue[0].ToString()); .Add("nested", JsonValue.Array(id1, id2)))));
}
[Fact]
public void Should_return_ids_from_assets_field()
{
var id1 = Guid.NewGuid();
var id2 = Guid.NewGuid();
var sut = Fields.Assets(1, "my-asset", Partitioning.Invariant);
var result = sut.GetReferencedIds(CreateValue(id1, id2)).ToArray();
Assert.Equal(new[] { id1, id2 }, result);
}
[Fact]
public void Should_return_empty_list_from_assets_field_for_referenced_ids_when_null()
{
var sut = Fields.Assets(1, "my-asset", Partitioning.Invariant);
var result = sut.GetReferencedIds(null).ToArray(); var expected =
new NamedContentData()
.AddField("references",
new ContentFieldData()
.AddJsonValue(JsonValue.Array(id2)))
.AddField("assets",
new ContentFieldData()
.AddJsonValue(JsonValue.Array()))
.AddField("array",
new ContentFieldData()
.AddJsonValue(
JsonValue.Array(
JsonValue.Object()
.Add("nested", JsonValue.Array(id2)))));
Assert.Empty(result); var cleaner = ValueReferencesConverter.CleanReferences(new HashSet<Guid> { id2 });
}
[Fact] var converter = FieldConverters.ForValues(cleaner);
public void Should_return_empty_list_from_assets_field_for_referenced_ids_when_other_type() var converterNested = FieldConverters.ForNestedName2Name(cleaner);
{
var sut = Fields.Assets(1, "my-asset", Partitioning.Invariant);
var result = sut.GetReferencedIds(JsonValue.Create("invalid")).ToArray(); var actual = source.ConvertName2Name(schema, converter, converterNested);
Assert.Empty(result); Assert.Equal(expected, actual);
} }
[Fact] [Fact]
@ -138,171 +109,127 @@ namespace Squidex.Domain.Apps.Core.Operations.ExtractReferenceIds
Assert.Empty(result); Assert.Empty(result);
} }
[Fact] [Theory]
public void Should_return_null_from_assets_field_when_removing_references_from_null_array() [MemberData(nameof(ReferencingNestedFields))]
{ public void Should_return_ids_from_nested_field(NestedField field)
var sut = Fields.Assets(1, "my-asset", Partitioning.Invariant);
var result = sut.CleanReferences(JsonValue.Null, null);
Assert.Equal(JsonValue.Null, result);
}
[Fact]
public void Should_remove_deleted_references_from_assets_field()
{ {
var id1 = Guid.NewGuid(); var id1 = Guid.NewGuid();
var id2 = Guid.NewGuid(); var id2 = Guid.NewGuid();
var sut = Fields.Assets(1, "my-asset", Partitioning.Invariant); var arrayField = Fields.Array(1, "my-array", Partitioning.Invariant, field);
var result = sut.CleanReferences(CreateValue(id1, id2), HashSet.Of(id2));
Assert.Equal(CreateValue(id1), result);
}
[Fact]
public void Should_return_same_token_from_assets_field_when_removing_references_and_nothing_to_remove()
{
var id1 = Guid.NewGuid();
var id2 = Guid.NewGuid();
var sut = Fields.Assets(1, "my-asset", Partitioning.Invariant);
var token = CreateValue(id1, id2);
var result = sut.CleanReferences(token, HashSet.Of(Guid.NewGuid()));
Assert.Same(token, result);
}
[Fact]
public void Should_return_ids_from_nested_references_field()
{
var id1 = Guid.NewGuid();
var id2 = Guid.NewGuid();
var sut =
Fields.Array(1, "my-array", Partitioning.Invariant,
Fields.References(1, "my-refs",
new ReferencesFieldProperties { SchemaId = schemaId }));
var value = var value =
JsonValue.Array( JsonValue.Array(
JsonValue.Object() JsonValue.Object()
.Add("my-refs", CreateValue(id1, id2))); .Add(field.Name, CreateValue(id1, id2)));
var result = sut.GetReferencedIds(value).ToArray(); var result = arrayField.GetReferencedIds(value).ToArray();
Assert.Equal(new[] { id1, id2, schemaId }, result); Assert.Equal(new[] { id1, id2 }, result);
} }
[Fact] [Theory]
public void Should_return_ids_from_references_field() [MemberData(nameof(ReferencingFields))]
public void Should_return_empty_list_from_field_when_value_item_is_invalid(IField field)
{ {
var id1 = Guid.NewGuid(); var result = field.GetReferencedIds(JsonValue.Array("invalid")).ToArray();
var id2 = Guid.NewGuid();
var sut = Fields.References(1, "my-refs", Partitioning.Invariant, Assert.Empty(result);
new ReferencesFieldProperties { SchemaId = schemaId }); }
var result = sut.GetReferencedIds(CreateValue(id1, id2)).ToArray(); [Theory]
[MemberData(nameof(ReferencingFields))]
public void Should_return_empty_list_from_field_when_value_is_invalid(IField field)
{
var result = field.GetReferencedIds(JsonValue.Create("invalid")).ToArray();
Assert.Equal(new[] { id1, id2, schemaId }, result); Assert.Empty(result);
} }
[Fact] [Theory]
public void Should_return_ids_from_references_field_without_schema_id() [MemberData(nameof(ReferencingFields))]
public void Should_return_empty_list_from_field_when_value_is_empty(IField field)
{ {
var id1 = Guid.NewGuid(); var result = field.GetReferencedIds(JsonValue.Array()).ToArray();
var id2 = Guid.NewGuid();
var sut = Fields.References(1, "my-refs", Partitioning.Invariant, Assert.Empty(result);
new ReferencesFieldProperties { SchemaId = schemaId }); }
var result = sut.GetReferencedIds(CreateValue(id1, id2), Ids.ContentOnly).ToArray(); [Theory]
[MemberData(nameof(ReferencingFields))]
public void Should_return_empty_list_from_field_when_value_is_json_null(IField field)
{
var result = field.GetReferencedIds(null).ToArray();
Assert.Equal(new[] { id1, id2 }, result); Assert.Empty(result);
} }
[Fact] [Theory]
public void Should_return_list_from_references_field_with_schema_id_list_for_referenced_ids_when_null() [MemberData(nameof(ReferencingFields))]
public void Should_return_empty_list_from_field_when_value_is_null(IField field)
{ {
var sut = Fields.References(1, "my-refs", Partitioning.Invariant, var result = field.GetReferencedIds(null).ToArray();
new ReferencesFieldProperties { SchemaId = schemaId });
var result = sut.GetReferencedIds(JsonValue.Null).ToArray();
Assert.Equal(new[] { schemaId }, result); Assert.Empty(result);
} }
[Fact] [Theory]
public void Should_return_list_from_references_field_with_schema_id_for_referenced_ids_when_other_type() [MemberData(nameof(ReferencingFields))]
public void Should_return_ids_from_field(IField field)
{ {
var sut = Fields.References(1, "my-refs", Partitioning.Invariant, var id1 = Guid.NewGuid();
new ReferencesFieldProperties { SchemaId = schemaId }); var id2 = Guid.NewGuid();
var result = sut.GetReferencedIds(JsonValue.Create("invalid")).ToArray(); var value = CreateValue(id1, id2);
Assert.Equal(new[] { schemaId }, result); var result = field.GetReferencedIds(value);
Assert.Equal(new HashSet<Guid> { id1, id2 }, result);
} }
[Fact] [Theory]
public void Should_return_null_from_references_field_when_removing_references_from_null_array() [MemberData(nameof(ReferencingFields))]
public void Should_return_same_value_from_field_when_value_is_json_null(IField field)
{ {
var sut = Fields.References(1, "my-refs", Partitioning.Invariant); var result = ValueReferencesConverter.CleanReferences(RandomIds())(JsonValue.Null, field);
var result = sut.CleanReferences(JsonValue.Null, null);
Assert.Equal(JsonValue.Null, result); Assert.Equal(JsonValue.Null, result);
} }
[Fact] [Theory]
public void Should_remove_deleted_references_from_references_field() [MemberData(nameof(ReferencingFields))]
public void Should_remove_deleted_ids_from_field(IField field)
{ {
var id1 = Guid.NewGuid(); var id1 = Guid.NewGuid();
var id2 = Guid.NewGuid(); var id2 = Guid.NewGuid();
var sut = Fields.References(1, "my-refs", Partitioning.Invariant, var value = CreateValue(id1, id2);
new ReferencesFieldProperties { SchemaId = schemaId });
var result = sut.CleanReferences(CreateValue(id1, id2), HashSet.Of(id2)); var result = ValueReferencesConverter.CleanReferences(HashSet.Of(id1))(value, field);
Assert.Equal(CreateValue(id1), result); Assert.Equal(CreateValue(id1), result);
} }
[Fact] public static IEnumerable<object[]> ReferencingNestedFields()
public void Should_remove_all_references_from_references_field_when_schema_is_removed()
{ {
var id1 = Guid.NewGuid(); yield return new object[] { Fields.References(1, "my-refs") };
var id2 = Guid.NewGuid(); yield return new object[] { Fields.Assets(1, "my-assets") };
var sut = Fields.References(1, "my-refs", Partitioning.Invariant,
new ReferencesFieldProperties { SchemaId = schemaId });
var result = sut.CleanReferences(CreateValue(id1, id2), HashSet.Of(schemaId));
Assert.Equal(CreateValue(), result);
} }
[Fact] public static IEnumerable<object[]> ReferencingFields()
public void Should_return_same_token_from_references_field_when_removing_references_and_nothing_to_remove()
{ {
var id1 = Guid.NewGuid(); yield return new object[] { Fields.References(1, "my-refs", Partitioning.Invariant) };
var id2 = Guid.NewGuid(); yield return new object[] { Fields.Assets(1, "my-assets", Partitioning.Invariant) };
}
var sut = Fields.References(1, "my-refs", Partitioning.Invariant);
var value = CreateValue(id1, id2);
var result = sut.CleanReferences(value, HashSet.Of(Guid.NewGuid()));
Assert.Same(value, result); private static HashSet<Guid> RandomIds()
{
return HashSet.Of(Guid.NewGuid());
} }
private static IJsonValue CreateValue(params Guid[] ids) private static IJsonValue CreateValue(params object[] ids)
{ {
return ids == null ? JsonValue.Null : JsonValue.Array(ids.Select(x => (object)x.ToString()).ToArray()); return JsonValue.Array(ids);
} }
} }
} }

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

@ -282,7 +282,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
new NamedContentData() new NamedContentData()
.AddField("city", .AddField("city",
new ContentFieldData() new ContentFieldData()
.AddValue("iv", JsonValue.Array())) .AddJsonValue(JsonValue.Array()))
}; };
var result = sut.Format(script, @event); var result = sut.Format(script, @event);
@ -301,7 +301,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
new NamedContentData() new NamedContentData()
.AddField("city", .AddField("city",
new ContentFieldData() new ContentFieldData()
.AddValue("iv", JsonValue.Object().Add("name", "Berlin"))) .AddJsonValue(JsonValue.Object().Add("name", "Berlin")))
}; };
var result = sut.Format(script, @event); var result = sut.Format(script, @event);
@ -339,8 +339,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
new NamedContentData() new NamedContentData()
.AddField("city", .AddField("city",
new ContentFieldData() new ContentFieldData()
.AddValue("iv", JsonValue.Array( .AddJsonValue(JsonValue.Array("Berlin")))
"Berlin")))
}; };
var result = sut.Format(script, @event); var result = sut.Format(script, @event);
@ -359,7 +358,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
new NamedContentData() new NamedContentData()
.AddField("city", .AddField("city",
new ContentFieldData() new ContentFieldData()
.AddValue("iv", JsonValue.Object().Add("name", "Berlin"))) .AddJsonValue(JsonValue.Object().Add("name", "Berlin")))
}; };
var result = sut.Format(script, @event); var result = sut.Format(script, @event);
@ -378,7 +377,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
new NamedContentData() new NamedContentData()
.AddField("city", .AddField("city",
new ContentFieldData() new ContentFieldData()
.AddValue("iv", JsonValue.Object().Add("name", "Berlin"))) .AddJsonValue(JsonValue.Object().Add("name", "Berlin")))
}; };
var result = sut.Format(script, @event); var result = sut.Format(script, @event);

12
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/ContentDataObjectTests.cs

@ -155,13 +155,13 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting
new NamedContentData() new NamedContentData()
.AddField("number", .AddField("number",
new ContentFieldData() new ContentFieldData()
.AddValue("iv", JsonValue.Array(1.0, 2.0))); .AddJsonValue(JsonValue.Array(1.0, 2.0)));
var expected = var expected =
new NamedContentData() new NamedContentData()
.AddField("number", .AddField("number",
new ContentFieldData() new ContentFieldData()
.AddValue("iv", JsonValue.Array(1.0, 4.0, 5.0))); .AddJsonValue(JsonValue.Array(1.0, 4.0, 5.0)));
var result = ExecuteScript(original, @"data.number.iv = [data.number.iv[0], data.number.iv[1] + 2, 5]"); var result = ExecuteScript(original, @"data.number.iv = [data.number.iv[0], data.number.iv[1] + 2, 5]");
@ -175,13 +175,13 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting
new NamedContentData() new NamedContentData()
.AddField("number", .AddField("number",
new ContentFieldData() new ContentFieldData()
.AddValue("iv", JsonValue.Object().Add("lat", 1.0))); .AddJsonValue(JsonValue.Object().Add("lat", 1.0)));
var expected = var expected =
new NamedContentData() new NamedContentData()
.AddField("number", .AddField("number",
new ContentFieldData() new ContentFieldData()
.AddValue("iv", JsonValue.Object().Add("lat", 1.0).Add("lon", 4.0))); .AddJsonValue(JsonValue.Object().Add("lat", 1.0).Add("lon", 4.0)));
var result = ExecuteScript(original, @"data.number.iv = { lat: data.number.iv.lat, lon: data.number.iv.lat + 3 }"); var result = ExecuteScript(original, @"data.number.iv = { lat: data.number.iv.lat, lon: data.number.iv.lat + 3 }");
@ -265,7 +265,7 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting
new NamedContentData() new NamedContentData()
.AddField("obj", .AddField("obj",
new ContentFieldData() new ContentFieldData()
.AddValue("iv", JsonValue.Object().Add("readonly", 1))); .AddJsonValue(JsonValue.Object().Add("readonly", 1)));
Assert.Throws<JavaScriptException>(() => ExecuteScript(original, "data.obj.iv.invalid = 1")); Assert.Throws<JavaScriptException>(() => ExecuteScript(original, "data.obj.iv.invalid = 1"));
Assert.Throws<JavaScriptException>(() => ExecuteScript(original, "data.obj.iv.readonly = 2")); Assert.Throws<JavaScriptException>(() => ExecuteScript(original, "data.obj.iv.readonly = 2"));
@ -278,7 +278,7 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting
new NamedContentData() new NamedContentData()
.AddField("obj", .AddField("obj",
new ContentFieldData() new ContentFieldData()
.AddValue("iv", JsonValue.Array())); .AddJsonValue(JsonValue.Array()));
ExecuteScript(original, "data.obj.iv[0] = 1"); ExecuteScript(original, "data.obj.iv[0] = 1");
} }

15
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Tags/TagNormalizerTests.cs

@ -12,6 +12,7 @@ using FakeItEasy;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Core.Tags; using Squidex.Domain.Apps.Core.Tags;
using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Json.Objects;
using Xunit; using Xunit;
@ -44,8 +45,8 @@ namespace Squidex.Domain.Apps.Core.Operations.Tags
var oldData = GenerateData("o_raw"); var oldData = GenerateData("o_raw");
A.CallTo(() => tagService.NormalizeTagsAsync(appId, TagGroups.Schemas(schemaId), A.CallTo(() => tagService.NormalizeTagsAsync(appId, TagGroups.Schemas(schemaId),
A<HashSet<string>>.That.IsSameSequenceAs("n_raw2_1", "n_raw2_2", "n_raw4"), A<HashSet<string>>.That.Is("n_raw2_1", "n_raw2_2", "n_raw4"),
A<HashSet<string>>.That.IsSameSequenceAs("o_raw2_1", "o_raw2_2", "o_raw4"))) A<HashSet<string>>.That.Is("o_raw2_1", "o_raw2_2", "o_raw4")))
.Returns(new Dictionary<string, string> .Returns(new Dictionary<string, string>
{ {
["n_raw2_2"] = "id2_2", ["n_raw2_2"] = "id2_2",
@ -65,7 +66,7 @@ namespace Squidex.Domain.Apps.Core.Operations.Tags
var newData = GenerateData("name"); var newData = GenerateData("name");
A.CallTo(() => tagService.NormalizeTagsAsync(appId, TagGroups.Schemas(schemaId), A.CallTo(() => tagService.NormalizeTagsAsync(appId, TagGroups.Schemas(schemaId),
A<HashSet<string>>.That.IsSameSequenceAs("name2_1", "name2_2", "name4"), A<HashSet<string>>.That.Is("name2_1", "name2_2", "name4"),
A<HashSet<string>>.That.IsEmpty())) A<HashSet<string>>.That.IsEmpty()))
.Returns(new Dictionary<string, string> .Returns(new Dictionary<string, string>
{ {
@ -86,7 +87,7 @@ namespace Squidex.Domain.Apps.Core.Operations.Tags
var newData = GenerateData("id"); var newData = GenerateData("id");
A.CallTo(() => tagService.NormalizeTagsAsync(appId, TagGroups.Schemas(schemaId), A.CallTo(() => tagService.NormalizeTagsAsync(appId, TagGroups.Schemas(schemaId),
A<HashSet<string>>.That.IsSameSequenceAs("id2_1", "id2_2", "id4"), A<HashSet<string>>.That.Is("id2_1", "id2_2", "id4"),
A<HashSet<string>>.That.IsEmpty())) A<HashSet<string>>.That.IsEmpty()))
.Returns(new Dictionary<string, string> .Returns(new Dictionary<string, string>
{ {
@ -114,16 +115,16 @@ namespace Squidex.Domain.Apps.Core.Operations.Tags
return new NamedContentData() return new NamedContentData()
.AddField("tags1", .AddField("tags1",
new ContentFieldData() new ContentFieldData()
.AddValue("iv", JsonValue.Array($"{prefix}1"))) .AddJsonValue(JsonValue.Array($"{prefix}1")))
.AddField("tags2", .AddField("tags2",
new ContentFieldData() new ContentFieldData()
.AddValue("iv", JsonValue.Array($"{prefix}2_1", $"{prefix}2_2"))) .AddJsonValue(JsonValue.Array($"{prefix}2_1", $"{prefix}2_2")))
.AddField("string", .AddField("string",
new ContentFieldData() new ContentFieldData()
.AddValue("iv", $"{prefix}stringValue")) .AddValue("iv", $"{prefix}stringValue"))
.AddField("array", .AddField("array",
new ContentFieldData() new ContentFieldData()
.AddValue("iv", .AddJsonValue(
JsonValue.Array( JsonValue.Array(
JsonValue.Object() JsonValue.Object()
.Add("nestedTags1", JsonValue.Array($"{prefix}3")) .Add("nestedTags1", JsonValue.Array($"{prefix}3"))

4
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ContentValidationTests.cs

@ -348,7 +348,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent
new NamedContentData() new NamedContentData()
.AddField("my-field", .AddField("my-field",
new ContentFieldData() new ContentFieldData()
.AddValue("iv", .AddJsonValue(
JsonValue.Array( JsonValue.Array(
JsonValue.Object(), JsonValue.Object(),
JsonValue.Object().Add("my-nested", 1), JsonValue.Object().Add("my-nested", 1),
@ -387,7 +387,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent
new NamedContentData() new NamedContentData()
.AddField("my-field", .AddField("my-field",
new ContentFieldData() new ContentFieldData()
.AddValue("iv", .AddJsonValue(
JsonValue.Array( JsonValue.Array(
JsonValue.Object()))); JsonValue.Object())));

2
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/UIFieldTests.cs

@ -100,7 +100,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent
var data = var data =
new NamedContentData() new NamedContentData()
.AddField("my-array", new ContentFieldData() .AddField("my-array", new ContentFieldData()
.AddValue("iv", .AddJsonValue(
JsonValue.Array( JsonValue.Array(
JsonValue.Object() JsonValue.Object()
.Add("my-ui", null)))); .Add("my-ui", null))));

31
backend/tests/Squidex.Domain.Apps.Core.Tests/TestHelpers/AExtensions.cs

@ -0,0 +1,31 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Linq;
using FakeItEasy;
namespace Squidex.Domain.Apps.Core.TestHelpers
{
public static class AExtensions
{
public static T[] Is<T>(this INegatableArgumentConstraintManager<T[]> that, params T[]? values)
{
return values == null ? that.IsNull() : that.IsSameSequenceAs(values);
}
public static HashSet<T> Is<T>(this INegatableArgumentConstraintManager<HashSet<T>> that, IEnumerable<T>? values)
{
return values == null ? that.IsNull() : that.Matches(x => x.Intersect(values).Count() == values.Count());
}
public static HashSet<T> Is<T>(this INegatableArgumentConstraintManager<HashSet<T>> that, params T[]? values)
{
return values == null ? that.IsNull() : that.Matches(x => x.Intersect(values).Count() == values.Length);
}
}
}

4
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetEnricherTests.cs

@ -59,7 +59,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
AppId = appId AppId = appId
}; };
A.CallTo(() => tagService.DenormalizeTagsAsync(appId.Id, TagGroups.Assets, A<HashSet<string>>.That.Has("id1", "id2"))) A.CallTo(() => tagService.DenormalizeTagsAsync(appId.Id, TagGroups.Assets, A<HashSet<string>>.That.Is("id1", "id2")))
.Returns(new Dictionary<string, string> .Returns(new Dictionary<string, string>
{ {
["id1"] = "name1", ["id1"] = "name1",
@ -137,7 +137,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
AppId = appId AppId = appId
}; };
A.CallTo(() => tagService.DenormalizeTagsAsync(appId.Id, TagGroups.Assets, A<HashSet<string>>.That.Has("id1", "id2", "id3"))) A.CallTo(() => tagService.DenormalizeTagsAsync(appId.Id, TagGroups.Assets, A<HashSet<string>>.That.Is("id1", "id2", "id3")))
.Returns(new Dictionary<string, string> .Returns(new Dictionary<string, string>
{ {
["id1"] = "name1", ["id1"] = "name1",

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryServiceTests.cs

@ -85,7 +85,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
var ids = HashSet.Of(found1.Id, found2.Id); var ids = HashSet.Of(found1.Id, found2.Id);
A.CallTo(() => assetRepository.QueryAsync(appId.Id, A<HashSet<Guid>>.That.IsSameSequenceAs(ids))) A.CallTo(() => assetRepository.QueryAsync(appId.Id, A<HashSet<Guid>>.That.Is(ids)))
.Returns(ResultList.CreateFrom(8, found1, found2)); .Returns(ResultList.CreateFrom(8, found1, found2));
A.CallTo(() => assetEnricher.EnrichAsync(A<IEnumerable<IAssetEntity>>.That.IsSameSequenceAs(found1, found2), requestContext)) A.CallTo(() => assetEnricher.EnrichAsync(A<IEnumerable<IAssetEntity>>.That.IsSameSequenceAs(found1, found2), requestContext))

146
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ConvertDataTests.cs

@ -0,0 +1,146 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using FakeItEasy;
using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.ConvertContent;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities.Assets.Repositories;
using Squidex.Domain.Apps.Entities.Contents.Queries.Steps;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Contents.Queries
{
public class ConvertDataTests
{
private readonly ISchemaEntity schema;
private readonly IAssetUrlGenerator assetUrlGenerator = A.Fake<IAssetUrlGenerator>();
private readonly IAssetRepository assetRepository = A.Fake<IAssetRepository>();
private readonly IContentRepository contentRepository = A.Fake<IContentRepository>();
private readonly Context requestContext;
private readonly NamedId<Guid> appId = NamedId.Of(Guid.NewGuid(), "my-app");
private readonly NamedId<Guid> schemaId = NamedId.Of(Guid.NewGuid(), "my-schema");
private readonly ProvideSchema schemaProvider;
private readonly ConvertData sut;
public ConvertDataTests()
{
requestContext = new Context(Mocks.ApiUser(), Mocks.App(appId));
var schemaDef =
new Schema("my-schema")
.AddReferences(1, "references", Partitioning.Invariant)
.AddAssets(2, "assets", Partitioning.Invariant)
.AddArray(3, "array", Partitioning.Invariant, a => a
.AddAssets(31, "nested"));
schema = Mocks.Schema(appId, schemaId, schemaDef);
schemaProvider = x => Task.FromResult(schema);
sut = new ConvertData(assetUrlGenerator, assetRepository, contentRepository);
}
[Fact]
public async Task Should_convert_data_only()
{
var source = PublishedContent();
await sut.EnrichAsync(requestContext, Enumerable.Repeat(source, 1), schemaProvider);
Assert.NotNull(source.Data);
Assert.Null(source.DataDraft);
}
[Fact]
public async Task Should_convert_data_and_data_draft_when_frontend_user()
{
var source = PublishedContent();
var ctx = new Context(Mocks.FrontendUser(), Mocks.App(appId));
await sut.EnrichAsync(ctx, Enumerable.Repeat(source, 1), schemaProvider);
Assert.NotNull(source.Data);
Assert.NotNull(source.DataDraft);
}
[Fact]
public async Task Should_cleanup_references()
{
var id1 = Guid.NewGuid();
var id2 = Guid.NewGuid();
var source =
new NamedContentData()
.AddField("references",
new ContentFieldData()
.AddJsonValue(JsonValue.Array(id1, id2)))
.AddField("assets",
new ContentFieldData()
.AddJsonValue(JsonValue.Array(id1)))
.AddField("array",
new ContentFieldData()
.AddJsonValue(
JsonValue.Array(
JsonValue.Object()
.Add("nested", JsonValue.Array(id1, id2)))));
var expected =
new NamedContentData()
.AddField("references",
new ContentFieldData()
.AddJsonValue(JsonValue.Array(id2)))
.AddField("assets",
new ContentFieldData()
.AddJsonValue(JsonValue.Array()))
.AddField("array",
new ContentFieldData()
.AddJsonValue(
JsonValue.Array(
JsonValue.Object()
.Add("nested", JsonValue.Array(id2)))));
var content = PublishedContent();
content.Data = source;
content.DataDraft = source;
A.CallTo(() => assetRepository.QueryIdsAsync(appId.Id, A<HashSet<Guid>>.That.Is(id1, id2)))
.Returns(new List<Guid> { id2 });
A.CallTo(() => contentRepository.QueryIdsAsync(appId.Id, A<HashSet<Guid>>.That.Is(id1, id2)))
.Returns(new List<(Guid, Guid)> { (id2, id2) });
var ctx = new Context(Mocks.FrontendUser(), Mocks.App(appId));
await sut.EnrichAsync(ctx, Enumerable.Repeat(content, 1), schemaProvider);
Assert.Equal(expected, content.Data);
Assert.Equal(expected, content.DataDraft);
}
private ContentEntity PublishedContent()
{
return new ContentEntity
{
Status = Status.Published,
Data = new NamedContentData(),
DataDraft = new NamedContentData(),
SchemaId = schemaId
};
}
}
}

6
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ResolveAssetsTests.cs

@ -137,8 +137,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
new NamedContentData() new NamedContentData()
.AddField("asset1", .AddField("asset1",
new ContentFieldData() new ContentFieldData()
.AddValue("iv", .AddValue("iv", $"url/to/{image1.Id}"))
$"url/to/{image1.Id}"))
.AddField("asset2", .AddField("asset2",
new ContentFieldData()), new ContentFieldData()),
source[0].ReferenceData); source[0].ReferenceData);
@ -149,8 +148,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
new ContentFieldData()) new ContentFieldData())
.AddField("asset2", .AddField("asset2",
new ContentFieldData() new ContentFieldData()
.AddValue("en", .AddValue("en", $"url/to/{image2.Id}")),
$"url/to/{image2.Id}")),
source[1].ReferenceData); source[1].ReferenceData);
} }

6
backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/AExtensions.cs

@ -24,12 +24,12 @@ namespace Squidex.Domain.Apps.Entities.TestHelpers
return values == null ? that.IsNull() : that.IsSameSequenceAs(values); return values == null ? that.IsNull() : that.IsSameSequenceAs(values);
} }
public static IEnumerable<T> Has<T>(this INegatableArgumentConstraintManager<IEnumerable<T>> that, params T[]? values) public static HashSet<T> Is<T>(this INegatableArgumentConstraintManager<HashSet<T>> that, IEnumerable<T>? values)
{ {
return values == null ? that.IsNull() : that.Matches(x => x.Intersect(values).Count() == values.Length); return values == null ? that.IsNull() : that.Matches(x => x.Intersect(values).Count() == values.Count());
} }
public static HashSet<T> Has<T>(this INegatableArgumentConstraintManager<HashSet<T>> that, params T[]? values) public static HashSet<T> Is<T>(this INegatableArgumentConstraintManager<HashSet<T>> that, params T[]? values)
{ {
return values == null ? that.IsNull() : that.Matches(x => x.Intersect(values).Count() == values.Length); return values == null ? that.IsNull() : that.Matches(x => x.Intersect(values).Count() == values.Length);
} }

4
backend/tools/TestSuite/TestSuite.ApiTests/ContentCleanupTests.cs

@ -66,7 +66,7 @@ namespace TestSuite.ApiTests
var content_1 = await contents.CreateAsync(data); var content_1 = await contents.CreateAsync(data);
Assert.Equal(data.String, content_1.DataDraft.String); Assert.Equal(data.String, content_1.Data.String);
// STEP 3: Delete a field from schema. // STEP 3: Delete a field from schema.
@ -77,7 +77,7 @@ namespace TestSuite.ApiTests
var content_2 = await contents.ChangeStatusAsync(content_1.Id, "Published"); var content_2 = await contents.ChangeStatusAsync(content_1.Id, "Published");
// Should not return deleted field. // Should not return deleted field.
Assert.Null(content_2.DataDraft.String); Assert.Null(content_2.Data.String);
} }
} }
} }

Loading…
Cancel
Save